@arcforgelabs/flow 2026.10.38 → 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 (45) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -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 +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +148 -0
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/ui/app.js +35 -3
  33. package/server/ui/custom-fields.js +252 -94
  34. package/server/ui/flow.css +185 -29
  35. package/server/ui/invoice-notes.js +2 -2
  36. package/server/ui/invoicing.js +92 -137
  37. package/server/ui/job-board.js +1 -1
  38. package/server/ui/job-change.js +23 -12
  39. package/server/ui/job-view.js +168 -33
  40. package/server/ui/jobs.js +316 -65
  41. package/server/ui/price-list.js +43 -86
  42. package/server/ui/price-match.js +93 -0
  43. package/server/ui/price-reconcile.js +357 -0
  44. package/server/ui/reconcile.js +1 -1
  45. package/server/ui/routes.js +1 -1
@@ -1,13 +1,13 @@
1
1
  // Settings, Price list (/price-list; server/core/price-list.js,
2
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`.
3
+ // integration connected: one table row per code, with its price and tax, and
4
+ // the line price setting below. Edit opens a dialog with the code's fields, its
5
+ // links and, when something differs, Mark resolved. While an integration with
6
+ // its own price list is on, Reconcile (price-reconcile.js) says how many
7
+ // records need a person and opens the work. Everyone can read it; an owner or
8
+ // admin changes it. The caller passes in app.js's shared pieces as `ui`.
10
9
  import { alertStrip } from '/assets/ui/alerts.js';
10
+ import { reconcileRows } from '/assets/ui/price-match.js';
11
11
 
12
12
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
13
13
  const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
@@ -25,14 +25,16 @@ const toCents = (text) => {
25
25
  export async function renderPriceList(main, ui) {
26
26
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
27
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' }));
28
+ const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
29
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');
30
+ const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
31
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);
32
+ main.append(pageHeader('Price list', { subtitle: 'The codes and prices that Flow puts on invoice lines.', actions: [reconcileButton, addButton] }), alerts.element, host);
33
33
 
34
34
  let data = null;
35
35
  let query = '';
36
+ let tab = 'active';
37
+ let opened = new URLSearchParams(location.search).get('code');
36
38
  const systemName = (id) => data.systems.find((s) => s.id === id)?.name ?? id;
37
39
 
38
40
  async function send(path, method, payload, done = 'Saved.') {
@@ -50,22 +52,6 @@ export async function renderPriceList(main, ui) {
50
52
  h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
51
53
  h('div', { class: 'fx-set__control fx-rule__controls' }, control));
52
54
 
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
55
  const findingText = (f) => {
70
56
  const name = systemName(f.system);
71
57
  if (f.kind === 'price') return `${name} price is ${money(f.other)}. Flow price is ${money(f.flow)}.`;
@@ -141,33 +127,6 @@ export async function renderPriceList(main, ui) {
141
127
  return load();
142
128
  }
143
129
 
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
130
  async function linePriceRow() {
172
131
  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
132
  try {
@@ -188,40 +147,46 @@ export async function renderPriceList(main, ui) {
188
147
 
189
148
  function draw() {
190
149
  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
- };
150
+ const live = data.items.filter((i) => !i.archivedAt);
151
+ const archived = data.items.filter((i) => i.archivedAt);
152
+ const shown = (tab === 'archived' ? archived : live).filter((i) => !words || `${i.code} ${i.name}`.toLowerCase().includes(words));
153
+ const todo = data.systems.length ? reconcileRows(data).length : 0;
154
+ reconcileButton.hidden = !data.systems.length;
155
+ reconcileButton.replaceChildren('Reconcile', todo ? h('span', { class: 'fx-tabs__count', 'data-tone': 'warn' }, String(todo)) : null);
156
+ reconcileButton.title = todo ? `${todo} records need a person` : 'Every record has a code, and every price agrees';
157
+
200
158
  const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a code', value: query, 'aria-label': 'Find a code' });
201
159
  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.');
160
+ const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
161
+ type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
162
+ }, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
163
+ const itemRow = (item) => h('tr', { onclick: () => edit(item), tabindex: '0', onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
164
+ h('td', { class: 'fx-price__code' }, item.code),
165
+ h('td', {}, item.name),
166
+ h('td', { class: 'fx-num' }, money(item.unitAmountCents)),
167
+ h('td', { class: 'fx-dim' }, TAX_LABELS[item.taxCode] ?? item.taxCode));
168
+ const table = shown.length
169
+ ? h('div', { class: 'fx-price__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-price__table' },
170
+ h('thead', {}, h('tr', {}, h('th', {}, 'Code'), h('th', {}, 'Name'), h('th', { class: 'fx-num' }, 'Price'), h('th', {}, 'Tax'))),
171
+ h('tbody', {}, shown.map(itemRow))))
172
+ : h('p', { class: 'fx-empty' }, words ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet. Add the first one.');
205
173
  const sections = [
206
174
  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.')])),
175
+ h('div', { class: 'fx-toolbar fx-price__head' }, tabs, search),
176
+ table,
209
177
  ];
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' }));
178
+ const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
221
179
  linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r,
222
180
  h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
223
181
  sections.push(settings);
224
182
  host.replaceChildren(...sections.filter(Boolean));
183
+ // Opened from Reconcile's "Edit code": that code's dialog, once.
184
+ if (opened) {
185
+ const item = data.items.find((i) => i.id === opened);
186
+ opened = null;
187
+ window.history.replaceState(null, '', '/price-list');
188
+ if (item) edit(item);
189
+ }
225
190
  }
226
191
 
227
192
  async function load() {
@@ -235,13 +200,5 @@ export async function renderPriceList(main, ui) {
235
200
  }
236
201
 
237
202
  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
203
  await load();
247
204
  }
@@ -0,0 +1,93 @@
1
+ // Likely Flow codes for a connected system's record (Price list › Reconcile).
2
+ // Pure: the page passes the record and Flow's codes. A code's score comes
3
+ // from its name (and code) against the record's name, then its price and tax.
4
+ // "Sure" means name, price and tax all agree and no other code comes close,
5
+ // so a person can link every sure one in one step.
6
+
7
+ const IGNORED = new Set(['a', 'an', 'and', 'the', 'of', 'for', 'to', 'with', 'per', 'than', 'more', 'unit', 'each']);
8
+
9
+ export function nameWords(text) {
10
+ return String(text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ')
11
+ .filter((w) => w && !IGNORED.has(w));
12
+ }
13
+
14
+ // Two words agree when they are the same, or one starts the other and the
15
+ // shorter has 4 letters or more ("frame" and "framework").
16
+ const wordsAgree = (a, b) => a === b || (Math.min(a.length, b.length) >= 4 && (a.startsWith(b) || b.startsWith(a)));
17
+
18
+ // 0 to 1: how much of both names the shared words cover (Dice).
19
+ export function nameScore(a, b) {
20
+ const left = nameWords(a);
21
+ const right = nameWords(b);
22
+ if (!left.length || !right.length) return 0;
23
+ const free = [...right];
24
+ let shared = 0;
25
+ for (const word of left) {
26
+ const at = free.findIndex((other) => wordsAgree(word, other));
27
+ if (at >= 0) { shared += 1; free.splice(at, 1); }
28
+ }
29
+ return (2 * shared) / (left.length + right.length);
30
+ }
31
+
32
+ export const SAME_NAME = 0.8;
33
+ const LIKELY_NAME = 0.5;
34
+
35
+ const fact = (left, right, same) => (left == null || right == null || left === '' || right === '' ? null : same ? 'same' : 'differs');
36
+
37
+ // The codes that may be this record, best first: { item, score, name, price,
38
+ // tax, sure }. name, price and tax are 'same', 'differs' or null (one side
39
+ // has no value). `limit` keeps the first few.
40
+ export function rankCodes(record, items, { limit = 3 } = {}) {
41
+ const ranked = [];
42
+ for (const item of items) {
43
+ if (item.archivedAt) continue;
44
+ const sameCode = Boolean(record.code) && String(record.code).trim().toLowerCase() === String(item.code).trim().toLowerCase();
45
+ const score = sameCode ? 1 : Math.max(nameScore(record.name, item.name), nameScore(record.name ?? record.code, item.code) * 0.9);
46
+ const price = fact(record.unitAmountCents, item.unitAmountCents, record.unitAmountCents === item.unitAmountCents);
47
+ const tax = fact(record.taxCode, item.taxCode, record.taxCode === item.taxCode);
48
+ if (score < LIKELY_NAME && !(price === 'same' && score >= 0.3)) continue;
49
+ ranked.push({ item, score, name: score >= SAME_NAME ? 'same' : 'differs', price, tax, rank: score * 2 + (price === 'same' ? 0.6 : 0) + (tax === 'same' ? 0.2 : 0) });
50
+ }
51
+ ranked.sort((a, b) => b.rank - a.rank || a.item.code.localeCompare(b.item.code));
52
+ const [best, next] = ranked;
53
+ if (best) best.sure = best.name === 'same' && best.price === 'same' && best.tax !== 'differs' && !(next && next.rank >= best.rank - 0.3);
54
+ return ranked.slice(0, limit).map(({ rank: _rank, ...rest }) => ({ sure: false, ...rest }));
55
+ }
56
+
57
+ // The search under "Other code": codes whose code or name holds every word.
58
+ export function findCodes(query, items, { limit = 8 } = {}) {
59
+ const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
60
+ if (!words.length) return [];
61
+ return items.filter((i) => !i.archivedAt && words.every((w) => `${i.code} ${i.name}`.toLowerCase().includes(w))).slice(0, limit);
62
+ }
63
+
64
+ // What a person still has to do on the price list, from GET /price-list:
65
+ // each system record that links to no code ('link'), then each code that
66
+ // differs from a linked record ('differs'). Records come sure first, then
67
+ // likely, then with no likely code. Every row has a stable key.
68
+ export function reconcileRows(data) {
69
+ const systems = new Map(data.systems.map((s) => [s.id, s]));
70
+ const rows = [];
71
+ for (const { system, records } of data.unlinked) {
72
+ const owner = systems.get(system);
73
+ if (!owner) continue;
74
+ for (const record of records) {
75
+ const candidates = rankCodes(record, data.items);
76
+ rows.push({ key: `link:${system}:${record.id}`, kind: 'link', system: owner, record, candidates, sure: Boolean(candidates[0]?.sure) });
77
+ }
78
+ }
79
+ // Sure ones first, then likely ones, then records no code fits.
80
+ const order = (row) => (row.sure ? 0 : row.candidates[0] ? 1 : 2);
81
+ rows.sort((a, b) => order(a) - order(b));
82
+ for (const item of data.items) {
83
+ if (item.archivedAt || !item.findings?.length) continue;
84
+ for (const system of new Set(item.findings.map((f) => f.system))) {
85
+ const owner = systems.get(system);
86
+ if (!owner) continue;
87
+ const findings = item.findings.filter((f) => f.system === system);
88
+ const link = item.links.find((l) => l.system === system && findings.some((f) => f.externalId === String(l.externalId))) ?? null;
89
+ rows.push({ key: `differs:${system}:${item.id}`, kind: 'differs', system: owner, item, findings, link, record: link?.record ?? null, sure: false });
90
+ }
91
+ }
92
+ return rows;
93
+ }
@@ -0,0 +1,357 @@
1
+ // Price list › Reconcile (/price-list/reconcile). Only while an integration
2
+ // with its own price list is on (Xero items, Seazona products). It lays the
3
+ // connected systems' records against Flow's own codes, in the two views of
4
+ // Xero › Import › Review:
5
+ // card one record beside its likely Flow code, one fact per row, ✓ or ≠
6
+ // between them; the invoice pager steps through them. Link, Other
7
+ // match, Find a code, Add as new code, Skip.
8
+ // list every record and code to check, ticked rows acted on together from
9
+ // the bulk bar.
10
+ // A record with no link offers likely codes (price-match.js). A code that
11
+ // differs from its linked record is an alert only: Mark resolved, or fix it
12
+ // at its source. It reads Flow's copies only: no call to Xero or Seazona.
13
+ // The price check itself runs on each pipeline pass, so there is no button.
14
+ import { bulkBar } from '/assets/ui/bulk-bar.js';
15
+ import { lucide } from '/assets/ui/custom-fields.js';
16
+ import { findCodes, nameScore, reconcileRows, SAME_NAME } from '/assets/ui/price-match.js';
17
+
18
+ const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
19
+ const money = (cents) => (cents === null || cents === undefined ? '—' : `$${(cents / 100).toFixed(2)}`);
20
+ const taxWords = (code) => (code ? TAX_LABELS[code] ?? code : '—');
21
+ const LOGOS = { xero: '/assets/ui/logos/xero.svg', seazona: '/assets/ui/logos/seazona.svg' };
22
+
23
+ export async function renderPriceReconcile(main, ui) {
24
+ const { h, flow, toast } = ui;
25
+ const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
26
+ const icon = (name, label) => lucide(h, name, label);
27
+ let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
28
+ const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
29
+ const subtitle = h('span', {});
30
+ const toggle = (name, label, glyph) => h('button', {
31
+ class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
32
+ onclick: () => setView(name),
33
+ }, icon(glyph));
34
+ const bulk = bulkBar(ui, { label: 'Ticked records' });
35
+ main.append(
36
+ h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
37
+ h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
38
+ ui.pageHeader('Reconcile prices', {
39
+ subtitle,
40
+ actions: [h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks'))],
41
+ }),
42
+ host, bulk.element);
43
+
44
+ let data = null;
45
+ let rows = [];
46
+ let at = 0;
47
+ let pick = 0;
48
+ let chosen = null;
49
+
50
+ function setView(name) {
51
+ view = name;
52
+ window.history.replaceState(null, '', view === 'list' ? '/price-list/reconcile?view=list' : '/price-list/reconcile');
53
+ for (const b of main.querySelectorAll('.fx-xi__view')) b.setAttribute('aria-selected', String(b.dataset.view === view));
54
+ draw();
55
+ }
56
+
57
+ async function load() {
58
+ try {
59
+ data = await flow('/api/invoicing/price-list');
60
+ } catch (error) {
61
+ host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
62
+ return;
63
+ }
64
+ rows = reconcileRows(data);
65
+ at = Math.min(at, Math.max(0, rows.length - 1));
66
+ draw();
67
+ }
68
+
69
+ async function call(path, method, payload) {
70
+ try {
71
+ return await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
72
+ } catch (error) {
73
+ toast(error.message, 'danger');
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Links many records at once: one call, one remap. An accounting system
79
+ // takes one record for each code, so a second record for the same code
80
+ // waits for a person.
81
+ async function linkMany(pairs) {
82
+ const seen = new Set();
83
+ const links = [];
84
+ for (const { row, item } of pairs) {
85
+ const key = `${row.system.id}:${item.id}`;
86
+ if (row.system.kind === 'accounting' && seen.has(key)) continue;
87
+ seen.add(key);
88
+ links.push({ priceItemId: item.id, system: row.system.id, externalId: String(row.record.id) });
89
+ }
90
+ if (!links.length) return 0;
91
+ const body = await call('/api/invoicing/price-links', 'POST', { links });
92
+ return body?.linked ?? 0;
93
+ }
94
+
95
+ const newCodeOf = (record) => ({
96
+ code: String(record.code || record.id).slice(0, 30), name: String(record.name || record.code || record.id).slice(0, 200),
97
+ unitAmountCents: record.unitAmountCents ?? 0, taxCode: record.taxCode ?? 'GST',
98
+ });
99
+
100
+ async function addAsNew(row) {
101
+ const draft = newCodeOf(row.record);
102
+ const input = (name, value, label, extra = {}) => h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, label),
103
+ h('input', { class: 'fx-input', name, value, required: true, ...extra }));
104
+ const tax = h('select', { class: 'fx-select', name: 'taxCode' }, Object.entries(TAX_LABELS).map(([value, label]) => h('option', { value, selected: value === draft.taxCode }, label)));
105
+ const form = await ui.dialog({
106
+ title: 'Add as new code',
107
+ body: h('div', { class: 'fx-fields' },
108
+ input('code', draft.code, 'Code', { maxlength: '30' }), input('name', draft.name, 'Name', { maxlength: '200' }),
109
+ input('price', (draft.unitAmountCents / 100).toFixed(2), 'Price, before tax', { inputmode: 'decimal' }),
110
+ h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Tax'), tax)),
111
+ confirm: 'Add and link',
112
+ });
113
+ if (!form) return;
114
+ const cents = Math.round(Number(String(form.get('price')).replace(/[$,\s]/g, '')) * 100);
115
+ if (!Number.isInteger(cents) || cents < 0) { toast('Write the price as a number, for example 55.00.', 'danger'); return; }
116
+ const made = await call('/api/invoicing/price-items', 'POST', { code: form.get('code'), name: form.get('name'), unitAmountCents: cents, taxCode: form.get('taxCode') });
117
+ if (!made) return;
118
+ if (await linkMany([{ row, item: made.item }])) toast(`Added ${made.item.code} and linked.`, 'success');
119
+ await load();
120
+ }
121
+
122
+ async function resolve(row) {
123
+ const body = await call(`/api/invoicing/price-items/${row.item.id}/resolve`, 'POST', {});
124
+ if (body) toast(body.state === 'agree' ? 'Prices agree now.' : 'Marked resolved.', 'success');
125
+ return body;
126
+ }
127
+
128
+ function draw() {
129
+ if (!data) return;
130
+ bulk.draw({ count: 0 });
131
+ if (!data.systems.length) {
132
+ subtitle.replaceChildren('');
133
+ host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
134
+ h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
135
+ return;
136
+ }
137
+ if (!rows.length) {
138
+ subtitle.replaceChildren('');
139
+ host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
140
+ h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
141
+ return;
142
+ }
143
+ if (view === 'list') drawList();
144
+ else drawCard();
145
+ }
146
+
147
+ const systemMark = (system) => (LOGOS[system.id] ? h('img', { class: 'fx-xi__mark', src: LOGOS[system.id], alt: system.name }) : h('span', { class: 'fx-strong' }, system.name));
148
+ const flowMark = () => h('img', { class: 'fx-xi__mark', src: '/assets/ui/flow-mark.svg', alt: 'Flow' });
149
+ const mark = (state) => (state === 'same' ? h('span', { class: 'fx-xi__agree', 'data-state': 'same' }, icon('equal', 'Same'))
150
+ : state === 'differs' ? h('span', { class: 'fx-xi__agree', 'data-state': 'differs' }, icon('equal-not', 'Different')) : h('span', {}));
151
+ const agree = (left, right) => (left == null || right == null || left === '' ? null : left === right ? 'same' : 'differs');
152
+
153
+ // The bulk shortcut above the card: every sure one at once, or the list.
154
+ function bulkStrip() {
155
+ const sure = rows.filter((r) => r.sure);
156
+ return h('div', { class: 'fx-xi__bulk' },
157
+ icon('list-checks'),
158
+ h('span', { class: 'fx-xi__bulk-text' }, `${rows.length} to check`),
159
+ canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: async (event) => {
160
+ event.currentTarget.disabled = true;
161
+ const linked = await linkMany(sure.map((row) => ({ row, item: row.candidates[0].item })));
162
+ if (linked) toast(`${linked} linked.`, 'success');
163
+ await load();
164
+ } }, icon('check-check'), `Link ${sure.length} sure`) : null,
165
+ h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => setView('list') }, icon('list-checks'), 'Tick many'));
166
+ }
167
+
168
+ // The invoice pager: Back to the price list, "n of N", previous and next.
169
+ function pager() {
170
+ const step = (to, label, path) => {
171
+ const button = h('button', {
172
+ class: 'fx-pager__step', type: 'button', disabled: to < 0 || to >= rows.length, 'aria-label': label, title: label,
173
+ onclick: () => { at = to; pick = 0; chosen = null; drawCard(); },
174
+ });
175
+ 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>`;
176
+ return button;
177
+ };
178
+ return h('nav', { class: 'fx-pager', 'aria-label': 'Records to check' },
179
+ h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/price-list') }, '← Back'),
180
+ h('span', { class: 'fx-pager__count' }, `${at + 1} of ${rows.length}`),
181
+ h('span', { class: 'fx-pager__steps' }, step(at - 1, 'Previous', 'M14.5 6l-6 6 6 6'), step(at + 1, 'Next', 'M9.5 6l6 6-6 6')));
182
+ }
183
+
184
+ // One record (or one differing code) at a time.
185
+ function drawCard() {
186
+ const row = rows[at];
187
+ subtitle.replaceChildren(`${rows.length} to check`);
188
+ const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
189
+ const record = row.record;
190
+ const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
191
+ const facts = [
192
+ ['hash', 'Code', h('span', { class: 'fx-num' }, record?.code ?? record?.id ?? '—'), h('span', { class: 'fx-num' }, item?.code ?? '—'), null],
193
+ ['tag', 'Name', record?.name ?? '—', item?.name ?? '—', item && record ? (row.kind === 'link' && !chosen ? row.candidates[pick]?.name : null) ?? (record.name && item.name ? (nameScore(record.name, item.name) >= SAME_NAME ? 'same' : 'differs') : null) : null],
194
+ ['dollar-sign', 'Price', h('span', { class: 'fx-num' }, money(record?.unitAmountCents)), h('span', { class: 'fx-num' }, money(item?.unitAmountCents)), item ? agree(record?.unitAmountCents, item.unitAmountCents) : null],
195
+ ['percent', 'Tax', taxWords(record?.taxCode), taxWords(item?.taxCode), item ? agree(record?.taxCode, item.taxCode) : null],
196
+ ['circle-dot', 'Status',
197
+ row.kind === 'link' ? h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'No code') : record ? h('span', { class: 'fx-status', 'data-tone': 'success' }, 'Linked') : h('span', { class: 'fx-status', 'data-tone': 'danger' }, `Not in ${row.system.name}`),
198
+ row.kind === 'link' ? (item ? h('span', { class: 'fx-status', 'data-tone': row.candidates[pick]?.sure && !chosen ? 'success' : 'neutral' }, chosen ? 'Your pick' : row.candidates[pick]?.sure ? 'Sure match' : 'Likely') : h('span', { class: 'fx-dim' }, 'No likely code'))
199
+ : h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Differs'), null],
200
+ ];
201
+ const table = h('table', { class: 'fx-xi-cmp' },
202
+ h('thead', {}, h('tr', {}, h('th', {}), h('th', {}, systemMark(row.system)), h('th', {}), h('th', {}, flowMark()))),
203
+ h('tbody', {}, facts.map(([glyph, label, left, right, state]) => h('tr', {},
204
+ h('th', { scope: 'row' }, icon(glyph, label)),
205
+ h('td', {}, left), h('td', { class: 'fx-xi-cmp__mid' }, mark(state)), h('td', {}, right)))));
206
+
207
+ const acts = [];
208
+ const busy = (button, run) => async () => {
209
+ button.disabled = true;
210
+ try { await run(); } finally { button.disabled = false; }
211
+ };
212
+ if (row.kind === 'link') {
213
+ const count = row.candidates.length;
214
+ if (count > 1 && !chosen) {
215
+ acts.push(h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', title: 'Other match', 'aria-label': 'Other match', onclick: () => { pick = (pick + 1) % count; drawCard(); } },
216
+ icon('arrow-left-right'), `${pick + 1}/${count}`));
217
+ }
218
+ acts.push(h('button', { class: 'fx-btn', type: 'button', onclick: next }, icon('skip-forward'), 'Skip'));
219
+ if (canAct) {
220
+ const add = h('button', { class: 'fx-btn', type: 'button' }, icon('plus'), 'Add as new code');
221
+ add.onclick = busy(add, () => addAsNew(row));
222
+ acts.push(add);
223
+ if (item) {
224
+ const link = h('button', { class: 'fx-btn fx-btn--primary', type: 'button' }, icon('link-2'), 'Link');
225
+ link.onclick = busy(link, async () => {
226
+ if (await linkMany([{ row, item }])) toast(`Linked to ${item.code}.`, 'success');
227
+ pick = 0;
228
+ chosen = null;
229
+ await load();
230
+ });
231
+ acts.push(link);
232
+ }
233
+ }
234
+ } else {
235
+ acts.push(h('button', { class: 'fx-btn', type: 'button', onclick: next }, icon('skip-forward'), 'Skip'));
236
+ acts.push(h('button', { class: 'fx-btn', type: 'button', onclick: () => ui.go(`/price-list?code=${row.item.id}`) }, icon('pencil'), 'Edit code'));
237
+ if (canAct && row.link && !row.record) {
238
+ const unlink = h('button', { class: 'fx-btn', type: 'button' }, icon('unlink'), 'Remove link');
239
+ unlink.onclick = busy(unlink, async () => {
240
+ if (await call(`/api/invoicing/price-links/${row.link.id}`, 'DELETE')) toast('Link removed.', 'success');
241
+ await load();
242
+ });
243
+ acts.push(unlink);
244
+ }
245
+ if (canAct) {
246
+ const done = h('button', { class: 'fx-btn fx-btn--primary', type: 'button' }, icon('check'), 'Mark resolved');
247
+ done.onclick = busy(done, async () => { await resolve(row); await load(); });
248
+ acts.push(done);
249
+ }
250
+ }
251
+
252
+ // Find a code: any Flow code by code or name, when no likely one fits.
253
+ const finder = row.kind === 'link' ? (() => {
254
+ const results = h('div', { class: 'fx-prc__found', role: 'listbox', 'aria-label': 'Codes found' });
255
+ const input = h('input', { class: 'fx-input fx-xi__search', type: 'search', autocomplete: 'off', placeholder: 'Find another code: code or name', 'aria-label': 'Find another code' });
256
+ input.addEventListener('input', () => {
257
+ results.replaceChildren(...findCodes(input.value, data.items).map((code) => h('button', {
258
+ class: 'fx-pop__item', type: 'button', role: 'option',
259
+ onclick: () => { chosen = code; drawCard(); },
260
+ }, h('span', { class: 'fx-num' }, code.code), h('span', {}, code.name), h('span', { class: 'fx-dim fx-num' }, money(code.unitAmountCents)))));
261
+ });
262
+ return h('div', { class: 'fx-prc__finder' }, h('div', { class: 'fx-xi__bulk' }, icon('search'), input), results);
263
+ })() : null;
264
+
265
+ host.replaceChildren(...[pager(), bulkStrip(), table, finder, h('div', { class: 'fx-xi__acts' }, acts)].filter(Boolean));
266
+ }
267
+
268
+ // Every record and code to check; ticked rows go from the bulk bar.
269
+ function drawList() {
270
+ subtitle.replaceChildren(`${rows.length} to check`);
271
+ const ticked = new Set();
272
+ const boxes = new Map();
273
+ const picked = () => rows.filter((r) => ticked.has(r.key));
274
+ const clear = () => { ticked.clear(); for (const b of boxes.values()) b.checked = false; all.checked = false; drawBar(); };
275
+ const drawBar = () => {
276
+ const chosenRows = picked();
277
+ const linkable = chosenRows.filter((r) => r.kind === 'link' && r.candidates[0]);
278
+ const unlinked = chosenRows.filter((r) => r.kind === 'link');
279
+ const differing = chosenRows.filter((r) => r.kind === 'differs');
280
+ bulk.draw({
281
+ count: canAct ? chosenRows.length : 0,
282
+ onClear: clear,
283
+ groups: [[
284
+ { icon: 'link-2', label: 'Link', title: 'Link each to its likely code', primary: true, run: async () => {
285
+ if (!linkable.length) { toast('No ticked record has a likely code. Use Add as new codes, or one at a time.', 'warn'); return; }
286
+ const linked = await linkMany(linkable.map((row) => ({ row, item: row.candidates[0].item })));
287
+ if (linked) toast(linked === unlinked.length ? `${linked} linked.` : `${linked} linked. The rest stay here.`, 'success');
288
+ await load();
289
+ } },
290
+ { icon: 'plus', label: 'Add as new codes', title: 'Add each ticked record as a new code, then link it', run: async () => {
291
+ if (!unlinked.length) { toast('Tick records with no code first.', 'warn'); return; }
292
+ const pairs = [];
293
+ for (const row of unlinked) {
294
+ const made = await call('/api/invoicing/price-items', 'POST', newCodeOf(row.record));
295
+ if (made) pairs.push({ row, item: made.item });
296
+ }
297
+ const linked = await linkMany(pairs);
298
+ toast(`${linked} added and linked.`, linked === unlinked.length ? 'success' : 'warn');
299
+ await load();
300
+ } },
301
+ { icon: 'check', label: 'Mark resolved', title: 'Accept each ticked difference', run: async () => {
302
+ if (!differing.length) { toast('Tick codes that differ first.', 'warn'); return; }
303
+ for (const id of new Set(differing.map((r) => r.item.id))) await call(`/api/invoicing/price-items/${id}/resolve`, 'POST', {});
304
+ toast('Marked resolved.', 'success');
305
+ await load();
306
+ } },
307
+ ]],
308
+ });
309
+ };
310
+ const all = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick all', onchange: () => {
311
+ for (const r of rows) { if (all.checked) ticked.add(r.key); else ticked.delete(r.key); }
312
+ for (const b of boxes.values()) b.checked = all.checked;
313
+ drawBar();
314
+ } });
315
+ const sure = rows.filter((r) => r.sure);
316
+ const state = (row) => {
317
+ if (row.kind === 'differs') return [icon('equal-not', 'Differs'), 'differs'];
318
+ if (row.sure) return [icon('check-check', 'Sure match'), 'sure'];
319
+ if (row.candidates[0]) return [icon('link-2', 'Likely code'), 'likely'];
320
+ return [icon('circle-dashed', 'No likely code'), 'none'];
321
+ };
322
+ host.replaceChildren(
323
+ h('div', { class: 'fx-xi__bulk' }, icon('list-checks'), h('span', { class: 'fx-xi__bulk-text' }, 'Tick rows, then use the bar below.'),
324
+ canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => {
325
+ for (const r of sure) { ticked.add(r.key); boxes.get(r.key).checked = true; }
326
+ drawBar();
327
+ } }, icon('check-check'), `Tick ${sure.length} sure`) : null),
328
+ h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list fx-prc-list' },
329
+ h('thead', {}, h('tr', {},
330
+ canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
331
+ h('th', { colspan: '4' }, 'Record'), h('th', { class: 'fx-xi-list__mid' }), h('th', { colspan: '3' }, h('span', { class: 'fx-prc-list__flow' }, flowMark(), 'Flow code')))),
332
+ h('tbody', {}, rows.map((row, index) => {
333
+ const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${row.record?.name ?? row.item?.code}`, onclick: (event) => event.stopPropagation(), onchange: () => {
334
+ if (box.checked) ticked.add(row.key); else ticked.delete(row.key);
335
+ drawBar();
336
+ } });
337
+ boxes.set(row.key, box);
338
+ const item = row.kind === 'differs' ? row.item : row.candidates[0]?.item ?? null;
339
+ const [glyph, tone] = state(row);
340
+ const differs = (kind) => row.kind === 'differs' && row.findings.some((f) => f.kind === kind);
341
+ return h('tr', { 'data-state': tone, onclick: () => { at = index; pick = 0; chosen = null; setView('card'); } },
342
+ canAct ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box) : null,
343
+ h('td', { class: 'fx-prc-list__sys' }, systemMark(row.system)),
344
+ h('td', { class: 'fx-prc-list__name' }, row.record?.name ?? h('span', { class: 'fx-dim' }, row.kind === 'differs' ? `Not in ${row.system.name}` : '—'),
345
+ row.record?.code ? h('span', { class: 'fx-dim fx-num' }, ` · ${row.record.code}`) : null),
346
+ h('td', { class: `fx-num${differs('price') ? ' fx-prc-list__bad' : ''}` }, money(row.record?.unitAmountCents)),
347
+ h('td', { class: differs('tax') ? 'fx-prc-list__bad' : 'fx-dim' }, taxWords(row.record?.taxCode)),
348
+ h('td', { class: 'fx-xi-list__mid' }, glyph),
349
+ h('td', { class: 'fx-num' }, item?.code ?? h('span', { class: 'fx-dim' }, 'No likely code')),
350
+ h('td', { class: 'fx-prc-list__name' }, item?.name ?? ''),
351
+ h('td', { class: 'fx-num fx-strong' }, item ? money(item.unitAmountCents) : ''));
352
+ })))));
353
+ drawBar();
354
+ }
355
+
356
+ await load();
357
+ }