@arcforgelabs/flow 2026.11.8 → 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 (33) 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/seazona/sweep.js +58 -2
  20. package/server/integrations/xero/create-contact.js +15 -1
  21. package/server/integrations/xero/delete-invoice.js +1 -1
  22. package/server/integrations/xero/drafts.js +20 -5
  23. package/server/integrations/xero/ledger.js +7 -4
  24. package/server/integrations/xero/links.js +85 -18
  25. package/server/integrations/xero/lookup.js +7 -4
  26. package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
  27. package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  29. package/server/integrations/xero/mirror.js +63 -8
  30. package/server/integrations/xero/reconcile.js +1 -1
  31. package/server/integrations/xero/routes.js +22 -2
  32. package/server/ui/app.js +31 -2
  33. package/server/ui/contacts.js +150 -15
@@ -23,6 +23,12 @@ import { withXeroLane } from './governor.js';
23
23
 
24
24
  export const MIRROR_RESOURCES = Object.freeze(['invoices', 'credit_notes', 'payments']);
25
25
 
26
+ // The mapper's version. Raise it when a mapper copies a new field: each
27
+ // resource is then read again from the beginning, so old rows get the field
28
+ // (docs/XERO.md, "Copy version").
29
+ export const COPY_VERSION = 1;
30
+ const copyVersion = (keepReference) => `${COPY_VERSION}:${keepReference ? 'text' : 'no_text'}`;
31
+
26
32
  // A Xero amount (a JSON number with up to 4 decimals) to whole cents, half away
27
33
  // from zero, without float drift.
28
34
  export function toCents(amount) {
@@ -202,13 +208,18 @@ export async function storeRows(tx, organisationId, resource, rows, counts) {
202
208
  // would keep the free text they were read with. The rows stay; each is
203
209
  // written again only if it differs.
204
210
  async function readCursor(db, organisationId, resource, { keepReference }) {
205
- await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource, kept_reference)
206
- values (${organisationId}, ${resource}, ${keepReference}) on conflict do nothing`);
207
- await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, kept_reference = ${keepReference}
208
- where organisation_id = ${organisationId} and resource = ${resource} and kept_reference is distinct from ${keepReference}`);
211
+ const version = copyVersion(keepReference);
212
+ await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource, kept_reference, copy_version)
213
+ values (${organisationId}, ${resource}, ${keepReference}, ${version}) on conflict do nothing`);
214
+ await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, kept_reference = ${keepReference}, copy_version = ${version}
215
+ where organisation_id = ${organisationId} and resource = ${resource}
216
+ and (kept_reference is distinct from ${keepReference} or (copy_version is not null and copy_version <> ${version}))`);
217
+ // A copy read before Flow noted its version takes the version as it is.
218
+ await db.execute(sql`update xero_mirror_cursors set copy_version = ${version}
219
+ where organisation_id = ${organisationId} and resource = ${resource} and copy_version is null`);
209
220
  const { rows: [row] } = await db.execute(sql`select * from xero_mirror_cursors
210
221
  where organisation_id = ${organisationId} and resource = ${resource}`);
211
- return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page };
222
+ return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page, rereadAt: row.reread_at ?? null };
212
223
  }
213
224
 
214
225
  // Where the next page starts, from the page just read. A full page moves the
@@ -255,18 +266,29 @@ export async function mirrorResource(db, runtime, organisationId, resource, { ma
255
266
  }
256
267
  const rows = mapRecords(resource, raw, settings);
257
268
  const next = nextPosition(position, rows, pageSize);
258
- await db.transaction(async (tx) => {
269
+ const asked = await db.transaction(async (tx) => {
259
270
  await storeRows(tx, organisationId, resource, rows, counts);
271
+ // An owner asked for a read again while this pass ran: keep the
272
+ // cursor at the start they set, and stop.
273
+ const { rows: [cursor] } = await tx.execute(sql`select reread_at from xero_mirror_cursors
274
+ where organisation_id = ${organisationId} and resource = ${resource} for update`);
275
+ if (String(cursor?.reread_at ?? '') !== String(position.rereadAt ?? '')) {
276
+ await tx.execute(sql`update xero_mirror_cursors set rows_read = rows_read + ${raw.length}, last_read_at = now(), last_error = null
277
+ where organisation_id = ${organisationId} and resource = ${resource}`);
278
+ return true;
279
+ }
260
280
  await tx.execute(sql`update xero_mirror_cursors set
261
281
  modified_since = ${next.modifiedSince}, page = ${next.page}, rows_read = rows_read + ${raw.length},
262
282
  last_read_at = now(), last_error = null,
263
283
  passes = passes + ${next.done ? 1 : 0},
264
284
  last_pass_finished_at = case when ${next.done} then now() else last_pass_finished_at end
265
285
  where organisation_id = ${organisationId} and resource = ${resource}`);
286
+ return false;
266
287
  });
267
288
  counts.pages += 1;
268
289
  counts.read += raw.length;
269
- position = next;
290
+ if (asked) return { resource, ...counts, stopped: 'reread' };
291
+ position = { ...next, rereadAt: position.rereadAt };
270
292
  if (next.done) return { resource, ...counts, stopped: 'done' };
271
293
  }
272
294
  });
