@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 +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +2 -1
- package/server/api/invoicing.js +50 -1
- package/server/core/custom-fields.js +18 -0
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +80 -1
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/reconcile.js +17 -11
- package/server/integrations/xero/routes.js +42 -0
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +54 -0
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/flow.css +141 -0
- package/server/ui/job-board.js +8 -6
- package/server/ui/job-view.js +30 -7
- package/server/ui/jobs.js +443 -26
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
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 |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
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
|
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
|
}));
|
package/server/api/invoicing.js
CHANGED
|
@@ -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,
|
|
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');
|
package/server/core/job-board.js
CHANGED
|
@@ -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
|
+
}
|
package/server/core/ledger.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|