@arcforgelabs/flow 2026.10.25 → 2026.10.27
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 +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +5 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
+
import {
|
|
4
|
+
billingObligations, contacts, externalLinks, linkRejections, linkSuggestions, obligationEvents, organisations, pipelineCursors,
|
|
5
|
+
} from '../db/schema.js';
|
|
6
|
+
import { FlowError } from './errors.js';
|
|
7
|
+
import { runAutomations } from './automations.js';
|
|
8
|
+
import { readInvoicingSettings } from './invoicing-rules.js';
|
|
9
|
+
import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
|
|
10
|
+
import { linkRecord, priorLinkFor } from './links.js';
|
|
11
|
+
import { enqueueJob, runNextJob } from './queue.js';
|
|
12
|
+
|
|
13
|
+
// The pipeline (docs/DATA_SPINE.md, issue 58). It joins what the integrations
|
|
14
|
+
// copy into Flow, and never writes to another system:
|
|
15
|
+
//
|
|
16
|
+
// 1. Customers: a work system's client and an accounting contact link to the
|
|
17
|
+
// Flow customer with the same name (`exact_name`), when exactly one record
|
|
18
|
+
// on each side has it. Anything looser waits for a person.
|
|
19
|
+
// 2. Work: each billable job the work system reports becomes, or updates, its
|
|
20
|
+
// billing obligation (ledger.js), with the Flow customer its client links to.
|
|
21
|
+
// 3. Matching: each job is compared with the accounting system's copy of its
|
|
22
|
+
// sales invoices. Exact evidence links the job. A likely invoice is a
|
|
23
|
+
// suggestion for a person. When the copy is complete and fresh and holds no
|
|
24
|
+
// invoice that could bill the job, the job has none.
|
|
25
|
+
// 4. Automations (automations.js): the routines that are on, for example
|
|
26
|
+
// marking a job ready to invoice from its work system status, and drafting
|
|
27
|
+
// the invoice of a ready job. Drafts wait in Flow for a person.
|
|
28
|
+
//
|
|
29
|
+
// The matcher reads only Flow's copies, so matching years of history costs no
|
|
30
|
+
// API calls (docs/JOB_INVOICE_LINKS.md).
|
|
31
|
+
|
|
32
|
+
export const ACTOR = 'system:matcher';
|
|
33
|
+
export const PIPELINE_KIND = 'pipeline.run';
|
|
34
|
+
const RUN_EVERY_MS = 2 * 60 * 1000;
|
|
35
|
+
const LEASE_MS = 15 * 60 * 1000;
|
|
36
|
+
const WORK_PAGES = 20;
|
|
37
|
+
const MATCH_BATCH = 500;
|
|
38
|
+
// A job older than this with no invoice found is held for a person: it may be
|
|
39
|
+
// work billed some other way, long ago.
|
|
40
|
+
export const STALE_DAYS = 90;
|
|
41
|
+
// How far an invoice date may be from the job date and still be a candidate.
|
|
42
|
+
const BEFORE_DAYS = 7;
|
|
43
|
+
const AFTER_DAYS = 45;
|
|
44
|
+
// A suggestion needs this score and this lead over the next candidate.
|
|
45
|
+
export const SUGGEST_SCORE = 8;
|
|
46
|
+
export const SUGGEST_LEAD = 3;
|
|
47
|
+
|
|
48
|
+
// A name with case, spacing, punctuation and a company suffix evened out, so
|
|
49
|
+
// "Example Dental Pty. Ltd." and "example dental" compare equal.
|
|
50
|
+
export function normaliseName(name) {
|
|
51
|
+
return String(name ?? '')
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replaceAll('&', ' and ')
|
|
54
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/\s+(pty\s+)?(ltd|limited)$/u, '')
|
|
57
|
+
.replace(/\s+/g, ' ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The one organisation of this installation, or null when there are none or
|
|
61
|
+
// several. The adapters' tables are installation-wide today (DATA_SPINE.md,
|
|
62
|
+
// phase 2), so the pipeline runs only for a single organisation.
|
|
63
|
+
export async function soleOrganisation(db) {
|
|
64
|
+
const rows = await db.select({ id: organisations.id }).from(organisations).limit(2);
|
|
65
|
+
return rows.length === 1 ? rows[0].id : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function activeLinks(db, organisationId, { system, entity }) {
|
|
69
|
+
return db.select({ flowId: externalLinks.flowId, externalId: externalLinks.externalId }).from(externalLinks).where(and(
|
|
70
|
+
eq(externalLinks.organisationId, organisationId),
|
|
71
|
+
eq(externalLinks.system, system),
|
|
72
|
+
eq(externalLinks.entity, entity),
|
|
73
|
+
eq(externalLinks.state, 'active'),
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function byName(records) {
|
|
78
|
+
const map = new Map();
|
|
79
|
+
for (const record of records) {
|
|
80
|
+
const key = normaliseName(record.name);
|
|
81
|
+
if (!key) continue;
|
|
82
|
+
map.set(key, [...(map.get(key) ?? []), record]);
|
|
83
|
+
}
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Links each customer of one system to the Flow customer with the same name,
|
|
88
|
+
// when one record on each side has it and neither side is linked yet.
|
|
89
|
+
export async function linkCustomers(db, organisationId, { system, customers }) {
|
|
90
|
+
const flow = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
91
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
|
|
92
|
+
const links = await activeLinks(db, organisationId, { system, entity: 'contact' });
|
|
93
|
+
const linkedFlow = new Set(links.map((l) => l.flowId));
|
|
94
|
+
const linkedExternal = new Set(links.map((l) => l.externalId));
|
|
95
|
+
const flowByName = byName(flow);
|
|
96
|
+
const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0 };
|
|
97
|
+
for (const [key, external] of byName(customers)) {
|
|
98
|
+
const own = flowByName.get(key) ?? [];
|
|
99
|
+
for (const customer of external) {
|
|
100
|
+
if (linkedExternal.has(String(customer.id))) result.alreadyLinked += 1;
|
|
101
|
+
}
|
|
102
|
+
if (external.length !== 1 || own.length !== 1) {
|
|
103
|
+
const waiting = external.filter((c) => !linkedExternal.has(String(c.id))).length;
|
|
104
|
+
if (own.length === 0) result.unmatched += waiting;
|
|
105
|
+
else result.sameName += waiting;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const [customer] = external;
|
|
109
|
+
const [contact] = own;
|
|
110
|
+
if (linkedExternal.has(String(customer.id)) || linkedFlow.has(contact.id)) continue;
|
|
111
|
+
const { outcome } = await linkRecord(db, organisationId, {
|
|
112
|
+
entity: 'contact', flowId: contact.id, system, externalId: String(customer.id), matchedBy: 'exact_name',
|
|
113
|
+
});
|
|
114
|
+
if (outcome === 'linked') result.linked += 1;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readCursor(db, organisationId, name) {
|
|
120
|
+
const [row] = await db.select().from(pipelineCursors)
|
|
121
|
+
.where(and(eq(pipelineCursors.organisationId, organisationId), eq(pipelineCursors.name, name)));
|
|
122
|
+
return row?.cursor ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function writeCursor(db, organisationId, name, cursor) {
|
|
126
|
+
await db.insert(pipelineCursors).values({ organisationId, name, cursor })
|
|
127
|
+
.onConflictDoUpdate({ target: [pipelineCursors.organisationId, pipelineCursors.name], set: { cursor, updatedAt: sql`now()` } });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Reads the work system's billable jobs since the last run into the ledger.
|
|
131
|
+
// `restart` reads every job again, for example after new customer links.
|
|
132
|
+
export async function ingestWork(db, organisationId, { adapter, restart = false, pages = WORK_PAGES }) {
|
|
133
|
+
const name = `${adapter.id}.work`;
|
|
134
|
+
let cursor = restart ? null : await readCursor(db, organisationId, name);
|
|
135
|
+
const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
|
|
136
|
+
const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
|
|
137
|
+
const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, more: false };
|
|
138
|
+
for (let page = 0; page < pages; page += 1) {
|
|
139
|
+
const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
|
|
140
|
+
for (const item of items) {
|
|
141
|
+
const contactId = contactOf.get(String(item.clientId ?? '')) ?? null;
|
|
142
|
+
try {
|
|
143
|
+
await observeJob(db, organisationId, { ...item.ledgerJob, contactId });
|
|
144
|
+
result.observed += 1;
|
|
145
|
+
if (!contactId) result.withoutCustomer += 1;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// A job with no lines cannot be billed; it comes back when it changes.
|
|
148
|
+
if (!(error instanceof FlowError) || error.code !== 'invalid') throw error;
|
|
149
|
+
result.skipped += 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const entry of withdrawn) {
|
|
153
|
+
if (!entry.ledgerHold) continue;
|
|
154
|
+
const { sourceSystem, sourceJobRef, holdState, reason } = entry.ledgerHold;
|
|
155
|
+
const [obligation] = await db.select().from(billingObligations).where(and(
|
|
156
|
+
eq(billingObligations.organisationId, organisationId),
|
|
157
|
+
eq(billingObligations.sourceSystem, sourceSystem),
|
|
158
|
+
eq(billingObligations.sourceJobRef, sourceJobRef),
|
|
159
|
+
));
|
|
160
|
+
if (!obligation || obligation.holdState === holdState) continue;
|
|
161
|
+
await setHold(db, organisationId, obligation.id, { holdState, reason, actor: `system:${adapter.id}` });
|
|
162
|
+
result.sentToReview += 1;
|
|
163
|
+
}
|
|
164
|
+
cursor = next;
|
|
165
|
+
await writeCursor(db, organisationId, name, cursor);
|
|
166
|
+
if (!more) return result;
|
|
167
|
+
}
|
|
168
|
+
result.more = true;
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const day = (iso) => Date.parse(`${iso}T00:00:00Z`);
|
|
173
|
+
const addDays = (iso, days) => new Date(day(iso) + days * 86_400_000).toISOString().slice(0, 10);
|
|
174
|
+
const daysApart = (a, b) => Math.abs(day(a) - day(b)) / 86_400_000;
|
|
175
|
+
const withGst = (cents) => Math.round(cents * 1.1);
|
|
176
|
+
|
|
177
|
+
// The totals an invoice for the job could carry: with GST, without it, and
|
|
178
|
+
// the subtotal plus 10% when the work system reported no tax.
|
|
179
|
+
function totalsFor(obligation) {
|
|
180
|
+
return new Set([obligation.totalCents, obligation.subtotalCents, withGst(obligation.subtotalCents)]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Scores one accounting invoice against one job. Answers the score and each
|
|
184
|
+
// signal that agreed, so a person can see why.
|
|
185
|
+
// `rivals` are the customer's other unlinked jobs: an invoice dated closer to
|
|
186
|
+
// one of them, at an amount that fits it, more likely bills that job.
|
|
187
|
+
export function scoreCandidate(obligation, invoice, { prior = null, rivals = [] } = {}) {
|
|
188
|
+
const signals = [];
|
|
189
|
+
if (invoice.totalCents === obligation.totalCents) signals.push({ signal: 'total', weight: 6 });
|
|
190
|
+
else if (totalsFor(obligation).has(invoice.totalCents)) signals.push({ signal: 'total_gst', weight: 5 });
|
|
191
|
+
const codes = new Set((obligation.lines ?? []).map((l) => l.itemCode).filter(Boolean));
|
|
192
|
+
const theirs = new Set(invoice.itemCodes ?? []);
|
|
193
|
+
if (codes.size && theirs.size) {
|
|
194
|
+
const shared = [...codes].filter((c) => theirs.has(c)).length;
|
|
195
|
+
if (shared === codes.size && shared === theirs.size) signals.push({ signal: 'items_same', weight: 3 });
|
|
196
|
+
else if (shared) signals.push({ signal: 'items_overlap', weight: 1 });
|
|
197
|
+
}
|
|
198
|
+
if (obligation.jobDate && invoice.issuedOn) {
|
|
199
|
+
const apart = daysApart(obligation.jobDate, invoice.issuedOn);
|
|
200
|
+
if (apart <= 3) signals.push({ signal: 'date_close', weight: 2 });
|
|
201
|
+
else if (apart <= 14) signals.push({ signal: 'date_near', weight: 1 });
|
|
202
|
+
}
|
|
203
|
+
if (prior && prior.externalId === invoice.id) signals.push({ signal: 'prior_link', weight: 4 });
|
|
204
|
+
if (obligation.jobDate && invoice.issuedOn) {
|
|
205
|
+
const ours = daysApart(obligation.jobDate, invoice.issuedOn);
|
|
206
|
+
const closer = rivals.some((r) => r.id !== obligation.id && r.jobDate
|
|
207
|
+
&& totalsFor(r).has(invoice.totalCents) && daysApart(r.jobDate, invoice.issuedOn) < ours);
|
|
208
|
+
if (closer) signals.push({ signal: 'closer_job', weight: -4 });
|
|
209
|
+
}
|
|
210
|
+
if (['voided', 'deleted'].includes(invoice.state)) signals.push({ signal: 'not_live', weight: -3 });
|
|
211
|
+
return { score: signals.reduce((sum, s) => sum + s.weight, 0), signals };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A candidate counts only when its total or an earlier link ties it to the
|
|
215
|
+
// job; an invoice for the same customer at another amount is other work.
|
|
216
|
+
const relevant = (scored) => scored.signals.some((s) => ['total', 'total_gst', 'prior_link'].includes(s.signal));
|
|
217
|
+
|
|
218
|
+
// Ranks the candidates and decides: `suggested` (one clear leader), `unsure`
|
|
219
|
+
// (several, or a weak one) or `none`.
|
|
220
|
+
export function rankCandidates(obligation, invoices, { prior = null, rivals = [] } = {}) {
|
|
221
|
+
const ranked = invoices
|
|
222
|
+
.map((invoice) => ({ invoice, ...scoreCandidate(obligation, invoice, { prior, rivals }) }))
|
|
223
|
+
.filter(relevant)
|
|
224
|
+
.sort((a, b) => b.score - a.score || String(a.invoice.issuedOn).localeCompare(String(b.invoice.issuedOn)));
|
|
225
|
+
if (!ranked.length) return { decision: 'none', ranked };
|
|
226
|
+
const [top, next] = ranked;
|
|
227
|
+
const clear = top.score >= SUGGEST_SCORE && (!next || top.score - next.score >= SUGGEST_LEAD);
|
|
228
|
+
return { decision: clear ? 'suggested' : 'unsure', ranked };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const amountsAgree = (obligation, invoice) => totalsFor(obligation).has(invoice.totalCents);
|
|
232
|
+
const withoutReissue = (ref) => String(ref).replace(/R$/i, '');
|
|
233
|
+
|
|
234
|
+
async function saveSuggestions(db, organisationId, obligationId, system, ranked) {
|
|
235
|
+
const keep = ranked.slice(0, 5);
|
|
236
|
+
await db.transaction(async (tx) => {
|
|
237
|
+
await tx.update(linkSuggestions)
|
|
238
|
+
.set({ state: 'stale', updatedAt: sql`now()` })
|
|
239
|
+
.where(and(
|
|
240
|
+
eq(linkSuggestions.organisationId, organisationId),
|
|
241
|
+
eq(linkSuggestions.obligationId, obligationId),
|
|
242
|
+
eq(linkSuggestions.state, 'open'),
|
|
243
|
+
));
|
|
244
|
+
for (const [index, { invoice, score, signals }] of keep.entries()) {
|
|
245
|
+
await tx.insert(linkSuggestions)
|
|
246
|
+
.values({ organisationId, obligationId, system, externalId: invoice.id, externalNumber: invoice.number, score, rank: index + 1, signals })
|
|
247
|
+
.onConflictDoUpdate({
|
|
248
|
+
target: [linkSuggestions.organisationId, linkSuggestions.obligationId, linkSuggestions.system, linkSuggestions.externalId],
|
|
249
|
+
// A person's decision stays.
|
|
250
|
+
set: {
|
|
251
|
+
score, rank: index + 1, signals, externalNumber: invoice.number,
|
|
252
|
+
state: sql`case when ${linkSuggestions.state} in ('accepted', 'rejected') then ${linkSuggestions.state} else 'open' end`,
|
|
253
|
+
updatedAt: sql`now()`,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Decides one job's accounting evidence from the mirror. Answers what it found
|
|
261
|
+
// in the ledger's evidence shape (or null to change nothing), and the
|
|
262
|
+
// candidates to keep as suggestions.
|
|
263
|
+
export async function matchJob(db, organisationId, obligation, {
|
|
264
|
+
accounting, xeroContactId, mirror, rejected = new Set(), taken = new Map(), rivals = [],
|
|
265
|
+
}) {
|
|
266
|
+
const find = async (query) => (await accounting.capabilities.findInvoices({ organisationId, ...query })).invoices;
|
|
267
|
+
const exact = (invoice, matchedBy) => ({ evidence: { state: invoice.state, accountingRef: invoice.id, matchedBy }, ranked: [] });
|
|
268
|
+
|
|
269
|
+
// Linked already: follow the invoice through its life (lifecycle).
|
|
270
|
+
if (obligation.accountingRef) {
|
|
271
|
+
const [invoice] = await find({ invoiceIds: [obligation.accountingRef] });
|
|
272
|
+
return invoice ? exact(invoice, 'accounting_ref') : { evidence: null, ranked: [] };
|
|
273
|
+
}
|
|
274
|
+
if (obligation.invoiceId) {
|
|
275
|
+
const sent = await find({ flowInvoiceIds: [obligation.invoiceId] });
|
|
276
|
+
if (sent.length === 1) return exact(sent[0], 'flow_invoice');
|
|
277
|
+
}
|
|
278
|
+
const prior = await priorLinkFor(db, organisationId, {
|
|
279
|
+
sourceSystem: obligation.sourceSystem, sourceJobRef: obligation.sourceJobRef, system: accounting.id,
|
|
280
|
+
});
|
|
281
|
+
if (prior) {
|
|
282
|
+
const [invoice] = await find({ invoiceIds: [prior.externalId] });
|
|
283
|
+
if (invoice && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
284
|
+
return exact(invoice, 'prior_link');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (obligation.sourceInvoiceRef) {
|
|
288
|
+
const numbers = [obligation.sourceInvoiceRef, withoutReissue(obligation.sourceInvoiceRef)];
|
|
289
|
+
const same = (await find({ numbers }))
|
|
290
|
+
.filter((i) => !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
291
|
+
if (same.length === 1) return exact(same[0], 'source_invoice_ref');
|
|
292
|
+
}
|
|
293
|
+
// Without the customer's accounting contact there is nothing to compare.
|
|
294
|
+
if (!xeroContactId) return { evidence: null, ranked: [], reason: 'no_contact' };
|
|
295
|
+
const window = obligation.jobDate
|
|
296
|
+
? { from: addDays(obligation.jobDate, -BEFORE_DAYS), to: addDays(obligation.jobDate, AFTER_DAYS) }
|
|
297
|
+
: {};
|
|
298
|
+
const candidates = (await find({ contactId: xeroContactId, ...window }))
|
|
299
|
+
// An invoice whose whole total other jobs already account for is theirs.
|
|
300
|
+
.filter((i) => !rejected.has(i.id) && (taken.get(i.id) ?? 0) < (i.totalCents ?? 0));
|
|
301
|
+
const { decision, ranked } = rankCandidates(obligation, candidates, { prior, rivals });
|
|
302
|
+
if (decision === 'none') {
|
|
303
|
+
// Only a complete, fresh copy proves that Xero holds no invoice.
|
|
304
|
+
if (!obligation.jobDate || !mirror.complete || !mirror.fresh) return { evidence: null, ranked: [] };
|
|
305
|
+
return { evidence: { state: 'not_found', accountingRef: null, matchedBy: null }, ranked: [] };
|
|
306
|
+
}
|
|
307
|
+
const [top] = ranked;
|
|
308
|
+
return { evidence: { state: top.invoice.state, accountingRef: top.invoice.id, matchedBy: decision === 'suggested' ? 'scored' : 'candidates' }, ranked, decision };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function holdIfStale(db, organisationId, obligation, { now, staleDays }) {
|
|
312
|
+
if (!staleDays || !obligation.jobDate || obligation.holdState !== 'none') return false;
|
|
313
|
+
if (now - day(obligation.jobDate) <= staleDays * 86_400_000) return false;
|
|
314
|
+
// Once only: a person who releases the job has decided.
|
|
315
|
+
const [held] = await db.select({ id: obligationEvents.id }).from(obligationEvents).where(and(
|
|
316
|
+
eq(obligationEvents.organisationId, organisationId),
|
|
317
|
+
eq(obligationEvents.obligationId, obligation.id),
|
|
318
|
+
eq(obligationEvents.actor, ACTOR),
|
|
319
|
+
eq(obligationEvents.kind, 'held'),
|
|
320
|
+
)).limit(1);
|
|
321
|
+
if (held) return false;
|
|
322
|
+
await setHold(db, organisationId, obligation.id, {
|
|
323
|
+
holdState: 'held',
|
|
324
|
+
reason: `the job is more than ${staleDays} days old and no accounting invoice was found for it`,
|
|
325
|
+
actor: ACTOR,
|
|
326
|
+
});
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Matches every job that changed, or whose candidates may have changed, since
|
|
331
|
+
// it was last checked. Answers counts by outcome.
|
|
332
|
+
export async function matchJobs(db, organisationId, { accounting, now = new Date(), staleDays = STALE_DAYS, limit = MATCH_BATCH }) {
|
|
333
|
+
const mirror = await accounting.capabilities.findInvoices({ organisationId });
|
|
334
|
+
const result = { checked: 0, linked: 0, followed: 0, suggested: 0, unsure: 0, notFound: 0, noContact: 0, held: 0, waiting: 0 };
|
|
335
|
+
if (!mirror.complete) return { ...result, skipped: 'the accounting copy is still importing' };
|
|
336
|
+
const contactLinks = await activeLinks(db, organisationId, { system: accounting.id, entity: 'contact' });
|
|
337
|
+
const xeroContactOf = new Map(contactLinks.map((l) => [l.flowId, l.externalId]));
|
|
338
|
+
const since = mirror.changedAt ?? new Date(0);
|
|
339
|
+
const due = await db.select().from(billingObligations).where(and(
|
|
340
|
+
eq(billingObligations.organisationId, organisationId),
|
|
341
|
+
sql`${billingObligations.accountingState} <> 'conflict'`,
|
|
342
|
+
sql`(${billingObligations.accountingCheckedAt} is null
|
|
343
|
+
or ${billingObligations.accountingCheckedAt} < ${billingObligations.sourceSeenAt}
|
|
344
|
+
or ${billingObligations.accountingCheckedAt} < ${since.toISOString()}::timestamptz)`,
|
|
345
|
+
)).orderBy(sql`${billingObligations.accountingCheckedAt} asc nulls first`).limit(limit);
|
|
346
|
+
if (!due.length) return result;
|
|
347
|
+
const ids = due.map((o) => o.id);
|
|
348
|
+
const rejections = await db.select().from(linkRejections).where(and(
|
|
349
|
+
eq(linkRejections.organisationId, organisationId),
|
|
350
|
+
eq(linkRejections.system, accounting.id),
|
|
351
|
+
inArray(linkRejections.obligationId, ids),
|
|
352
|
+
));
|
|
353
|
+
const rejectedFor = new Map();
|
|
354
|
+
for (const r of rejections) rejectedFor.set(r.obligationId, (rejectedFor.get(r.obligationId) ?? new Set()).add(r.externalId));
|
|
355
|
+
// How much of each invoice the jobs linked to it already account for.
|
|
356
|
+
const { rows: linked } = await db.execute(sql`
|
|
357
|
+
select accounting_ref, sum(total_cents)::bigint as cents from billing_obligations
|
|
358
|
+
where organisation_id = ${organisationId} and accounting_ref is not null group by accounting_ref`);
|
|
359
|
+
const taken = new Map(linked.map((r) => [r.accounting_ref, Number(r.cents)]));
|
|
360
|
+
// Each customer's unlinked jobs, so two jobs at the same price share out
|
|
361
|
+
// their invoices by date.
|
|
362
|
+
const contactIds = [...new Set(due.map((o) => o.contactId).filter(Boolean))];
|
|
363
|
+
const open = contactIds.length
|
|
364
|
+
? await db.select({
|
|
365
|
+
id: billingObligations.id, contactId: billingObligations.contactId, jobDate: billingObligations.jobDate,
|
|
366
|
+
totalCents: billingObligations.totalCents, subtotalCents: billingObligations.subtotalCents,
|
|
367
|
+
}).from(billingObligations).where(and(
|
|
368
|
+
eq(billingObligations.organisationId, organisationId),
|
|
369
|
+
inArray(billingObligations.contactId, contactIds),
|
|
370
|
+
sql`${billingObligations.accountingRef} is null`,
|
|
371
|
+
))
|
|
372
|
+
: [];
|
|
373
|
+
const rivalsOf = new Map();
|
|
374
|
+
for (const o of open) rivalsOf.set(o.contactId, [...(rivalsOf.get(o.contactId) ?? []), o]);
|
|
375
|
+
|
|
376
|
+
for (const obligation of due) {
|
|
377
|
+
result.checked += 1;
|
|
378
|
+
const { evidence, ranked, decision, reason } = await matchJob(db, organisationId, obligation, {
|
|
379
|
+
accounting,
|
|
380
|
+
xeroContactId: obligation.contactId ? xeroContactOf.get(obligation.contactId) ?? null : null,
|
|
381
|
+
mirror,
|
|
382
|
+
rejected: rejectedFor.get(obligation.id) ?? new Set(),
|
|
383
|
+
taken,
|
|
384
|
+
rivals: rivalsOf.get(obligation.contactId) ?? [],
|
|
385
|
+
});
|
|
386
|
+
await saveSuggestions(db, organisationId, obligation.id, accounting.id, ranked);
|
|
387
|
+
if (!evidence) {
|
|
388
|
+
if (reason === 'no_contact') result.noContact += 1;
|
|
389
|
+
else result.waiting += 1;
|
|
390
|
+
// Seen, so the next run waits for a change before it looks again.
|
|
391
|
+
await db.update(billingObligations).set({ accountingCheckedAt: now })
|
|
392
|
+
.where(eq(billingObligations.id, obligation.id));
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const before = obligation.accountingRef;
|
|
396
|
+
await recordAccountingEvidence(db, organisationId, obligation.id, { ...evidence, checkedAt: now, actor: ACTOR });
|
|
397
|
+
if (evidence.state === 'not_found') {
|
|
398
|
+
result.notFound += 1;
|
|
399
|
+
if (await holdIfStale(db, organisationId, obligation, { now, staleDays })) result.held += 1;
|
|
400
|
+
} else if (decision === 'suggested') result.suggested += 1;
|
|
401
|
+
else if (decision === 'unsure') result.unsure += 1;
|
|
402
|
+
else if (before) result.followed += 1;
|
|
403
|
+
else {
|
|
404
|
+
result.linked += 1;
|
|
405
|
+
taken.set(evidence.accountingRef, (taken.get(evidence.accountingRef) ?? 0) + obligation.totalCents);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// One pass of the whole pipeline for the installation's organisation.
|
|
412
|
+
export async function runPipelineOnce(db, integrations, { now = new Date() } = {}) {
|
|
413
|
+
const organisationId = await soleOrganisation(db);
|
|
414
|
+
if (!organisationId) return { skipped: 'the installation needs exactly one organisation' };
|
|
415
|
+
const summary = { organisationId };
|
|
416
|
+
const [work] = integrations.with('pullWorkItems');
|
|
417
|
+
const [accounting] = integrations.with('findInvoices');
|
|
418
|
+
if (work) {
|
|
419
|
+
let restart = false;
|
|
420
|
+
if (typeof work.capabilities.listCustomers === 'function') {
|
|
421
|
+
summary.workCustomers = await linkCustomers(db, organisationId, { system: work.id, customers: await work.capabilities.listCustomers({ organisationId }) });
|
|
422
|
+
// New customer links reach jobs read before them.
|
|
423
|
+
restart = summary.workCustomers.linked > 0;
|
|
424
|
+
}
|
|
425
|
+
summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
|
|
426
|
+
}
|
|
427
|
+
if (accounting) {
|
|
428
|
+
if (typeof accounting.capabilities.listCustomers === 'function') {
|
|
429
|
+
summary.accountingCustomers = await linkCustomers(db, organisationId, {
|
|
430
|
+
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
434
|
+
summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
|
|
435
|
+
}
|
|
436
|
+
if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
|
|
437
|
+
return summary;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The background worker: one pipeline run every two minutes, on the job
|
|
441
|
+
// queue, so two Flow processes never run it at once. It only reads other
|
|
442
|
+
// systems' copies and writes Flow's own records.
|
|
443
|
+
export function createPipelineWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = RUN_EVERY_MS, firstDelayMs = 15_000 }) {
|
|
444
|
+
const workerId = `pipeline-${randomUUID()}`;
|
|
445
|
+
let timer = null;
|
|
446
|
+
let running = null;
|
|
447
|
+
let stopped = true;
|
|
448
|
+
let last = '';
|
|
449
|
+
|
|
450
|
+
function schedule(delayMs) {
|
|
451
|
+
if (stopped) return;
|
|
452
|
+
timer = timers.setTimeout(tick, delayMs);
|
|
453
|
+
timer?.unref?.();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function tick() {
|
|
457
|
+
timer = null;
|
|
458
|
+
if (stopped) return;
|
|
459
|
+
running = (async () => {
|
|
460
|
+
try {
|
|
461
|
+
if (!integrations.with('pullWorkItems').length && !integrations.with('findInvoices').length) return;
|
|
462
|
+
const organisationId = await soleOrganisation(db);
|
|
463
|
+
if (!organisationId) return;
|
|
464
|
+
await enqueueJob(db, organisationId, { kind: PIPELINE_KIND, dedupeKey: 'all' });
|
|
465
|
+
const ran = await runNextJob(db, {
|
|
466
|
+
workerId,
|
|
467
|
+
leaseMs: LEASE_MS,
|
|
468
|
+
handlers: { [PIPELINE_KIND]: () => runPipelineOnce(db, integrations) },
|
|
469
|
+
});
|
|
470
|
+
const line = ran ? `${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}` : '';
|
|
471
|
+
// Log a run only when its outcome differs from the last one.
|
|
472
|
+
if (line && line !== last) log.info?.(`[pipeline] ${line}`);
|
|
473
|
+
last = line || last;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
log.error?.(`[pipeline] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
476
|
+
}
|
|
477
|
+
})();
|
|
478
|
+
await running;
|
|
479
|
+
running = null;
|
|
480
|
+
schedule(everyMs);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
start() {
|
|
485
|
+
stopped = false;
|
|
486
|
+
schedule(firstDelayMs);
|
|
487
|
+
},
|
|
488
|
+
async stop() {
|
|
489
|
+
stopped = true;
|
|
490
|
+
if (timer) timers.clearTimeout(timer);
|
|
491
|
+
timer = null;
|
|
492
|
+
await running;
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|