@arcforgelabs/flow 2026.10.30 → 2026.10.32-beta.1

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 (67) 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 +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/history.js +65 -0
  9. package/server/core/invoice-pdf.js +266 -0
  10. package/server/core/invoicing-rules.js +12 -0
  11. package/server/core/job-board.js +25 -3
  12. package/server/core/line-mapping.js +36 -10
  13. package/server/core/pipeline.js +75 -3
  14. package/server/core/price-list.js +348 -0
  15. package/server/db/commands.js +42 -0
  16. package/server/db/doctor.js +168 -0
  17. package/server/db/migrations/0014_price_list.sql +34 -0
  18. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  19. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  20. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +44 -0
  23. package/server/flow-server.js +20 -1
  24. package/server/index.js +2 -1
  25. package/server/integrations/call-guard.js +362 -0
  26. package/server/integrations/port.js +20 -1
  27. package/server/integrations/seazona/feed.js +23 -1
  28. package/server/integrations/seazona/governor.js +50 -122
  29. package/server/integrations/seazona/history/backfill.js +208 -0
  30. package/server/integrations/seazona/history/csv.js +65 -0
  31. package/server/integrations/seazona/history/index.js +584 -0
  32. package/server/integrations/seazona/history/read.js +196 -0
  33. package/server/integrations/seazona/index.js +37 -1
  34. package/server/integrations/seazona/lanes.js +186 -50
  35. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  36. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  37. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  38. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  40. package/server/integrations/seazona/routes.js +145 -0
  41. package/server/integrations/seazona/schema.js +94 -0
  42. package/server/integrations/seazona/settings.js +14 -3
  43. package/server/integrations/seazona/sync-once.js +1 -1
  44. package/server/integrations/seazona/tables.js +4 -0
  45. package/server/integrations/xero/client.js +27 -7
  46. package/server/integrations/xero/drafts.js +46 -8
  47. package/server/integrations/xero/import.js +125 -17
  48. package/server/integrations/xero/index.js +36 -7
  49. package/server/integrations/xero/lookup.js +26 -0
  50. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  51. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  52. package/server/integrations/xero/mirror.js +11 -2
  53. package/server/integrations/xero/oauth.js +4 -1
  54. package/server/integrations/xero/reconcile.js +173 -0
  55. package/server/integrations/xero/routes.js +35 -0
  56. package/server/integrations/xero/runtime.js +45 -4
  57. package/server/release-channel.js +3 -3
  58. package/server/ui/alerts.js +77 -0
  59. package/server/ui/app-page.js +1 -1
  60. package/server/ui/app.js +48 -14
  61. package/server/ui/clerk-appearance.js +42 -9
  62. package/server/ui/flow.css +31 -2
  63. package/server/ui/invoicing-settings.js +1 -1
  64. package/server/ui/invoicing.js +121 -8
  65. package/server/ui/line-mappings.js +3 -2
  66. package/server/ui/price-list.js +247 -0
  67. package/server/ui/routes.js +1 -1
@@ -5,6 +5,7 @@
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';
8
9
 
9
10
  const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
10
11
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
@@ -12,7 +13,8 @@ const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
12
13
  // every draft is in one of them. A person marks a checked draft ready; any
13
14
  // later change sends it back. An issued invoice that failed to send is a
14
15
  // problem.
15
- const TABS = [['to_check', 'Drafts'], ['problem', 'Problem'], ['ready', 'Ready'], ['issued', 'Issued'], ['void', 'Void']];
16
+ // Ready first: it opens by default, as the drafts most often wait there to send.
17
+ const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
16
18
  const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
17
19
  const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
