@arcforgelabs/flow 2026.10.48-1 → 2026.10.49

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.
@@ -68,13 +68,19 @@ function xeroDate(value) {
68
68
 
69
69
  const problem = (code, message, extra = {}) => ({ code, message, ...extra });
70
70
 
71
+ // A person can always link the Flow contact to a Xero contact of another
72
+ // name (create-contact.js, linkCustomer): no Xero call.
71
73
  function missingContact(name, { offerCreate }) {
72
74
  const message = offerCreate
73
- ? `No Xero contact is named "${name}". Create the client in Xero from this contact.`
74
- : `No Xero contact is named "${name}". Add it in Xero, then sync.`;
75
- return problem('xero_contact_missing', message, { short: 'Client not in Xero', ...(offerCreate ? { offerCreate: true } : {}) });
75
+ ? `No Xero contact is named "${name}". Link the Xero client, or create it from this contact.`
76
+ : `No Xero contact is named "${name}". Link the Xero client, or add it in Xero, then sync.`;
77
+ return problem('xero_contact_missing', message, { short: 'Client not in Xero', offerLink: true, ...(offerCreate ? { offerCreate: true } : {}) });
76
78
  }
77
79
 
80
+ const twiceContact = (name) => problem('xero_contact_ambiguous',
81
+ `More than one Xero contact is named "${name}". Link the right one, or merge or rename them in Xero, then sync.`,
82
+ { short: 'Client twice in Xero', offerLink: true });
83
+
78
84
  // ---------------------------------------------------------------------------
79
85
  // The document to send
80
86
 
@@ -300,7 +306,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
300
306
  const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
301
307
  const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
302
308
  if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
303
- if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`, { short: 'Client twice in Xero' }));
309
+ if (match.state === 'ambiguous') problems.push(twiceContact(document.contact.name));
304
310
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
305
311
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
306
312
  const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
@@ -399,7 +405,9 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
399
405
  for (const preview of invoices) {
400
406
  found.set(preview.invoiceId, preview.problems
401
407
  .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
402
- .map(({ code, message, short, offerCreate }) => ({ code, message, ...(short ? { short } : {}), ...(offerCreate ? { offerCreate: true } : {}) })));
408
+ .map(({ code, message, short, offerCreate, offerLink }) => ({
409
+ code, message, ...(short ? { short } : {}), ...(offerCreate ? { offerCreate: true } : {}), ...(offerLink ? { offerLink: true } : {}),
410
+ })));
403
411
  }
404
412
  }
405
413
  return found;
@@ -484,7 +492,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
484
492
  throw xeroError(match.state === 'missing' ? 'xero_contact_missing' : 'xero_contact_ambiguous',
485
493
  match.state === 'missing'
486
494
  ? missingContact(document.contact.name, { offerCreate }).message
487
- : `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`,
495
+ : twiceContact(document.contact.name).message,
488
496
  { status: 409 });
489
497
  }
490
498
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
@@ -3,6 +3,7 @@ import { sql } from 'drizzle-orm';
3
3
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from '../../core/queue.js';
4
4
  import { isCallsStopped } from '../call-guard.js';
5
5
  import { lastSync, syncCatalogue } from './catalogue.js';
6
+ import { syncContactsForAll } from './contact-sync.js';
6
7
  import { mirrorState } from './lookup.js';
7
8
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
8
9
  import { matchXero } from './links.js';
@@ -17,7 +18,8 @@ import { runWebhookWork, WEBHOOK_REFRESH_MS, webhooksActive } from './webhooks.j
17
18
  // same job is queued again for REFRESH_MS later and reads only what changed
18
19
  // since its cursor (issue #131), so the copy stays fresh (lookup.js). While
19
20
  // Xero's webhooks arrive (webhooks.js), it waits WEBHOOK_REFRESH_MS instead.
20
- // It only reads from Xero.
21
+ // It only reads from Xero; the one write on its tick is a contact edit, with
22
+ // "Two-way edits" on (contact-sync.js).
21
23
 
22
24
  export const IMPORT_KIND = 'xero.import';
23
25
  const IMPORT_KEY = 'history';
@@ -219,6 +221,8 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
219
221
  running = (async () => {
220
222
  try {
221
223
  await refreshStaleCatalogues(db, runtime, { log });
224
+ // Two-way contact edits, when that setting is on (contact-sync.js).
225
+ await syncContactsForAll(db, runtime, { log });
222
226
  await keepReadsQueued(db, runtime, { startedAtMs });
223
227
  await reportStaleCopies(db, runtime, { stale, log });
224
228
  await runWebhookWork(db, runtime, { log, holds });
@@ -1,6 +1,8 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
+ import { listReview } from '../../core/review.js';
3
4
  import { guardAlerts } from '../call-guard.js';
5
+ import { CONTACT_DIFFERS } from './contact-sync.js';
4
6
  import { deleteXeroInvoice } from './delete-invoice.js';
5
7
  import { createCustomer } from './create-contact.js';
6
8
  import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
@@ -18,7 +20,7 @@ import { readChangesFirst } from './webhooks.js';
18
20
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
19
21
  // is on (docs/INTEGRATIONS.md).
20
22
 
21
- export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks', 'xero_invoice_links', 'xero_import_settings']);
23
+ export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks', 'xero_invoice_links', 'xero_import_settings', 'xero_contact_sync']);
22
24
 
23
25
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
24
26
  // The refresh token's key comes from Flow's secrets key.
@@ -113,7 +115,18 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
113
115
  async alerts({ organisationId }) {
114
116
  const connection = await readConnection(database, organisationId);
115
117
  if (!connection || connection.mode !== current().settings.mode) return [];
116
- return guardAlerts('Xero', await current().guardFor(database).status(connection.tenantId));
118
+ const alerts = guardAlerts('Xero', await current().guardFor(database).status(connection.tenantId));
119
+ // Contacts both sides changed (Two-way edits, contact-sync.js).
120
+ const differ = await listReview(database, organisationId, { kind: CONTACT_DIFFERS, limit: 500 });
121
+ if (differ.length) {
122
+ alerts.push({
123
+ id: 'contacts-differ',
124
+ tone: 'warn',
125
+ title: `${differ.length === 1 ? '1 contact differs' : `${differ.length} contacts differ`} in Flow and Xero. Open the contact, then make both sides the same.`,
126
+ members: differ.map((item) => `${item.flowId}:${JSON.stringify(item.evidence?.fields?.map((f) => f.field) ?? [])}`),
127
+ });
128
+ }
129
+ return alerts;
117
130
  },
118
131
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
119
132
  // and version are the idempotency key.
@@ -14,12 +14,25 @@ export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
14
14
  // and must still match jobs.
15
15
  export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
16
16
 
17
+ // `contact` is the Flow contact a new client becomes (docs/CONTACTS.md,
18
+ // "Add Xero clients"). A contact Xero marks a supplier only still links by
19
+ // name, but never becomes a Flow customer (`adoptable` false).
17
20
  export async function listCustomers(db, organisationId) {
18
21
  const { rows } = await db.execute(sql`
19
- select xero_contact_id, name, status from xero_contacts
22
+ select xero_contact_id, name, first_name, last_name, email, phone, mobile, account_number, tax_number, address,
23
+ is_customer, is_supplier
24
+ from xero_contacts
20
25
  where organisation_id = ${organisationId} and status = 'ACTIVE'
21
26
  order by name`);
22
- return rows.map((r) => ({ id: r.xero_contact_id, name: r.name }));
27
+ return rows.map((r) => ({
28
+ id: r.xero_contact_id,
29
+ name: r.name,
30
+ adoptable: Boolean(r.is_customer || !r.is_supplier),
31
+ contact: {
32
+ kind: 'customer', name: r.name, firstName: r.first_name, lastName: r.last_name, email: r.email, phone: r.phone,
33
+ mobile: r.mobile, accountNumber: r.account_number, taxNumber: r.tax_number, address: r.address,
34
+ },
35
+ }));
23
36
  }
24
37
 
25
38
  // Xero's items, from Flow's copy (catalogue.js, read on each sync), for the
@@ -0,0 +1,15 @@
1
+ -- Two-way contact edits (docs/CONTACTS.md, xero/contact-sync.js). One row for
2
+ -- each linked pair: the fields both sides last agreed on (the merge base), so
3
+ -- Flow can tell which side changed a field. `clashes` lists the fields both
4
+ -- sides changed; they wait for a person. The Xero adapter's own table.
5
+ CREATE TABLE IF NOT EXISTS "xero_contact_sync" (
6
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
7
+ "xero_contact_id" text NOT NULL,
8
+ "contact_id" uuid NOT NULL,
9
+ "base" jsonb NOT NULL,
10
+ "clashes" jsonb DEFAULT '[]'::jsonb NOT NULL,
11
+ "last_error" text,
12
+ "checked_at" timestamp with time zone DEFAULT now() NOT NULL,
13
+ "pushed_at" timestamp with time zone,
14
+ CONSTRAINT "xero_contact_sync_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
15
+ );
@@ -92,6 +92,13 @@
92
92
  "when": 1792550000000,
93
93
  "tag": "0012_xero_contact_details",
94
94
  "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "7",
99
+ "when": 1792560000000,
100
+ "tag": "0013_xero_contact_sync",
101
+ "breakpoints": true
95
102
  }
96
103
  ]
97
104
  }
@@ -4,7 +4,7 @@ import { FlowError } from '../../core/errors.js';
4
4
  import { createActionGuard } from '../call-guard.js';
5
5
  import { billingObligations, organisations } from '../../db/schema.js';
6
6
  import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
7
- import { createCustomer, matchCustomer } from './create-contact.js';
7
+ 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';
@@ -31,6 +31,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
31
31
  // GET /contacts/match?organisationId=&contactId= any signed-in person:
32
32
  // whether this Flow contact is in the copy
33
33
  // POST /contacts owner or admin: create the Xero contact
34
+ // POST /contacts/link owner or admin: link a Flow contact to a
35
+ // Xero contact of another name (no Xero call)
34
36
  // GET /items?organisationId= any signed-in person: the copy
35
37
  // POST /invoices/preview any signed-in person: the Xero drafts Flow
36
38
  // would make, and what stops them (no Xero calls)
@@ -274,7 +276,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
274
276
 
275
277
  router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
276
278
  const organisationId = await requireOrganisation(db, req.query.organisationId);
277
- res.json({ ok: true, contacts: await listContacts(db, organisationId, { q: typeof req.query.q === 'string' ? req.query.q : '' }) });
279
+ const limit = Number(req.query.limit) || undefined;
280
+ res.json({ ok: true, contacts: await listContacts(db, organisationId, { q: typeof req.query.q === 'string' ? req.query.q : '', limit }) });
278
281
  }));
279
282
 
280
283
  // Whether this Flow contact is a Xero contact, and whether Create is offered.
@@ -293,6 +296,17 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
293
296
  res.status(result.outcome === 'created' ? 201 : 200).json({ ok: true, ...result });
294
297
  }));
295
298
 
299
+ // A person links a Flow contact to a Xero contact of another name. Flow's
300
+ // identity map only: no Xero call.
301
+ router.post('/contacts/link', ...manager, handle(log, async (req, res) => {
302
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
303
+ if (typeof req.body?.contactId !== 'string' || !UUID.test(req.body.contactId)) throw new FlowError('invalid', 'contactId must be a Flow contact id');
304
+ if (typeof req.body?.xeroContactId !== 'string' || !req.body.xeroContactId.trim()) throw new FlowError('invalid', 'xeroContactId must be a Xero contact id');
305
+ const result = await linkCustomer(db, runtime, { organisationId, contactId: req.body.contactId, xeroContactId: req.body.xeroContactId.trim() });
306
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} linked Xero contact ${result.xeroContactId} to Flow contact ${req.body.contactId}`);
307
+ res.json({ ok: true, ...result });
308
+ }));
309
+
296
310
  router.get('/items', ...signedIn, handle(log, async (req, res) => {
297
311
  const organisationId = await requireOrganisation(db, req.query.organisationId);
298
312
  res.json({ ok: true, items: await listItems(db, organisationId) });
package/server/ui/app.js CHANGED
@@ -25,7 +25,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
25
25
  import { alertStrip } from '/assets/ui/alerts.js';
26
26
  import { initialsOf, personLabel } from '/assets/ui/person.js';
27
27
  import { startTips } from '/assets/ui/tip.js';
28
- import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
28
+ import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
29
29
  import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
30
30
 
31
31
  const $ = (id) => document.getElementById(id);
@@ -332,7 +332,7 @@ function navigate(event) {
332
332
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
333
333
  // takes a path, or a link's click event.
334
334
  const ui = () => ({
335
- h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
335
+ h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
336
336
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
337
337
  });
338
338
 
@@ -622,6 +622,11 @@ const CATALOGUE = {
622
622
  const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
623
623
  const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
624
624
 
625
+ // The two notices every integration page can show, each one short line. The
626
+ // detail is on the ? (docs/INTEGRATIONS.md has the rest).
627
+ const readOnlyNotice = () => notice('Verified owner sign-in needed.', 'warn', 'Read only.');
628
+ const secretsKeyNotice = () => notice('Set FLOW_SECRETS_KEY, then restart Flow.', 'warn', 'No secrets key.');
629
+
625
630
  function integrationLogo(integration, size = '') {
626
631
  const file = CATALOGUE[integration.id]?.logo;
627
632
  return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
@@ -631,7 +636,7 @@ function integrationLogo(integration, size = '') {
631
636
  async function renderIntegrations(main) {
632
637
  const state = { tab: 'all', category: 'all', q: '' };
633
638
  const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
634
- const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search integrations', 'aria-label': 'Search integrations' });
639
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search', 'aria-label': 'Search integrations' });
635
640
  const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
636
641
  CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
637
642
  const notices = h('div', { class: 'fx-fields' });
@@ -650,8 +655,8 @@ async function renderIntegrations(main) {
650
655
  }
651
656
  const canChange = Boolean(me.ownerActions?.allowed);
652
657
  notices.replaceChildren(...[
653
- canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
654
- body.secretsKey ? null : notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
658
+ canChange ? null : readOnlyNotice(),
659
+ body.secretsKey ? null : secretsKeyNotice(),
655
660
  ].filter(Boolean));
656
661
  const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
657
662
  const connected = (i) => i.enabled;
@@ -706,8 +711,8 @@ function integrationTile(integration) {
706
711
  }
707
712
 
708
713
 
709
- // One integration's page (/integrations/<id>): its logo and maker, Connect
710
- // with each step shown as it happens, the setup steps, its settings, and the
714
+ // One integration's page (/integrations/<id>): its logo, name and Connect
715
+ // (each step shown as it runs), the setup steps (closed), its settings, and the
711
716
  // facts beside them.
712
717
  async function renderIntegration(main, id) {
713
718
  const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
@@ -733,6 +738,9 @@ async function renderIntegration(main, id) {
733
738
  ? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
734
739
  : h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
735
740
  let shown = found;
741
+ // Settings: closed on a phone, open on a wider screen, then as the person
742
+ // leaves it while the page redraws (Connect).
743
+ let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
736
744
  const alerts = alertStrip(ui(), 'integrations', {
737
745
  scope: id,
738
746
  onLoad: ({ danger }) => {
@@ -750,7 +758,7 @@ async function renderIntegration(main, id) {
750
758
  // The host sets it: a value to read, not a box to type in.
751
759
  rows.push(settingRow({
752
760
  label: field.label,
753
- hint: 'Set by whoever hosts Flow.',
761
+ help: 'Set by whoever hosts Flow.',
754
762
  control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
755
763
  }));
756
764
  continue;
@@ -772,10 +780,11 @@ async function renderIntegration(main, id) {
772
780
  const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
773
781
  rows.push(settingRow({
774
782
  label: field.label,
775
- hint: field.unreadable ? 'Stored with another key. Enter it again.' : field.help,
783
+ hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
784
+ help: field.help,
776
785
  control: suggestion && !disabled
777
786
  ? h('div', { class: 'fx-setting__stack' }, input,
778
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this installation\'s address'))
787
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this address'))
779
788
  : input,
780
789
  }));
781
790
  }
@@ -831,7 +840,6 @@ async function renderIntegration(main, id) {
831
840
  const on = current.enabled;
832
841
  const guide = current.guide;
833
842
  const hostOwned = current.enabledBy === 'host';
834
- const entry = CATALOGUE[current.id];
835
843
  const mode = current.fields.find((f) => /_MODE$/.test(f.name));
836
844
  const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
837
845
  const busy = progress && progress.steps.some((step) => step.state === 'running');
@@ -843,44 +851,41 @@ async function renderIntegration(main, id) {
843
851
 
844
852
  host.replaceChildren(
845
853
  crumbs(['/integrations', 'Integrations'], current.name),
846
- h('header', { class: 'fx-int-hero' },
847
- integrationLogo(current, 'xl'),
848
- h('div', { class: 'fx-int-hero__text' },
849
- h('h1', { class: 'fx-title' }, current.name),
850
- h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
851
- h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
852
- h('div', { class: 'fx-int-hero__actions' },
853
- primary,
854
- on ? statusPill(current) : null,
855
- progress ? connectProgress(progress) : null))),
856
854
  h('div', { class: 'fx-int-page__grid' },
857
855
  h('div', { class: 'fx-int-page__main' }, ...[
856
+ h('header', { class: 'fx-int-hero' },
857
+ integrationLogo(current, 'lg'),
858
+ h('h1', { class: 'fx-title' }, current.name),
859
+ h('div', { class: 'fx-int-hero__actions' },
860
+ on ? statusPill(current) : null,
861
+ primary,
862
+ progress ? connectProgress(progress) : null)),
858
863
  alerts.element,
859
- !canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
860
- !body.secretsKey ? notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
864
+ !canChange ? readOnlyNotice() : null,
865
+ !body.secretsKey ? secretsKeyNotice() : null,
861
866
  current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
862
867
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
863
868
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
864
869
  on && current.state === 'ready' ? personSettingsSection(current) : null,
865
870
  current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
871
+ current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
866
872
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
867
- guide ? h('section', { class: 'fx-int-section' },
868
- h('h2', { class: 'fx-int-section__title' }, 'Set up'),
873
+ guide ? h('details', { class: 'fx-int-section fx-int-fold' },
874
+ h('summary', { class: 'fx-int-section__title' }, 'Set up'),
869
875
  h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
870
- guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `Open ${current.name} developer site ↗`) : null) : null,
871
- rows.length ? h('section', { class: 'fx-int-section' },
872
- h('h2', { class: 'fx-int-section__title' }, 'Settings'),
876
+ guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
877
+ rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
878
+ h('summary', { class: 'fx-int-section__title' }, 'Settings'),
873
879
  h('div', { class: 'fx-settings' }, rows),
874
880
  canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
875
881
  ].filter(Boolean)),
876
882
  h('aside', { class: 'fx-int-page__meta' },
877
883
  h('dl', { class: 'fx-meta' }, ...[
878
884
  ['Status', statusPill(current)],
879
- ['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
880
885
  modeLabel ? ['Mode', modeLabel] : null,
881
- ['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
882
- current.updatedAt ? ['Last changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
883
- guide?.link ? ['Documentation', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, 'Developer site ↗')] : null,
886
+ current.enabledBy === 'host' ? ['On by', 'Host'] : current.enabledBy === 'flow' ? ['On by', 'Owner'] : null,
887
+ current.updatedAt ? ['Changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
888
+ guide?.link ? ['Docs', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} ↗`)] : null,
884
889
  ].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
885
890
  };
886
891
  document.title = `${found.name} · Flow`;
@@ -910,7 +915,7 @@ function personSettingsSection(integration) {
910
915
  }
911
916
  input.disabled = false;
912
917
  });
913
- return settingRow({ label: setting.label, hint: setting.hint, control: input });
918
+ return settingRow({ label: setting.label, help: setting.hint, control: input });
914
919
  });
915
920
  section.replaceChildren(section.firstChild, h('div', { class: 'fx-settings' }, rows));
916
921
  }).catch(() => section.remove());
@@ -933,7 +938,7 @@ function xeroImportSection(organisations) {
933
938
  const organisationId = organisations?.[0]?.id;
934
939
  const canAct = ['owner', 'admin'].includes(me?.person?.role);
935
940
  const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
936
- const copied = h('span', {}, 'Every Xero invoice Flow copied. Search it with no Xero call.');
941
+ const copied = h('span', {}, '');
937
942
  const switchFor = (name, label) => {
938
943
  const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
939
944
  button.addEventListener('click', async () => {
@@ -954,16 +959,16 @@ function xeroImportSection(organisations) {
954
959
  const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
955
960
  const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
956
961
  h('h2', { class: 'fx-set__title' }, 'Import'),
957
- settingRow({ label: 'To check', hint: 'Xero invoices that fit a Flow invoice or a Flow job.',
962
+ settingRow({ label: 'To check', help: 'Xero invoices that fit a Flow invoice or a Flow job.',
958
963
  control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
959
- settingRow({ label: 'Link sure matches', hint: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
960
- settingRow({ label: 'Make Flow invoices for the rest', hint: 'Off: they stay in the Xero history.', control: makeUnmatched }),
961
- settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
964
+ settingRow({ label: 'Link sure matches', help: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
965
+ settingRow({ label: 'Make Flow invoices for the rest', help: 'Off: they stay in the Xero history.', control: makeUnmatched }),
966
+ settingRow({ label: 'Xero history', hint: copied, help: 'Every Xero invoice Flow copied. A search makes no Xero call.', control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
962
967
  if (organisationId) {
963
968
  flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
964
969
  .then(({ counts: c, settings }) => {
965
970
  count.replaceChildren(String(c.job_fits + c.invoice_fits));
966
- copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} Xero invoices copied, ${c.linked} in Flow. Search with no Xero call.`);
971
+ copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} copied, ${c.linked} in Flow`);
967
972
  autoLink.setAttribute('aria-checked', String(settings.autoLink));
968
973
  makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
969
974
  autoLink.disabled = makeUnmatched.disabled = !canAct;
@@ -9,7 +9,7 @@ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/AUTOMATIONS.md
9
9
 
10
10
  // A section of the automations kept at `place`, filled when they load.
11
11
  export function automationSection(ui, place, title) {
12
- const { h, flow, toast, notice, me } = ui;
12
+ const { h, flow, toast, notice, me, settingRow } = ui;
13
13
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
14
14
  const disabled = !['owner', 'admin'].includes(me?.person.role);
15
15
 
@@ -27,9 +27,7 @@ export function automationSection(ui, place, title) {
27
27
  }
28
28
  }
29
29
 
30
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
31
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
32
- h('div', { class: 'fx-set__control' }, control));
30
+ const row = (label, help, control) => settingRow({ label, help, control });
33
31
 
34
32
  // A menu of the statuses the work system reported, each with its job count.
35
33
  // A status saved earlier stays in the menu even when no job has it now.
@@ -1,8 +1,9 @@
1
1
  // Contacts (/contacts, /contacts/<id>). Flow's own customers and suppliers
2
2
  // (server/core/contacts.js, docs/CONTACTS.md). The list and the record work
3
3
  // with no integration on. When Xero is connected, the record says whether
4
- // that contact is in Xero and, for an owner or admin, offers Create.
4
+ // that contact is in Xero and, for an owner or admin, offers Link and Create.
5
5
  import { initialsOf } from '/assets/ui/person.js';
6
+ import { xeroContactActions } from '/assets/ui/xero-contact.js';
6
7
 
7
8
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CONTACTS.md';
8
9
  const KINDS = [['customer', 'Customer'], ['supplier', 'Supplier']];
@@ -225,36 +226,28 @@ async function loadXero(ui, contact, canEdit, host) {
225
226
  return;
226
227
  }
227
228
  if (match.state === 'off') return;
228
- const create = match.offerCreate && canEdit
229
- ? h('button', {
230
- class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
231
- onclick: async (event) => {
232
- const button = event.currentTarget;
233
- const confirmed = await ui.dialog({
234
- title: `Create ${contact.name} in Xero?`,
235
- body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
236
- confirm: 'Create',
237
- });
238
- if (!confirmed) return;
239
- button.disabled = true;
240
- try {
241
- await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
242
- toast(`${contact.name} is in Xero.`, 'success');
243
- host.replaceChildren(h('section', { class: 'fx-card' }, notice(`${contact.name} is in Xero.`, 'success')));
244
- } catch (error) {
245
- toast(error.message, error.status === 429 ? 'info' : 'danger');
246
- if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
247
- else button.disabled = false;
248
- }
249
- },
250
- }, 'Create')
251
- : null;
229
+ const actions = canEdit ? xeroContactActions(ui, {
230
+ organisationId, contact, offerLink: match.offerLink, offerCreate: match.offerCreate,
231
+ done: (outcome) => host.replaceChildren(h('section', { class: 'fx-card' },
232
+ notice(outcome === 'linked' ? `${contact.name} is linked to Xero.` : `${contact.name} is in Xero.`, 'success'))),
233
+ }) : [];
252
234
  const text = match.state === 'matched'
253
235
  ? `In Xero${match.name ? ` as ${match.name}` : ''}.`
254
236
  : match.state === 'ambiguous'
255
237
  ? `More than one Xero contact is named "${contact.name}".`
256
238
  : `No Xero contact is named "${contact.name}".`;
239
+ // Fields both sides changed (Two-way edits): alert only. A person makes the
240
+ // two the same, here or in Xero, and the next check clears it.
241
+ const differs = match.differs ?? [];
242
+ const clash = differs.length
243
+ ? notice(`Changed in both: ${differs.map((d) => FIELD_LABELS[d.field] ?? d.field).join(', ')}. Make Flow and Xero the same.`, 'warn', 'Xero differs.')
244
+ : null;
257
245
  host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
258
- notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
259
- create));
246
+ clash ?? notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
247
+ ...actions));
260
248
  }
249
+
250
+ const FIELD_LABELS = {
251
+ name: 'Name', firstName: 'First name', lastName: 'Last name', email: 'Email', phone: 'Phone',
252
+ mobile: 'Mobile', accountNumber: 'Account', taxNumber: 'Tax number', address: 'Address',
253
+ };