@arcforgelabs/flow 2026.10.30 → 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.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +75 -3
- package/server/core/price-list.js +348 -0
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +20 -1
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +37 -1
- package/server/integrations/seazona/lanes.js +186 -50
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -17
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- 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.
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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') {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { adapters } from '../integrations/adapters.js';
|
|
2
|
+
import { diagnoseDatabase, formatReport, liveMigrationCounts, publishVerdict } from './doctor.js';
|
|
3
|
+
import { openDatabase } from './index.js';
|
|
4
|
+
import { migrateEverything } from './migrate-all.js';
|
|
5
|
+
|
|
6
|
+
// `flow-server migrate` and `flow-server doctor` (server/flow-server.js).
|
|
7
|
+
// Each returns the exit code.
|
|
8
|
+
export async function runCommand(command, args, { log = console } = {}) {
|
|
9
|
+
const option = (name) => {
|
|
10
|
+
const at = args.indexOf(name);
|
|
11
|
+
return at === -1 ? undefined : args[at + 1];
|
|
12
|
+
};
|
|
13
|
+
let handle;
|
|
14
|
+
try {
|
|
15
|
+
handle = openDatabase({ max: 1 });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
log.error(`[doctor] ${error.message}`);
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
if (command === 'migrate') {
|
|
22
|
+
await migrateEverything(handle.db, { log: (line) => log.log(`[migrate] ${line}`) });
|
|
23
|
+
log.log('[migrate] the database is at this build');
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const report = await diagnoseDatabase(handle.pool, { adapters });
|
|
27
|
+
const origin = option('--live');
|
|
28
|
+
const live = origin ? await liveMigrationCounts(origin) : undefined;
|
|
29
|
+
const verdict = origin ? publishVerdict(report, live) : null;
|
|
30
|
+
if (args.includes('--json')) {
|
|
31
|
+
log.log(JSON.stringify({ ...report, live: live ?? null, verdict }, null, 2));
|
|
32
|
+
} else {
|
|
33
|
+
for (const line of formatReport(report, verdict)) log.log(`[doctor] ${line}`);
|
|
34
|
+
}
|
|
35
|
+
return report.ok ? 0 : 1;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
log.error(`[doctor] ${error.message}`);
|
|
38
|
+
return 2;
|
|
39
|
+
} finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
}
|