@@ -311,10 +333,39 @@ export async function mirrorXero(db, runtime, organisationId, { resources = MIRR
311
333
  });
312
334
  }
313
335
 
336
+ // A person asks for the whole copy to be read again (an owner only): the next
337
+ // read of each resource starts from the beginning. The rows stay; each is
338
+ // written again only if it differs. No call is made here.
339
+ export async function rereadCopy(db, organisationId, { resources = MIRROR_RESOURCES, actor }) {
340
+ if (!actor) throw new TypeError('actor is required');
341
+ for (const resource of resources) {
342
+ await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource) values (${organisationId}, ${resource}) on conflict do nothing`);
343
+ await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, reread_by = ${actor}, reread_at = now()
344
+ where organisation_id = ${organisationId} and resource = ${resource}`);
345
+ }
346
+ return mirrorStatus(db, organisationId);
347
+ }
348
+
349
+ // For each of the last 12 months, the sales invoices in the copy and how many
350
+ // have a Reference and line item codes: the fields the matcher reads. A low
351
+ // month is a copy that missed them (docs/XERO.md, "Copy version").
352
+ async function completeness(db, organisationId) {
353
+ const { rows } = await db.execute(sql`select to_char(date_trunc('month', issued_on), 'YYYY-MM') as month,
354
+ count(*)::int as invoices,
355
+ count(*) filter (where coalesce(trim(reference), '') <> '')::int as with_reference,
356
+ count(*) filter (where jsonb_typeof(lines) = 'array'
357
+ and exists (select 1 from jsonb_array_elements(lines) l where coalesce(l->>'itemCode', '') <> ''))::int as with_item_codes
358
+ from xero_invoices
359
+ where organisation_id = ${organisationId} and type = 'ACCREC' and status not in ('DELETED', 'VOIDED')
360
+ and issued_on >= date_trunc('month', now()) - interval '11 months'
361
+ group by 1 order by 1`);
362
+ return rows.map((r) => ({ month: r.month, invoices: r.invoices, withReference: r.with_reference, withItemCodes: r.with_item_codes }));
363
+ }
364
+
314
365
  // What the copy holds, and where each cursor stands. No Xero calls.
315
366
  export async function mirrorStatus(db, organisationId) {
316
367
  const { rows: cursors } = await db.execute(sql`select resource, modified_since, page, passes, rows_read,
317
- last_read_at, last_pass_finished_at, last_error from xero_mirror_cursors where organisation_id = ${organisationId}
368
+ last_read_at, last_pass_finished_at, last_error, copy_version, kept_reference, reread_by, reread_at from xero_mirror_cursors where organisation_id = ${organisationId}
318
369
  order by resource`);
319
370
  const { rows: [counts] } = await db.execute(sql`select
320
371
  (select count(*)::int from xero_invoices where organisation_id = ${organisationId}) as invoices,
@@ -331,6 +382,10 @@ export async function mirrorStatus(db, organisationId) {
331
382
  lastReadAt: c.last_read_at,
332
383
  lastPassFinishedAt: c.last_pass_finished_at,
333
384
  lastError: c.last_error,
385
+ copyVersion: c.copy_version ?? null,
386
+ keptReference: c.kept_reference ?? null,
387
+ rereadAt: c.reread_at ?? null,
334
388
  })),
389
+ completeness: await completeness(db, organisationId),
335
390
  };
336
391
  }
@@ -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}".`