18
20
  const EMPTY = {
@@ -26,6 +28,31 @@ const TICKS = { to_check: true, ready: true };
26
28
  const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
27
29
  export const formatCents = (cents) => money.format((cents ?? 0) / 100);
28
30
 
31
+ // Xero's status of an issued invoice, from Flow's copy of Xero (issue #132).
32
+ // A finding (a difference, or a void on one side only) shows as Out of sync.
33
+ const XERO_TONE = {
34
+ not_sent: 'mute', sending: 'info', not_copied: 'info', draft: 'info',
35
+ approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
36
+ };
37
+ const FIELD_LABEL = { contact: 'Client', subtotal: 'Subtotal', tax: 'GST', total: 'Total', 'line count': 'Lines', items: 'Items', date: 'Date' };
38
+
39
+ export function xeroTag(ui, status) {
40
+ const { h } = ui;
41
+ if (!status) return null;
42
+ return status.finding
43
+ ? h('span', { class: 'fx-status', 'data-tone': 'danger', title: status.message }, 'Out of sync with Xero')
44
+ : h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
45
+ }
46
+
47
+ const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
48
+
49
+ // Xero's status for some invoices, or an empty map while Xero is off.
50
+ async function xeroStatuses(ui, organisationId, invoiceIds) {
51
+ if (!organisationId || !invoiceIds.length) return new Map();
52
+ const res = await ui.flow('/api/xero/invoices/status', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds }) }).catch(() => null);
53
+ return new Map((res?.invoices ?? []).map((s) => [s.invoiceId, s]));
54
+ }
55
+
29
56
  function gstCents(lineAmountCents) {
30
57
  const sign = Math.sign(lineAmountCents);
31
58
  const abs = Math.abs(lineAmountCents) * 1000;
@@ -48,22 +75,40 @@ function localDate(date = new Date()) {
48
75
 
49
76
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
50
77
 
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
+
51
93
  // ── The list ───────────────────────────────────────────────────────────────
52
94
 
53
95
  export async function renderInvoiceList(main, ui) {
54
96
  const { h, flow, pageHeader, notice, go, toast } = ui;
55
97
  const saved = sessionStorage.getItem('flow_invoice_tab');
56
- const state = { status: TABS.some(([id]) => id === saved) ? saved : 'to_check', q: '', ticked: new Set() };
98
+ const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: '', ticked: new Set() };
57
99
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
58
100
  const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
59
101
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
60
102
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
61
103
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
62
104
  let body = null;
105
+ const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
106
+ const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
63
107
  main.append(
64
108
  pageHeader('Invoices', {
65
109
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
66
110
  }),
111
+ alerts.element,
67
112
  h('div', { class: 'fx-toolbar' }, tabs, search),
68
113
  bar,
69
114
  host);
@@ -85,6 +130,7 @@ export async function renderInvoiceList(main, ui) {
85
130
  toast(...done(res));
86
131
  state.ticked.clear();
87
132
  await load();
133
+ alerts.refresh();
88
134
  } catch (error) {
89
135
  toast(error.message, 'danger');
90
136
  el.disabled = false;
@@ -107,7 +153,7 @@ export async function renderInvoiceList(main, ui) {
107
153
  : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
108
154
  }
109
155
  } 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.'));
156
+ parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
111
157
  const buttons = [];
112
158
  if (ticked.length) {
113
159
  buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
@@ -144,7 +190,7 @@ export async function renderInvoiceList(main, ui) {
144
190
  role: 'tab',
145
191
  'aria-selected': String(state.status === id),
146
192
  ...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
147
- onclick: () => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); },
193
+ onclick: () => openTab(id),
148
194
  }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
149
195
  drawBar();
150
196
  if (!body.invoices.length) {
@@ -175,6 +221,7 @@ export async function renderInvoiceList(main, ui) {
175
221
  drawBar();
176
222
  },
177
223
  });
224
+ const statusCells = new Map();
178
225
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
179
226
  h('thead', {}, h('tr', {},
180
227
  ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
@@ -193,10 +240,23 @@ export async function renderInvoiceList(main, ui) {
193
240
  : h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
194
241
  h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
195
242
  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])));
243
+ (() => {
244
+ const cell = h('td', { class: 'fx-status-cell' }, view
245
+ ? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
246
+ : h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
247
+ statusCells.set(invoice.id, cell);
248
+ return cell;
249
+ })());
199
250
  }))));
