@arcforgelabs/flow 2026.10.30 → 2026.10.32

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 (79) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
@@ -5,6 +5,7 @@ import {
5
5
  import { FlowError, notFound } from './errors.js';
6
6
  import { listRules, ruleReasons } from './invoice-rules.js';
7
7
  import { draftInvoiceForObligation } from './ledger.js';
8
+ import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
8
9
 
9
10
  // Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
10
11
  // is ready to invoice passes before Flow drafts its invoice, and how a checked
@@ -30,6 +31,7 @@ export const INVOICING_DEFAULTS = Object.freeze({
30
31
  requirePractitioner: false,
31
32
  rushItemCode: null,
32
33
  rushPercent: 20,
34
+ referenceTemplate: DEFAULT_REFERENCE,
33
35
  });
34
36
 
35
37
  const CUSTOMER_DEFAULTS = Object.freeze({
@@ -58,6 +60,15 @@ function shortText(value, name) {
58
60
  return value.trim() || null;
59
61
  }
60
62
 
63
+ // An empty rule gives no Reference: the accounting system shows the number.
64
+ function referenceTemplate(value) {
65
+ if (value === null) return '';
66
+ if (typeof value !== 'string' || value.length > 200) throw invalid('referenceTemplate must be text of 200 characters at most');
67
+ const problem = checkReferenceTemplate(value);
68
+ if (problem) throw invalid(`referenceTemplate: ${problem}`);
69
+ return value.trim();
70
+ }
71
+
61
72
  function sendMode(value, name) {
62
73
  if (!SEND_MODES.includes(value)) throw invalid(`${name} must be one of ${SEND_MODES.join(', ')}`);
63
74
  return value;
@@ -79,6 +90,7 @@ function readSettingChanges(changes) {
79
90
  else if (key === 'paymentTermsDays') out[key] = wholeNumber(value, key, 0, 365);
80
91
  else if (key === 'rushPercent') out[key] = wholeNumber(value, key, 0, 100);
81
92
  else if (key === 'rushItemCode') out[key] = shortText(value, key);
93
+ else if (key === 'referenceTemplate') out[key] = referenceTemplate(value);
82
94
  else throw invalid(`unknown setting: ${key}`);
83
95
  }
84
96
  return out;
@@ -158,6 +170,18 @@ export async function sendModeFor(db, organisationId, contactId) {
158
170
  return effectiveSettings(settings, customers.get(contactId)).sendMode;
159
171
  }
160
172
 
173
+ // How Flow sends invoices to the accounting system: the organisation's mode,
174
+ // and how many customers have a mode of their own that differs from it.
175
+ export async function sendModeSummary(db, organisationId) {
176
+ const settings = await readInvoicingSettings(db, organisationId);
177
+ const [row] = await db.select({ n: sql`count(*)::int` }).from(customerBillingSettings).where(and(
178
+ eq(customerBillingSettings.organisationId, organisationId),
179
+ sql`${customerBillingSettings.sendMode} is not null`,
180
+ sql`${customerBillingSettings.sendMode} <> ${settings.sendMode}`,
181
+ ));
182
+ return { mode: settings.sendMode, otherCustomers: row?.n ?? 0 };
183
+ }
184
+
161
185
  // What a job still waits on before Flow drafts it, as reasons a person can
162
186
  // read: Flow's own ready stage, then the lab's invoice rules (invoice-rules.js)
163
187
  // in their order. An empty list means the job may be drafted. `rules` is the
@@ -203,7 +227,7 @@ export function copyCheck(accounting, organisationId) {
203
227
  // The draft automation (automations.js) runs it. Answers counts.
204
228
  // With `accounting` null no accounting system is connected: Flow's own records
205
229
  // are the only check, as for a person's draft (ledger.js, ledgerView).
206
- export async function autoDraftJobs(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
230
+ export async function autoDraftJobs(db, organisationId, { accounting = null, work = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
207
231
  const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
208
232
  const settings = await readInvoicingSettings(db, organisationId);
209
233
  const rules = await listRules(db, organisationId);
@@ -243,6 +267,7 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, now
243
267
  liveCheck,
244
268
  actor,
245
269
  now,
270
+ reference: await jobReference(db, organisationId, obligation, { template: settings.referenceTemplate, work }),
246
271
  dueDate: addDays(invoiceDate, effective.paymentTermsDays),
247
272
  });
248
273
  result.drafted += 1;
@@ -3,6 +3,7 @@ import { contacts, invoices, priceItems } from '../db/schema.js';
3
3
  import { listRules } from './invoice-rules.js';
4
4
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
5
5
  import { listObligations } from './ledger.js';
6
+ import { isHistoryHeld } from './history.js';
6
7
  import { invoiceViews } from './sending.js';
7
8
 
8
9
  // The Jobs board (/jobs, server/ui/jobs.js). Its columns are the work
@@ -68,7 +69,9 @@ const ledgerLines = (lines) => (lines ?? []).map((l) => ({
68
69
  // Answers { jobs, statuses }. `work` is the adapters that offer listWork;
69
70
  // `live` says an accounting system checks the ledger (ledgerView).
70
71
  export async function boardJobs(db, organisationId, { work = [], accounting = null, live = false, log = console } = {}) {
71
- const listed = await listObligations(db, organisationId, { accounting: live });
72
+ // Held history jobs are past work, so the board leaves them out
73
+ // (core/history.js); the history import lists them by accounting state.
74
+ const listed = (await listObligations(db, organisationId, { accounting: live })).filter((entry) => !isHistoryHeld(entry.obligation));
72
75
  const obligations = await describeObligations(db, organisationId, listed, { accounting });
73
76
  const byJob = new Map(obligations.map((o) => [keyOf(o.sourceSystem, o.sourceJobRef), o]));
74
77
 
@@ -93,7 +96,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
93
96
  totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
94
97
  };
95
98
  });
96
- return { jobs, statuses: [...statuses.values()] };
99
+ return { jobs: await withIssues(jobs, work, log), statuses: [...statuses.values()] };
97
100
  }
98
101
 
99
102
  const codes = new Set((await db.select({ code: priceItems.code }).from(priceItems)
@@ -126,5 +129,24 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
126
129
  });
127
130
  }
128
131
  }
129
- return { jobs, statuses };
132
+ return { jobs: await withIssues(jobs, work, log), statuses };
133
+ }
134
+
135
+ // Each job's open issues from its integration (the port's jobIssues): a
136
+ // short phrase and the page where a person fixes it. A failed read leaves
137
+ // the board as it is.
138
+ async function withIssues(jobs, adapters, log) {
139
+ const issues = new Map();
140
+ for (const adapter of adapters) {
141
+ if (typeof adapter.capabilities.jobIssues !== 'function') continue;
142
+ const refs = jobs.filter((j) => j.sourceSystem === adapter.id).map((j) => j.sourceJobRef);
143
+ if (!refs.length) continue;
144
+ try {
145
+ const found = await adapter.capabilities.jobIssues({ refs });
146
+ for (const [ref, list] of Object.entries(found ?? {})) issues.set(keyOf(adapter.id, ref), list);
147
+ } catch (error) {
148
+ log.error?.(`[jobs] ${adapter.id} jobIssues: ${error.message}`);
149
+ }
150
+ }
151
+ return jobs.map((j) => ({ ...j, issues: issues.get(keyOf(j.sourceSystem, j.sourceJobRef)) ?? [] }));
130
152
  }
@@ -399,7 +399,7 @@ function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
399
399
  // the write, finds no invoice for the job. Any doubt refuses the draft, and
400
400
  // the refusal is recorded.
401
401
  export async function draftInvoiceForObligation(db, organisationId, obligationId, {
402
- liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null,
402
+ liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null, reference = null,
403
403
  }) {
404
404
  requireActor(actor);
405
405
  // A null liveCheck says no accounting system is connected (ledgerView).
@@ -423,7 +423,8 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
423
423
  }
424
424
  const invoice = await createDraftInvoice(tx, organisationId, {
425
425
  contactId: obligation.contactId,
426
- reference: obligation.caseRef ?? obligation.sourceJobRef,
426
+ // The Reference rule's text (references.js), never a work system id.
427
+ reference,
427
428
  dueDate,
428
429
  lines: obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
429
430
  { priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
@@ -1,5 +1,5 @@
1
1
  import { and, asc, eq, isNull, sql } from 'drizzle-orm';
2
- import { LINE_MAPPING_KINDS, lineMappings, pipelineCursors, priceItems } from '../db/schema.js';
2
+ import { LINE_MAPPING_KINDS, lineMappings, pipelineCursors, priceItemLinks, priceItems } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
 
5
5
  // Line mapping (docs/LINE_MAPPING.md): how each line a work system reports
@@ -8,6 +8,10 @@ import { FlowError, notFound } from './errors.js';
8
8
  // step reads either the lab's price list or the lab's own mapping rows, so no
9
9
  // lab's labels or codes are fixed in Flow's code.
10
10
  //
11
+ // Before the steps, a line whose code is a linked product of its work system
12
+ // (the price list's links, server/core/price-list.js) takes that price item.
13
+ // A link is a person's or an exact code's choice, so it always goes first.
14
+ //
11
15
  // same_code the line's own code is a price item code
12
16
  // account an account row: a line with no price item (a surcharge)
13
17
  // saved a row a person saved for this label at this price
@@ -20,7 +24,8 @@ import { FlowError, notFound } from './errors.js';
20
24
  //
21
25
  // A mapped line keeps the work system's amount and tax flag; the price item
22
26
  // gives its code, its text and, when the work system gave none, its price and
23
- // tax. A line no step answers keeps no code, so the ledger holds the job.
27
+ // tax. With `linePrice: 'list'` the price item's price wins instead. A line
28
+ // no step answers keeps no code, so the ledger holds the job.
24
29
 
25
30
  export const STEPS = Object.freeze([
26
31
  'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
@@ -36,7 +41,10 @@ export const STEP_LABELS = Object.freeze({
36
41
  alias: 'Label',
37
42
  });
38
43
  export const ZERO_LINES = Object.freeze(['keep', 'drop']);
39
- export const MAPPING_DEFAULTS = Object.freeze({ steps: [...STEPS], zeroLines: 'keep' });
44
+ // Where a mapped line's price comes from: the work system's amount (as the
45
+ // earlier app did), or the lab's own price list.
46
+ export const LINE_PRICES = Object.freeze(['work', 'list']);
47
+ export const MAPPING_DEFAULTS = Object.freeze({ steps: [...STEPS], zeroLines: 'keep', linePrice: 'work' });
40
48
 
41
49
  const SCORE_MIN = 16;
42
50
  const SCORE_GAP = 8;
@@ -96,8 +104,16 @@ export function nameScore(item, { exact, lookup, original }) {
96
104
  }
97
105
 
98
106
  // The lab's price list and mapping rows, indexed once for a run.
99
- export function mappingContext({ items = [], mappings = [], config = MAPPING_DEFAULTS } = {}) {
107
+ export function mappingContext({ items = [], mappings = [], links = [], config = MAPPING_DEFAULTS } = {}) {
100
108
  const live = items.filter((i) => !i.archivedAt);
109
+ // A linked work system code: its price item, by the code in lookup form.
110
+ const byId = new Map(live.map((i) => [i.id, i]));
111
+ const byLinkedCode = new Map();
112
+ for (const link of links) {
113
+ const item = byId.get(link.priceItemId);
114
+ const code = lineText(link.externalCode);
115
+ if (item && code && !byLinkedCode.has(code)) byLinkedCode.set(code, item);
116
+ }
101
117
  const byCode = new Map(live.map((i) => [i.code, i]));
102
118
  const byCodeText = new Map(live.map((i) => [lineText(i.code), i]));
103
119
  const byText = new Map();
@@ -113,7 +129,7 @@ export function mappingContext({ items = [], mappings = [], config = MAPPING_DEF
113
129
  }
114
130
  const rows = mappings.filter((m) => m.enabled !== false);
115
131
  const rowsOf = (kind) => rows.filter((m) => m.kind === kind);
116
- return { byCode, byCodeText, byText, byPrice, rowsOf, config: { ...MAPPING_DEFAULTS, ...config } };
132
+ return { byCode, byCodeText, byText, byPrice, byLinkedCode, rowsOf, config: { ...MAPPING_DEFAULTS, ...config } };
117
133
  }
118
134
 
119
135
  const rowMatches = (row, lookup) => (row.match === 'starts_with' ? lookup === row.label || lookup.startsWith(`${row.label} `) : lookup === row.label);
@@ -139,7 +155,7 @@ export function mapLine(line, context) {
139
155
  accountCode: null,
140
156
  description: (item.description || item.name || original).trim(),
141
157
  quantity,
142
- unitAmountCents: price ?? item.unitAmountCents,
158
+ unitAmountCents: config.linePrice === 'list' ? item.unitAmountCents : (price ?? item.unitAmountCents),
143
159
  taxCode: taxCode ?? line.taxCode ?? item.taxCode,
144
160
  mappedBy: step,
145
161
  });
@@ -149,6 +165,9 @@ export function mapLine(line, context) {
149
165
  };
150
166
  const unmapped = (reason) => ({ ...base, itemCode: null, accountCode: null, mappedBy: null, unmapped: reason });
151
167
 
168
+ const linked = line.itemCode ? context.byLinkedCode?.get(lineText(line.itemCode)) : null;
169
+ if (linked) return toItem(linked, 'linked');
170
+
152
171
  for (const step of config.steps) {
153
172
  switch (step) {
154
173
  case 'same_code': {
@@ -229,6 +248,10 @@ function readConfig(raw = {}) {
229
248
  if (!ZERO_LINES.includes(raw.zeroLines)) throw invalid(`zeroLines must be one of ${ZERO_LINES.join(', ')}`);
230
249
  out.zeroLines = raw.zeroLines;
231
250
  }
251
+ if ('linePrice' in raw) {
252
+ if (!LINE_PRICES.includes(raw.linePrice)) throw invalid(`linePrice must be one of ${LINE_PRICES.join(', ')}`);
253
+ out.linePrice = raw.linePrice;
254
+ }
232
255
  return out;
233
256
  }
234
257
  export { readConfig as readMappingConfig };
@@ -327,11 +350,14 @@ export async function deleteMapping(db, organisationId, id) {
327
350
  await remapJobs(db, organisationId);
328
351
  }
329
352
 
330
- // The price list and mapping rows for a pipeline run.
331
- export async function loadMappingContext(db, organisationId, config) {
332
- const [items, mappings] = await Promise.all([
353
+ // The price list, mapping rows and `system`'s product links for a pipeline run.
354
+ export async function loadMappingContext(db, organisationId, config, { system = null } = {}) {
355
+ const [items, mappings, links] = await Promise.all([
333
356
  db.select().from(priceItems).where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt))),
334
357
  db.select().from(lineMappings).where(eq(lineMappings.organisationId, organisationId)),
358
+ system
359
+ ? db.select().from(priceItemLinks).where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system)))
360
+ : [],
335
361
  ]);
336
- return mappingContext({ items, mappings, config });
362
+ return mappingContext({ items, mappings, links, config });
337
363
  }
@@ -8,7 +8,9 @@ import { listAutomations, runAutomations } from './automations.js';
8
8
  import { readInvoicingSettings } from './invoicing-rules.js';
9
9
  import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
10
10
  import { loadMappingContext, mapLines } from './line-mapping.js';
11
+ import { checkPrices, readCatalogues } from './price-list.js';
11
12
  import { linkRecord, priorLinkFor } from './links.js';
13
+ import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
12
14
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
13
15
 
14
16
  // The pipeline (docs/DATA_SPINE.md, issue 58). It joins what the integrations
@@ -138,7 +140,7 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
138
140
  const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, unmappedLines: 0, more: false };
139
141
  // Line mapping (line-mapping.js): each line finds its price item first.
140
142
  const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
141
- const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config) : null;
143
+ const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config, { system: adapter.id }) : null;
142
144
  for (let page = 0; page < pages; page += 1) {
143
145
  const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
144
146
  for (const item of items) {
@@ -175,6 +177,66 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
175
177
  return result;
176
178
  }
177
179
 
180
+ // Reads the work system's past jobs (a history import, docs/SEAZONA.md) into
181
+ // the ledger. A job new to the ledger is held with reason `history_import`:
182
+ // it is history, most likely invoiced long ago, so it never drafts until a
183
+ // person releases it, and the matcher still links it to its invoice. A job
184
+ // the ledger already has from the live feed is left as it is: its hold, ready
185
+ // mark and invoice never change here. A held history job that only history
186
+ // has reported takes a later import's changes.
187
+ export async function ingestHistory(db, organisationId, { adapter, pages = WORK_PAGES }) {
188
+ const name = `${adapter.id}.history`;
189
+ let cursor = await readCursor(db, organisationId, name);
190
+ const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
191
+ const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
192
+ const result = { held: 0, updated: 0, alreadyLive: 0, skipped: 0, more: false };
193
+ const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
194
+ const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config) : null;
195
+ for (let page = 0; page < pages; page += 1) {
196
+ const { items, cursor: next, more } = await adapter.capabilities.pullHistoryJobs(cursor, { limit: 200 });
197
+ for (const item of items) {
198
+ const contactId = contactOf.get(String(item.clientId ?? '')) ?? null;
199
+ const base = { ...item.ledgerJob, actor: HISTORY_ACTOR };
200
+ const job = context ? { ...base, lines: mapLines(base.lines ?? [], context) } : base;
201
+ const [existing] = await db.select().from(billingObligations).where(and(
202
+ eq(billingObligations.organisationId, organisationId),
203
+ eq(billingObligations.sourceSystem, job.sourceSystem),
204
+ eq(billingObligations.sourceJobRef, job.sourceJobRef),
205
+ ));
206
+ if (existing) {
207
+ const [live] = existing && isHistoryHeld(existing)
208
+ ? await db.select({ id: obligationEvents.id }).from(obligationEvents).where(and(
209
+ eq(obligationEvents.organisationId, organisationId),
210
+ eq(obligationEvents.obligationId, existing.id),
211
+ inArray(obligationEvents.kind, ['observed', 'source_changed']),
212
+ sql`${obligationEvents.actor} <> ${HISTORY_ACTOR}`,
213
+ )).limit(1)
214
+ : [{ id: 'live' }];
215
+ if (live) {
216
+ result.alreadyLive += 1;
217
+ continue;
218
+ }
219
+ await observeJob(db, organisationId, { ...job, contactId: contactId ?? existing.contactId });
220
+ result.updated += 1;
221
+ continue;
222
+ }
223
+ try {
224
+ const row = await observeJob(db, organisationId, { ...job, contactId });
225
+ await setHold(db, organisationId, row.id, { holdState: 'held', reason: HISTORY_HOLD_REASON, actor: HISTORY_ACTOR });
226
+ result.held += 1;
227
+ } catch (error) {
228
+ if (!(error instanceof FlowError) || error.code !== 'invalid') throw error;
229
+ result.skipped += 1;
230
+ }
231
+ }
232
+ cursor = next;
233
+ await writeCursor(db, organisationId, name, cursor);
234
+ if (!more) return result;
235
+ }
236
+ result.more = true;
237
+ return result;
238
+ }
239
+
178
240
  const day = (iso) => Date.parse(`${iso}T00:00:00Z`);
179
241
  const addDays = (iso, days) => new Date(day(iso) + days * 86_400_000).toISOString().slice(0, 10);
180
242
  const daysApart = (a, b) => Math.abs(day(a) - day(b)) / 86_400_000;
@@ -421,14 +483,24 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
421
483
  const summary = { organisationId };
422
484
  const [work] = integrations.with('pullWorkItems');
423
485
  const [accounting] = integrations.with('findInvoices');
486
+ // The price check (price-list.js) links price lists by code first, so a
487
+ // new product link maps the jobs read before it.
488
+ const priceSystems = integrations.with('listPriceItems');
489
+ if (priceSystems.length) {
490
+ summary.prices = await checkPrices(db, organisationId, { catalogues: await readCatalogues(organisationId, priceSystems) });
491
+ }
424
492
  if (work) {
425
- let restart = false;
493
+ let restart = (summary.prices?.linked ?? 0) > 0;
426
494
  if (typeof work.capabilities.listCustomers === 'function') {
427
495
  summary.workCustomers = await linkCustomers(db, organisationId, { system: work.id, customers: await work.capabilities.listCustomers({ organisationId }) });
428
496
  // New customer links reach jobs read before them.
429
- restart = summary.workCustomers.linked > 0;
497
+ restart ||= summary.workCustomers.linked > 0;
430
498
  }
431
499
  summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
500
+ // After the live feed, so a job both report is the live one.
501
+ if (typeof work.capabilities.pullHistoryJobs === 'function') {
502
+ summary.history = await ingestHistory(db, organisationId, { adapter: work });
503
+ }
432
504
  }
433
505
  if (accounting) {
434
506
  if (typeof accounting.capabilities.listCustomers === 'function') {
@@ -439,7 +511,7 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
439
511
  const settings = await readInvoicingSettings(db, organisationId);
440
512
  summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
441
513
  }
442
- if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
514
+ if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, work: work ?? null, now });
443
515
  return summary;
444
516
  }
445
517