@arcforgelabs/flow 2026.10.37 → 2026.10.38

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.
@@ -15,7 +15,7 @@ import { readChangesFirst } from './webhooks.js';
15
15
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
16
16
  // is on (docs/INTEGRATIONS.md).
17
17
 
18
- 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']);
18
+ 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']);
19
19
 
20
20
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
21
21
  // The refresh token's key comes from Flow's secrets key.
@@ -218,7 +218,10 @@ export async function recordXeroPayments(db, runtime, organisationId, { actor, s
218
218
  const xeroIds = [...new Set(payments.map((p) => p.xeroInvoiceId))];
219
219
  const byFlowInvoice = new Map();
220
220
  if (xeroIds.length) {
221
+ // Flow invoices sent to Xero, and those the Import page linked (links.js).
221
222
  const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
223
+ where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])
224
+ union all select xero_invoice_id, invoice_id from xero_invoice_links
222
225
  where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
223
226
  for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
224
227
  }
@@ -0,0 +1,293 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { contacts, priceItems } from '../../db/schema.js';
3
+ import { createContact } from '../../core/directory.js';
4
+ import { FlowError } from '../../core/errors.js';
5
+ import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
6
+ import { linkJobToInvoice } from '../../core/ledger.js';
7
+ import { normaliseName } from './catalogue.js';
8
+ import { canFit, markRivals, scoreFit, sureFits } from './fit.js';
9
+
10
+ // The Import page (docs/XERO_IMPORT.md): each sales invoice in Flow's copy of
11
+ // Xero against Flow's own invoices. Flow's own records are the ones it works
12
+ // from; this integration says which Flow invoice each Xero invoice is.
13
+ //
14
+ // linked a Flow invoice is this Xero invoice: Flow sent it (a push),
15
+ // the import matched it, or a person linked or made it.
16
+ // invoice_fits a Flow invoice with no Xero invoice fits (fit.js: hard
17
+ // evidence from its jobs, or the same client, an amount that
18
+ // fits and dates within two weeks). A sure fit is linked by
19
+ // `matchXero`; the rest wait for a person.
20
+ // job_fits no Flow invoice, but a Flow job with no invoice fits: the
21
+ // earlier matcher tied it to this Xero invoice, its work-system
22
+ // invoice number is this Xero number, or it has the
23
+ // same client and total within a week. A person makes the Flow
24
+ // invoice from the Xero one and links the job.
25
+ // none nothing in Flow fits. The Xero invoice stays in the copy; a
26
+ // person can make its Flow invoice at any time, or the
27
+ // `make_unmatched` setting makes one for each.
28
+ //
29
+ // It reads only Flow's database: no Xero call.
30
+
31
+ export const IMPORT_STATES = Object.freeze(['linked', 'invoice_fits', 'job_fits', 'none']);
32
+ const CHECK = new Set(['invoice_fits', 'job_fits']);
33
+ const FIT_DAYS = 7;
34
+ const SKIP_STATUSES = ['DELETED', 'VOIDED'];
35
+ const num = (value) => (value === null || value === undefined ? null : Number(value));
36
+ const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : null);
37
+ const daysApart = (a, b) => (a && b ? Math.abs(Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000)) : null);
38
+ const near = (a, b) => {
39
+ const days = daysApart(a, b);
40
+ return days !== null && days <= FIT_DAYS;
41
+ };
42
+
43
+ // Every sales invoice in the copy, and what in Flow fits it.
44
+ async function classify(db, organisationId) {
45
+ const { rows: xero } = await db.execute(sql`
46
+ select x.xero_invoice_id, x.invoice_number, x.reference, x.status, x.issued_on, x.total_cents, x.amount_due_cents,
47
+ x.xero_contact_id, x.lines, c.name as contact_name, c.email as contact_email
48
+ from xero_invoices x
49
+ left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
50
+ where x.organisation_id = ${organisationId} and x.type = 'ACCREC'
51
+ and not (x.status = any(${sql.param(SKIP_STATUSES)}::text[]))
52
+ order by x.issued_on desc nulls last, x.invoice_number desc`);
53
+ const { rows: linkRows } = await db.execute(sql`
54
+ select xero_invoice_id, invoice_id, 'sent' as matched_by from xero_invoice_pushes
55
+ where organisation_id = ${organisationId} and xero_invoice_id is not null
56
+ union all
57
+ select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId}`);
58
+ const linkOf = new Map();
59
+ const linkedInvoices = new Set();
60
+ for (const r of linkRows) {
61
+ if (!linkOf.has(r.xero_invoice_id)) linkOf.set(r.xero_invoice_id, r);
62
+ linkedInvoices.add(r.invoice_id);
63
+ }
64
+ // Flow's customers by name, as the drafts match them (only a name with one
65
+ // contact counts).
66
+ const people = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
67
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
68
+ const byName = new Map();
69
+ for (const p of people) {
70
+ const key = normaliseName(p.name);
71
+ byName.set(key, byName.has(key) ? null : p.id);
72
+ }
73
+ const { rows: flowInvoices } = await db.execute(sql`
74
+ select i.id, i.number, i.contact_id, i.total_cents, i.subtotal_cents, i.reference, i.status, coalesce(i.issue_date, i.created_at::date) as date,
75
+ coalesce((select array_agg(distinct p.code) from invoice_lines l join price_items p on p.id = l.price_item_id
76
+ where l.invoice_id = i.id and p.code is not null), '{}') as item_codes,
77
+ coalesce((select jsonb_agg(jsonb_build_object('accountingRef', o.accounting_ref, 'sourceInvoiceRef', o.source_invoice_ref, 'practitioner', o.practitioner))
78
+ from billing_obligations o where o.organisation_id = i.organisation_id and o.invoice_id = i.id), '[]') as jobs
79
+ from invoices i
80
+ where i.organisation_id = ${organisationId} and i.status <> 'void'`);
81
+ const open = flowInvoices.filter((i) => !linkedInvoices.has(i.id));
82
+ const { rows: jobs } = await db.execute(sql`
83
+ select o.id, o.contact_id, o.total_cents, o.job_date, o.practitioner, o.case_ref, o.accounting_ref, o.source_invoice_ref, o.lines
84
+ from billing_obligations o
85
+ where o.organisation_id = ${organisationId} and o.invoice_id is null`);
86
+ const jobByRef = new Map(jobs.filter((j) => j.accounting_ref).map((j) => [j.accounting_ref, j]));
87
+ // The work system's own invoice number, as Xero numbers it (a trailing R is
88
+ // a reissue). Only a number one job holds counts.
89
+ const workNumber = (ref) => String(ref ?? '').trim().replace(/R$/i, '').toLowerCase();
90
+ const jobByNumber = new Map();
91
+ for (const j of jobs) {
92
+ if (!j.source_invoice_ref) continue;
93
+ const key = workNumber(j.source_invoice_ref);
94
+ jobByNumber.set(key, jobByNumber.has(key) ? null : j);
95
+ }
96
+
97
+ const rows = xero.map((x) => {
98
+ const contactId = x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null;
99
+ const row = {
100
+ xeroInvoiceId: x.xero_invoice_id,
101
+ number: x.invoice_number,
102
+ reference: x.reference,
103
+ status: x.status,
104
+ issuedOn: day(x.issued_on),
105
+ totalCents: num(x.total_cents),
106
+ amountDueCents: num(x.amount_due_cents),
107
+ clientName: x.contact_name,
108
+ contactId,
109
+ link: null,
110
+ invoices: [],
111
+ jobs: [],
112
+ };
113
+ const link = linkOf.get(x.xero_invoice_id);
114
+ if (link) {
115
+ const invoice = flowInvoices.find((i) => i.id === link.invoice_id);
116
+ row.link = { invoiceId: link.invoice_id, number: invoice?.number ?? null, matchedBy: link.matched_by };
117
+ return { ...row, state: 'linked' };
118
+ }
119
+ row.itemCodes = [...new Set((x.lines ?? []).map((l) => l.itemCode).filter(Boolean))];
120
+ const xeroFit = { id: x.xero_invoice_id, number: x.invoice_number, contactId, totalCents: row.totalCents, issuedOn: row.issuedOn,
121
+ itemCodes: (x.lines ?? []).map((l) => l.itemCode).filter(Boolean), reference: x.reference };
122
+ row.invoices = open.map((i) => {
123
+ const flowFit = { id: i.id, contactId: i.contact_id, totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
124
+ itemCodes: i.item_codes ?? [], reference: i.reference, jobs: i.jobs ?? [] };
125
+ const scored = scoreFit(xeroFit, flowFit);
126
+ return canFit(xeroFit, flowFit, scored)
127
+ ? { id: i.id, number: i.number, status: i.status, date: day(i.date), totalCents: num(i.total_cents), reference: i.reference, itemCodes: flowFit.itemCodes,
128
+ score: scored.score, hard: scored.hard, apart: daysApart(day(i.date), row.issuedOn) ?? 99 }
129
+ : null;
130
+ }).filter(Boolean);
131
+ const tied = jobByRef.get(x.xero_invoice_id) ?? (x.invoice_number ? jobByNumber.get(workNumber(x.invoice_number)) : null) ?? null;
132
+ const fits = tied ? [tied] : contactId
133
+ ? jobs.filter((j) => !j.accounting_ref && j.contact_id === contactId && num(j.total_cents) === row.totalCents && near(day(j.job_date), row.issuedOn))
134
+ : [];
135
+ row.jobs = fits.map((j) => ({ id: j.id, jobDate: day(j.job_date), practitioner: j.practitioner, caseRef: j.case_ref, totalCents: num(j.total_cents), tied: j === tied,
136
+ itemCodes: [...new Set((j.lines ?? []).map((l) => l.itemCode).filter(Boolean))] }));
137
+ return row;
138
+ });
139
+ // A Flow invoice dated closer to another Xero invoice more likely is that
140
+ // one (the job matcher's closer_job lesson): those fits lose points.
141
+ markRivals(rows.flatMap((r) => r.invoices.map((i) => ({ xeroId: r.xeroInvoiceId, flowId: i.id, apart: i.apart, fit: i }))));
142
+ for (const r of rows) {
143
+ if (r.state === 'linked') continue;
144
+ r.invoices.sort((a, b) => b.score - a.score);
145
+ const hard = r.invoices.some((i) => i.hard);
146
+ const tiedJob = r.jobs.some((j) => j.tied);
147
+ const plain = r.invoices.some((i) => !i.rival);
148
+ // Hard evidence wins; a job tied to this Xero invoice beats a plain fit;
149
+ // a Flow invoice that is closer to another Xero invoice yields to a job.
150
+ if (hard || (!tiedJob && (plain || !r.jobs.length) && r.invoices.length)) r.state = 'invoice_fits';
151
+ else if (r.jobs.length) {
152
+ r.state = 'job_fits';
153
+ r.invoices = [];
154
+ } else r.state = 'none';
155
+ if (r.state === 'invoice_fits') r.jobs = [];
156
+ }
157
+ // Sure only when it is clearly the one fit both ways (fit.js).
158
+ const sure = sureFits(rows.filter((r) => r.state === 'invoice_fits')
159
+ .flatMap((r) => r.invoices.map((i) => ({ xeroId: r.xeroInvoiceId, flowId: i.id, score: i.score, hard: i.hard }))));
160
+ for (const r of rows) {
161
+ r.sure = r.state === 'invoice_fits' && sure.has(r.xeroInvoiceId);
162
+ if (r.sure) r.invoices.sort((a, b) => (b.id === sure.get(r.xeroInvoiceId)) - (a.id === sure.get(r.xeroInvoiceId)));
163
+ for (const i of r.invoices) { delete i.apart; delete i.rival; }
164
+ }
165
+ return rows;
166
+ }
167
+
168
+ const matches = (row, q) => {
169
+ const words = q.trim().toLowerCase();
170
+ if (!words) return true;
171
+ const amount = /^\$?\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)$/.exec(words);
172
+ if (amount && row.totalCents === Math.round(Number(amount[1].replace(/,/g, '')) * 100)) return true;
173
+ return [row.number, row.clientName, row.reference].some((v) => v && String(v).toLowerCase().includes(words));
174
+ };
175
+
176
+ // The page: counts by state, and one page of rows. `show` is a state, 'check'
177
+ // (what a person checks: a Flow invoice or a job fits), or 'all'; `q`
178
+ // searches number, client, Reference or amount.
179
+ export async function importOverview(db, organisationId, { show = 'all', q = '', limit = 50, offset = 0 } = {}) {
180
+ const rows = await classify(db, organisationId);
181
+ const counts = Object.fromEntries(IMPORT_STATES.map((s) => [s, 0]));
182
+ for (const r of rows) counts[r.state] += 1;
183
+ const shown = rows.filter((r) => (show === 'all' || r.state === show || (show === 'check' && CHECK.has(r.state))) && matches(r, q));
184
+ const settings = await readImportSettings(db, organisationId);
185
+ return { counts, total: shown.length, rows: shown.slice(offset, offset + limit), settings };
186
+ }
187
+
188
+ // `autoLink` (on): the import links each sure fit. `makeUnmatched` (off):
189
+ // it also makes a Flow invoice for each Xero invoice nothing fits.
190
+ export async function readImportSettings(db, organisationId) {
191
+ const { rows } = await db.execute(sql`select auto_link, make_unmatched from xero_import_settings where organisation_id = ${organisationId}`);
192
+ return { autoLink: rows[0] ? Boolean(rows[0].auto_link) : true, makeUnmatched: Boolean(rows[0]?.make_unmatched) };
193
+ }
194
+
195
+ // Saves the settings given; a setting left out keeps its value.
196
+ export async function saveImportSettings(db, organisationId, { autoLink, makeUnmatched, actor }) {
197
+ for (const [name, value] of [['autoLink', autoLink], ['makeUnmatched', makeUnmatched]]) {
198
+ if (value !== undefined && typeof value !== 'boolean') throw new FlowError('invalid', `${name} must be true or false`);
199
+ }
200
+ const next = { ...(await readImportSettings(db, organisationId)) };
201
+ if (autoLink !== undefined) next.autoLink = autoLink;
202
+ if (makeUnmatched !== undefined) next.makeUnmatched = makeUnmatched;
203
+ await db.execute(sql`insert into xero_import_settings (organisation_id, auto_link, make_unmatched, updated_by, updated_at)
204
+ values (${organisationId}, ${next.autoLink}, ${next.makeUnmatched}, ${actor}, now())
205
+ on conflict (organisation_id) do update set auto_link = excluded.auto_link, make_unmatched = excluded.make_unmatched,
206
+ updated_by = excluded.updated_by, updated_at = now()`);
207
+ return readImportSettings(db, organisationId);
208
+ }
209
+
210
+ async function insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy, actor }) {
211
+ const { rows } = await db.execute(sql`insert into xero_invoice_links (organisation_id, xero_invoice_id, invoice_id, matched_by, linked_by)
212
+ values (${organisationId}, ${xeroInvoiceId}, ${invoiceId}, ${matchedBy}, ${actor})
213
+ on conflict do nothing returning xero_invoice_id`);
214
+ if (!rows.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
215
+ }
216
+
217
+ async function rowFor(db, organisationId, xeroInvoiceId) {
218
+ const row = (await classify(db, organisationId)).find((r) => r.xeroInvoiceId === xeroInvoiceId);
219
+ if (!row) throw new FlowError('not_found', 'Xero invoice not found in the copy');
220
+ if (row.state === 'linked') throw new FlowError('invalid', 'the Xero invoice is linked already');
221
+ return row;
222
+ }
223
+
224
+ // A person says which Flow invoice a Xero invoice is. Any Flow invoice of the
225
+ // same client with no Xero invoice may be picked, not only a fit.
226
+ export async function linkXeroInvoice(db, organisationId, xeroInvoiceId, { invoiceId, actor }) {
227
+ await rowFor(db, organisationId, xeroInvoiceId);
228
+ const { rows } = await db.execute(sql`select id, status from invoices where organisation_id = ${organisationId} and id = ${invoiceId}`);
229
+ if (!rows.length) throw new FlowError('not_found', 'Flow invoice not found');
230
+ if (rows[0].status === 'void') throw new FlowError('invalid', 'the Flow invoice is void');
231
+ const { rows: pushed } = await db.execute(sql`select 1 from xero_invoice_pushes where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id is not null limit 1`);
232
+ if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
233
+ await insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy: 'person', actor });
234
+ return { xeroInvoiceId, invoiceId };
235
+ }
236
+
237
+ // Makes Flow's own invoice from a Xero invoice in the copy, finalised on the
238
+ // Xero invoice's date, and links the two. With `jobId`, the job gets it as its
239
+ // invoice. The client is Flow's customer of the same name, or a new one.
240
+ // Lines keep the Xero item code where Flow's price list has it; Flow works
241
+ // out its own totals from the lines.
242
+ export async function makeFlowInvoice(db, organisationId, xeroInvoiceId, { jobId = null, actor }) {
243
+ return makeFrom(db, organisationId, await rowFor(db, organisationId, xeroInvoiceId), { jobId, actor });
244
+ }
245
+
246
+ async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
247
+ const { xeroInvoiceId } = row;
248
+ const { rows: [x] } = await db.execute(sql`select x.lines, x.due_on, c.email from xero_invoices x
249
+ left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
250
+ where x.organisation_id = ${organisationId} and x.xero_invoice_id = ${xeroInvoiceId}`);
251
+ if (!row.clientName) throw new FlowError('invalid', 'the Xero invoice has no contact in the copy; sync contacts first');
252
+ return db.transaction(async (tx) => {
253
+ const contactId = row.contactId ?? (await createContact(tx, organisationId, { kind: 'customer', name: row.clientName, email: x.email ?? null })).id;
254
+ const items = await tx.select({ id: priceItems.id, code: priceItems.code, name: priceItems.name }).from(priceItems)
255
+ .where(eq(priceItems.organisationId, organisationId));
256
+ const byCode = new Map(items.map((i) => [i.code.toUpperCase(), i]));
257
+ const lines = (x.lines ?? []).filter((l) => l.lineAmountCents).map((l) => {
258
+ const item = l.itemCode ? byCode.get(String(l.itemCode).toUpperCase()) : null;
259
+ const whole = Number.isInteger(l.quantity) && l.quantity > 0 && l.lineAmountCents % l.quantity === 0;
260
+ return {
261
+ priceItemId: item?.id ?? null,
262
+ accountCode: item ? null : l.accountCode ?? null,
263
+ description: l.description ?? item?.name ?? l.itemCode ?? 'Line from Xero',
264
+ quantity: whole ? l.quantity : 1,
265
+ unitAmountCents: whole ? l.lineAmountCents / l.quantity : l.lineAmountCents,
266
+ taxCode: l.taxAmountCents ? 'GST' : 'GST_FREE',
267
+ };
268
+ });
269
+ if (!lines.length) throw new FlowError('invalid', 'the Xero invoice has no lines in the copy');
270
+ const draft = await createDraftInvoice(tx, organisationId, { contactId, reference: row.reference ?? null, dueDate: day(x.due_on), lines });
271
+ const invoice = await issueInvoice(tx, organisationId, draft.id, { issuedBy: actor, issueDate: row.issuedOn ?? new Date().toISOString().slice(0, 10) });
272
+ await insertLink(tx, organisationId, { xeroInvoiceId, invoiceId: invoice.id, matchedBy: 'made', actor });
273
+ if (jobId) await linkJobToInvoice(tx, organisationId, jobId, { invoiceId: invoice.id, accountingRef: xeroInvoiceId, actor });
274
+ return { xeroInvoiceId, invoice };
275
+ });
276
+ }
277
+
278
+ // With `autoLink` on, links each sure fit; with `makeUnmatched` on, makes a Flow invoice for
279
+ // each Xero invoice nothing fits. Answers counts. The history import runs it
280
+ // after each read of the copy; a person can run it from the page.
281
+ export async function matchXero(db, organisationId, { actor = 'automation:xero-import' } = {}) {
282
+ const rows = await classify(db, organisationId);
283
+ const { autoLink, makeUnmatched } = await readImportSettings(db, organisationId);
284
+ const result = { linked: 0, made: 0, failed: 0 };
285
+ for (const row of rows) {
286
+ if (row.sure && autoLink) {
287
+ await insertLink(db, organisationId, { xeroInvoiceId: row.xeroInvoiceId, invoiceId: row.invoices[0].id, matchedBy: 'auto', actor }).then(() => { result.linked += 1; }, () => { result.failed += 1; });
288
+ } else if (makeUnmatched && row.state === 'none') {
289
+ await makeFrom(db, organisationId, row, { actor }).then(() => { result.made += 1; }, () => { result.failed += 1; });
290
+ }
291
+ }
292
+ return result;
293
+ }
@@ -0,0 +1,26 @@
1
+ -- Which Flow invoice each Xero invoice is (the Import page, xero/links.js). A
2
+ -- Flow invoice that Flow sent to Xero is linked by its push already; this
3
+ -- table holds the others: matched by the import (`auto`), picked by a person
4
+ -- (`person`), or a Flow invoice made from the Xero invoice (`made`). One Flow
5
+ -- invoice per Xero invoice, and one Xero invoice per Flow invoice.
6
+ CREATE TABLE IF NOT EXISTS "xero_invoice_links" (
7
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
8
+ "xero_invoice_id" text NOT NULL,
9
+ "invoice_id" uuid NOT NULL,
10
+ "matched_by" text NOT NULL,
11
+ "linked_by" text NOT NULL,
12
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
13
+ CONSTRAINT "xero_invoice_links_pk" PRIMARY KEY("organisation_id", "xero_invoice_id"),
14
+ CONSTRAINT "xero_invoice_links_invoice" UNIQUE("organisation_id", "invoice_id"),
15
+ CONSTRAINT "xero_invoice_links_invoice_same_org" FOREIGN KEY ("organisation_id", "invoice_id") REFERENCES "invoices"("organisation_id", "id"),
16
+ CONSTRAINT "xero_invoice_links_matched_by_valid" CHECK ("matched_by" in ('auto', 'person', 'made'))
17
+ );
18
+ --> statement-breakpoint
19
+ -- The Import page's settings, one row for each organisation.
20
+ CREATE TABLE IF NOT EXISTS "xero_import_settings" (
21
+ "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
22
+ "auto_link" boolean DEFAULT true NOT NULL,
23
+ "make_unmatched" boolean DEFAULT false NOT NULL,
24
+ "updated_by" text,
25
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
26
+ );
@@ -57,6 +57,13 @@
57
57
  "when": 1792500000000,
