@arcforgelabs/flow 2026.10.30 → 2026.10.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
@@ -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', '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