@arcforgelabs/flow 2026.11.9 → 2026.11.10

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 (32) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +18 -2
  4. package/server/core/contacts.js +79 -5
  5. package/server/core/invoice-rules.js +14 -1
  6. package/server/core/invoices.js +2 -0
  7. package/server/core/invoicing-rules.js +55 -4
  8. package/server/core/ledger.js +25 -2
  9. package/server/core/merge-contacts.js +182 -0
  10. package/server/core/pipeline.js +77 -13
  11. package/server/db/migrations/0030_contact_hidden.sql +1 -0
  12. package/server/db/migrations/0031_bill_to.sql +10 -0
  13. package/server/db/migrations/0032_contact_merge.sql +12 -0
  14. package/server/db/migrations/meta/0030_snapshot.json +5076 -0
  15. package/server/db/migrations/meta/0031_snapshot.json +5123 -0
  16. package/server/db/migrations/meta/0032_snapshot.json +5167 -0
  17. package/server/db/migrations/meta/_journal.json +21 -0
  18. package/server/db/schema.js +28 -0
  19. package/server/integrations/xero/create-contact.js +15 -1
  20. package/server/integrations/xero/delete-invoice.js +1 -1
  21. package/server/integrations/xero/drafts.js +20 -5
  22. package/server/integrations/xero/ledger.js +7 -4
  23. package/server/integrations/xero/links.js +85 -18
  24. package/server/integrations/xero/lookup.js +7 -4
  25. package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
  26. package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
  27. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  28. package/server/integrations/xero/mirror.js +63 -8
  29. package/server/integrations/xero/reconcile.js +1 -1
  30. package/server/integrations/xero/routes.js +22 -2
  31. package/server/ui/app.js +31 -2
  32. package/server/ui/contacts.js +150 -15
@@ -105,7 +105,7 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
105
105
  ) p on true
106
106
  left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
107
107
  -- A Flow invoice the Import page linked to a Xero invoice (links.js).
108
- left join xero_invoice_links l on l.organisation_id = i.organisation_id and l.invoice_id = i.id
108
+ left join xero_invoice_links l on l.organisation_id = i.organisation_id and l.invoice_id = i.id and l.released_at is null
109
109
  left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = coalesce(p.xero_invoice_id, l.xero_invoice_id)
110
110
  where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
111
111
  const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
@@ -8,9 +8,9 @@ import { createCustomer, linkCustomer, matchCustomer } from './create-contact.js
8
8
  import { createXeroClient } from './client.js';
9
9
  import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
10
10
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
11
- import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings, useXeroInvoice } from './links.js';
11
+ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, releaseXeroLink, saveImportSettings, useXeroInvoice } from './links.js';
12
12
  import { queueHistoryImport, resetImport } from './import.js';
13
- import { MIRROR_RESOURCES, mirrorStatus, mirrorXero, readInvoiceFromXero } from './mirror.js';
13
+ import { MIRROR_RESOURCES, mirrorStatus, mirrorXero, readInvoiceFromXero, rereadCopy } from './mirror.js';
14
14
  import { accountingStatus, checkSentInvoices } from './reconcile.js';
15
15
  import { CUSTOM_CONNECTION, decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
16
16
  import { authorizeUrl, clientCredentials, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
@@ -415,6 +415,18 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
415
415
  res.json({ ok: true, results, ...(await mirrorStatus(db, organisationId)) });
416
416
  }));
417
417
 