251
+ // Issued and void invoices also show Xero's status, from Flow's copy.
252
+ if (['issued', 'void'].includes(state.status)) {
253
+ const shownBody = body;
254
+ const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
255
+ if (shownBody !== body) return;
256
+ for (const [invoiceId, status] of statuses) {
257
+ if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
258
+ }
259
+ }
200
260
  }
201
261
  await load();
202
262
  }
@@ -516,10 +576,26 @@ function issuedView(invoice, ui, context, redraw) {
516
576
  toast(error.message, 'danger');
517
577
  }
518
578
  };
579
+ const organisationId = context.organisations?.[0]?.id;
580
+ const xeroHost = h('div');
581
+ let xeroStatus = null;
582
+ const loadXero = async () => {
583
+ if (!context.xero) return;
584
+ xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
585
+ xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
586
+ };
587
+ loadXero();
588
+ // Voiding in Flow never changes Xero: say so while Xero still holds it live.
589
+ const voidBody = () => {
590
+ const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
591
+ return h('div', {},
592
+ h('p', {}, 'The number stays used. A void invoice cannot be issued again.'),
593
+ 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);
594
+ };
519
595
  const actions = invoice.status === 'issued' && context.canIssue ? [
520
596
  h('button', {
521
597
  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' }),
598
+ onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
523
599
  }, 'Void'),
524
600
  h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
525
601
  sendToXero(invoice, ui, context),
@@ -551,6 +627,7 @@ function issuedView(invoice, ui, context, redraw) {
551
627
  h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
552
628
  h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
553
629
  h('aside', { class: 'fx-invoice__aside' },
630
+ xeroHost,
554
631
  jobCard(ui, invoice.job),
555
632
  invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
556
633
  h('h3', { class: 'fx-label' }, 'History'),
@@ -560,6 +637,42 @@ function issuedView(invoice, ui, context, redraw) {
560
637
  ];
561
638
  }
562
639
 
640
+ // Xero's copy of this invoice: its status, any difference from what Flow
641
+ // sent, and Mark resolved, which reads Xero's changes and checks again. It
642
+ // never writes to Xero: a person fixes the difference where they choose.
643
+ function xeroCard(invoice, ui, context, status, reload) {
644
+ const { h, flow, toast } = ui;
645
+ if (!status || (status.state === 'not_sent' && !status.finding)) return null;
646
+ const rows = status.differences.map((d) => h('tr', {},
647
+ h('td', {}, FIELD_LABEL[d.field] ?? d.field),
648
+ h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
649
+ h('td', { class: 'fx-num' }, showField(d.field, d.xero))));
650
+ const resolve = status.finding && context.canIssue ? h('button', {
651
+ class: 'fx-btn fx-btn--sm', type: 'button',
652
+ onclick: async () => {
653
+ resolve.disabled = true;
654
+ try {
655
+ const res = await flow(`/api/xero/invoices/${invoice.id}/recheck`, { method: 'POST', body: JSON.stringify({ organisationId: context.organisations[0].id }) });
656
+ toast(res.resolved ? 'Flow and Xero agree now.' : 'Still different. Fix it in Xero or in Flow, then try again.', res.resolved ? 'success' : 'danger');
657
+ await reload();
658
+ } catch (error) {
659
+ toast(error.message, 'danger');
660
+ resolve.disabled = false;
661
+ }
662
+ },
663
+ }, 'Mark resolved') : null;
664
+ return h('section', { class: 'fx-card fx-side-card' },
665
+ h('h3', { class: 'fx-label' }, 'Xero'),
666
+ h('div', { class: 'fx-row' }, xeroTag(ui, status), status.xero?.number ? h('span', { class: 'fx-dim' }, status.xero.number) : null),
667
+ status.finding ? h('p', { class: 'fx-problem' }, status.message) : null,
668
+ rows.length ? h('table', { class: 'fx-table' },
669
+ h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', { class: 'fx-num' }, 'Flow sent'), h('th', { class: 'fx-num' }, 'Xero now'))),
670
+ h('tbody', {}, rows)) : null,
671
+ h('div', { class: 'fx-row' },
672
+ 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,
673
+ resolve));
674
+ }
675
+
563
676
  // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
564
677
  // (Settings, Invoicing) decides what Xero gets: a draft that a person approves
565
678
  // there, or an approved invoice that Xero emails to the customer.
@@ -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;
@@ -0,0 +1,247 @@
1
+ // Settings, Price list (/price-list; server/core/price-list.js,
2
+ // docs/PRICE_LIST.md). Flow's own codes and prices, which work with no
3
+ // integration connected. Each row is one code: its name, its price and tax,
4
+ // and a chip for each connected system (Xero, Seazona) that says whether that
5
+ // system's record agrees. Edit opens a dialog with the code's fields, its
6
+ // links and, when something differs, Mark resolved. Below the list, each
7
+ // system's records that link to no code, with a menu to link each one, and
8
+ // the line price setting. Everyone can read it; an owner or admin changes it.
9
+ // The caller passes in app.js's shared pieces as `ui`.
10
+ import { alertStrip } from '/assets/ui/alerts.js';
11
+
12
+ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
13
+ const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
14
+ const LINE_PRICES = [
15
+ { value: 'work', label: 'Work system amount' },
16
+ { value: 'list', label: 'Price list' },
17
+ ];
18
+
19
+ const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
20
+ const toCents = (text) => {
21
+ const value = Number(String(text ?? '').replace(/[$,\s]/g, ''));
22
+ return Number.isFinite(value) ? Math.round(value * 100) : NaN;
23
+ };
24
+
25
+ export async function renderPriceList(main, ui) {
26
+ const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
27
+ const disabled = !['owner', 'admin'].includes(me?.person.role);
28
+ const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
29
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
30
+ const checkButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', disabled }, 'Check prices');
31
+ const alerts = alertStrip(ui, 'price-list');
32
+ main.append(pageHeader('Price list', { subtitle: 'The codes and prices that Flow puts on invoice lines.', actions: [checkButton, addButton] }), alerts.element, host);
33
+
34
+ let data = null;
35
+ let query = '';
36
+ const systemName = (id) => data.systems.find((s) => s.id === id)?.name ?? id;
37
+
38
+ async function send(path, method, payload, done = 'Saved.') {
39
+ try {
40
+ const body = await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
41
+ if (done) toast(done, 'success');
42
+ return body;
43
+ } catch (error) {
44
+ toast(error.message, 'danger');
45
+ return null;
46
+ }
47
+ }
48
+
49
+ const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
50
+ h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
51
+ h('div', { class: 'fx-set__control fx-rule__controls' }, control));
52
+
53
+ // One chip for each connected system: agrees, differs (with its price), or
54
+ // has no record for this code.
55
+ function chips(item) {
56
+ return data.systems.map((system) => {
57
+ const mine = item.findings.filter((f) => f.system === system.id);
58
+ const linked = item.links.some((l) => l.system === system.id);
59
+ if (mine.some((f) => f.kind === 'not_linked')) return h('span', { class: 'fx-status', 'data-tone': 'warn' }, `Not in ${system.name}`);
60
+ if (!linked) return null;
61
+ const price = mine.find((f) => f.kind === 'price');
62
+ if (price) return h('span', { class: 'fx-status', 'data-tone': 'danger' }, `${system.name} ${money(price.other)}`);
63
+ if (mine.some((f) => f.kind === 'tax')) return h('span', { class: 'fx-status', 'data-tone': 'danger' }, `${system.name} tax`);
64
+ if (mine.some((f) => f.kind === 'missing')) return h('span', { class: 'fx-status', 'data-tone': 'danger' }, `${system.name} removed`);
65
+ return h('span', { class: 'fx-status', 'data-tone': 'success' }, system.name);
66
+ }).filter(Boolean);
67
+ }
68
+
69
+ const findingText = (f) => {
70
+ const name = systemName(f.system);
71
+ if (f.kind === 'price') return `${name} price is ${money(f.other)}. Flow price is ${money(f.flow)}.`;
72
+ if (f.kind === 'tax') return `${name} tax is ${TAX_LABELS[f.other] ?? f.other}. Flow tax is ${TAX_LABELS[f.flow] ?? f.flow}.`;
73
+ if (f.kind === 'missing') return `${name} no longer has ${f.externalCode ?? 'the linked record'}.`;
74
+ return `${name} has no item with this code.`;
75
+ };
76
+
77
+ // The dialog for a new code (item null) or an existing one.
78
+ async function edit(item, draft = item) {
79
+ const field = (label, control) => h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, label), control);
80
+ const code = h('input', { class: 'fx-input', required: true, maxlength: '30', value: draft?.code ?? '', 'aria-label': 'Code' });
81
+ const name = h('input', { class: 'fx-input', required: true, maxlength: '200', value: draft?.name ?? '', 'aria-label': 'Name' });
82
+ const price = h('input', { class: 'fx-input', required: true, inputmode: 'decimal', value: draft ? (draft.unitAmountCents / 100).toFixed(2) : '', 'aria-label': 'Price' });
83
+ const tax = h('select', { class: 'fx-select', 'aria-label': 'Tax' }, Object.entries(TAX_LABELS).map(([value, label]) => h('option', { value }, label)));
84
+ tax.value = draft?.taxCode ?? 'GST';
85
+ const account = h('input', { class: 'fx-input', maxlength: '20', value: draft?.accountCode ?? '', 'aria-label': 'Account code', placeholder: 'Optional' });
86
+ const description = h('input', { class: 'fx-input', maxlength: '1000', value: draft?.description ?? '', 'aria-label': 'Invoice text', placeholder: 'Optional' });
87
+
88
+ let action = null;
89
+ const button = (label, value, extra = '') => {
90
+ const b = h('button', { class: `fx-btn fx-btn--ghost fx-btn--sm ${extra}`, type: 'button' }, label);
91
+ b.addEventListener('click', () => { action = value; b.closest('dialog').close(); });
92
+ return b;
93
+ };
94
+ const parts = [
95
+ field('Code', code), field('Name', name), field('Price, before tax', price), field('Tax', tax),
96
+ field('Invoice text', description), field('Account code', account),
97
+ ];
98
+ if (item) {
99
+ const findings = item.findings ?? [];
100
+ if (findings.length) {
101
+ parts.push(field('Differences', h('div', { class: 'fx-fields' },
102
+ ...findings.map((f) => h('span', { class: 'fx-dim' }, findingText(f))),
103
+ button('Mark resolved', { kind: 'resolve' }))));
104
+ }
105
+ if (item.links.length) {
106
+ parts.push(field('Links', h('div', { class: 'fx-fields' }, ...item.links.map((l) => h('div', { class: 'fx-rule__value' },
107
+ h('span', { class: 'fx-dim' }, `${systemName(l.system)} · ${l.externalCode ?? l.externalId}${l.record ? ` · ${l.record.name ?? ''} · ${money(l.record.unitAmountCents)}` : ''}`),
108
+ button('Remove', { kind: 'unlink', id: l.id }))))));
109
+ }
110
+ parts.push(button(item.archivedAt ? 'Bring back' : 'Archive code', { kind: 'archive', archived: !item.archivedAt }, item.archivedAt ? '' : 'fx-rule__delete'));
111
+ }
112
+ const ok = await dialog({ title: item ? `Edit ${item.code}` : 'New code', body: h('div', { class: 'fx-fields fx-rule__dialog' }, ...parts), confirm: item ? 'Save' : 'Add code' });
113
+
114
+ if (action?.kind === 'resolve') {
115
+ const body = await send(`/api/invoicing/price-items/${item.id}/resolve`, 'POST', {}, null);
116
+ if (body) toast(body.state === 'agree' ? 'Prices agree now.' : 'Marked resolved.', 'success');
117
+ alerts.refresh();
118
+ return load();
119
+ }
120
+ if (action?.kind === 'unlink') {
121
+ if (await send(`/api/invoicing/price-links/${action.id}`, 'DELETE', null, 'Link removed.')) return load();
122
+ return null;
123
+ }
124
+ if (action?.kind === 'archive') {
125
+ if (await send(`/api/invoicing/price-items/${item.id}/archive`, 'POST', { archived: action.archived }, action.archived ? 'Archived.' : 'Brought back.')) return load();
126
+ return null;
127
+ }
128
+ if (!ok) return null;
129
+ const payload = {
130
+ code: code.value, name: name.value, unitAmountCents: toCents(price.value), taxCode: tax.value,
131
+ accountCode: account.value || null, description: description.value || null,
132
+ };
133
+ if (!Number.isInteger(payload.unitAmountCents) || payload.unitAmountCents < 0) {
134
+ toast('Write the price as a number, for example 55.00.', 'danger');
135
+ return edit(item, { ...payload, unitAmountCents: 0 });
136
+ }
137
+ const body = item
138
+ ? await send(`/api/invoicing/price-items/${item.id}`, 'PUT', payload)
139
+ : await send('/api/invoicing/price-items', 'POST', payload);
140
+ if (!body) return edit(item, payload);
141
+ return load();
142
+ }
143
+
144
+ // A system's record that links to no code: a menu of codes to link it to,
145
+ // or "New code" to add it to the list as it is.
146
+ function unlinkedRow(system, record, items) {
147
+ const select = h('select', { class: 'fx-select fx-price__link', 'aria-label': `Code for ${record.name ?? record.code}`, disabled },
148
+ h('option', { value: '' }, 'Link to a code…'),
149
+ h('option', { value: 'new' }, 'Add as a new code'),
150
+ ...items.filter((i) => !i.archivedAt).map((i) => h('option', { value: i.id }, `${i.code} · ${i.name} · ${money(i.unitAmountCents)}`)));
151
+ select.addEventListener('change', async () => {
152
+ if (!select.value) return;
153
+ select.disabled = true;
154
+ let itemId = select.value;
155
+ if (itemId === 'new') {
156
+ const made = await send('/api/invoicing/price-items', 'POST', {
157
+ code: record.code || record.id, name: record.name || record.code || record.id,
158
+ unitAmountCents: record.unitAmountCents ?? 0, taxCode: record.taxCode ?? 'GST',
159
+ }, null);
160
+ itemId = made?.item.id ?? null;
161
+ }
162
+ if (itemId && await send(`/api/invoicing/price-items/${itemId}/links`, 'POST', { system: system.id, externalId: record.id }, 'Linked. Jobs with this item update in a few minutes.')) return load();
163
+ select.disabled = disabled;
164
+ select.value = '';
165
+ return null;
166
+ });
167
+ const hint = [record.code, money(record.unitAmountCents), TAX_LABELS[record.taxCode]].filter(Boolean).join(' · ');
168
+ return row(record.name ?? record.code ?? record.id, h('span', { class: 'fx-set__hint' }, hint), select);
169
+ }
170
+
171
+ async function linePriceRow() {
172
+ const select = h('select', { class: 'fx-select', 'aria-label': 'Line price from', disabled }, LINE_PRICES.map((p) => h('option', { value: p.value }, p.label)));
173
+ try {
174
+ const { automations } = await flow('/api/invoicing/automations');
175
+ select.value = automations.find((a) => a.key === 'map_lines')?.config.linePrice ?? 'work';
176
+ } catch {
177
+ select.value = 'work';
178
+ }
179
+ let saved = select.value;
180
+ select.addEventListener('change', async () => {
181
+ select.disabled = true;
182
+ if (await send('/api/invoicing/automations/map_lines', 'PUT', { config: { linePrice: select.value } })) saved = select.value;
183
+ else select.value = saved;
184
+ select.disabled = disabled;
185
+ });
186
+ return row('Line price from', h('span', { class: 'fx-set__hint' }, 'Where an invoice line takes its price.'), select);
187
+ }
188
+
189
+ function draw() {
190
+ const words = query.trim().toLowerCase();
191
+ const shown = data.items.filter((i) => !words || `${i.code} ${i.name}`.toLowerCase().includes(words));
192
+ const live = shown.filter((i) => !i.archivedAt);
193
+ const archived = shown.filter((i) => i.archivedAt);
194
+ const itemRow = (item) => {
195
+ const editButton = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Edit');
196
+ editButton.addEventListener('click', () => edit(item));
197
+ const hint = h('span', { class: 'fx-set__hint' }, `${money(item.unitAmountCents)} · ${TAX_LABELS[item.taxCode] ?? item.taxCode}`);
198
+ return row(`${item.code} · ${item.name}`, h('div', { class: 'fx-price__hint' }, hint, ...chips(item)), editButton);
199
+ };
200
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a code', value: query, 'aria-label': 'Find a code' });
201
+ search.addEventListener('input', () => { query = search.value; draw(); search.focus(); search.setSelectionRange(query.length, query.length); });
202
+ const systems = data.systems.length
203
+ ? h('div', { class: 'fx-price__systems' }, ...data.systems.map((s) => h('span', { class: 'fx-chip fx-chip--static' }, `${s.name} · ${s.count} ${s.kind === 'accounting' ? 'items' : 'products'}`)))
204
+ : h('p', { class: 'fx-dim' }, 'No integration has a price list. Flow uses this list alone.');
205
+ const sections = [
206
+ disabled ? notice('Only an owner or admin can change these.', 'info') : null,
207
+ h('section', { class: 'fx-set' }, h('div', { class: 'fx-price__head' }, h('h2', { class: 'fx-set__title' }, `Codes · ${data.items.filter((i) => !i.archivedAt).length}`), search), systems,
208
+ ...(live.length ? live.map(itemRow) : [h('p', { class: 'fx-dim' }, words ? 'No code matches.' : 'No codes yet. Add the first one.')])),
209
+ ];
210
+ for (const { system: id, records } of data.unlinked) {
211
+ if (!records.length) continue;
212
+ const system = data.systems.find((s) => s.id === id);
213
+ sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `${system.name} · not on the list · ${records.length}`),
214
+ ...records.slice(0, 200).map((r) => unlinkedRow(system, r, data.items)),
215
+ records.length > 200 ? h('p', { class: 'fx-dim' }, `${records.length - 200} more. Link these first.`) : null));
216
+ }
217
+ if (archived.length) {
218
+ sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Archived · ${archived.length}`), ...archived.map(itemRow)));
219
+ }
220
+ const settings = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
221
+ linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r,
222
+ h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
223
+ sections.push(settings);
224
+ host.replaceChildren(...sections.filter(Boolean));
225
+ }
226
+
227
+ async function load() {
228
+ try {
229
+ data = await flow('/api/invoicing/price-list');
230
+ } catch (error) {
231
+ host.replaceChildren(notice(error.message, 'danger'));
232
+ return;
233
+ }
234
+ draw();
235
+ }
236
+
237
+ addButton.addEventListener('click', () => edit(null));
238
+ checkButton.addEventListener('click', async () => {
239
+ checkButton.disabled = true;
240
+ const body = await send('/api/invoicing/price-list/check', 'POST', {}, null);
241
+ if (body) toast(body.summary.differing ? `${body.summary.differing} codes differ.` : 'Every linked price agrees.', body.summary.differing ? 'info' : 'success');
242
+ checkButton.disabled = disabled;
243
+ alerts.refresh();
244
+ await load();
245
+ });
246
+ await load();
247
+ }
@@ -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', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'flow-mark.svg']);
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']);
24
24
  const MAX_AGE = '1h';
25
25
 
26
26
  // Send one named file from dir. Passing dir as root limits the dotfile check