58
58
  "tag": "0007_xero_webhooks",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1792510000000,
65
+ "tag": "0008_xero_invoice_links",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
@@ -51,8 +51,7 @@ const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10)
51
51
  const codes = (lines) => [...(lines ?? []).map((l) => l.itemCode ?? '')].sort().join(',');
52
52
 
53
53
  // What differs between the Xero copy and the version Flow sent. Free text
54
- // (reference, descriptions) is not compared: the copy keeps it only when the
55
- // lab turns that on (XERO_KEEP_REFERENCE).
54
+ // (reference, descriptions) is not compared yet.
56
55
  export function sentDifferences(sent, document, copy) {
57
56
  const found = [];
58
57
  const add = (field, flow, xero) => found.push({ field, flow, xero });
@@ -90,12 +89,12 @@ const MESSAGES = {
90
89
  // from Flow invoice id to { state, label, xero, differences, finding, message }.
91
90
  export async function accountingStatus(db, runtime, organisationId, invoiceIds = null) {
92
91
  const only = invoiceIds ? sql`and i.id = any(${sql.param([...new Set(invoiceIds.map(String))])}::uuid[])` : sql``;
93
- // Without ids: only invoices Flow sent, for the check after each read.
94
- const sentOnly = invoiceIds ? sql`` : sql`and p.id is not null`;
92
+ // Without ids: only invoices Flow sent or linked, for the check after each read.
93
+ const sentOnly = invoiceIds ? sql`` : sql`and (p.id is not null or l.xero_invoice_id is not null)`;
95
94
  const { rows } = await db.execute(sql`
96
95
  select i.id, i.status, i.issued_version, i.total_cents,
97
96
  p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
98
- v.document,
97
+ v.document, l.xero_invoice_id as linked_xero_id, l.matched_by as linked_by,
99
98
  x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
100
99
  x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
101
100
  from invoices i
@@ -105,13 +104,17 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
105
104
  order by version desc limit 1
106
105
  ) p on true
107
106
  left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
108
- left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = p.xero_invoice_id
107
+ -- A Flow invoice the Import page linked to a Xero invoice (links.js).
108
+ left join xero_invoice_links l on l.organisation_id = i.organisation_id and l.invoice_id = i.id
109
+ left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = coalesce(p.xero_invoice_id, l.xero_invoice_id)
109
110
  where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
110
111
  const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
111
112
  const out = new Map();
112
113
  for (const r of rows) {
113
114
  let state;
114
- if (!r.push_state) state = 'not_sent';
115
+ const linked = !r.push_state && Boolean(r.linked_xero_id);
116
+ if (linked) state = r.x_status ? xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents }) : 'not_copied';
117
+ else if (!r.push_state) state = 'not_sent';
115
118
  else if (['dispatching', 'ambiguous'].includes(r.push_state)) state = 'sending';
116
119
  else if (!r.x_status) state = 'not_copied';
117
120
  else state = xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents });
@@ -121,23 +124,26 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
121
124
  } catch {
122
125
  sent = null;
123
126
  }
127
+ const xeroInvoiceId = r.xero_invoice_id ?? r.linked_xero_id;
124
128
  const xero = r.x_status ? {
125
- invoiceId: r.xero_invoice_id,
129
+ invoiceId: xeroInvoiceId,
126
130
  number: r.x_number ?? r.xero_invoice_number ?? null,
127
131
  status: r.x_status,
128
132
  totalCents: num(r.x_total_cents),
129
133
  amountDueCents: num(r.amount_due_cents),
130
- url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(r.xero_invoice_id)}`,
134
+ url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(xeroInvoiceId)}`,
131
135
  } : null;
132
136
  const status = {
133
137
  invoiceId: r.id,
134
138
  flowStatus: r.status,
135
139
  sentVersion: r.sent_version ?? null,
136
- current: r.sent_version !== null && r.sent_version === r.issued_version,
140
+ // A linked invoice was never sent: it is the Xero invoice, as it stands.
141
+ current: linked || (r.sent_version !== null && r.sent_version === r.issued_version),
142
+ linkedBy: linked ? r.linked_by : null,
137
143
  state,
138
144
  label: LABELS[state],
139
145
  xero,
140
- differences: xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
146
+ differences: !linked && xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
141
147
  flowTotalCents: num(r.total_cents),
142
148
  };
143
149
  status.finding = findingFor(status);
@@ -6,6 +6,7 @@ import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js
6
6
  import { createXeroClient } from './client.js';
7
7
  import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
8
8
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
9
+ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings } from './links.js';
9
10
  import { queueHistoryImport, resetImport } from './import.js';
10
11
  import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
11
12
  import { accountingStatus, checkSentInvoices } from './reconcile.js';
@@ -307,5 +308,46 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
307
308
  res.json({ ok: true, ...(await mirrorStatus(db, organisationId)) });
308
309
  }));
309
310
 
311
+ // The Import page (links.js): each Xero sales invoice in Flow's copy against
312
+ // Flow's own invoices. Reads Flow's database only.
313
+ router.get('/import', ...signedIn, handle(log, async (req, res) => {
314
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
315
+ const show = typeof req.query.show === 'string' && [...IMPORT_STATES, 'check', 'all'].includes(req.query.show) ? req.query.show : 'all';
316
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : '';
317
+ const offset = Math.max(0, Math.min(Number.parseInt(req.query.offset, 10) || 0, 1_000_000));
318
+ const limit = Math.max(1, Math.min(Number.parseInt(req.query.limit, 10) || 50, 100));
319
+ res.json({ ok: true, ...(await importOverview(db, organisationId, { show, q, limit, offset })) });
320
+ }));
321
+
322
+ router.post('/import/match', ...manager, handle(log, async (req, res) => {
323
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
324
+ const result = await matchXero(db, organisationId, { actor: `person:${req.person.id}` });
325
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} matched Xero invoices: ${JSON.stringify(result)}`);
326
+ res.json({ ok: true, ...result });
327
+ }));
328
+
329
+ router.put('/import/settings', ...manager, handle(log, async (req, res) => {
330
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
331
+ res.json({ ok: true, settings: await saveImportSettings(db, organisationId, { autoLink: req.body?.autoLink, makeUnmatched: req.body?.makeUnmatched, actor: `person:${req.person.id}` }) });
332
+ }));
333
+
334
+ const xeroId = (raw) => {
335
+ if (typeof raw !== 'string' || !UUID.test(raw)) throw Object.assign(new FlowError('not_found', 'Xero invoice not found'), { status: 404 });
336
+ return raw;
337
+ };
338
+
339
+ router.post('/import/:xeroInvoiceId/link', ...manager, handle(log, async (req, res) => {
340
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
341
+ const [invoiceId] = requireInvoiceIds([req.body?.invoiceId], 'invoiceId');
342
+ res.json({ ok: true, ...(await linkXeroInvoice(db, organisationId, xeroId(req.params.xeroInvoiceId), { invoiceId, actor: `person:${req.person.id}` })) });
343
+ }));
344
+
345
+ router.post('/import/:xeroInvoiceId/make', ...manager, handle(log, async (req, res) => {
346
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
347
+ const jobId = req.body?.jobId == null ? null : requireInvoiceIds([req.body.jobId], 'jobId')[0];
348
+ const { invoice } = await makeFlowInvoice(db, organisationId, xeroId(req.params.xeroInvoiceId), { jobId, actor: `person:${req.person.id}` });
349
+ res.json({ ok: true, invoice: { id: invoice.id, number: invoice.number } });
350
+ }));
351
+
310
352
  return router;
