@arcforgelabs/flow 2026.10.37 → 2026.10.39

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 (57) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +27 -9
  5. package/server/api/invoicing.js +198 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +89 -3
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +109 -8
  13. package/server/core/price-list.js +27 -2
  14. package/server/core/read-before-act.js +3 -2
  15. package/server/core/sending.js +36 -18
  16. package/server/db/doctor.js +18 -19
  17. package/server/db/index.js +74 -3
  18. package/server/db/migrate-all.js +3 -4
  19. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  20. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +14 -0
  23. package/server/integrations/call-guard.js +148 -0
  24. package/server/integrations/port.js +3 -2
  25. package/server/integrations/schema.js +9 -5
  26. package/server/integrations/seazona/feed.js +4 -1
  27. package/server/integrations/seazona/settings.js +8 -4
  28. package/server/integrations/xero/drafts.js +4 -4
  29. package/server/integrations/xero/fit.js +108 -0
  30. package/server/integrations/xero/import.js +6 -1
  31. package/server/integrations/xero/index.js +6 -2
  32. package/server/integrations/xero/ledger.js +3 -0
  33. package/server/integrations/xero/links.js +293 -0
  34. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  35. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/mirror.js +4 -3
  38. package/server/integrations/xero/reconcile.js +42 -12
  39. package/server/integrations/xero/routes.js +66 -7
  40. package/server/integrations/xero/settings.js +6 -5
  41. package/server/ui/app-page.js +1 -1
  42. package/server/ui/app.js +89 -3
  43. package/server/ui/bulk-bar.js +4 -3
  44. package/server/ui/custom-fields.js +252 -94
  45. package/server/ui/flow.css +326 -29
  46. package/server/ui/invoice-notes.js +2 -2
  47. package/server/ui/invoicing.js +92 -137
  48. package/server/ui/job-board.js +9 -7
  49. package/server/ui/job-change.js +23 -12
  50. package/server/ui/job-view.js +189 -31
  51. package/server/ui/jobs.js +738 -70
  52. package/server/ui/price-list.js +43 -86
  53. package/server/ui/price-match.js +93 -0
  54. package/server/ui/price-reconcile.js +357 -0
  55. package/server/ui/reconcile.js +275 -0
  56. package/server/ui/routes.js +1 -1
  57. package/server/ui/xero-import.js +329 -0
@@ -38,9 +38,10 @@ function readEnv(env, name) {
38
38
  return typeof value === 'string' ? value.trim() : '';
39
39
  }
40
40
 
41
- function readOnOff(env, name) {
41
+ function readOnOff(env, name, fallback = false) {
42
42
  const raw = readEnv(env, name).toLowerCase();
43
- if (raw === '' || raw === 'off') return false;
43
+ if (raw === '') return fallback;
44
+ if (raw === 'off') return false;
44
45
  if (raw === 'on') return true;
45
46
  throw new Error(`${name} must be "on", "off" or unset`);
46
47
  }
@@ -166,9 +167,9 @@ export function resolveXeroSettings(env = process.env) {
166
167
  defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
167
168
  invoiceNumber,
168
169
  replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
169
- // Free text on Xero records (references, line descriptions) can name a
170
- // patient. Flow keeps it only when the lab turns this on (VISION.md).
171
- keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE'),
170
+ // Free text on Xero records (references, line descriptions) is the lab's
171
+ // own data, and the Import matches on it. On unless the lab turns it off.
172
+ keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE', true),
172
173
  mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
173
174
  // The share of the day's calls the mirror leaves for drafts and people.
174
175
  mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({
package/server/ui/app.js CHANGED
@@ -13,8 +13,11 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
13
  import { automationSection } from '/assets/ui/automations.js';
14
14
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
15
  import { renderPriceList } from '/assets/ui/price-list.js';
16
+ import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
16
17
  import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
17
18
  import { renderJobs } from '/assets/ui/jobs.js';
19
+ import { renderReconcile } from '/assets/ui/reconcile.js';
20
+ import { renderXeroImport } from '/assets/ui/xero-import.js';
18
21
  import { startMonitoring } from '/assets/ui/monitoring.js';
19
22
  import { openSearch } from '/assets/ui/search.js';
20
23
  import { alertStrip } from '/assets/ui/alerts.js';
@@ -124,10 +127,41 @@ async function flow(path, options = {}) {
124
127
  headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
125
128
  });
126
129
  const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
127
- if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details });
130
+ // A button that calls Xero or Seazona waits between presses (the action
131
+ // guard): a press too soon says how long (retryAfterMs), and a good run
132
+ // says its own wait (Flow-Cooldown-Ms). holdButton shows the wait.
133
+ if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details, retryAfterMs: body.retryAfterMs });
134
+ const cooldownMs = Number(res.headers.get('Flow-Cooldown-Ms'));
135
+ if (cooldownMs > 0 && body && typeof body === 'object') body.cooldownMs = cooldownMs;
128
136
  return body;
129
137
  }
130
138
 
139
+ // Keeps a button off for `ms`, with the time left on it, then puts it back.
140
+ function holdButton(button, ms) {
141
+ if (!button || !(ms > 0)) return;
142
+ clearInterval(button.fxHold);
143
+ const label = button.dataset.label ?? button.textContent;
144
+ button.dataset.label = label;
145
+ const until = Date.now() + ms;
146
+ const draw = () => {
147
+ const left = Math.ceil((until - Date.now()) / 1000);
148
+ if (left <= 0) {
149
+ clearInterval(button.fxHold);
150
+ button.textContent = label;
151
+ delete button.dataset.label;
152
+ button.removeAttribute('title');
153
+ button.disabled = false;
154
+ return;
155
+ }
156
+ const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
157
+ button.disabled = true;
158
+ button.textContent = `${label} · ${time}`;
159
+ button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
160
+ };
161
+ draw();
162
+ button.fxHold = setInterval(draw, 1000);
163
+ }
164
+
131
165
  // ── Shared pieces ──────────────────────────────────────────────────────────