418
+ // An owner asks for the whole copy to be read again; the next sync reads it.
419
+ router.post('/mirror/reread', ...owner, handle(log, async (req, res) => {
420
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
421
+ const resources = req.body?.resources ?? MIRROR_RESOURCES;
422
+ if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
423
+ throw new FlowError('invalid', `resources must list some of: ${MIRROR_RESOURCES.join(', ')}`);
424
+ }
425
+ const status = await rereadCopy(db, organisationId, { resources: [...new Set(resources)], actor: `person:${req.person.id}` });
426
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} asked for the copy to be read again: ${resources.join(', ')}`);
427
+ res.json({ ok: true, ...status });
428
+ }));
429
+
418
430
  router.get('/mirror', ...signedIn, handle(log, async (req, res) => {
419
431
  const organisationId = await requireOrganisation(db, req.query.organisationId);
420
432
  res.json({ ok: true, ...(await mirrorStatus(db, organisationId)) });
@@ -463,6 +475,14 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
463
475
  res.json({ ok: true, source: 'xero', ...found });
464
476
  }));
465
477
 
478
+ // A wrong pair: the Xero invoice's Flow invoice is void and the Xero invoice
479
+ // is another job's bill. Frees the Xero invoice and the jobs that point at it.
480
+ router.post('/import/:xeroInvoiceId/release', ...manager, handle(log, async (req, res) => {
481
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
482
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason : null;
483
+ res.json({ ok: true, ...(await releaseXeroLink(db, organisationId, xeroId(req.params.xeroInvoiceId), { actor: `person:${req.person.id}`, reason })) });
484
+ }));
485
+
466
486
  router.post('/import/:xeroInvoiceId/make', ...manager, handle(log, async (req, res) => {
467
487
  const organisationId = await requireOrganisation(db, req.body?.organisationId);
468
488
  const jobId = req.body?.jobId == null ? null : requireInvoiceIds([req.body.jobId], 'jobId')[0];
package/server/ui/app.js CHANGED
@@ -1140,6 +1140,18 @@ function seazonaHistorySection() {
1140
1140
  return section;
1141
1141
  }
1142
1142
 
1143
+ // How much of the last 12 months of the copy has a Reference: a low share
1144
+ // is a copy that missed it (docs/XERO.md, "Copy version").
1145
+ function copyFill(mirror) {
1146
+ // With free text off the copy keeps no Reference: that is the setting.
1147
+ if (mirror.cursors?.find((c) => c.resource === 'invoices')?.keptReference === false) return '';
1148
+ const months = mirror.completeness ?? [];
1149
+ const invoices = months.reduce((sum, m) => sum + m.invoices, 0);
1150
+ if (!invoices) return '';
1151
+ const withReference = months.reduce((sum, m) => sum + m.withReference, 0);
1152
+ return ` Reference on ${withReference} of ${invoices} in the last 12 months.`;
1153
+ }
1154
+
1143
1155
  // Once Xero is on: each organisation's connection, the history copy, and
1144
1156
  // Connect, which goes to Xero and comes back.
1145
1157
  function xeroConnection(organisations, canChange) {
@@ -1153,6 +1165,7 @@ function xeroConnection(organisations, canChange) {
1153
1165
  let control = null;
1154
1166
  let stopped = false;
1155
1167
  let refused = false;
1168
+ let mirror = null;
1156
1169
  try {
1157
1170
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
1158
1171
  usage.replaceChildren(xeroUsage(status) ?? '');
@@ -1168,8 +1181,8 @@ function xeroConnection(organisations, canChange) {
1168
1181
  stopped = status.calls.all;
1169
1182
  refused = !status.calls.all;
1170
1183
  } else if (status.connected) {
1171
- const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
1172
- hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
1184
+ mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
1185
+ hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.${copyFill(mirror)}` : ''}`;
1173
1186
  } else {
1174
1187
  hint = 'Not connected.';
1175
1188
  }
@@ -1185,6 +1198,22 @@ function xeroConnection(organisations, canChange) {
1185
1198
  toast(error.message, 'danger');
1186
1199
  }
1187
1200
  } }, status.connected ? 'Connect again' : 'Connect to Xero');
1201
+ // The owner can ask for the whole copy to be read again (a copy
1202
+ // that missed a field). The next sync reads it; no call now.
1203
+ if (mirror && me?.person.role === 'owner') {
1204
+ const reread = h('button', { class: 'fx-btn fx-btn--sm', type: 'button', title: 'Read every Xero invoice again on the next sync', onclick: async () => {
1205
+ // One press is enough: each press starts the read again.
1206
+ reread.disabled = true;
1207
+ try {
1208
+ await flow('/api/xero/mirror/reread', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
1209
+ toast('Flow reads the copy again on the next sync.');
1210
+ } catch (error) {
1211
+ reread.disabled = false;
1212
+ toast(error.message, 'danger');
1213
+ }
1214
+ } }, 'Read again');
1215
+ control = h('div', { class: 'fx-row' }, reread, control);
1216
+ }
1188
1217
  }
1189
1218
  } catch (error) {
1190
1219
  hint = error.message;
@@ -23,7 +23,9 @@ async function renderContactList(main, ui) {
23
23
  const search = input(h, { type: 'search', 'aria-label': 'Search', placeholder: 'Search', class: 'fx-input fx-contacts__search' });
24
24
  const kind = h('select', { class: 'fx-select', 'aria-label': 'Kind' },
25
25
  h('option', { value: 'all', selected: true }, 'All'),
26
- ...KINDS.map(([value, label]) => h('option', { value }, `${label}s`)));
26
+ ...KINDS.map(([value, label]) => h('option', { value }, `${label}s`)),
27
+ // Contacts that are not clients (docs/CONTACTS.md, "Hidden contacts").
28
+ h('option', { value: 'hidden' }, 'Hidden'));
27
29
  const page = h('div', { class: 'fx-contacts' });
28
30
  const add = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', hidden: !canEdit, onclick: () => createContact() }, 'New');
29
31
  page.append(
@@ -36,7 +38,7 @@ async function renderContactList(main, ui) {
36
38
  const draw = () => {
37
39
  const needle = search.value.trim().toLowerCase();
38
40
  const shown = rows.filter((contact) => {
39
- if (kind.value !== 'all' && contact.kind !== kind.value) return false;
41
+ if (!['all', 'hidden'].includes(kind.value) && contact.kind !== kind.value) return false;
40
42
  if (!needle) return true;
41
43
  return [contact.name, contact.email, contact.accountNumber].some((value) => String(value ?? '').toLowerCase().includes(needle));
42
44
  });
@@ -47,7 +49,9 @@ async function renderContactList(main, ui) {
47
49
  h('td', {}, h('a', { class: 'fx-person', href: `/contacts/${contact.id}`, onclick: go },
48
50
  h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(contact.name)),
49
51
  h('span', { class: 'fx-person__text' },
50
- h('span', { class: 'fx-person__name' }, contact.name, contact.active ? null : h('span', { class: 'fx-tag' }, 'Inactive')),
52
+ h('span', { class: 'fx-person__name' }, contact.name,
53
+ contact.active ? null : h('span', { class: 'fx-tag' }, 'Inactive'),
54
+ contact.hidden ? h('span', { class: 'fx-tag' }, 'Hidden') : null),
51
55
  contact.email ? h('span', { class: 'fx-person__email' }, contact.email) : null))),
52
56
  h('td', {}, KINDS.find(([value]) => value === contact.kind)?.[1] ?? contact.kind),
53
57
  h('td', {}, contact.accountNumber ?? '')))))
@@ -58,8 +62,23 @@ async function renderContactList(main, ui) {
58
62
  );
59
63
  };
60
64
 
65
+ // The hidden contacts are their own list: the server leaves them out of
66
+ // every other one.
67
+ let listed = null;
68
+ async function load() {
69
+ const hidden = kind.value === 'hidden' ? 'true' : 'false';
70
+ if (hidden === listed) return draw();
71
+ try {
72
+ ({ contacts: rows } = await flow(`/api/invoicing/contacts?kind=all&active=all&hidden=${hidden}&limit=500`));
73
+ listed = hidden;
74
+ draw();
75
+ } catch (error) {
76
+ host.replaceChildren(notice(error.message, 'danger'));
77
+ }
78
+ }
79
+
61
80
  search.addEventListener('input', draw);
62
- kind.addEventListener('change', draw);
81
+ kind.addEventListener('change', load);
63
82
 
64
83
  async function createContact() {
65
84
  const name = input(h, { name: 'name', required: true, 'aria-label': 'Name' });
@@ -90,17 +109,12 @@ async function renderContactList(main, ui) {
90
109
  }
91
110
  }
92
111
 
93
- try {
94
- ({ contacts: rows } = await flow('/api/invoicing/contacts?kind=all&active=all&limit=200'));
95
- draw();
96
- } catch (error) {
97
- host.replaceChildren(notice(error.message, 'danger'));
98
- }
112
+ await load();
99
113
  }
100
114
 
101
115
  async function renderContact(main, ui, id) {
102
- const { h, flow, toast, notice, pageHeader, crumbs, me } = ui;
103
- const canEdit = ['owner', 'admin'].includes(me?.person.role);
116
+ const { h, flow, toast, notice, pageHeader, crumbs, me, go } = ui;
117
+ let canEdit = ['owner', 'admin'].includes(me?.person.role);
104
118
  const page = h('div', { class: 'fx-contacts' }, h('div', { class: 'fx-skeleton' }));
105
119
  main.append(page);
106
120
  let contact;
@@ -110,6 +124,8 @@ async function renderContact(main, ui, id) {
110
124
  page.replaceChildren(notice(error.message, 'danger'));
111
125
  return;
112
126
  }
127
+ // A merged contact is read only, and points at the one it moved into.
128
+ if (contact.mergedIntoId) canEdit = false;
113
129
 
114
130
  const values = {
115
131
  name: input(h, { value: contact.name ?? '' }),
@@ -176,10 +192,67 @@ async function renderContact(main, ui, id) {
176
192
  },
177
193
  }, 'Save');
178
194
 
195
+ // Merge (docs/CONTACTS.md, "Merge two contacts"): another contact moves
196
+ // into this one, which keeps its name as an alias.
197
+ const merge = h('button', {
198
+ class: 'fx-btn', type: 'button', hidden: !canEdit, title: 'Merge another contact into this one',
199
+ onclick: async () => {
200
+ const picked = await ui.pickOne({
201
+ title: `Merge into ${contact.name}`,
202
+ intro: `Its jobs, invoices, links and rules move to ${contact.name}. Its name stays as another name of ${contact.name}.`,
203
+ label: 'Contacts',
204
+ find: 'Find a contact',
205
+ remote: true,
206
+ load: async (q) => (await flow(`/api/invoicing/contacts?${new URLSearchParams({ q, kind: contact.kind, hidden: 'all', active: 'all', limit: '50' })}`)).contacts
207
+ .filter((row) => row.id !== contact.id),
208
+ item: (row) => ({ value: row.id, title: row.name, meta: row.email ?? null }),
209
+ empty: 'No contact has that name.',
210
+ confirm: 'Merge',
211
+ });
212
+ if (!picked) return;
213
+ merge.disabled = true;
214
+ try {
215
+ const { moved } = await flow(`/api/invoicing/contacts/${id}/merge`, { method: 'POST', body: JSON.stringify({ fromId: picked.id }) });
216
+ toast(`Merged: ${moved.jobs} jobs, ${moved.invoices} invoices.`, 'success');
217
+ go(`/contacts/${id}`);
218
+ } catch (error) {
219
+ toast(error.message, 'danger');
220
+ merge.disabled = false;
221
+ }
222
+ },
223
+ }, 'Merge');
224
+ const mergedNote = contact.mergedIntoId ? h('section', { class: 'fx-card fx-offer' },
225
+ notice('This contact moved into another.', 'info', 'Merged.'),
226
+ h('a', { class: 'fx-btn', href: `/contacts/${contact.mergedIntoId}`, onclick: go }, 'Open')) : null;
179
227
  const xero = h('div');
228
+ const billTo = h('div');
229
+ // A contact that is not a client (docs/CONTACTS.md, "Hidden contacts").
230
+ const hiddenNote = h('div');
231
+ const drawHidden = () => {
232
+ if (!contact.hidden) return hiddenNote.replaceChildren();
233
+ const show = canEdit ? h('button', {
234
+ class: 'fx-btn', type: 'button', title: 'Show this contact in the lists',
235
+ onclick: async () => {
236
+ show.disabled = true;
237
+ try {
238
+ ({ contact } = await flow(`/api/invoicing/contacts/${id}`, { method: 'PATCH', body: JSON.stringify({ hidden: false }) }));
239
+ toast('Shown.', 'success');
240
+ drawHidden();
241
+ } catch (error) {
242
+ toast(error.message, 'danger');
243
+ show.disabled = false;
244
+ }
245
+ },
246
+ }, 'Show') : null;
247
+ hiddenNote.replaceChildren(h('section', { class: 'fx-card fx-offer' },
248
+ notice('Not a client: no job and no invoice.', 'info', 'Hidden.'), show));
249
+ };
250
+ drawHidden();
180
251
  page.replaceChildren(
181
252
  crumbs(['/contacts', 'Contacts'], contact.name),
182
- pageHeader(contact.name, { actions: [save] }),
253
+ pageHeader(contact.name, { actions: [merge, save] }),
254
+ mergedNote,
255
+ contact.mergedIntoId ? null : hiddenNote,
183
256
  h('section', { class: 'fx-card' },
184
257
  h('div', { class: 'fx-contacts__grid' },
185
258
  field(h, 'Name', values.name),
@@ -204,9 +277,67 @@ async function renderContact(main, ui, id) {
204
277
  h('span', { class: 'fx-label' }, 'Practitioners'),
205
278
  practitioners),
206
279
  h('label', { class: 'fx-check' }, active, 'Active'))),
280
+ billTo,
207
281
  xero,
208
282
  );
209
- void loadXero(ui, contact, canEdit, xero);
283
+ const reloadXero = () => loadXero(ui, contact, canEdit, xero);
284
+ if (contact.kind === 'customer' && !contact.mergedIntoId) void loadBillTo(ui, contact, canEdit, billTo, reloadXero);
285
+ void reloadXero();
286
+ }
287
+
288
+ // Bill to (docs/CONTACTS.md): another customer, the payer, whose accounting
289
+ // contact this client's work is billed to. Many clients may name one payer.
290
+ async function loadBillTo(ui, contact, canEdit, host, changed) {
291
+ const { h, flow, toast, notice } = ui;
292
+ let payer = null;
293
+ const save = async (billToContactId, button) => {
294
+ if (button) button.disabled = true;
295
+ try {
296
+ await flow(`/api/invoicing/contacts/${contact.id}/billing`, { method: 'PUT', body: JSON.stringify({ billToContactId }) });
297
+ toast(billToContactId ? 'Bill to saved.' : 'Bill to cleared.', 'success');
298
+ await load();
299
+ await changed();
300
+ } catch (error) {
301
+ toast(error.message, 'danger');
302
+ if (button) button.disabled = false;
303
+ }
304
+ };
305
+ const pick = async (event) => {
306
+ const button = event.currentTarget;
307
+ const picked = await ui.pickOne({
308
+ title: `Bill ${contact.name} to`,
309
+ intro: 'Pick the customer that pays for this client. Its Xero contact gets the invoices.',
310
+ label: 'Customers',
311
+ find: 'Find a customer',
312
+ remote: true,
313
+ load: async (q) => (await flow(`/api/invoicing/contacts?${new URLSearchParams({ q, limit: '50' })}`)).contacts
314
+ .filter((row) => row.id !== contact.id),
315
+ item: (row) => ({ value: row.id, title: row.name, meta: row.email ?? null }),
316
+ empty: 'No customer has that name.',
317
+ confirm: 'Save',
318
+ });
319
+ if (picked) await save(picked.id, button);
320
+ };
321
+ const draw = () => {
322
+ const change = canEdit ? h('button', { class: 'fx-btn', type: 'button', title: 'Pick the customer that pays', onclick: pick }, payer ? 'Change' : 'Set') : null;
323
+ const clear = canEdit && payer ? h('button', {
324
+ class: 'fx-btn fx-btn--ghost', type: 'button', title: 'Bill this client itself',
325
+ onclick: (event) => save(null, event.currentTarget),
326
+ }, 'Clear') : null;
327
+ host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
328
+ notice(payer ? `Billed to ${payer.name}.` : 'Billed to itself.', 'info', 'Bill to.'),
329
+ change, clear));
330
+ };
331
+ async function load() {
332
+ try {
333
+ const { billing } = await flow(`/api/invoicing/contacts/${contact.id}/billing`);
334
+ payer = billing.billToContactId ? (await flow(`/api/invoicing/contacts/${billing.billToContactId}`)).contact : null;
335
+ draw();
336
+ } catch (error) {
337
+ host.replaceChildren(notice(error.message, 'danger'));
338
+ }
339
+ }
340
+ await load();
210
341
  }
211
342
 
212
343
  async function loadXero(ui, contact, canEdit, host) {
@@ -231,7 +362,11 @@ async function loadXero(ui, contact, canEdit, host) {
231
362
  done: (outcome) => host.replaceChildren(h('section', { class: 'fx-card' },
232
363
  notice(outcome === 'linked' ? `${contact.name} is linked to Xero.` : `${contact.name} is in Xero.`, 'success'))),
233
364
  }) : [];
234
- const text = match.state === 'matched'
365
+ const text = match.billTo
366
+ ? (match.state === 'matched'
367
+ ? `Billed to ${match.billTo.name}, in Xero as ${match.name}.`
368
+ : `Billed to ${match.billTo.name}, which is not linked to Xero. Link it on its contact page.`)
369
+ : match.state === 'matched'
235
370
  ? `In Xero${match.name ? ` as ${match.name}` : ''}.`
236
371
  : match.state === 'ambiguous'
237
372
  ? `More than one Xero contact is named "${contact.name}".`