@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.
package/README.md CHANGED
@@ -55,6 +55,7 @@ run a lab.
55
55
  | `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
56
56
  | `docs/INTEGRATIONS.md` | How an integration adapter is built, turned on and off, and owns its tables |
57
57
  | `docs/XERO.md` | The Xero adapter: modes, settings, rate limits, connecting Xero and draft invoices |
58
+ | `docs/XERO_IMPORT.md` | Xero › Import: each Xero invoice in Flow's copy linked to its Flow invoice, or made into one |
58
59
  | `docs/SEAZONA.md` | The Seazona adapter: modes, settings, the rate governor, replay files and its tables |
59
60
  | `docs/SENTRY.md` | The Sentry adapter: errors and problem reports to the installation's own Sentry project |
60
61
  | `server/integrations/port.js` | The integration port every adapter implements |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.37",
3
+ "version": "2026.10.38",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.37",
9
+ "version": "2026.10.38",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.37",
3
+ "version": "2026.10.38",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import express from 'express';
2
2
  import {
3
- CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, jobFieldHistory, listFields,
3
+ CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, ensureAssignee, jobFieldHistory, listFields,
4
4
  listJobFieldValues, orderFields, setJobFieldValue, updateField,
5
5
  } from '../core/custom-fields.js';
6
6
  import { FlowError } from '../core/errors.js';
@@ -71,6 +71,7 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
71
71
  router.get('/', ...signedIn, handle(async (req, res) => {
72
72
  const organisationId = await organisationOf(req);
73
73
  const archived = req.query.archived === '1' || req.query.archived === 'true';
74
+ await ensureAssignee(db, organisationId);
74
75
  const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
75
76
  res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
76
77
  }));
@@ -17,12 +17,14 @@ import {
17
17
  } from '../core/invoicing-rules.js';
18
18
  import { boardJobs, describeObligations } from '../core/job-board.js';
19
19
  import { readBeforeAct } from '../core/read-before-act.js';
20
+ import { invoiceMatchDetail, jobMatchDetail, listReconcile } from '../core/job-match.js';
20
21
  import { jobReference } from '../core/references.js';
21
22
  import {
22
23
  acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
23
24
  } from '../core/price-list.js';
24
25
  import {
25
- draftInvoiceForObligation, explainObligation, jobChangeFor, listObligations, setReady, useSourceLines,
26
+ confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
27
+ setReady, useSourceLines,
26
28
  } from '../core/ledger.js';
27
29
  import { isHistoryHeld } from '../core/history.js';
28
30
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
@@ -588,6 +590,53 @@ export function createInvoicingRouter({
588
590
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
589
591
  }));
590
592
 
593
+ // A job with no invoice whose match is unclear (ledger.js). The linking
594
+ // tool lists the matcher's suggestions, read from the accounting copy;
595
+ // "Make invoice" says there is none yet; "Link" picks one of them.
596
+ // The Reconcile page: Flow's jobs with no Flow invoice, Flow invoices with
597
+ // no job, and one of them beside its possible other side
598
+ // (core/job-match.js). Flow's own records only; no integration is called.
599
+ router.get('/reconcile', ...signedIn, handle(log, async (req, res) => {
600
+ const organisationId = await organisationOf(req);
601
+ res.json({ ok: true, ...await listReconcile(db, organisationId) });
602
+ }));
603
+
604
+ router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
605
+ const obligationId = pathId(req.params.id, 'job');
606
+ const organisationId = await organisationOf(req);
607
+ const [obligation] = await db.select().from(billingObligations)
608
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
609
+ if (!obligation) throw new FlowError('not_found', 'job not found');
610
+ // The Reference Flow would give its invoice, from Flow's own copy only.
611
+ const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
612
+ const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
613
+ const reference = await jobReference(db, organisationId, obligation, { template, work: source });
614
+ const detail = await jobMatchDetail(db, organisationId, obligationId, { reference, q: text(req.query.q) ?? '' });
615
+ res.json({ ok: true, ...detail, job: { ...detail.job, sourceName: source?.name ?? null } });
616
+ }));
617
+
618
+ router.get('/invoices/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
619
+ const invoiceId = pathId(req.params.id, 'invoice');
620
+ const organisationId = await organisationOf(req);
621
+ res.json({ ok: true, ...await invoiceMatchDetail(db, organisationId, invoiceId, { q: text(req.query.q) ?? '' }) });
622
+ }));
623
+
624
+ router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
625
+ const obligationId = pathId(req.params.id, 'job');
626
+ const organisationId = await organisationOf(req);
627
+ const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
628
+ res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
629
+ }));
630
+
631
+ // Links a job to a Flow invoice that already exists.
632
+ router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
633
+ const obligationId = pathId(req.params.id, 'job');
634
+ const organisationId = await organisationOf(req);
635
+ const invoiceId = pathId(text(req.body?.invoiceId) ?? '', 'invoice');
636
+ const job = await linkJobToInvoice(db, organisationId, obligationId, { invoiceId, actor: `person:${req.person.id}` });
637
+ res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId } });
638
+ }));
639
+
591
640
  router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
592
641
  const obligationId = pathId(req.params.id, 'job');
593
642
  const organisationId = await organisationOf(req);
@@ -133,6 +133,24 @@ export async function createField(db, organisationId, raw, { personId = null } =
133
133
  });
134
134
  }
135
135
 
136
+ // Every lab gets an Assignee: a person field, made once, the first time
137
+ // the lab's fields are read and it has no person field at all (live or
138
+ // archived). The lab can then rename it or archive it like any field, and
139
+ // Flow does not make it again. A field already named Assignee of another
140
+ // type is left alone.
141
+ export const ASSIGNEE = { name: 'Assignee', type: 'person' };
142
+ export async function ensureAssignee(db, organisationId) {
143
+ const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFields)
144
+ .where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.type, 'person')));
145
+ if (count > 0) return null;
146
+ try {
147
+ return await createField(db, organisationId, ASSIGNEE);
148
+ } catch (error) {
149
+ if (error.code === 'conflict' || error.code === 'invalid' || error.code === '23505') return null;
150
+ throw error;
151
+ }
152
+ }
153
+
136
154
  export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
137
155
  const previous = await readRow(db, organisationId, id);
138
156
  if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
@@ -102,7 +102,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
102
102
  if (status && !statuses.has(status)) statuses.set(status, { label: status, final: false, firstSeenAt: null });
103
103
  return {
104
104
  sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, status,
105
- clientName: o.contactName, workClientName: null, practitioner: o.practitioner, department: null,
105
+ clientName: o.contactName, workClientName: null, practitioner: o.practitioner, patient: null, department: null,
106
106
  orderedOn: null, dueOn: null, jobDate: o.jobDate, shippedOn: o.shippedOn,
107
107
  totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
108
108
  };
@@ -129,6 +129,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
129
129
  workClientName: item.clientName ?? null,
130
130
  practitioner: item.practitioner ?? o?.practitioner ?? null,
131
131
  department: item.department ?? null,
132
+ patient: item.patient ?? null,
132
133
  orderedOn: item.orderedOn ?? null,
133
134
  dueOn: item.dueOn ?? null,
134
135
  jobDate: o?.jobDate ?? null,
@@ -0,0 +1,241 @@
1
+ import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm';
2
+ import { billingObligations, contacts, invoiceLines, invoices, priceItems } from '../db/schema.js';
3
+ import { notFound } from './errors.js';
4
+
5
+ // The Reconcile page (docs/JOB_INVOICE_LINKS.md, "Reconcile"): Flow's own
6
+ // jobs against Flow's own invoices. A job that is ready to invoice and has
7
+ // no Flow invoice, and a Flow invoice that no job is linked to, are gaps.
8
+ // For one of them it lists the possible other side, each fact compared. It
9
+ // only reads; linkJobToInvoice and confirmNoInvoice (ledger.js) write.
10
+ // Matching a Flow invoice to one in an accounting system is that
11
+ // integration's own work, not this page's.
12
+
13
+ // A job with no Flow invoice that should have one: ready, not held, and not
14
+ // tied to an accounting invoice by the earlier matcher.
15
+ const JOB_GAP = and(
16
+ isNotNull(billingObligations.readyAt),
17
+ isNull(billingObligations.invoiceId),
18
+ isNull(billingObligations.accountingRef),
19
+ ne(billingObligations.holdState, 'held'),
20
+ );
21
+
22
+ const hasJob = sql`exists (select 1 from billing_obligations o
23
+ where o.organisation_id = ${invoices.organisationId} and o.invoice_id = ${invoices.id})`;
24
+
25
+ const SAME = 'same';
26
+ const DIFFERS = 'differs';
27
+ const daysApart = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000);
28
+ const norm = (text) => String(text ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
29
+ const WINDOW_DAYS = 60;
30
+ // An invoice's date: its issue date, or the day a draft was made.
31
+ const dateOf = (i) => i.issueDate ?? (i.createdAt ? new Date(i.createdAt).toISOString().slice(0, 10) : null);
32
+ const SHOWN = 3;
33
+ const SEARCHED = 8;
34
+
35
+ // One fact against the other side's: `same`, `differs`, or null when either
36
+ // side does not know.
37
+ function compare(ours, theirs) {
38
+ if (ours == null || ours === '' || theirs == null || theirs === '') return null;
39
+ return norm(ours) === norm(theirs) ? SAME : DIFFERS;
40
+ }
41
+
42
+ // Two sets of lines, the same price items and amounts in any order.
43
+ function compareLines(a, b) {
44
+ if (!a.length || !b.length) return null;
45
+ const key = (l) => `${l.priceItemId ?? norm(l.code)}|${l.amountCents ?? ''}`;
46
+ return a.map(key).sort().join(',') === b.map(key).sort().join(',') ? SAME : DIFFERS;
47
+ }
48
+
49
+ // A job line's amount before tax.
50
+ function lineCents(line) {
51
+ if (line.lineAmountCents != null) return line.lineAmountCents;
52
+ if (line.unitAmountCents == null) return null;
53
+ return line.unitAmountCents * (line.quantity ?? 1);
54
+ }
55
+
56
+ // Every fact of one candidate against the record it may belong to. `a` and
57
+ // `b` are { contactId, reference, date, lines, totalCents }.
58
+ function checksOf(a, b, otherCount) {
59
+ return {
60
+ client: a.contactId && b.contactId ? (a.contactId === b.contactId ? SAME : DIFFERS) : null,
61
+ reference: compare(a.reference, b.reference),
62
+ date: a.date && b.date ? { days: daysApart(a.date, b.date) } : null,
63
+ lines: compareLines(a.lines, b.lines),
64
+ total: a.totalCents == null || b.totalCents == null ? null : a.totalCents === b.totalCents ? SAME : DIFFERS,
65
+ others: otherCount,
66
+ };
67
+ }
68
+
69
+ // Higher is a better match: the total, the lines, the Reference, the date,
70
+ // and not taken by another record.
71
+ function scoreOf(c) {
72
+ return (c.total === SAME ? 4 : 0) + (c.lines === SAME ? 3 : 0) + (c.reference === SAME ? 3 : 0) + (c.client === SAME ? 2 : 0)
73
+ + (c.date && Math.abs(c.date.days) <= 3 ? 1 : 0) - (c.others ? 2 : 0);
74
+ }
75
+
76
+ async function codesFor(db, organisationId, ids) {
77
+ if (!ids.length) return new Map();
78
+ const rows = await db.select({ id: priceItems.id, code: priceItems.code }).from(priceItems)
79
+ .where(and(eq(priceItems.organisationId, organisationId), inArray(priceItems.id, ids)));
80
+ return new Map(rows.map((r) => [r.id, r.code]));
81
+ }
82
+
83
+ function jobLines(o, codes) {
84
+ return (o.lines ?? []).map((l) => ({
85
+ priceItemId: l.priceItemId ?? null, code: l.itemCode ?? codes.get(l.priceItemId) ?? null, description: l.description ?? null, amountCents: lineCents(l),
86
+ }));
87
+ }
88
+
89
+ // The two queues, oldest first. jobs: { id, clientName, practitioner,
90
+ // jobDate, totalCents, possible }; invoices: { id, number, clientName,
91
+ // issueDate, status, totalCents, possible }. `possible` counts the other
92
+ // side with the same client and total.
93
+ export async function listReconcile(db, organisationId) {
94
+ const jobs = await db.select({
95
+ id: billingObligations.id,
96
+ clientName: contacts.name,
97
+ practitioner: billingObligations.practitioner,
98
+ jobDate: billingObligations.jobDate,
99
+ totalCents: billingObligations.totalCents,
100
+ possible: sql`(select count(*) from invoices i
101
+ where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
102
+ and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
103
+ and not exists (select 1 from billing_obligations x where x.organisation_id = i.organisation_id and x.invoice_id = i.id))`.mapWith(Number),
104
+ }).from(billingObligations)
105
+ .leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
106
+ .where(and(eq(billingObligations.organisationId, organisationId), JOB_GAP))
107
+ .orderBy(sql`${billingObligations.jobDate} asc nulls last`, asc(billingObligations.createdAt));
108
+ const lonely = await db.select({
109
+ id: invoices.id,
110
+ number: invoices.number,
111
+ clientName: contacts.name,
112
+ issueDate: sql`coalesce(${invoices.issueDate}, ${invoices.createdAt}::date)::text`,
113
+ status: invoices.status,
114
+ totalCents: invoices.totalCents,
115
+ possible: sql`(select count(*) from billing_obligations o
116
+ where o.organisation_id = ${invoices.organisationId} and o.contact_id = ${invoices.contactId}
117
+ and o.invoice_id is null and o.accounting_ref is null and o.total_cents = ${invoices.totalCents})`.mapWith(Number),
118
+ }).from(invoices)
119
+ .leftJoin(contacts, eq(contacts.id, invoices.contactId))
120
+ .where(and(eq(invoices.organisationId, organisationId), ne(invoices.status, 'void'), sql`not ${hasJob}`))
121
+ .orderBy(sql`${invoices.issueDate} asc nulls last`, asc(invoices.createdAt));
122
+ return { jobs, invoices: lonely };
123
+ }
124
+
125
+ // One job and the Flow invoices that may be its own, best first. With `q`,
126
+ // any Flow invoice whose number, client, Reference or total matches.
127
+ // `reference` is the Reference Flow would give the job's invoice.
128
+ export async function jobMatchDetail(db, organisationId, obligationId, { reference = null, q = '' } = {}) {
129
+ const [row] = await db.select({ obligation: billingObligations, clientName: contacts.name })
130
+ .from(billingObligations)
131
+ .leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
132
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
133
+ if (!row) throw notFound('job');
134
+ const o = row.obligation;
135
+ const codes = await codesFor(db, organisationId, (o.lines ?? []).map((l) => l.priceItemId).filter(Boolean));
136
+ const job = {
137
+ id: o.id,
138
+ clientName: row.clientName,
139
+ practitioner: o.practitioner,
140
+ reference,
141
+ jobDate: o.jobDate,
142
+ sourceSystem: o.sourceSystem,
143
+ sourceJobRef: o.sourceJobRef,
144
+ caseRef: o.caseRef,
145
+ sourceStatus: o.sourceStatus,
146
+ lines: jobLines(o, codes),
147
+ totalCents: o.totalCents,
148
+ open: !o.invoiceId && !o.accountingRef,
149
+ // An unclear accounting match: "Make invoice" settles it first.
150
+ unclear: o.accountingState === 'ambiguous',
151
+ };
152
+
153
+ const search = String(q ?? '').trim();
154
+ const where = [eq(invoices.organisationId, organisationId), ne(invoices.status, 'void')];
155
+ if (search) where.push(searchInvoices(search));
156
+ else {
157
+ if (!o.contactId) return { job, invoices: [] };
158
+ where.push(eq(invoices.contactId, o.contactId));
159
+ if (o.jobDate) where.push(sql`abs(coalesce(${invoices.issueDate}, ${invoices.createdAt}::date) - ${o.jobDate}::date) <= ${WINDOW_DAYS}`);
160
+ }
161
+ const found = await db.select({ invoice: invoices, clientName: contacts.name }).from(invoices)
162
+ .leftJoin(contacts, eq(contacts.id, invoices.contactId))
163
+ .where(and(...where)).orderBy(desc(invoices.createdAt)).limit(50);
164
+ const detail = await invoiceFacts(db, organisationId, found.map((f) => f.invoice));
165
+ const ours = { contactId: o.contactId, reference, date: o.jobDate, lines: job.lines, totalCents: o.totalCents };
166
+ const ranked = found.map(({ invoice, clientName }) => {
167
+ const d = detail.get(invoice.id);
168
+ const checks = checksOf(ours, { contactId: invoice.contactId, reference: invoice.reference, date: dateOf(invoice), lines: d.lines, totalCents: invoice.totalCents }, d.jobs.length);
169
+ return {
170
+ id: invoice.id, number: invoice.number, clientName, issueDate: invoice.issueDate, date: dateOf(invoice), status: invoice.status,
171
+ reference: invoice.reference, lines: d.lines, totalCents: invoice.totalCents, jobs: d.jobs, checks, score: scoreOf(checks),
172
+ };
173
+ }).sort((a, b) => b.score - a.score).slice(0, search ? SEARCHED : SHOWN);
174
+ return { job, invoices: ranked.map((c, index) => ({ ...c, best: index === 0 && c.score > 0 })) };
175
+ }
176
+
177
+ // One Flow invoice with no job, and the jobs with no invoice that may be its
178
+ // own, best first.
179
+ export async function invoiceMatchDetail(db, organisationId, invoiceId, { q = '' } = {}) {
180
+ const [row] = await db.select({ invoice: invoices, clientName: contacts.name }).from(invoices)
181
+ .leftJoin(contacts, eq(contacts.id, invoices.contactId))
182
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
183
+ if (!row) throw notFound('invoice');
184
+ const i = row.invoice;
185
+ const d = (await invoiceFacts(db, organisationId, [i])).get(i.id);
186
+ const invoice = {
187
+ id: i.id, number: i.number, clientName: row.clientName, issueDate: i.issueDate, date: dateOf(i), status: i.status, reference: i.reference,
188
+ lines: d.lines, totalCents: i.totalCents, jobs: d.jobs, open: d.jobs.length === 0 && i.status !== 'void',
189
+ };
190
+ const search = String(q ?? '').trim();
191
+ const where = [eq(billingObligations.organisationId, organisationId), isNull(billingObligations.invoiceId), isNull(billingObligations.accountingRef)];
192
+ if (search) {
193
+ const like = `%${search.replace(/[%_\\]/g, '\\$&')}%`;
194
+ where.push(or(ilike(contacts.name, like), ilike(billingObligations.practitioner, like), ilike(billingObligations.caseRef, like), ilike(billingObligations.sourceJobRef, like)));
195
+ } else {
196
+ where.push(eq(billingObligations.contactId, i.contactId));
197
+ if (invoice.date) where.push(sql`abs(coalesce(${billingObligations.jobDate}, ${billingObligations.createdAt}::date) - ${invoice.date}::date) <= ${WINDOW_DAYS}`);
198
+ }
199
+ const found = await db.select({ obligation: billingObligations, clientName: contacts.name }).from(billingObligations)
200
+ .leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
201
+ .where(and(...where)).orderBy(desc(billingObligations.createdAt)).limit(50);
202
+ const codes = await codesFor(db, organisationId, found.flatMap((f) => (f.obligation.lines ?? []).map((l) => l.priceItemId)).filter(Boolean));
203
+ const theirs = { contactId: i.contactId, reference: i.reference, date: invoice.date, lines: d.lines, totalCents: i.totalCents };
204
+ const ranked = found.map(({ obligation: o, clientName }) => {
205
+ const lines = jobLines(o, codes);
206
+ const checks = checksOf(theirs, { contactId: o.contactId, reference: null, date: o.jobDate, lines, totalCents: o.totalCents }, 0);
207
+ return {
208
+ id: o.id, clientName, practitioner: o.practitioner, jobDate: o.jobDate, caseRef: o.caseRef, sourceStatus: o.sourceStatus,
209
+ sourceSystem: o.sourceSystem, lines, totalCents: o.totalCents, ready: Boolean(o.readyAt), checks, score: scoreOf(checks),
210
+ };
211
+ }).sort((a, b) => b.score - a.score).slice(0, search ? SEARCHED : SHOWN);
212
+ return { invoice, jobs: ranked.map((c, index) => ({ ...c, best: index === 0 && c.score > 0 })) };
213
+ }
214
+
215
+ // A search box's words against Flow invoices: number, client, Reference, or
216
+ // a total in dollars ("150" or "$150.00").
217
+ function searchInvoices(search) {
218
+ const like = `%${search.replace(/[%_\\]/g, '\\$&')}%`;
219
+ const terms = [ilike(invoices.number, like), ilike(contacts.name, like), ilike(invoices.reference, like)];
220
+ const amount = /^\$?\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)$/.exec(search);
221
+ if (amount) terms.push(eq(invoices.totalCents, Math.round(Number(amount[1].replace(/,/g, '')) * 100)));
222
+ return or(...terms);
223
+ }
224
+
225
+ // Each invoice's lines (code and amount before tax) and the jobs linked to it.
226
+ async function invoiceFacts(db, organisationId, rows) {
227
+ const ids = rows.map((r) => r.id);
228
+ const facts = new Map(ids.map((id) => [id, { lines: [], jobs: [] }]));
229
+ if (!ids.length) return facts;
230
+ const lines = await db.select({ invoiceId: invoiceLines.invoiceId, priceItemId: invoiceLines.priceItemId, code: priceItems.code, description: invoiceLines.description, amountCents: invoiceLines.lineAmountCents })
231
+ .from(invoiceLines)
232
+ .leftJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
233
+ .where(and(eq(invoiceLines.organisationId, organisationId), inArray(invoiceLines.invoiceId, ids)))
234
+ .orderBy(asc(invoiceLines.position));
235
+ for (const { invoiceId, ...line } of lines) facts.get(invoiceId).lines.push(line);
236
+ const jobs = await db.select({ id: billingObligations.id, invoiceId: billingObligations.invoiceId, jobDate: billingObligations.jobDate, practitioner: billingObligations.practitioner })
237
+ .from(billingObligations)
238
+ .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
239
+ for (const { invoiceId, ...job } of jobs) facts.get(invoiceId).jobs.push(job);
240
+ return facts;
241
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import {
4
- billingObligations, invoices, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
4
+ billingObligations, invoices, linkRejections, linkSuggestions, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
7
  import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
@@ -668,6 +668,85 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
668
668
  });
669
669
  }
670
670
 
671
+ // A job with no invoice of its own whose accounting evidence is ambiguous:
672
+ // the matcher found accounting invoices that may be this job's, but none
673
+ // exactly. A person settles it on the Reconcile page: "Make invoice", or a
674
+ // link to a Flow invoice (linkJobToInvoice).
675
+ const unlinked = (o) => o.accountingState === 'ambiguous' && !o.invoiceId && !o.accountingRef;
676
+
677
+ // The matcher's open suggestions for one job, best first.
678
+ export async function linkSuggestionsFor(db, organisationId, obligationId) {
679
+ return db.select({
680
+ system: linkSuggestions.system, externalId: linkSuggestions.externalId, externalNumber: linkSuggestions.externalNumber,
681
+ score: linkSuggestions.score, signals: linkSuggestions.signals,
682
+ }).from(linkSuggestions).where(and(
683
+ eq(linkSuggestions.organisationId, organisationId),
684
+ eq(linkSuggestions.obligationId, obligationId),
685
+ eq(linkSuggestions.state, 'open'),
686
+ )).orderBy(asc(linkSuggestions.rank));
687
+ }
688
+
689
+ // Rejects every open suggestion for the job ("not this one"): the matcher
690
+ // never suggests those pairs again.
691
+ async function rejectSuggestions(tx, obligation, { actor }) {
692
+ const open = await tx.select().from(linkSuggestions).where(and(
693
+ eq(linkSuggestions.organisationId, obligation.organisationId),
694
+ eq(linkSuggestions.obligationId, obligation.id),
695
+ eq(linkSuggestions.state, 'open'),
696
+ ));
697
+ for (const suggestion of open) {
698
+ await tx.update(linkSuggestions).set({ state: 'rejected', decidedBy: actor, decidedAt: sql`now()`, updatedAt: sql`now()` })
699
+ .where(eq(linkSuggestions.id, suggestion.id));
700
+ await tx.insert(linkRejections)
701
+ .values({ organisationId: obligation.organisationId, obligationId: obligation.id, system: suggestion.system, externalId: suggestion.externalId, actor })
702
+ .onConflictDoNothing();
703
+ }
704
+ return open.map((s) => s.externalId);
705
+ }
706
+
707
+ // "Make invoice": a person says the job has no invoice yet. Every suggestion
708
+ // is rejected and the job is recorded as not found, so it can draft. The
709
+ // live check before the draft still runs (draftInvoiceForObligation).
710
+ export async function confirmNoInvoice(db, organisationId, obligationId, { actor }) {
711
+ requireActor(actor);
712
+ return db.transaction(async (tx) => {
713
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
714
+ if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
715
+ const rejected = await rejectSuggestions(tx, obligation, { actor });
716
+ const row = await update(tx, obligation, { accountingState: 'not_found', accountingCheckedAt: sql`now()` });
717
+ await addEvent(tx, row, 'accounting_checked', actor, { reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected });
718
+ return row;
719
+ });
720
+ }
721
+
722
+ // A person links a job to a Flow invoice that already exists (Reconcile).
723
+ // The job then has its invoice: Flow drafts no other. A job held on unclear
724
+ // accounting evidence is settled by it; whether the Flow invoice matches one
725
+ // in the accounting system is that integration's own work, not the job's.
726
+ // `accountingRef` is given by an accounting integration that made the Flow
727
+ // invoice from the accounting invoice the job is tied to already.
728
+ export async function linkJobToInvoice(db, organisationId, obligationId, { invoiceId, accountingRef = null, actor }) {
729
+ requireActor(actor);
730
+ return db.transaction(async (tx) => {
731
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
732
+ if (obligation.invoiceId) throw new FlowError('invalid', 'the job has an invoice already');
733
+ if (obligation.accountingRef && obligation.accountingRef !== accountingRef) throw new FlowError('invalid', 'the job is linked to an accounting invoice already');
734
+ const [invoice] = await tx.select({ id: invoices.id, status: invoices.status, contactId: invoices.contactId }).from(invoices)
735
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
736
+ if (!invoice) throw notFound('invoice');
737
+ if (invoice.status === 'void') throw new FlowError('invalid', 'the invoice is void');
738
+ const rejected = await rejectSuggestions(tx, obligation, { actor });
739
+ const row = await update(tx, obligation, {
740
+ invoiceId: invoice.id,
741
+ draftBasis: basisOf(obligation),
742
+ ...(obligation.accountingState === 'ambiguous' ? { accountingState: 'unknown' } : {}),
743
+ ...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
744
+ });
745
+ await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
746
+ return row;
747
+ });
748
+ }
749
+
671
750
  // The obligation's view and the reasons for it, from its stored evidence only.
672
751
  // `accounting` is false while no accounting system is connected: Flow's own
673
752
  // records are then the only check, so a job nobody checked in one can draft.
@@ -202,7 +202,7 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
202
202
  return {
203
203
  sourceSystem: 'seazona',
204
204
  sourceJobRef: order.id,
205
- // The Seazona order id is the case number. Never patient data.
205
+ // The Seazona order id is the case number.
206
206
  caseRef: order.id,
207
207
  status,
208
208
  clientId: order.clientId,
@@ -211,6 +211,9 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
211
211
  department: order.department,
212
212
  orderedOn: parseSeazonaDate(order.orderedText),
213
213
  dueOn: parseSeazonaDate(order.dueText),
214
+ // The patient, only when this installation keeps patient names
215
+ // (SEAZONA_KEEP_PATIENT_REFERENCE); otherwise the copy has none.
216
+ patient: order.patientReference ?? null,
214
217
  lines: (order.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null })),
215
218
  totalCents: order.subtotalCents,
216
219
  };
@@ -0,0 +1,108 @@
1
+ // How sure Flow is that a Xero invoice in the copy is one Flow invoice
2
+ // (docs/XERO_IMPORT.md, "Sure match"). The checks are the ones the job
3
+ // matcher learned on work-system jobs against Xero (server/core/pipeline.js
4
+ // scoreCandidate), plus what the Flow invoice's own jobs know: the matcher's
5
+ // tie to this Xero invoice, and the work system's invoice number.
6
+ //
7
+ // A pair is sure only when one piece of hard evidence ties it, or when the
8
+ // score is high and clearly ahead; and in both cases only when it is the one
9
+ // fit both ways (`sureFits`).
10
+
11
+ export const SURE_SCORE = 11; // total, same items, and within 3 days
12
+ export const SURE_LEAD = 3;
13
+ export const POOL_DAYS = 14;
14
+ const day = (iso) => Date.parse(`${iso}T00:00:00Z`);
15
+ const daysApart = (a, b) => (a && b ? Math.abs(day(a) - day(b)) / 86_400_000 : null);
16
+ const withGst = (cents) => Math.round(cents * 1.1);
17
+ const withoutReissue = (ref) => String(ref ?? '').trim().replace(/R$/i, '').toLowerCase();
18
+ const words = (text) => String(text ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
19
+
20
+ // xero: { id, number, contactId, totalCents, issuedOn, itemCodes[], reference }
21
+ // flow: { id, contactId, totalCents, subtotalCents, date, itemCodes[], reference,
22
+ // jobs: [{ accountingRef, sourceInvoiceRef, practitioner }] }
23
+ // Answers { score, signals, hard }; `hard` names the evidence that ties them.
24
+ export function scoreFit(xero, flow) {
25
+ const signals = [];
26
+ const add = (signal, weight) => signals.push({ signal, weight });
27
+ const jobs = flow.jobs ?? [];
28
+ let hard = null;
29
+ if (jobs.some((j) => j.accountingRef && j.accountingRef === xero.id)) hard = 'job_tied';
30
+ else if (xero.number && jobs.some((j) => j.sourceInvoiceRef && withoutReissue(j.sourceInvoiceRef) === withoutReissue(xero.number))) hard = 'work_invoice_number';
31
+ if (hard) add(hard, 10);
32
+ if (xero.contactId && xero.contactId === flow.contactId) add('client', 2);
33
+ if (xero.totalCents === flow.totalCents) add('total', 6);
34
+ else if ([flow.subtotalCents, withGst(flow.subtotalCents)].includes(xero.totalCents)) add('total_gst', 5);
35
+ const ours = new Set(flow.itemCodes ?? []);
36
+ const theirs = new Set(xero.itemCodes ?? []);
37
+ if (ours.size && theirs.size) {
38
+ const shared = [...ours].filter((c) => theirs.has(c)).length;
39
+ if (shared === ours.size && shared === theirs.size) add('items_same', 3);
40
+ else if (shared) add('items_overlap', 1);
41
+ else add('items_differ', -3);
42
+ }
43
+ const apart = daysApart(xero.issuedOn, flow.date);
44
+ if (apart !== null && apart <= 3) add('date_close', 2);
45
+ else if (apart !== null && apart <= POOL_DAYS) add('date_near', 1);
46
+ // A Reference is in the copy unless XERO_KEEP_REFERENCE is off.
47
+ if (xero.reference && flow.reference) {
48
+ if (words(xero.reference) === words(flow.reference)) add('reference_same', 3);
49
+ else add('reference_differs', -2);
50
+ } else if (xero.reference && jobs.some((j) => j.practitioner && words(xero.reference).includes(words(j.practitioner)))) {
51
+ add('practitioner', 1);
52
+ }
53
+ return { score: signals.reduce((sum, s) => sum + s.weight, 0), signals, hard };
54
+ }
55
+
56
+ // Whether the pair may be offered at all: hard evidence, or the same client
57
+ // with an amount that fits and dates within two weeks.
58
+ export function canFit(xero, flow, scored) {
59
+ if (scored.hard) return true;
60
+ const amount = scored.signals.some((s) => s.signal === 'total' || s.signal === 'total_gst');
61
+ const near = scored.signals.some((s) => s.signal === 'date_close' || s.signal === 'date_near');
62
+ return Boolean(xero.contactId && xero.contactId === flow.contactId && amount && near);
63
+ }
64
+
65
+ // pairs: [{ xeroId, flowId, apart, fit }], `fit` being the candidate with its
66
+ // score. A fit loses RIVAL_WEIGHT, and is marked `rival`, when the same Flow
67
+ // invoice has another Xero invoice dated at least RIVAL_DAYS closer to it, or
68
+ // the same Xero invoice has another Flow invoice that much closer. Fits a day
69
+ // or two apart stay level, so a person picks.
70
+ export const RIVAL_WEIGHT = 4;
71
+ export const RIVAL_DAYS = 3;
72
+ export function markRivals(pairs) {
73
+ for (const p of pairs) {
74
+ if (p.fit.hard) continue;
75
+ const closer = pairs.some((o) => o !== p && o.apart + RIVAL_DAYS <= p.apart && (o.flowId === p.flowId || o.xeroId === p.xeroId));
76
+ if (closer) {
77
+ p.fit.score -= RIVAL_WEIGHT;
78
+ p.fit.rival = true;
79
+ }
80
+ }
81
+ }
82
+
83
+ // pairs: [{ xeroId, flowId, score, hard }]. Answers the set of `xeroId` whose
84
+ // best fit is sure: hard evidence or SURE_SCORE, at least SURE_LEAD ahead of
85
+ // that Xero invoice's next fit, and that Flow invoice's best fit back.
86
+ export function sureFits(pairs) {
87
+ const byXero = new Map();
88
+ const byFlow = new Map();
89
+ for (const p of pairs) {
90
+ if (!byXero.has(p.xeroId)) byXero.set(p.xeroId, []);
91
+ if (!byFlow.has(p.flowId)) byFlow.set(p.flowId, []);
92
+ byXero.get(p.xeroId).push(p);
93
+ byFlow.get(p.flowId).push(p);
94
+ }
95
+ const ranked = (list) => [...list].sort((a, b) => b.score - a.score);
96
+ const clear = (list) => {
97
+ const [top, next] = ranked(list);
98
+ if (!top || !(top.hard || top.score >= SURE_SCORE)) return null;
99
+ if (next && (next.hard || top.score - next.score < SURE_LEAD)) return null;
100
+ return top;
101
+ };
102
+ const sure = new Map();
103
+ for (const [xeroId, list] of byXero) {
104
+ const top = clear(list);
105
+ if (top && clear(byFlow.get(top.flowId))?.xeroId === xeroId) sure.set(xeroId, top.flowId);
106
+ }
107
+ return sure;
108
+ }
@@ -5,6 +5,7 @@ import { isCallsStopped } from '../call-guard.js';
5
5
  import { lastSync, syncCatalogue } from './catalogue.js';
6
6
  import { mirrorState } from './lookup.js';
7
7
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
8
+ import { matchXero } from './links.js';
8
9
  import { checkSentInvoices } from './reconcile.js';
9
10
  import { runWebhookWork, WEBHOOK_REFRESH_MS, webhooksActive } from './webhooks.js';
10
11
 
@@ -82,7 +83,11 @@ export async function runImportSlice(db, runtime, job, { maxPages = SLICE_PAGES
82
83
  const sent = invoices && (invoices.inserted || invoices.changed || job.payload?.reason !== 'changes')
83
84
  ? await checkSentInvoices(db, runtime, job.organisationId)
84
85
  : null;
85
- return { results, more: stopped, ...(sent ? { sent } : {}) };
86
+ // Each Xero invoice against Flow's own (links.js): sure fits are linked, and
87
+ // with the Import setting on, a Flow invoice is made for each with no fit.
88
+ // Flow's database only.
89
+ const matched = sent ? await matchXero(db, job.organisationId) : null;
90
+ return { results, more: stopped, ...(sent ? { sent } : {}), ...(matched ? { matched } : {}) };
86
91
  }
87
92
 
88
93
  // Claims and runs one due import job, then queues the next slice when one is