311
353
  }
@@ -38,9 +38,10 @@ function readEnv(env, name) {
38
38
  return typeof value === 'string' ? value.trim() : '';
39
39
  }
40
40
 
41
- function readOnOff(env, name) {
41
+ function readOnOff(env, name, fallback = false) {
42
42
  const raw = readEnv(env, name).toLowerCase();
43
- if (raw === '' || raw === 'off') return false;
43
+ if (raw === '') return fallback;
44
+ if (raw === 'off') return false;
44
45
  if (raw === 'on') return true;
45
46
  throw new Error(`${name} must be "on", "off" or unset`);
46
47
  }
@@ -166,9 +167,9 @@ export function resolveXeroSettings(env = process.env) {
166
167
  defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
167
168
  invoiceNumber,
168
169
  replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
169
- // Free text on Xero records (references, line descriptions) can name a
170
- // patient. Flow keeps it only when the lab turns this on (VISION.md).
171
- keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE'),
170
+ // Free text on Xero records (references, line descriptions) is the lab's
171
+ // own data, and the Import matches on it. On unless the lab turns it off.
172
+ keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE', true),
172
173
  mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
173
174
  // The share of the day's calls the mirror leaves for drafts and people.
174
175
  mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({
package/server/ui/app.js CHANGED
@@ -15,6 +15,8 @@ import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-s
15
15
  import { renderPriceList } from '/assets/ui/price-list.js';
16
16
  import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
17
17
  import { renderJobs } from '/assets/ui/jobs.js';
18
+ import { renderReconcile } from '/assets/ui/reconcile.js';
19
+ import { renderXeroImport } from '/assets/ui/xero-import.js';
18
20
  import { startMonitoring } from '/assets/ui/monitoring.js';
19
21
  import { openSearch } from '/assets/ui/search.js';
20
22
  import { alertStrip } from '/assets/ui/alerts.js';
@@ -200,6 +202,7 @@ let me = null;
200
202
  const VIEWS = {
201
203
  jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
202
204
  invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
205
+ reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
203
206
  'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
204
207
  'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
205
208
  'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
@@ -207,6 +210,8 @@ const VIEWS = {
207
210
  integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
208
211
  // Not in the sidebar: "Manage account" in the account menu opens the same
209
212
  // Clerk profile. The page stays for links and Search.
213
+ // Opened from the Xero integration page: Xero invoices against Flow's own.
214
+ 'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
210
215
  account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
211
216
  appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
212
217
  };
@@ -215,6 +220,7 @@ const VIEWS = {
215
220
  const ICON_PATHS = {
216
221
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
217
222
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
223
+ reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
218
224
  people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
219
225
  tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
220
226
  fields: '<rect x="3" y="4" width="18" height="5" rx="1.5"/><rect x="3" y="13" width="18" height="5" rx="1.5"/><path d="M7 6.5h4M7 15.5h7"/>',
@@ -825,6 +831,7 @@ async function renderIntegration(main, id) {
825
831
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
826
832
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
827
833
  on && current.state === 'ready' ? personSettingsSection(current) : null,
834
+ current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
828
835
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
829
836
  guide ? h('section', { class: 'fx-int-section' },
830
837
  h('h2', { class: 'fx-int-section__title' }, 'Set up'),
@@ -904,6 +911,53 @@ function connectProgress({ steps, started }) {
904
911
  step.label))));
905
912
  }
906
913
 
914
+ // Xero › Import, in settings rows: how many Xero invoices to check (Review
915
+ // opens them one at a time), the two import settings, and the history. The
916
+ // counts come from Flow's copy of Xero; the pages are server/ui/xero-import.js.
917
+ function xeroImportSection(organisations) {
918
+ const organisationId = organisations?.[0]?.id;
919
+ const canAct = ['owner', 'admin'].includes(me?.person?.role);
920
+ const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
921
+ const copied = h('span', {}, 'Every Xero invoice Flow copied. Search it with no Xero call.');
922
+ const switchFor = (name, label) => {
923
+ const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
924
+ button.addEventListener('click', async () => {
925
+ const on = button.getAttribute('aria-checked') !== 'true';
926
+ button.disabled = true;
927
+ try {
928
+ const { settings } = await flow('/api/xero/import/settings', { method: 'PUT', body: JSON.stringify({ organisationId, [name]: on }) });
929
+ button.setAttribute('aria-checked', String(settings[name]));
930
+ toast('Saved.', 'success');
931
+ } catch (error) {
932
+ toast(error.message, 'danger');
933
+ }
934
+ button.disabled = !canAct;
935
+ });
936
+ return button;
937
+ };
938
+ const autoLink = switchFor('autoLink', 'Link sure matches');
939
+ const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
940
+ const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
941
+ h('h2', { class: 'fx-set__title' }, 'Import'),
942
+ settingRow({ label: 'To check', hint: 'Xero invoices that fit a Flow invoice or a Flow job.',
943
+ control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
944
+ settingRow({ label: 'Link sure matches', hint: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
945
+ settingRow({ label: 'Make Flow invoices for the rest', hint: 'Off: they stay in the Xero history.', control: makeUnmatched }),
946
+ settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
947
+ if (organisationId) {
948
+ flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
949
+ .then(({ counts: c, settings }) => {
950
+ count.replaceChildren(String(c.job_fits + c.invoice_fits));
951
+ copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} Xero invoices copied, ${c.linked} in Flow. Search with no Xero call.`);
952
+ autoLink.setAttribute('aria-checked', String(settings.autoLink));
953
+ makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
954
+ autoLink.disabled = makeUnmatched.disabled = !canAct;
955
+ })
956
+ .catch(() => count.replaceChildren('—'));
957
+ }
958
+ return section;
959
+ }
960
+
907
961
  // Once Xero is on: each organisation's connection, the history copy, and
908
962
  // Connect, which goes to Xero and comes back.
909
963
  function xeroConnection(organisations, canChange) {