@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.48-1",
3
+ "version": "2026.10.49",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.48-1",
9
+ "version": "2026.10.49",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.48-1",
3
+ "version": "2026.10.49",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,10 @@ import { refreshReferences } from './references.js';
22
22
  // on the Invoices page, until a person changes it or moves it back.
23
23
  // reference_from_rule: a draft made from a job keeps the Reference the
24
24
  // lab's Reference rule gives (references.js), as the job's fields arrive.
25
+ // add_contacts, add_xero_contacts: a customer of the work system, or of the
26
+ // accounting system, that no Flow contact is named like becomes one.
27
+ // sync_contact_edits: contact edits on either side of the Xero link reach
28
+ // the other (server/integrations/xero/contact-sync.js).
25
29
 
26
30
  const BATCH = 500;
27
31
 
@@ -71,6 +75,36 @@ export const AUTOMATIONS = Object.freeze([
71
75
  readConfig: (config) => readMappingConfig(config),
72
76
  remap: true,
73
77
  },
78
+ {
79
+ // Runs inside the pipeline's read of the work system's customers
80
+ // (pipeline.js, linkCustomers), before its jobs are read.
81
+ key: 'add_contacts',
82
+ place: 'seazona',
83
+ name: 'Add new clients',
84
+ hint: 'A new Seazona client becomes a contact.',
85
+ defaults: { enabled: true, config: {} },
86
+ readConfig: () => ({}),
87
+ },
88
+ {
89
+ // Runs inside the pipeline's read of the accounting system's customers
90
+ // (pipeline.js, linkCustomers). Off until a person turns it on.
91
+ key: 'add_xero_contacts',
92
+ place: 'xero',
93
+ name: 'Add Xero clients',
94
+ hint: 'Each Xero client becomes a contact.',
95
+ defaults: { enabled: false, config: {} },
96
+ readConfig: () => ({}),
97
+ },
98
+ {
99
+ // Runs on the Xero adapter's own worker (xero/contact-sync.js), which
100
+ // writes to Xero only while writes are allowed. Off until a person turns it on.
101
+ key: 'sync_contact_edits',
102
+ place: 'xero',
103
+ name: 'Two-way edits',
104
+ hint: 'A contact edit in Flow or Xero reaches the other. A clash waits for you.',
105
+ defaults: { enabled: false, config: {} },
106
+ readConfig: () => ({}),
107
+ },
74
108
  {
75
109
  key: 'mark_ready_from_work_status',
76
110
  place: 'seazona',
@@ -10,6 +10,7 @@ import { observeJob, observeWithdrawn, recordAccountingEvidence, setHold } from
10
10
  import { loadMappingContext, mapLines } from './line-mapping.js';
11
11
  import { checkPrices, readCatalogues } from './price-list.js';
12
12
  import { linkRecord, priorLinkFor } from './links.js';
13
+ import { createContact, readContact } from './contacts.js';
13
14
  import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
14
15
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
15
16
 
@@ -88,20 +89,29 @@ function byName(records) {
88
89
  }
89
90
 
90
91
  // Links each customer of one system to the Flow customer with the same name,
91
- // when one record on each side has it and neither side is linked yet.
92
- export async function linkCustomers(db, organisationId, { system, customers }) {
92
+ // when one record on each side has it and neither side is linked yet. With
93
+ // `adopt`, a customer no Flow contact is named like becomes a new Flow
94
+ // contact, linked to it (`imported`), so its jobs find their customer
95
+ // (docs/CONTACTS.md). Two customers of one name stay for a person.
96
+ export async function linkCustomers(db, organisationId, { system, customers, adopt = false }) {
93
97
  const flow = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
94
98
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
95
99
  const links = await activeLinks(db, organisationId, { system, entity: 'contact' });
96
100
  const linkedFlow = new Set(links.map((l) => l.flowId));
97
101
  const linkedExternal = new Set(links.map((l) => l.externalId));
98
102
  const flowByName = byName(flow);
99
- const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0 };
103
+ const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0, added: 0 };
100
104
  for (const [key, external] of byName(customers)) {
101
105
  const own = flowByName.get(key) ?? [];
102
106
  for (const customer of external) {
103
107
  if (linkedExternal.has(String(customer.id))) result.alreadyLinked += 1;
104
108
  }
109
+ if (adopt && own.length === 0 && external.length === 1 && external[0].adoptable !== false && !linkedExternal.has(String(external[0].id))) {
110
+ if (await adoptCustomer(db, organisationId, { system, customer: external[0] })) {
111
+ result.added += 1;
112
+ continue;
113
+ }
114
+ }
105
115
  if (external.length !== 1 || own.length !== 1) {
106
116
  const waiting = external.filter((c) => !linkedExternal.has(String(c.id))).length;
107
117
  if (own.length === 0) result.unmatched += waiting;
@@ -119,6 +129,32 @@ export async function linkCustomers(db, organisationId, { system, customers }) {
119
129
  return result;
120
130
  }
121
131
 
132
+ // The customer's own fields when Flow can keep them all, else its name only.
133
+ async function adoptCustomer(db, organisationId, { system, customer }) {
134
+ const name = String(customer.name ?? '').trim();
135
+ const offered = { ...(customer.contact ?? {}), kind: 'customer', name };
136
+ let values;
137
+ try {
138
+ values = readContact(offered);
139
+ } catch (error) {
140
+ if (!(error instanceof FlowError)) throw error;
141
+ values = readContact({ kind: 'customer', name });
142
+ }
143
+ return db.transaction(async (tx) => {
144
+ let row;
145
+ try {
146
+ row = await createContact(tx, organisationId, values);
147
+ } catch (error) {
148
+ if (error instanceof FlowError && error.code === 'conflict') return false;
149
+ throw error;
150
+ }
151
+ const { outcome } = await linkRecord(tx, organisationId, {
152
+ entity: 'contact', flowId: row.id, system, externalId: String(customer.id), matchedBy: 'imported',
153
+ });
154
+ return outcome === 'linked' || outcome === 'existing';
155
+ });
156
+ }
157
+
122
158
  async function readCursor(db, organisationId, name) {
123
159
  const [row] = await db.select().from(pipelineCursors)
124
160
  .where(and(eq(pipelineCursors.organisationId, organisationId), eq(pipelineCursors.name, name)));
@@ -500,9 +536,12 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
500
536
  if (work) {
501
537
  let restart = (summary.prices?.linked ?? 0) > 0;
502
538
  if (typeof work.capabilities.listCustomers === 'function') {
503
- summary.workCustomers = await linkCustomers(db, organisationId, { system: work.id, customers: await work.capabilities.listCustomers({ organisationId }) });
539
+ const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_contacts');
540
+ summary.workCustomers = await linkCustomers(db, organisationId, {
541
+ system: work.id, customers: await work.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
542
+ });
504
543
  // New customer links reach jobs read before them.
505
- restart ||= summary.workCustomers.linked > 0;
544
+ restart ||= summary.workCustomers.linked + summary.workCustomers.added > 0;
506
545
  }
507
546
  summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
508
547
  // After the live feed, so a job both report is the live one.
@@ -512,8 +551,9 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
512
551
  }
513
552
  if (accounting) {
514
553
  if (typeof accounting.capabilities.listCustomers === 'function') {
554
+ const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_xero_contacts');
515
555
  summary.accountingCustomers = await linkCustomers(db, organisationId, {
516
- system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
556
+ system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
517
557
  });
518
558
  }
519
559
  const settings = await readInvoicingSettings(db, organisationId);
@@ -32,7 +32,7 @@ export const CAPABILITIES = Object.freeze([
32
32
  'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
33
33
  'setPersonSetting', // { personId, key, value }: change one, answers the new list
34
34
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
35
- 'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
35
+ 'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts). An optional `contact` is the Flow contact a new customer becomes; `adoptable` false keeps one from becoming a contact (docs/CONTACTS.md)
36
36
  'createCustomer', // { organisationId, contactId, actor }: create the system's customer from the Flow contact. Answers { outcome, contact }. Xero, when Create missing clients is on.
37
37
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
38
38
  'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
@@ -134,12 +134,19 @@ export async function readBillableOrder(db, orderId) {
134
134
  // with no company name is listed by its full name. A client the client list
135
135
  // never gave (the list can be closed to an API client) is listed by the
136
136
  // company name its orders carry, so its jobs still find their customer.
137
+ // `contact` is the Flow contact a new client becomes (docs/CONTACTS.md).
137
138
  export async function listClients(db) {
138
- const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
139
- .from(seazonaClients);
139
+ const rows = await db.select({
140
+ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active,
141
+ accountNumber: seazonaClients.accountNumber, email: seazonaClients.email, phone: seazonaClients.phone,
142
+ address: seazonaClients.address, taxRate: seazonaClients.taxRate, doctors: seazonaClients.doctors,
143
+ }).from(seazonaClients);
140
144
  const listed = rows
141
145
  .filter((r) => r.active !== false)
142
- .map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
146
+ .map((r) => {
147
+ const name = (r.company || r.fullName || '').trim();
148
+ return { id: r.id, name, contact: clientContact(r, name) };
149
+ })
143
150
  .filter((r) => r.name);
144
151
  const known = new Set(rows.map((r) => r.id));
145
152
  const fromOrders = await db.selectDistinctOn([seazonaOrders.clientId], { id: seazonaOrders.clientId, company: seazonaOrders.company })
@@ -148,10 +155,40 @@ export async function listClients(db) {
148
155
  .orderBy(seazonaOrders.clientId, sql`${seazonaOrders.lastChangedAt} desc`);
149
156
  return [
150
157
  ...listed,
151
- ...fromOrders.filter((r) => !known.has(r.id)).map((r) => ({ id: r.id, name: r.company.trim() })),
158
+ ...fromOrders.filter((r) => !known.has(r.id)).map((r) => {
159
+ const name = r.company.trim();
160
+ return { id: r.id, name, contact: { kind: 'customer', name } };
161
+ }),
152
162
  ];
153
163
  }
154
164
 
165
+ // A Seazona client as a Flow contact: the billing fields and the doctors as
166
+ // practitioners. A field Flow cannot keep is left out.
167
+ const cut = (value, max) => {
168
+ const text = String(value ?? '').trim();
169
+ return text && text.length <= max ? text : null;
170
+ };
171
+ export function clientContact(row, name) {
172
+ const address = row.address ?? {};
173
+ const kept = {
174
+ line1: cut(address.address1, 255), line2: cut(address.address2, 255), city: cut(address.city, 255),
175
+ region: cut(address.region, 255), postalCode: cut(address.postalCode, 255), country: cut(address.countryCode, 255),
176
+ };
177
+ const practitioners = (Array.isArray(row.doctors) ? row.doctors : [])
178
+ .map((doctor) => cut(doctor?.fullName, 255)).filter(Boolean).slice(0, 30).map((person) => ({ name: person }));
179
+ const contact = { kind: 'customer', name, practitioners };
180
+ const email = cut(row.email, 255);
181
+ if (email) contact.email = email;
182
+ const phone = cut(row.phone, 50);
183
+ if (phone) contact.phone = phone;
184
+ const account = cut(row.accountNumber, 50);
185
+ if (account) contact.accountNumber = account;
186
+ const taxRate = cut(row.taxRate, 50);
187
+ if (taxRate) contact.taxRate = taxRate;
188
+ if (Object.values(kept).some(Boolean)) contact.address = Object.fromEntries(Object.entries(kept).filter(([, v]) => v));
189
+ return contact;
190
+ }
191
+
155
192
  // Seazona's products, from Flow's copy (the products lane reads them daily),
156
193
  // for the price list's links and price check (server/core/price-list.js).
157
194
  // Seazona says only whether a product is taxable: taxable reads as GST.
@@ -47,8 +47,8 @@ export const SENTRY_SETUP = {
47
47
  { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
48
48
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
49
49
  { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
50
- { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
- { name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
50
+ { name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
+ { name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
52
52
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
53
53
  ],
54
54
  };
@@ -233,6 +233,9 @@ export function createXeroClient({
233
233
  async listContacts({ maxPages } = {}) {
234
234
  return collectPages(async (page) => (await request('GET', 'Contacts', { query: { page } })).Contacts, { maxPages });
235
235
  },
236
+ async getContact(contactId) {
237
+ return first((await request('GET', `Contacts/${encodeURIComponent(contactId)}`)).Contacts);
238
+ },
236
239
  async findContactsByName(name) {
237
240
  return (await request('GET', 'Contacts', { query: { where: `Name==${quote(name)}` } })).Contacts ?? [];
238
241
  },
@@ -329,6 +332,26 @@ export function createXeroClient({
329
332
  }
330
333
  return created;
331
334
  },
335
+ // Changes the fields in `changes` on one contact (contact-sync.js). The
336
+ // body holds only those fields and the id; Xero keeps every other field.
337
+ async updateContact(contactId, changes, { idempotencyKey }) {
338
+ if (!contactId) throw new TypeError('updateContact: contactId is required');
339
+ if (!idempotencyKey) throw new TypeError('updateContact: idempotencyKey is required');
340
+ const payload = await request('POST', `Contacts/${encodeURIComponent(contactId)}`, {
341
+ query: { summarizeErrors: 'false' },
342
+ body: { Contacts: [{ ...changes, ContactID: contactId }] },
343
+ headers: { 'Idempotency-Key': idempotencyKey },
344
+ });
345
+ const updated = first(payload.Contacts);
346
+ const messages = validationMessages(updated);
347
+ if (!updated?.ContactID || updated.HasErrors || updated.StatusAttributeString === 'ERROR' || messages.length) {
348
+ throw xeroError('xero_validation_failed', messages.join(' ') || 'Xero refused the contact change.', {
349
+ status: 422,
350
+ details: messages.length ? { validationErrors: messages } : undefined,
351
+ });
352
+ }
353
+ return updated;
354
+ },
332
355
  async createInvoice(invoice, { idempotencyKey }) {
333
356
  if (!idempotencyKey) throw new TypeError('createInvoice: idempotencyKey is required');
334
357
  const payload = await request('POST', 'Invoices', {
@@ -0,0 +1,289 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { listAutomations } from '../../core/automations.js';
4
+ import { getContact, updateContact } from '../../core/contacts.js';
5
+ import { FlowError } from '../../core/errors.js';
6
+ import { openReview, resolveReviewFor } from '../../core/review.js';
7
+ import { isCallsStopped } from '../call-guard.js';
8
+ import { mapContact, upsertContact } from './catalogue.js';
9
+ import { readConnection } from './connections.js';
10
+ import { withXeroLane } from './governor.js';
11
+ import { writesAllowed } from './settings.js';
12
+
13
+ // Two-way contact edits (docs/CONTACTS.md), when "Two-way edits"
14
+ // (sync_contact_edits) is on. For each Flow contact linked to a Xero contact,
15
+ // Flow compares three versions of each field: Flow's, Flow's copy of Xero
16
+ // (catalogue.js), and the base, the value both sides last agreed on
17
+ // (xero_contact_sync). The side that changed a field since the base wins:
18
+ //
19
+ // only Flow changed it Flow writes it to Xero (read the live contact first)
20
+ // only Xero changed it Flow takes it from the copy (no Xero call)
21
+ // both changed it a clash: neither side changes. A review item says
22
+ // which fields differ, alert only; it closes when a
23
+ // person makes the two sides the same.
24
+ //
25
+ // The first time a pair is compared there is no base: a field blank on one
26
+ // side takes the other side's value, and a field set on both sides to
27
+ // different values is a clash. Practitioners, the tax rate and Active stay
28
+ // Flow's own. Writes to Xero follow XERO_ALLOW_LIVE_WRITES; while they are
29
+ // not allowed, a Flow change waits and Xero's changes still come in.
30
+
31
+ export const CONTACT_FIELDS = Object.freeze(['name', 'firstName', 'lastName', 'email', 'phone', 'mobile', 'accountNumber', 'taxNumber', 'address']);
32
+ export const CONTACT_DIFFERS = 'contact.accounting_differs';
33
+ export const ACTOR = 'system:xero-contacts';
34
+ const AUTOMATION = 'sync_contact_edits';
35
+ const ADDRESS_KEYS = ['line1', 'line2', 'city', 'region', 'postalCode', 'country'];
36
+ // Xero writes in one pass, so a long list of edits spreads over passes.
37
+ const PUSHES_PER_PASS = 20;
38
+
39
+ // One field's value in the form both sides compare: text trimmed or null, an
40
+ // address with its keys in order or null.
41
+ export function fieldValue(field, value) {
42
+ if (field === 'address') {
43
+ if (!value || typeof value !== 'object') return null;
44
+ const kept = {};
45
+ for (const key of ADDRESS_KEYS) {
46
+ const text = String(value[key] ?? '').trim();
47
+ if (text) kept[key] = text;
48
+ }
49
+ return Object.keys(kept).length ? kept : null;
50
+ }
51
+ const text = String(value ?? '').trim();
52
+ return text || null;
53
+ }
54
+
55
+ export const fieldsOf = (record) => Object.fromEntries(CONTACT_FIELDS.map((field) => [field, fieldValue(field, record?.[field])]));
56
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
57
+
58
+ // What to change on each side, from the three versions. `base` null is the
59
+ // first compare of a pair.
60
+ export function planContactSync(flow, xero, base) {
61
+ const toFlow = {};
62
+ const toXero = {};
63
+ const clashes = [];
64
+ const next = {};
65
+ for (const field of CONTACT_FIELDS) {
66
+ const mine = flow[field];
67
+ const theirs = xero[field];
68
+ if (same(mine, theirs)) {
69
+ next[field] = mine;
70
+ continue;
71
+ }
72
+ const was = base ? base[field] ?? null : null;
73
+ const flowChanged = base ? !same(mine, was) : mine !== null;
74
+ const xeroChanged = base ? !same(theirs, was) : theirs !== null;
75
+ if (flowChanged && !xeroChanged) {
76
+ toXero[field] = mine;
77
+ next[field] = mine;
78
+ } else if (xeroChanged && !flowChanged) {
79
+ toFlow[field] = theirs;
80
+ next[field] = theirs;
81
+ } else {
82
+ clashes.push({ field, flow: mine, xero: theirs });
83
+ next[field] = was;
84
+ }
85
+ }
86
+ return { toFlow, toXero, clashes, base: next };
87
+ }
88
+
89
+ // The Xero body for the fields Flow changed. A blank clears the field.
90
+ export function xeroContactChanges(changes) {
91
+ const body = {};
92
+ const simple = { name: 'Name', firstName: 'FirstName', lastName: 'LastName', email: 'EmailAddress', accountNumber: 'AccountNumber', taxNumber: 'TaxNumber' };
93
+ for (const [field, key] of Object.entries(simple)) {
94
+ if (field in changes) body[key] = changes[field] ?? '';
95
+ }
96
+ const phones = [];
97
+ for (const [field, type] of [['phone', 'DEFAULT'], ['mobile', 'MOBILE']]) {
98
+ if (field in changes) phones.push({ PhoneType: type, PhoneNumber: changes[field] ?? '', PhoneAreaCode: '', PhoneCountryCode: '' });
99
+ }
100
+ if (phones.length) body.Phones = phones;
101
+ if ('address' in changes) {
102
+ const address = changes.address ?? {};
103
+ const lines = {
104
+ AddressLine1: address.line1 || '', AddressLine2: address.line2 || '', City: address.city || '',
105
+ Region: address.region || '', PostalCode: address.postalCode || '', Country: address.country || '',
106
+ };
107
+ body.Addresses = [{ AddressType: 'POBOX', ...lines }, { AddressType: 'STREET', ...lines }];
108
+ }
109
+ return body;
110
+ }
111
+
112
+ function idempotencyKey(organisationId, xeroContactId, body) {
113
+ const digest = createHash('sha256').update([organisationId, xeroContactId, JSON.stringify(body)].join('\u0000')).digest('hex');
114
+ return `flow-xero-contact-edit-${digest.slice(0, 40)}`;
115
+ }
116
+
117
+ // Each Flow contact linked to an active Xero contact in the copy, with the
118
+ // pair's base when there is one.
119
+ async function linkedPairs(db, organisationId) {
120
+ const { rows } = await db.execute(sql`
121
+ select l.flow_id, x.*, s.contact_id as base_contact_id, s.base, s.clashes as saved_clashes
122
+ from external_links l
123
+ join xero_contacts x on x.organisation_id = l.organisation_id and x.xero_contact_id = l.external_id
124
+ join contacts c on c.organisation_id = l.organisation_id and c.id = l.flow_id
125
+ left join xero_contact_sync s on s.organisation_id = l.organisation_id and s.xero_contact_id = l.external_id
126
+ where l.organisation_id = ${organisationId} and l.system = 'xero' and l.entity = 'contact' and l.state = 'active'
127
+ and x.status = 'ACTIVE'
128
+ order by x.name`);
129
+ return rows;
130
+ }
131
+
132
+ const copyFields = (row) => fieldsOf({
133
+ name: row.name, firstName: row.first_name, lastName: row.last_name, email: row.email, phone: row.phone,
134
+ mobile: row.mobile, accountNumber: row.account_number, taxNumber: row.tax_number, address: row.address,
135
+ });
136
+
137
+ async function saveBase(db, organisationId, { xeroContactId, contactId, base, clashes, error = null, pushed = false }) {
138
+ await db.execute(sql`insert into xero_contact_sync (organisation_id, xero_contact_id, contact_id, base, clashes, last_error, checked_at, pushed_at)
139
+ values (${organisationId}, ${xeroContactId}, ${contactId}, ${JSON.stringify(base)}::jsonb, ${JSON.stringify(clashes)}::jsonb, ${error}, now(),
140
+ case when ${pushed} then now() else null end)
141
+ on conflict (organisation_id, xero_contact_id) do update set
142
+ contact_id = excluded.contact_id, base = excluded.base, clashes = excluded.clashes, last_error = excluded.last_error,
143
+ checked_at = now(), pushed_at = case when ${pushed} then now() else xero_contact_sync.pushed_at end`);
144
+ }
145
+
146
+ // Takes Xero's values into the Flow contact. A value Flow cannot keep (too
147
+ // long for the field) stays a clash.
148
+ async function takeFromXero(db, organisationId, contactId, changes) {
149
+ const taken = {};
150
+ const refused = [];
151
+ const attempt = async (patch) => {
152
+ await updateContact(db, organisationId, contactId, patch);
153
+ Object.assign(taken, patch);
154
+ };
155
+ try {
156
+ await attempt(changes);
157
+ } catch (error) {
158
+ if (!(error instanceof FlowError)) throw error;
159
+ for (const [field, value] of Object.entries(changes)) {
160
+ try {
161
+ await attempt({ [field]: value });
162
+ } catch (inner) {
163
+ if (!(inner instanceof FlowError)) throw inner;
164
+ refused.push(field);
165
+ }
166
+ }
167
+ }
168
+ return { taken, refused };
169
+ }
170
+
171
+ // Writes Flow's changed fields to one Xero contact. Reads the live contact
172
+ // first: when Xero changed one of those fields since the copy, the copy takes
173
+ // it and nothing is written; the next pass compares again.
174
+ async function pushToXero(db, runtime, organisationId, xeroContactId, changes, copy) {
175
+ return runtime.serial(organisationId, () => withXeroLane('writes', async () => {
176
+ const { client } = await runtime.clientFor(db, organisationId);
177
+ await runtime.assertEnvironment(client);
178
+ const live = await client.getContact(xeroContactId);
179
+ if (!live) return { outcome: 'gone' };
180
+ const now = fieldsOf(mapContact(live));
181
+ if (Object.keys(changes).some((field) => !same(now[field], copy[field]))) {
182
+ await upsertContact(db, organisationId, mapContact(live));
183
+ return { outcome: 'moved' };
184
+ }
185
+ const body = xeroContactChanges(changes);
186
+ const updated = await client.updateContact(xeroContactId, body, { idempotencyKey: idempotencyKey(organisationId, xeroContactId, body) });
187
+ await upsertContact(db, organisationId, mapContact(updated));
188
+ return { outcome: 'written' };
189
+ }));
190
+ }
191
+
192
+ async function noteClashes(db, organisationId, contactId, xeroContactId, clashes) {
193
+ if (clashes.length) {
194
+ await openReview(db, organisationId, {
195
+ kind: CONTACT_DIFFERS, entity: 'contact', flowId: contactId, systems: ['flow', 'xero'],
196
+ evidence: { xeroContactId, fields: clashes },
197
+ });
198
+ } else {
199
+ await resolveReviewFor(db, organisationId, { kind: CONTACT_DIFFERS, entity: 'contact', flowId: contactId, resolution: 'contacts_agree', actor: ACTOR });
200
+ }
201
+ }
202
+
203
+ // One pass for one organisation. Answers counts.
204
+ export async function syncContactEdits(db, runtime, organisationId) {
205
+ const result = { compared: 0, toFlow: 0, toXero: 0, waiting: 0, clashes: 0, errors: 0 };
206
+ const canWrite = writesAllowed(runtime.settings);
207
+ let pushes = 0;
208
+ for (const row of await linkedPairs(db, organisationId)) {
209
+ result.compared += 1;
210
+ const contactId = row.flow_id;
211
+ const xeroContactId = row.xero_contact_id;
212
+ // A base belongs to one pair: a link to another Flow contact starts again.
213
+ const base = row.base && row.base_contact_id === contactId ? row.base : null;
214
+ const copy = copyFields(row);
215
+ const flow = fieldsOf(await getContact(db, organisationId, contactId));
216
+ const plan = planContactSync(flow, copy, base);
217
+ if (Object.keys(plan.toFlow).length) {
218
+ const { taken, refused } = await takeFromXero(db, organisationId, contactId, plan.toFlow);
219
+ if (Object.keys(taken).length) result.toFlow += 1;
220
+ for (const field of refused) {
221
+ plan.clashes.push({ field, flow: flow[field], xero: copy[field] });
222
+ plan.base[field] = base?.[field] ?? null;
223
+ }
224
+ }
225
+ let error = null;
226
+ let pushed = false;
227
+ if (Object.keys(plan.toXero).length) {
228
+ if (!canWrite || pushes >= PUSHES_PER_PASS) {
229
+ // Not written yet: the base keeps the old value, so the change stays Flow's.
230
+ for (const field of Object.keys(plan.toXero)) plan.base[field] = base?.[field] ?? copy[field];
231
+ result.waiting += 1;
232
+ } else {
233
+ pushes += 1;
234
+ try {
235
+ const sent = await pushToXero(db, runtime, organisationId, xeroContactId, plan.toXero, copy);
236
+ if (sent.outcome === 'written') {
237
+ pushed = true;
238
+ result.toXero += 1;
239
+ } else {
240
+ for (const field of Object.keys(plan.toXero)) plan.base[field] = base?.[field] ?? copy[field];
241
+ result.waiting += 1;
242
+ }
243
+ } catch (failure) {
244
+ // A stop (budget, a refused token) ends the pass; it is not this contact's fault.
245
+ if (isCallsStopped(failure)) throw failure;
246
+ error = `${failure.code ?? 'error'}: ${String(failure.message).slice(0, 300)}`;
247
+ for (const field of Object.keys(plan.toXero)) {
248
+ plan.clashes.push({ field, flow: flow[field], xero: copy[field], error: failure.message });
249
+ plan.base[field] = base?.[field] ?? copy[field];
250
+ }
251
+ result.errors += 1;
252
+ }
253
+ }
254
+ }
255
+ if (plan.clashes.length) result.clashes += 1;
256
+ await saveBase(db, organisationId, { xeroContactId, contactId, base: plan.base, clashes: plan.clashes, error, pushed });
257
+ await noteClashes(db, organisationId, contactId, xeroContactId, plan.clashes);
258
+ }
259
+ return result;
260
+ }
261
+
262
+ // The fields of one Flow contact that differ from Xero and wait for a person
263
+ // (the contact page). No Xero calls.
264
+ export async function contactClashes(db, organisationId, contactId) {
265
+ const { rows } = await db.execute(sql`select clashes from xero_contact_sync
266
+ where organisation_id = ${organisationId} and contact_id = ${contactId}`);
267
+ return rows.flatMap((row) => row.clashes ?? []);
268
+ }
269
+
270
+ // Every connected organisation whose "Two-way edits" is on. The import worker
271
+ // runs this on its tick. A stop of Xero calls skips that organisation.
272
+ export async function syncContactsForAll(db, runtime, { log = console } = {}) {
273
+ const { rows } = await db.execute(sql`select organisation_id from xero_connections where mode = ${runtime.settings.mode}`);
274
+ for (const { organisation_id: organisationId } of rows) {
275
+ const setting = (await listAutomations(db, organisationId)).find((a) => a.key === AUTOMATION);
276
+ if (!setting?.enabled) continue;
277
+ const connection = await readConnection(db, organisationId);
278
+ if (!connection) continue;
279
+ try {
280
+ const result = await syncContactEdits(db, runtime, organisationId);
281
+ if (result.toFlow || result.toXero || result.errors) {
282
+ log.info?.(`[xero] contact edits for organisation ${organisationId}: ${result.toFlow} from Xero, ${result.toXero} to Xero, ${result.clashes} differ, ${result.errors} refused`);
283
+ }
284
+ } catch (error) {
285
+ if (isCallsStopped(error)) continue;
286
+ log.error?.(`[xero] contact edits for organisation ${organisationId} failed: ${error?.code ?? 'error'}: ${error?.message ?? error}`);
287
+ }
288
+ }
289
+ }
@@ -1,9 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
2
3
  import { getContact } from '../../core/contacts.js';
3
- import { linkRecord } from '../../core/links.js';
4
+ import { flowIdFor, linkRecord, supersedeLink } from '../../core/links.js';
4
5
  import { mapContact, matchContactFor, normaliseName, upsertContact } from './catalogue.js';
5
6
  import { xeroError } from './client.js';
6
7
  import { readConnection } from './connections.js';
8
+ import { contactClashes } from './contact-sync.js';
7
9
  import { withXeroLane } from './governor.js';
8
10
  import { writesAllowed } from './settings.js';
9
11
 
@@ -75,12 +77,15 @@ function activeNamed(contacts, name) {
75
77
  export async function matchCustomer(db, runtime, { organisationId, contactId }) {
76
78
  const connection = await readConnection(db, organisationId);
77
79
  const connected = Boolean(connection && connection.mode === runtime.settings.mode);
78
- if (!connected) return { state: 'off', offerCreate: false, xeroContactId: null, name: null };
80
+ if (!connected) return { state: 'off', offerCreate: false, offerLink: false, xeroContactId: null, name: null, differs: [] };
79
81
  const contact = await getContact(db, organisationId, contactId);
80
82
  const match = await matchContactFor(db, organisationId, { contactId, name: contact.name });
81
83
  return {
84
+ // Fields both sides changed (Two-way edits, contact-sync.js): alert only.
85
+ differs: match.state === 'matched' ? (await contactClashes(db, organisationId, contactId)).map(({ field, flow, xero }) => ({ field, flow, xero })) : [],
82
86
  state: match.state,
83
87
  offerCreate: match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings),
88
+ offerLink: match.state !== 'matched',
84
89
  xeroContactId: match.contact?.xeroContactId ?? null,
85
90
  name: match.contact?.name ?? null,
86
91
  };
@@ -124,3 +129,38 @@ export async function createCustomer(db, runtime, { organisationId, contactId, a
124
129
  return { outcome: 'created', ...kept };
125
130
  }));
126
131
  }
132
+
133
+ // A person says which Xero contact a Flow contact is, when the names differ
134
+ // or Xero has the name twice. Flow writes only its identity map (`person`);
135
+ // Xero is not called. A link to a Xero contact that is archived or gone from
136
+ // the copy ends first. A Xero contact another Flow contact has is refused.
137
+ export async function linkCustomer(db, runtime, { organisationId, contactId, xeroContactId }) {
138
+ const connection = await readConnection(db, organisationId);
139
+ if (!connection || connection.mode !== runtime.settings.mode) {
140
+ throw xeroError('xero_not_connected', 'Connect Xero first.', { status: 409 });
141
+ }
142
+ const flowContact = await getContact(db, organisationId, contactId);
143
+ const { rows: [row] } = await db.execute(sql`select xero_contact_id, name, status from xero_contacts
144
+ where organisation_id = ${organisationId} and xero_contact_id = ${xeroContactId}`);
145
+ if (!row) throw xeroError('xero_contact_unknown', 'Flow has no copy of that Xero contact. Sync, then try again.', { status: 404 });
146
+ if (row.status !== 'ACTIVE') throw xeroError('xero_contact_archived', `"${row.name}" is archived in Xero. Restore it in Xero, then sync.`, { status: 409 });
147
+ const owner = await flowIdFor(db, organisationId, { system: 'xero', entity: 'contact', externalId: xeroContactId });
148
+ if (owner && owner !== contactId) {
149
+ throw xeroError('xero_contact_linked', `"${row.name}" is already the Xero contact of another Flow contact.`, { status: 409 });
150
+ }
151
+ const { rows: [current] } = await db.execute(sql`select l.id, l.external_id, x.status from external_links l
152
+ left join xero_contacts x on x.organisation_id = l.organisation_id and x.xero_contact_id = l.external_id
153
+ where l.organisation_id = ${organisationId} and l.system = 'xero' and l.entity = 'contact'
154
+ and l.flow_id = ${contactId} and l.state = 'active'`);
155
+ if (current && current.external_id !== xeroContactId) {
156
+ if (current.status === 'ACTIVE') {
157
+ throw xeroError('xero_contact_linked', `${flowContact.name} is already linked to a Xero contact.`, { status: 409 });
158
+ }
159
+ await supersedeLink(db, organisationId, current.id);
160
+ }
161
+ const link = await linkRecord(db, organisationId, {
162
+ entity: 'contact', flowId: contactId, system: 'xero', externalId: xeroContactId, matchedBy: 'person',
163
+ });
164
+ if (link.outcome === 'conflict') throw xeroError('xero_contact_linked', 'One side is already linked. Check the review list.', { status: 409 });
165
+ return { outcome: link.outcome, xeroContactId, name: row.name };
166
+ }