@arcforgelabs/flow 2026.10.48 → 2026.10.50
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.
- package/VISION.md +18 -9
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +7 -3
- package/server/core/automations.js +34 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/pipeline.js +46 -6
- package/server/core/sending.js +27 -9
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -0
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/contact-sync.js +289 -0
- package/server/integrations/xero/create-contact.js +42 -2
- package/server/integrations/xero/drafts.js +14 -6
- package/server/integrations/xero/import.js +5 -1
- package/server/integrations/xero/index.js +16 -2
- package/server/integrations/xero/lookup.js +15 -2
- package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +74 -42
- package/server/integrations/xero/runtime.js +7 -4
- package/server/integrations/xero/settings.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -40
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +125 -41
- package/server/ui/invoice-rules.js +3 -5
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +69 -84
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +49 -4
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
|
@@ -10,6 +10,8 @@ import { xeroError } from './client.js';
|
|
|
10
10
|
// settings and are never stored. Tokens are never logged.
|
|
11
11
|
|
|
12
12
|
const FORMAT = 'v1';
|
|
13
|
+
// What a Custom Connection's row seals in place of a refresh token.
|
|
14
|
+
export const CUSTOM_CONNECTION = 'custom-connection';
|
|
13
15
|
|
|
14
16
|
function aad(organisationId) {
|
|
15
17
|
return Buffer.from(`flow:xero:refresh-token:${organisationId}`);
|
|
@@ -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}".
|
|
74
|
-
: `No Xero contact is named "${name}".
|
|
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(
|
|
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 }) => ({
|
|
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
|
-
:
|
|
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.
|
|
@@ -38,6 +40,7 @@ export const XERO_SETUP = {
|
|
|
38
40
|
},
|
|
39
41
|
fields: [
|
|
40
42
|
{ name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
|
|
43
|
+
{ name: 'XERO_CONNECTION', label: 'Xero app type', kind: 'choice', choices: [{ value: 'app', label: 'Web app' }, { value: 'custom', label: 'Custom connection (Demo Company only)' }], help: 'Custom connection: no Xero sign-in step. Sandbox mode only.' },
|
|
41
44
|
{ name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
|
|
42
45
|
{ name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
|
|
43
46
|
{ name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
@@ -113,7 +116,18 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
113
116
|
async alerts({ organisationId }) {
|
|
114
117
|
const connection = await readConnection(database, organisationId);
|
|
115
118
|
if (!connection || connection.mode !== current().settings.mode) return [];
|
|
116
|
-
|
|
119
|
+
const alerts = guardAlerts('Xero', await current().guardFor(database).status(connection.tenantId));
|
|
120
|
+
// Contacts both sides changed (Two-way edits, contact-sync.js).
|
|
121
|
+
const differ = await listReview(database, organisationId, { kind: CONTACT_DIFFERS, limit: 500 });
|
|
122
|
+
if (differ.length) {
|
|
123
|
+
alerts.push({
|
|
124
|
+
id: 'contacts-differ',
|
|
125
|
+
tone: 'warn',
|
|
126
|
+
title: `${differ.length === 1 ? '1 contact differs' : `${differ.length} contacts differ`} in Flow and Xero. Open the contact, then make both sides the same.`,
|
|
127
|
+
members: differ.map((item) => `${item.flowId}:${JSON.stringify(item.evidence?.fields?.map((f) => f.field) ?? [])}`),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return alerts;
|
|
117
131
|
},
|
|
118
132
|
// One issued invoice to a Xero draft (drafts.js). The Flow invoice id
|
|
119
133
|
// 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,
|
|
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) => ({
|
|
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
|
+
);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { DEFAULT_TIMEOUT_MS, xeroError } from './client.js';
|
|
3
3
|
|
|
4
|
-
// Xero OAuth 2.0
|
|
5
|
-
//
|
|
4
|
+
// Xero OAuth 2.0: the authorisation code with offline_access, or a Custom
|
|
5
|
+
// Connection's client credentials (settings.js, XERO_CONNECTION). Bodies that
|
|
6
|
+
// carry tokens never reach a log or an error message.
|
|
6
7
|
|
|
7
8
|
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
8
9
|
|
|
@@ -58,7 +59,7 @@ async function tokenRequest(settings, fetchImpl, form, what) {
|
|
|
58
59
|
oauthError: typeof body?.error === 'string' ? body.error.slice(0, 40) : null,
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
if (!body?.access_token || !body?.refresh_token) {
|
|
62
|
+
if (!body?.access_token || (form.grant_type !== 'client_credentials' && !body?.refresh_token)) {
|
|
62
63
|
throw xeroError('xero_token_failed', `Xero's answer to ${what} had no tokens.`, { status: 502 });
|
|
63
64
|
}
|
|
64
65
|
return body;
|
|
@@ -73,6 +74,12 @@ export function refreshTokens(settings, fetchImpl, refreshToken) {
|
|
|
73
74
|
return tokenRequest(settings, fetchImpl, { grant_type: 'refresh_token', refresh_token: refreshToken }, 'the token refresh');
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// A Custom Connection's access token. There is no refresh token: Flow asks
|
|
78
|
+
// again when the token runs out.
|
|
79
|
+
export function clientCredentials(settings, fetchImpl) {
|
|
80
|
+
return tokenRequest(settings, fetchImpl, { grant_type: 'client_credentials', scope: settings.scope }, 'the Custom Connection token');
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
// Best effort: a disconnect deletes Flow's copy even when Xero is down.
|
|
77
84
|
export async function revokeToken(settings, fetchImpl, refreshToken) {
|
|
78
85
|
const basic = Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString('base64');
|