@arcforgelabs/flow 2026.10.25 → 2026.10.26

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