132
166
 
133
167
  function pageHeader(title, { subtitle, actions = [] } = {}) {
@@ -200,13 +234,16 @@ let me = null;
200
234
  const VIEWS = {
201
235
  jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
202
236
  invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
237
+ reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
203
238
  'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
204
- 'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
239
+ 'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
205
240
  'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
206
241
  people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
207
242
  integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
208
243
  // Not in the sidebar: "Manage account" in the account menu opens the same
209
244
  // Clerk profile. The page stays for links and Search.
245
+ // Opened from the Xero integration page: Xero invoices against Flow's own.
246
+ 'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
210
247
  account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
211
248
  appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
212
249
  };
@@ -215,6 +252,7 @@ const VIEWS = {
215
252
  const ICON_PATHS = {
216
253
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
217
254
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
255
+ reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
218
256
  people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
219
257
  tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
220
258
  fields: '<rect x="3" y="4" width="18" height="5" rx="1.5"/><rect x="3" y="13" width="18" height="5" rx="1.5"/><path d="M7 6.5h4M7 15.5h7"/>',
@@ -365,7 +403,7 @@ function navigate(event) {
365
403
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
366
404
  // takes a path, or a link's click event.
367
405
  const ui = () => ({
368
- h, flow, toast, notice, pageHeader, dialog, me,
406
+ h, flow, toast, notice, pageHeader, dialog, me, holdButton,
369
407
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
370
408
  });
371
409
 
@@ -825,6 +863,7 @@ async function renderIntegration(main, id) {
825
863
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
826
864
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
827
865
  on && current.state === 'ready' ? personSettingsSection(current) : null,
866
+ current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
828
867
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
829
868
  guide ? h('section', { class: 'fx-int-section' },
830
869
  h('h2', { class: 'fx-int-section__title' }, 'Set up'),
@@ -904,6 +943,53 @@ function connectProgress({ steps, started }) {
904
943
  step.label))));
905
944
  }
906
945
 
946
+ // Xero › Import, in settings rows: how many Xero invoices to check (Review
947
+ // opens them one at a time), the two import settings, and the history. The
948
+ // counts come from Flow's copy of Xero; the pages are server/ui/xero-import.js.
949
+ function xeroImportSection(organisations) {
950
+ const organisationId = organisations?.[0]?.id;
951
+ const canAct = ['owner', 'admin'].includes(me?.person?.role);
952
+ const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
953
+ const copied = h('span', {}, 'Every Xero invoice Flow copied. Search it with no Xero call.');
954
+ const switchFor = (name, label) => {
955
+ const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
956
+ button.addEventListener('click', async () => {
957
+ const on = button.getAttribute('aria-checked') !== 'true';
958
+ button.disabled = true;
959
+ try {
960
+ const { settings } = await flow('/api/xero/import/settings', { method: 'PUT', body: JSON.stringify({ organisationId, [name]: on }) });
961
+ button.setAttribute('aria-checked', String(settings[name]));
962
+ toast('Saved.', 'success');
963
+ } catch (error) {
964
+ toast(error.message, 'danger');
965
+ }
966
+ button.disabled = !canAct;
967
+ });
968
+ return button;
969
+ };
970
+ const autoLink = switchFor('autoLink', 'Link sure matches');
971
+ const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
972
+ const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
973
+ h('h2', { class: 'fx-set__title' }, 'Import'),
974
+ settingRow({ label: 'To check', hint: 'Xero invoices that fit a Flow invoice or a Flow job.',
975
+ control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
976
+ settingRow({ label: 'Link sure matches', hint: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
977
+ settingRow({ label: 'Make Flow invoices for the rest', hint: 'Off: they stay in the Xero history.', control: makeUnmatched }),
978
+ settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
979
+ if (organisationId) {
980
+ flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
981
+ .then(({ counts: c, settings }) => {
982
+ count.replaceChildren(String(c.job_fits + c.invoice_fits));
983
+ copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} Xero invoices copied, ${c.linked} in Flow. Search with no Xero call.`);
984
+ autoLink.setAttribute('aria-checked', String(settings.autoLink));
985
+ makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
986
+ autoLink.disabled = makeUnmatched.disabled = !canAct;
987
+ })
988
+ .catch(() => count.replaceChildren('—'));
989
+ }
990
+ return section;
991
+ }
992
+
907
993
  // Once Xero is on: each organisation's connection, the history copy, and
908
994
  // Connect, which goes to Xero and comes back.
909
995
  function xeroConnection(organisations, canChange) {
@@ -5,7 +5,8 @@
5
5
  // its name on hover and to a screen reader. More opens every action, with a
6
6
  // search box, grouped under small heads.
7
7
  //
8
- // bulkBar(ui) answers { element, draw(state) }. state = { count, onClear,
8
+ // bulkBar(ui, { label }) answers { element, draw(state) }. label names the
9
+ // bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
9
10
  // groups: [[action]], more: [{ head, actions: [action] }] }; an action is
10
11
  // { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
11
12
  // long name, for the hover tip and the More menu. A count of 0 hides the
@@ -14,10 +15,10 @@ import { lucide } from '/assets/ui/custom-fields.js';
14
15
 
15
16
  let barCount = 0;
16
17
 
17
- export function bulkBar(ui) {
18
+ export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
18
19
  const { h } = ui;
19
20
  const id = `fx-bulk-more-${++barCount}`;
20
- const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': 'Ticked invoices', hidden: true });
21
+ const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
21
22
  const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
22
23
  let current = null;
23
24
  let busy = false;