@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1

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 (76) 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 +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -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/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. package/server/ui/routes.js +1 -1
@@ -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,8 +8,10 @@ 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';
12
- import { enqueueJob, runNextJob } from './queue.js';
13
+ import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
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
15
17
  // copy into Flow, and never writes to another system:
@@ -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') {
@@ -448,6 +520,7 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
448
520
  // systems' copies and writes Flow's own records.
449
521
  export function createPipelineWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = RUN_EVERY_MS, firstDelayMs = 15_000 }) {
450
522
  const workerId = `pipeline-${randomUUID()}`;
523
+ const startedAtMs = Date.now();
451
524
  let timer = null;
452
525
  let running = null;
453
526
  let stopped = true;
@@ -467,6 +540,9 @@ export function createPipelineWorker({ db, integrations, log = console, timers =
467
540
  if (!integrations.with('pullWorkItems').length && !integrations.with('findInvoices').length) return;
468
541
  const organisationId = await soleOrganisation(db);
469
542
  if (!organisationId) return;
543
+ // Issue #101: a run that gave up waits for a person, a restart or the
544
+ // next day; the timer never starts it again by itself.
545
+ if (await deadSince(db, organisationId, { kind: PIPELINE_KIND, dedupeKey: 'all', since: deadHoldSince(startedAtMs) })) return;
470
546
  await enqueueJob(db, organisationId, { kind: PIPELINE_KIND, dedupeKey: 'all' });
471
547
  const ran = await runNextJob(db, {
472
548
  workerId,
@@ -474,8 +550,10 @@ export function createPipelineWorker({ db, integrations, log = console, timers =
474
550
  handlers: { [PIPELINE_KIND]: () => runPipelineOnce(db, integrations) },
475
551
  });
476
552
  const line = ran ? `${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}` : '';
477
- // Log a run only when its outcome differs from the last one.
478
- if (line && line !== last) log.info?.(`[pipeline] ${line}`);
553
+ // Log a run only when its outcome differs from the last one. A run
554
+ // that gave up is reported once to the error services.
555
+ if (ran?.outcome === 'dead') log.error?.('[pipeline] the pipeline run gave up after its last attempt');
556
+ if (line && line !== last) (ran.outcome === 'succeeded' ? log.info : log.warn)?.(`[pipeline] ${line}`);
479
557
  last = line || last;
480
558
  } catch (error) {
481
559
  log.error?.(`[pipeline] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
@@ -0,0 +1,348 @@
1
+ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
2
+ import { priceItemLinks, priceItems, reviewItems, TAX_CODES } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+ import { remapJobs } from './line-mapping.js';
5
+ import { openReview, resolveReview } from './review.js';
6
+
7
+ // The price list (docs/PRICE_LIST.md). Flow's own price items are the source
8
+ // of truth: a lab adds, changes and archives them here with no integration
9
+ // connected. Each connected system that keeps its own price list (Xero items,
10
+ // Seazona products) offers it through the port's `listPriceItems`, read from
11
+ // Flow's copy, so nothing here calls another system.
12
+ //
13
+ // A link joins one record of a system's list to one price item:
14
+ // - Flow links by itself when both sides have the same code;
15
+ // - a person links the rest;
16
+ // - an accounting system (Xero) has one record per price item at most; a
17
+ // work system (Seazona) can link many products to one price item.
18
+ // The price check compares each price item with its linked records. A
19
+ // difference is an alert only (review item `price_check`): Flow never changes
20
+ // a price on either side. "Mark resolved" accepts what the check found; the
21
+ // same finding never opens again, a new one does.
22
+
23
+ const invalid = (message) => new FlowError('invalid', message);
24
+ const REVIEW_KIND = 'price_check';
25
+ const MAX_ITEMS = 5000;
26
+ const FINDING_KINDS = Object.freeze(['price', 'tax', 'missing', 'not_linked']);
27
+ export { FINDING_KINDS };
28
+
29
+ const codeKey = (value) => String(value ?? '').trim().toLowerCase();
30
+
31
+ const shortText = (value, name, max, { required = false } = {}) => {
32
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) {
33
+ if (required) throw invalid(`${name} is required`);
34
+ return null;
35
+ }
36
+ if (typeof value !== 'string' || value.trim().length > max) throw invalid(`${name} must be text of ${max} characters at most`);
37
+ return value.trim();
38
+ };
39
+
40
+ // A price item as the API takes it. `partial` reads a change: only the fields
41
+ // it names.
42
+ export function readPriceItem(raw, { partial = false } = {}) {
43
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the price item must be an object');
44
+ const known = ['code', 'name', 'description', 'unitAmountCents', 'taxCode', 'accountCode'];
45
+ const unknown = Object.keys(raw).filter((k) => !known.includes(k));
46
+ if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
47
+ const out = {};
48
+ const has = (key) => !partial || key in raw;
49
+ if (has('code')) out.code = shortText(raw.code, 'code', 30, { required: true });
50
+ if (has('name')) out.name = shortText(raw.name, 'name', 200, { required: true });
51
+ if (has('description')) out.description = shortText(raw.description, 'description', 1000);
52
+ if (has('unitAmountCents')) {
53
+ const cents = raw.unitAmountCents;
54
+ if (!Number.isInteger(cents) || cents < 0 || cents > 100_000_000) throw invalid('unitAmountCents must be whole cents, 0 or more');
55
+ out.unitAmountCents = cents;
56
+ }
57
+ if (has('taxCode')) {
58
+ const taxCode = raw.taxCode ?? 'GST';
59
+ if (!TAX_CODES.includes(taxCode)) throw invalid(`taxCode must be one of ${TAX_CODES.join(', ')}`);
60
+ out.taxCode = taxCode;
61
+ }
62
+ if (has('accountCode')) out.accountCode = shortText(raw.accountCode, 'accountCode', 20);
63
+ return out;
64
+ }
65
+
66
+ const shapeItem = (r) => ({
67
+ id: r.id, code: r.code, name: r.name, description: r.description, unitAmountCents: r.unitAmountCents,
68
+ taxCode: r.taxCode, accountCode: r.accountCode, archivedAt: r.archivedAt, updatedAt: r.updatedAt,
69
+ });
70
+
71
+ const conflictOf = (error) => error?.code === '23505' || error?.cause?.code === '23505';
72
+
73
+ async function loadItem(db, organisationId, id) {
74
+ const [row] = await db.select().from(priceItems).where(and(eq(priceItems.organisationId, organisationId), eq(priceItems.id, id)));
75
+ if (!row) throw notFound('price item');
76
+ return row;
77
+ }
78
+
79
+ export async function createPriceItem(db, organisationId, raw, { personId = null } = {}) {
80
+ const item = readPriceItem(raw);
81
+ const [{ count }] = await db.select({ count: sql`count(*)::int` }).from(priceItems).where(eq(priceItems.organisationId, organisationId));
82
+ if (count >= MAX_ITEMS) throw invalid(`an organisation can have ${MAX_ITEMS} price items at most`);
83
+ try {
84
+ const [row] = await db.insert(priceItems).values({ organisationId, ...item, updatedBy: personId }).returning();
85
+ await remapJobs(db, organisationId);
86
+ return shapeItem(row);
87
+ } catch (error) {
88
+ if (conflictOf(error)) throw new FlowError('conflict', `the code ${item.code} is already on the price list`);
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ // A change to a price item. Invoice lines keep the price they were made with;
94
+ // jobs not yet invoiced map again on the next pipeline run.
95
+ export async function updatePriceItem(db, organisationId, id, raw, { personId = null } = {}) {
96
+ const row = await loadItem(db, organisationId, id);
97
+ const changes = readPriceItem(raw, { partial: true });
98
+ if (!Object.keys(changes).length) return shapeItem(row);
99
+ try {
100
+ const [saved] = await db.update(priceItems).set({ ...changes, updatedBy: personId, updatedAt: sql`now()` })
101
+ .where(eq(priceItems.id, row.id)).returning();
102
+ await remapJobs(db, organisationId);
103
+ return shapeItem(saved);
104
+ } catch (error) {
105
+ if (conflictOf(error)) throw new FlowError('conflict', `the code ${changes.code} is already on the price list`);
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ // Archives (or brings back) a price item. An archived item keeps its invoice
111
+ // lines and its links; new lines no longer map to it.
112
+ export async function archivePriceItem(db, organisationId, id, { archived = true, personId = null } = {}) {
113
+ const row = await loadItem(db, organisationId, id);
114
+ const [saved] = await db.update(priceItems)
115
+ .set({ archivedAt: archived ? (row.archivedAt ?? sql`now()`) : null, updatedBy: personId, updatedAt: sql`now()` })
116
+ .where(eq(priceItems.id, row.id)).returning();
117
+ await remapJobs(db, organisationId);
118
+ return shapeItem(saved);
119
+ }
120
+
121
+ // Each connected system's list, by system id: { id, name, kind, syncedAt,
122
+ // items, byId }. A system that cannot answer is left out (it is off, or its
123
+ // copy is not there yet).
124
+ export async function readCatalogues(organisationId, systems = [], { log = console } = {}) {
125
+ const out = new Map();
126
+ for (const adapter of systems) {
127
+ try {
128
+ const answer = await adapter.capabilities.listPriceItems({ organisationId });
129
+ if (!answer || !Array.isArray(answer.items)) continue;
130
+ const items = answer.items.filter((i) => i && i.id);
131
+ out.set(adapter.id, {
132
+ id: adapter.id,
133
+ name: adapter.name,
134
+ kind: answer.kind === 'accounting' ? 'accounting' : 'work',
135
+ syncedAt: answer.syncedAt ?? null,
136
+ items,
137
+ byId: new Map(items.map((i) => [String(i.id), i])),
138
+ });
139
+ } catch (error) {
140
+ log.warn?.(`[price-list] ${adapter.id} price list could not be read: ${error.message}`);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ async function readLinks(db, organisationId) {
147
+ return db.select().from(priceItemLinks).where(eq(priceItemLinks.organisationId, organisationId))
148
+ .orderBy(asc(priceItemLinks.system), asc(priceItemLinks.externalCode));
149
+ }
150
+
151
+ // Links each record of a system's list whose code is a price item's code, when
152
+ // neither side is linked yet in a way that stops it. Answers how many it made.
153
+ export async function linkByCode(db, organisationId, catalogue, { items = null, links = null } = {}) {
154
+ const all = items ?? await db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId));
155
+ const existing = links ?? await readLinks(db, organisationId);
156
+ const mine = existing.filter((l) => l.system === catalogue.id);
157
+ const linkedExternal = new Set(mine.map((l) => String(l.externalId)));
158
+ const linkedItem = new Set(mine.map((l) => l.priceItemId));
159
+ const byCode = new Map(all.filter((i) => !i.archivedAt).map((i) => [codeKey(i.code), i]));
160
+ const rows = [];
161
+ for (const record of catalogue.items) {
162
+ const item = byCode.get(codeKey(record.code));
163
+ if (!item || !codeKey(record.code) || linkedExternal.has(String(record.id))) continue;
164
+ if (catalogue.kind === 'accounting' && linkedItem.has(item.id)) continue;
165
+ rows.push({ organisationId, priceItemId: item.id, system: catalogue.id, externalId: String(record.id), externalCode: record.code ?? null, matchedBy: 'code' });
166
+ linkedExternal.add(String(record.id));
167
+ linkedItem.add(item.id);
168
+ }
169
+ if (!rows.length) return 0;
170
+ const made = await db.insert(priceItemLinks).values(rows).onConflictDoNothing().returning({ id: priceItemLinks.id });
171
+ return made.length;
172
+ }
173
+
174
+ // A person's link: `externalId` in `system`'s list goes to this price item.
175
+ // For an accounting system it replaces the item's earlier link; a record
176
+ // linked elsewhere moves here.
177
+ export async function linkPriceItem(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId = null }) {
178
+ const item = await loadItem(db, organisationId, priceItemId);
179
+ const catalogue = catalogues.get(system);
180
+ if (!catalogue) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
181
+ const record = catalogue.byId.get(String(externalId ?? ''));
182
+ if (!record) throw notFound(`${catalogue.name} record`);
183
+ const link = await db.transaction(async (tx) => {
184
+ await tx.delete(priceItemLinks).where(and(
185
+ eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.externalId, String(record.id)),
186
+ ));
187
+ if (catalogue.kind === 'accounting') {
188
+ await tx.delete(priceItemLinks).where(and(
189
+ eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.priceItemId, item.id),
190
+ ));
191
+ }
192
+ const [row] = await tx.insert(priceItemLinks).values({
193
+ organisationId, priceItemId: item.id, system, externalId: String(record.id), externalCode: record.code ?? null, matchedBy: 'person', createdBy: personId,
194
+ }).returning();
195
+ return row;
196
+ });
197
+ await remapJobs(db, organisationId);
198
+ return link;
199
+ }
200
+
201
+ export async function unlinkPriceItem(db, organisationId, linkId) {
202
+ const rows = await db.delete(priceItemLinks)
203
+ .where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.id, linkId))).returning();
204
+ if (!rows.length) throw notFound('link');
205
+ await remapJobs(db, organisationId);
206
+ }
207
+
208
+ // What differs between one price item and its linked records. Pure: the
209
+ // caller passes the item, its links and the catalogues.
210
+ export function findingsFor(item, links, catalogues) {
211
+ const findings = [];
212
+ for (const catalogue of catalogues.values()) {
213
+ const mine = links.filter((l) => l.system === catalogue.id);
214
+ if (!mine.length && catalogue.kind === 'accounting') {
215
+ findings.push({ system: catalogue.id, kind: 'not_linked', code: item.code });
216
+ continue;
217
+ }
218
+ for (const link of mine) {
219
+ const record = catalogue.byId.get(String(link.externalId));
220
+ const base = { system: catalogue.id, externalId: String(link.externalId), externalCode: record?.code ?? link.externalCode ?? null };
221
+ if (!record) {
222
+ findings.push({ ...base, kind: 'missing' });
223
+ continue;
224
+ }
225
+ if (record.unitAmountCents !== null && record.unitAmountCents !== undefined && record.unitAmountCents !== item.unitAmountCents) {
226
+ findings.push({ ...base, kind: 'price', flow: item.unitAmountCents, other: record.unitAmountCents });
227
+ }
228
+ if (record.taxCode && record.taxCode !== item.taxCode) {
229
+ findings.push({ ...base, kind: 'tax', flow: item.taxCode, other: record.taxCode });
230
+ }
231
+ }
232
+ }
233
+ return findings;
234
+ }
235
+
236
+ // The same findings always give the same signature, so an accepted finding
237
+ // is never raised again, and a changed one is.
238
+ export function findingsSignature(item, findings) {
239
+ const parts = findings.map((f) => [f.system, f.kind, f.externalId ?? '', f.flow ?? '', f.other ?? ''].join('|')).sort();
240
+ return `${item.unitAmountCents}|${item.taxCode}|${parts.join(';')}`;
241
+ }
242
+
243
+ const moneyAtRisk = (findings) => findings.reduce((max, f) => (f.kind === 'price' ? Math.max(max, Math.abs(f.flow - f.other)) : max), 0);
244
+
245
+ async function lastClosedSignatures(db, organisationId, ids) {
246
+ if (!ids.length) return new Map();
247
+ const rows = await db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence, state: reviewItems.state, resolution: reviewItems.resolution })
248
+ .from(reviewItems)
249
+ .where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND), inArray(reviewItems.flowId, ids)))
250
+ .orderBy(desc(reviewItems.updatedAt));
251
+ const out = new Map();
252
+ for (const row of rows) if (!out.has(row.flowId)) out.set(row.flowId, row);
253
+ return out;
254
+ }
255
+
256
+ // The price check. Links by code first, then compares every live price item
257
+ // with its linked records and keeps one review item per price item in step:
258
+ // opened or refreshed while something differs, closed when nothing does.
259
+ // `only` checks one price item. Reads Flow's copies only.
260
+ export async function checkPrices(db, organisationId, { catalogues, only = null, actor = 'flow:price-check' }) {
261
+ const items = await db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId));
262
+ let linked = 0;
263
+ for (const catalogue of catalogues.values()) linked += await linkByCode(db, organisationId, catalogue, { items });
264
+ const links = await readLinks(db, organisationId);
265
+ const live = items.filter((i) => !i.archivedAt && (!only || i.id === only));
266
+ const latest = await lastClosedSignatures(db, organisationId, live.map((i) => i.id));
267
+ const summary = { linked, checked: live.length, differing: 0, opened: 0, closed: 0, accepted: 0 };
268
+ for (const item of live) {
269
+ const findings = findingsFor(item, links.filter((l) => l.priceItemId === item.id), catalogues);
270
+ const signature = findingsSignature(item, findings);
271
+ const last = latest.get(item.id);
272
+ if (!findings.length) {
273
+ if (last?.state === 'open') {
274
+ await db.update(reviewItems).set({ state: 'resolved', resolution: 'prices_agree', resolvedBy: actor, resolvedAt: sql`now()`, updatedAt: sql`now()` })
275
+ .where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND), eq(reviewItems.flowId, item.id), eq(reviewItems.state, 'open')));
276
+ summary.closed += 1;
277
+ }
278
+ continue;
279
+ }
280
+ summary.differing += 1;
281
+ if (last && last.resolution === 'accepted' && last.evidence?.signature === signature) {
282
+ summary.accepted += 1;
283
+ continue;
284
+ }
285
+ await openReview(db, organisationId, {
286
+ kind: REVIEW_KIND,
287
+ entity: 'price_item',
288
+ flowId: item.id,
289
+ systems: [...new Set(findings.map((f) => f.system))],
290
+ evidence: { code: item.code, name: item.name, signature, findings },
291
+ moneyAtRiskCents: moneyAtRisk(findings),
292
+ });
293
+ if (last?.state !== 'open') summary.opened += 1;
294
+ }
295
+ return summary;
296
+ }
297
+
298
+ // "Mark resolved": checks the one price item again, then accepts what is
299
+ // still different, so the same finding stays closed.
300
+ export async function acceptFindings(db, organisationId, priceItemId, { catalogues, actor }) {
301
+ await loadItem(db, organisationId, priceItemId);
302
+ await checkPrices(db, organisationId, { catalogues, only: priceItemId, actor });
303
+ const [open] = await db.select().from(reviewItems).where(and(
304
+ eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND),
305
+ eq(reviewItems.flowId, priceItemId), eq(reviewItems.state, 'open'),
306
+ ));
307
+ if (!open) return { state: 'agree' };
308
+ await resolveReview(db, organisationId, open.id, { resolution: 'accepted', actor });
309
+ return { state: 'accepted' };
310
+ }
311
+
312
+ // Everything the price list screen shows: each price item with its links,
313
+ // the linked records and what differs; each system's records that link to no
314
+ // price item; and the systems themselves. Reads Flow's copies only.
315
+ export async function readPriceList(db, organisationId, { catalogues }) {
316
+ const [items, links, open] = await Promise.all([
317
+ db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId)).orderBy(asc(priceItems.code)),
318
+ readLinks(db, organisationId),
319
+ db.select({ flowId: reviewItems.flowId }).from(reviewItems)
320
+ .where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND), eq(reviewItems.state, 'open'))),
321
+ ]);
322
+ const alerting = new Set(open.map((r) => r.flowId));
323
+ const linksOf = new Map();
324
+ for (const link of links) {
325
+ if (!linksOf.has(link.priceItemId)) linksOf.set(link.priceItemId, []);
326
+ linksOf.get(link.priceItemId).push(link);
327
+ }
328
+ const linked = new Set(links.map((l) => `${l.system}:${l.externalId}`));
329
+ return {
330
+ systems: [...catalogues.values()].map((c) => ({ id: c.id, name: c.name, kind: c.kind, syncedAt: c.syncedAt, count: c.items.length })),
331
+ items: items.map((item) => {
332
+ const mine = linksOf.get(item.id) ?? [];
333
+ return {
334
+ ...shapeItem(item),
335
+ alert: alerting.has(item.id),
336
+ findings: item.archivedAt ? [] : findingsFor(item, mine, catalogues),
337
+ links: mine.map((l) => {
338
+ const record = catalogues.get(l.system)?.byId.get(String(l.externalId)) ?? null;
339
+ return { id: l.id, system: l.system, externalId: l.externalId, externalCode: l.externalCode, matchedBy: l.matchedBy, record };
340
+ }),
341
+ };
342
+ }),
343
+ unlinked: [...catalogues.values()].map((c) => ({
344
+ system: c.id,
345
+ records: c.items.filter((r) => r.sold !== false && !linked.has(`${c.id}:${r.id}`)),
346
+ })),
347
+ };
348
+ }
@@ -38,6 +38,28 @@ export async function enqueueJob(db, organisationId, {
38
38
  return { job: open, queued: false };
39
39
  }
40
40
 
41
+ // Issue #101: the newest job of this kind and key that gave up after `since`,
42
+ // or null. A worker on a timer checks this before it queues the next run, so
43
+ // a job that always fails is not started again and again: it waits for a
44
+ // person (the review item), a restart on a new release, or the next day.
45
+ export async function deadSince(db, organisationId, { kind, dedupeKey = '', since }) {
46
+ const [row] = await db.select().from(backgroundJobs).where(and(
47
+ eq(backgroundJobs.organisationId, organisationId),
48
+ eq(backgroundJobs.kind, kind),
49
+ eq(backgroundJobs.dedupeKey, dedupeKey),
50
+ eq(backgroundJobs.state, 'dead'),
51
+ sql`${backgroundJobs.updatedAt} > ${since}`,
52
+ )).orderBy(sql`${backgroundJobs.updatedAt} desc`).limit(1);
53
+ return row ?? null;
54
+ }
55
+
56
+ // When a timer may queue the job again after it gave up: the later of this
57
+ // process's start and one day ago.
58
+ export const DEAD_HOLD_MS = 24 * 60 * 60 * 1000;
59
+ export function deadHoldSince(startedAtMs, nowMs = Date.now()) {
60
+ return new Date(Math.max(startedAtMs, nowMs - DEAD_HOLD_MS));
61
+ }
62
+
41
63
  async function giveUp(tx, job, error) {
42
64
  const [row] = await tx.update(backgroundJobs)
43
65
  .set({ state: 'dead', lockedBy: null, lockedUntil: null, lastError: error, updatedAt: sql`now()` })