@arcforgelabs/flow 2026.10.48 → 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.
@@ -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
+ }
@@ -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) });