@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -0,0 +1,337 @@
1
+ import { and, asc, eq, isNull, sql } from 'drizzle-orm';
2
+ import { LINE_MAPPING_KINDS, lineMappings, pipelineCursors, priceItems } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+
5
+ // Line mapping (docs/LINE_MAPPING.md): how each line a work system reports
6
+ // finds the lab's price item before the job reaches the ledger. The routine is
7
+ // a list of steps, tried in order; the first step that answers wins. Every
8
+ // step reads either the lab's price list or the lab's own mapping rows, so no
9
+ // lab's labels or codes are fixed in Flow's code.
10
+ //
11
+ // same_code the line's own code is a price item code
12
+ // account an account row: a line with no price item (a surcharge)
13
+ // saved a row a person saved for this label at this price
14
+ // catalog_name exactly one price item has this name
15
+ // price_alias a row for this label at this price
16
+ // catalog_name_price the price items with this name, at this price: one wins,
17
+ // more than one stops the line for a person
18
+ // catalog_score the price item at this price whose name is closest
19
+ // alias a row for this label, or for the start of it
20
+ //
21
+ // A mapped line keeps the work system's amount and tax flag; the price item
22
+ // 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.
24
+
25
+ export const STEPS = Object.freeze([
26
+ 'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
27
+ ]);
28
+ export const STEP_LABELS = Object.freeze({
29
+ same_code: 'Same code',
30
+ account: 'Account lines',
31
+ saved: 'Saved choices',
32
+ catalog_name: 'Price list name',
33
+ price_alias: 'Label and price',
34
+ catalog_name_price: 'Price list name and price',
35
+ catalog_score: 'Closest name at the same price',
36
+ alias: 'Label',
37
+ });
38
+ export const ZERO_LINES = Object.freeze(['keep', 'drop']);
39
+ export const MAPPING_DEFAULTS = Object.freeze({ steps: [...STEPS], zeroLines: 'keep' });
40
+
41
+ const SCORE_MIN = 16;
42
+ const SCORE_GAP = 8;
43
+ const MAX_MAPPINGS = 2000;
44
+ const invalid = (message) => new FlowError('invalid', message);
45
+
46
+ // Text in the form every comparison uses: lower case, "&" as "and", every
47
+ // other mark a space.
48
+ export function lineText(value) {
49
+ return String(value ?? '').trim().toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
50
+ }
51
+
52
+ // The label without what varies between orders of the same item: sizes
53
+ // ("2 x 3"), arch ("upper", "lower 4") and "per set" or "per unit".
54
+ export function lineLookupText(value) {
55
+ return lineText(value)
56
+ .replace(/\b\d+\s*x\s*\d+(?:\s+\d+)?\b/g, ' ')
57
+ .replace(/\b(upper|lower)\s+\d+\b/g, ' ')
58
+ .replace(/\b(upper|lower)\b/g, ' ')
59
+ .replace(/\bper set\b/g, ' ')
60
+ .replace(/\bper unit\b/g, ' ')
61
+ .replace(/\s+/g, ' ')
62
+ .trim();
63
+ }
64
+
65
+ // A label that carries its own quantity and unit price: "Teeth (6 x $12.50)".
66
+ export function quantityOf(text) {
67
+ const raw = String(text ?? '').trim();
68
+ const found = raw.match(/\((\d+(?:\.\d+)?)\s*x\s*\$?([0-9]+(?:\.\d{1,2})?)\)/i);
69
+ if (!found) return { base: raw, quantity: 1, unitAmountCents: null };
70
+ const quantity = Number(found[1]);
71
+ const base = raw.replace(found[0], ' ').replace(/\s+/g, ' ').trim();
72
+ return {
73
+ base: base || raw,
74
+ quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
75
+ unitAmountCents: Number.isInteger(quantity) && quantity > 0 ? Math.round(Number(found[2]) * 100) : null,
76
+ };
77
+ }
78
+
79
+ const tokens = (text) => [...new Set(text.split(' ').filter((t) => t.length > 1))];
80
+
81
+ // How close a price item's name is to a label. The weights are the earlier
82
+ // lab app's, so its results carry over.
83
+ export function nameScore(item, { exact, lookup, original }) {
84
+ const texts = [lineText(item.name), lineText(item.description)];
85
+ const lookups = [lineLookupText(item.name), lineLookupText(item.description)];
86
+ let score = 0;
87
+ if (exact && texts.includes(exact)) score += 120;
88
+ if (lookup && lookups.includes(lookup)) score += 90;
89
+ if (lookup && lookups.some((t) => t && (t.includes(lookup) || lookup.includes(t)))) score += 35;
90
+ const lookupTokens = new Set(lookups.flatMap(tokens));
91
+ for (const token of tokens(lookup)) if (lookupTokens.has(token)) score += 8;
92
+ const exactTokens = new Set(texts.flatMap(tokens));
93
+ for (const token of tokens(exact)) if (exactTokens.has(token)) score += 8;
94
+ if (original && texts.some((t) => t && t.includes(original))) score += 20;
95
+ return score;
96
+ }
97
+
98
+ // The lab's price list and mapping rows, indexed once for a run.
99
+ export function mappingContext({ items = [], mappings = [], config = MAPPING_DEFAULTS } = {}) {
100
+ const live = items.filter((i) => !i.archivedAt);
101
+ const byCode = new Map(live.map((i) => [i.code, i]));
102
+ const byCodeText = new Map(live.map((i) => [lineText(i.code), i]));
103
+ const byText = new Map();
104
+ const byPrice = new Map();
105
+ const add = (map, key, item) => {
106
+ if (!key) return;
107
+ if (!map.has(key)) map.set(key, []);
108
+ if (!map.get(key).includes(item)) map.get(key).push(item);
109
+ };
110
+ for (const item of live) {
111
+ for (const text of [lineText(item.name), lineText(item.description), lineLookupText(item.name), lineLookupText(item.description)]) add(byText, text, item);
112
+ add(byPrice, item.unitAmountCents, item);
113
+ }
114
+ const rows = mappings.filter((m) => m.enabled !== false);
115
+ const rowsOf = (kind) => rows.filter((m) => m.kind === kind);
116
+ return { byCode, byCodeText, byText, byPrice, rowsOf, config: { ...MAPPING_DEFAULTS, ...config } };
117
+ }
118
+
119
+ const rowMatches = (row, lookup) => (row.match === 'starts_with' ? lookup === row.label || lookup.startsWith(`${row.label} `) : lookup === row.label);
120
+
121
+ // Maps one line. Answers the line as the ledger takes it, with `mappedBy` (the
122
+ // step) and `label` (the lookup text), or `skip: true` for a zero line the
123
+ // routine drops. A line no step answers comes back with `unmapped` set.
124
+ export function mapLine(line, context) {
125
+ const { config } = context;
126
+ const original = String(line.description ?? '').trim();
127
+ const meta = quantityOf(original);
128
+ const exact = lineText(meta.base);
129
+ const lookup = lineLookupText(meta.base);
130
+ const lineCents = line.unitAmountCents ?? null;
131
+ const price = meta.unitAmountCents ?? lineCents;
132
+ const quantity = meta.unitAmountCents !== null ? meta.quantity : (line.quantity ?? 1);
133
+ const base = { ...line, label: lookup || exact || null };
134
+ if (lineCents !== null && lineCents <= 0 && config.zeroLines === 'drop') return { ...base, skip: true, mappedBy: 'zero_line' };
135
+
136
+ const toItem = (item, step, taxCode = null) => ({
137
+ ...base,
138
+ itemCode: item.code,
139
+ accountCode: null,
140
+ description: (item.description || item.name || original).trim(),
141
+ quantity,
142
+ unitAmountCents: price ?? item.unitAmountCents,
143
+ taxCode: taxCode ?? line.taxCode ?? item.taxCode,
144
+ mappedBy: step,
145
+ });
146
+ const fromRow = (row, step) => {
147
+ const item = context.byCode.get(row.itemCode);
148
+ return item ? toItem(item, step, row.taxCode) : null;
149
+ };
150
+ const unmapped = (reason) => ({ ...base, itemCode: null, accountCode: null, mappedBy: null, unmapped: reason });
151
+
152
+ for (const step of config.steps) {
153
+ switch (step) {
154
+ case 'same_code': {
155
+ const item = line.itemCode ? context.byCode.get(line.itemCode.trim()) ?? context.byCodeText.get(lineText(line.itemCode)) : null;
156
+ if (item) return toItem(item, step);
157
+ break;
158
+ }
159
+ case 'account': {
160
+ const row = context.rowsOf('account').find((r) => rowMatches(r, lookup));
161
+ if (row) {
162
+ return {
163
+ ...base, itemCode: null, accountCode: row.accountCode, description: row.description || original, quantity,
164
+ unitAmountCents: price, taxCode: row.taxCode ?? line.taxCode, mappedBy: step,
165
+ };
166
+ }
167
+ break;
168
+ }
169
+ case 'saved':
170
+ case 'price_alias': {
171
+ if (price === null) break;
172
+ const row = context.rowsOf(step).find((r) => r.unitAmountCents === price && rowMatches(r, lookup));
173
+ const mapped = row && fromRow(row, step);
174
+ if (mapped) return mapped;
175
+ break;
176
+ }
177
+ case 'catalog_name': {
178
+ const found = [...new Set([...(context.byText.get(exact) ?? []), ...(context.byText.get(lookup) ?? [])])];
179
+ if (found.length === 1) return toItem(found[0], step);
180
+ break;
181
+ }
182
+ case 'catalog_name_price': {
183
+ if (price === null) break;
184
+ const found = [...new Set([...(context.byText.get(exact) ?? []), ...(context.byText.get(lookup) ?? [])])]
185
+ .filter((i) => i.unitAmountCents === price);
186
+ if (found.length === 1) return toItem(found[0], step);
187
+ if (found.length > 1) return unmapped(`more than one price item is named "${original}" at this price`);
188
+ break;
189
+ }
190
+ case 'catalog_score': {
191
+ if (price === null) break;
192
+ const scored = (context.byPrice.get(price) ?? [])
193
+ .map((item) => ({ item, score: nameScore(item, { exact, lookup, original: lineText(original) }) }))
194
+ .sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name) || a.item.code.localeCompare(b.item.code));
195
+ const [top, next] = scored;
196
+ if (top && top.score >= SCORE_MIN && !(next && top.score < next.score + SCORE_GAP)) return toItem(top.item, step);
197
+ break;
198
+ }
199
+ case 'alias': {
200
+ const rows = context.rowsOf('alias');
201
+ const row = rows.find((r) => r.match === 'exact' && rowMatches(r, lookup)) ?? rows.find((r) => r.match === 'starts_with' && rowMatches(r, lookup));
202
+ const mapped = row && fromRow(row, step);
203
+ if (mapped) return mapped;
204
+ break;
205
+ }
206
+ default:
207
+ break;
208
+ }
209
+ }
210
+ return unmapped(original ? `no mapping step found a price item for "${original}"` : 'the line has no text');
211
+ }
212
+
213
+ // Maps every line of a job. Lines the routine drops leave the job.
214
+ export function mapLines(lines, context) {
215
+ return lines.map((line) => mapLine(line, context)).filter((l) => !l.skip)
216
+ .map(({ skip: _skip, unmapped: _unmapped, ...line }) => line);
217
+ }
218
+
219
+ function readConfig(raw = {}) {
220
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the routine settings must be an object');
221
+ const out = { ...MAPPING_DEFAULTS };
222
+ if ('steps' in raw) {
223
+ if (!Array.isArray(raw.steps) || raw.steps.some((s) => !STEPS.includes(s)) || new Set(raw.steps).size !== raw.steps.length) {
224
+ throw invalid(`steps must list some of ${STEPS.join(', ')}, each once`);
225
+ }
226
+ out.steps = raw.steps;
227
+ }
228
+ if ('zeroLines' in raw) {
229
+ if (!ZERO_LINES.includes(raw.zeroLines)) throw invalid(`zeroLines must be one of ${ZERO_LINES.join(', ')}`);
230
+ out.zeroLines = raw.zeroLines;
231
+ }
232
+ return out;
233
+ }
234
+ export { readConfig as readMappingConfig };
235
+
236
+ const shape = (r) => ({
237
+ id: r.id, kind: r.kind, match: r.match, label: r.label, unitAmountCents: r.unitAmountCents, itemCode: r.itemCode,
238
+ accountCode: r.accountCode, taxCode: r.taxCode, description: r.description, enabled: r.enabled, origin: r.origin, updatedAt: r.updatedAt,
239
+ });
240
+
241
+ const shortText = (value, name, max = 200) => {
242
+ if (value === null || value === undefined || value === '') return null;
243
+ if (typeof value !== 'string' || value.trim().length > max) throw invalid(`${name} must be text of ${max} characters at most`);
244
+ return value.trim() || null;
245
+ };
246
+
247
+ export function readMapping(raw) {
248
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the mapping must be an object');
249
+ const known = ['kind', 'match', 'label', 'unitAmountCents', 'itemCode', 'accountCode', 'taxCode', 'description', 'enabled'];
250
+ const unknown = Object.keys(raw).filter((k) => !known.includes(k));
251
+ if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
252
+ if (!LINE_MAPPING_KINDS.includes(raw.kind)) throw invalid(`kind must be one of ${LINE_MAPPING_KINDS.join(', ')}`);
253
+ const match = raw.match ?? 'exact';
254
+ if (!['exact', 'starts_with'].includes(match)) throw invalid('match must be exact or starts_with');
255
+ const label = lineLookupText(raw.label) || lineText(raw.label);
256
+ if (!label || label.length > 200) throw invalid('the label must be text of 200 characters at most');
257
+ const priced = ['price_alias', 'saved'].includes(raw.kind);
258
+ let unitAmountCents = raw.unitAmountCents ?? null;
259
+ if (unitAmountCents !== null && (!Number.isInteger(unitAmountCents) || unitAmountCents < 0 || unitAmountCents > 100_000_000)) {
260
+ throw invalid('unitAmountCents must be whole cents');
261
+ }
262
+ if (priced && unitAmountCents === null) throw invalid('this kind needs a unit price');
263
+ if (!priced) unitAmountCents = null;
264
+ const taxCode = raw.taxCode ?? null;
265
+ if (taxCode !== null && !['GST', 'GST_FREE'].includes(taxCode)) throw invalid('taxCode must be GST or GST_FREE');
266
+ const itemCode = shortText(raw.itemCode, 'itemCode', 100);
267
+ const accountCode = shortText(raw.accountCode, 'accountCode', 20);
268
+ if (raw.kind === 'account' ? !accountCode : !itemCode) throw invalid(raw.kind === 'account' ? 'an account line needs an account code' : 'the mapping needs a price item');
269
+ const enabled = raw.enabled ?? true;
270
+ if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
271
+ return {
272
+ kind: raw.kind, match, label, unitAmountCents,
273
+ itemCode: raw.kind === 'account' ? null : itemCode,
274
+ accountCode: raw.kind === 'account' ? accountCode : null,
275
+ taxCode, description: shortText(raw.description, 'description'), enabled,
276
+ };
277
+ }
278
+
279
+ export async function listMappings(db, organisationId) {
280
+ const rows = await db.select().from(lineMappings).where(eq(lineMappings.organisationId, organisationId))
281
+ .orderBy(asc(lineMappings.kind), asc(lineMappings.label), asc(lineMappings.unitAmountCents));
282
+ return rows.map(shape);
283
+ }
284
+
285
+ // A change to the mappings reads every job again on the next pipeline run, so
286
+ // jobs already in the ledger take the new mapping. It reads Flow's own copy of
287
+ // the work system, not the work system.
288
+ export async function remapJobs(db, organisationId) {
289
+ await db.delete(pipelineCursors).where(and(eq(pipelineCursors.organisationId, organisationId), sql`${pipelineCursors.name} like '%.work'`));
290
+ }
291
+
292
+ export async function createMapping(db, organisationId, raw, { personId = null, origin = 'person' } = {}) {
293
+ const mapping = readMapping(raw);
294
+ const [{ count }] = await db.select({ count: sql`count(*)::int` }).from(lineMappings).where(eq(lineMappings.organisationId, organisationId));
295
+ if (count >= MAX_MAPPINGS) throw invalid(`an organisation can have ${MAX_MAPPINGS} line mappings at most`);
296
+ const [row] = await db.insert(lineMappings).values({ organisationId, ...mapping, origin, updatedBy: personId })
297
+ .onConflictDoNothing().returning();
298
+ if (!row) throw new FlowError('conflict', 'a mapping for this label already exists');
299
+ await remapJobs(db, organisationId);
300
+ return shape(row);
301
+ }
302
+
303
+ async function loadMapping(db, organisationId, id) {
304
+ const [row] = await db.select().from(lineMappings).where(and(eq(lineMappings.organisationId, organisationId), eq(lineMappings.id, id)));
305
+ if (!row) throw notFound('mapping');
306
+ return row;
307
+ }
308
+
309
+ export async function updateMapping(db, organisationId, id, changes, { personId = null } = {}) {
310
+ const row = await loadMapping(db, organisationId, id);
311
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
312
+ const mapping = readMapping({ ...shape(row), ...changes, id: undefined, origin: undefined, updatedAt: undefined });
313
+ try {
314
+ const [saved] = await db.update(lineMappings).set({ ...mapping, updatedBy: personId, updatedAt: sql`now()` })
315
+ .where(eq(lineMappings.id, row.id)).returning();
316
+ await remapJobs(db, organisationId);
317
+ return shape(saved);
318
+ } catch (error) {
319
+ if (error?.code === '23505' || error?.cause?.code === '23505') throw new FlowError('conflict', 'a mapping for this label already exists');
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ export async function deleteMapping(db, organisationId, id) {
325
+ const row = await loadMapping(db, organisationId, id);
326
+ await db.delete(lineMappings).where(eq(lineMappings.id, row.id));
327
+ await remapJobs(db, organisationId);
328
+ }
329
+
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([
333
+ db.select().from(priceItems).where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt))),
334
+ db.select().from(lineMappings).where(eq(lineMappings.organisationId, organisationId)),
335
+ ]);
336
+ return mappingContext({ items, mappings, config });
337
+ }
@@ -0,0 +1,34 @@
1
+ // The logger every part of Flow writes to. It passes each line to the host's
2
+ // own log first, unchanged, then sends each error to the error services that
3
+ // are on (the `captureError` capability, docs/SENTRY.md). With none on, it is
4
+ // the host's log. A line's leading "[tag]" is its source.
5
+ //
6
+ // `reporters()` answers the adapters to send to; it is read at each error,
7
+ // so an integration turned on or off later is followed. A failure to send is
8
+ // logged as a warning at most once a minute, never sent, so it cannot loop.
9
+ export function createMonitoredLog({ log = console, reporters = () => [], now = Date.now }) {
10
+ let lastFailure = -Infinity;
11
+
12
+ function error(...args) {
13
+ log.error?.(...args);
14
+ const cause = args.find((arg) => arg instanceof Error) ?? null;
15
+ const message = args.map((arg) => (arg instanceof Error ? arg.message : String(arg))).join(' ');
16
+ const source = /^\[([a-z0-9_-]+)\]/i.exec(message)?.[1].toLowerCase() ?? 'server';
17
+ for (const adapter of reporters()) {
18
+ try {
19
+ adapter.capabilities.captureError({ message, source, error: cause });
20
+ } catch (failure) {
21
+ const at = now();
22
+ if (at - lastFailure < 60_000) continue;
23
+ lastFailure = at;
24
+ log.warn?.(`[monitoring] ${adapter.id} could not take an error: ${failure.message}`);
25
+ }
26
+ }
27
+ }
28
+
29
+ return {
30
+ info: (...args) => log.info?.(...args),
31
+ warn: (...args) => log.warn?.(...args),
32
+ error,
33
+ };
34
+ }
@@ -4,7 +4,10 @@ import {
4
4
  billingObligations, contacts, externalLinks, linkRejections, linkSuggestions, obligationEvents, organisations, pipelineCursors,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError } from './errors.js';
7
+ import { listAutomations, runAutomations } from './automations.js';
8
+ import { readInvoicingSettings } from './invoicing-rules.js';
7
9
  import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
10
+ import { loadMappingContext, mapLines } from './line-mapping.js';
8
11
  import { linkRecord, priorLinkFor } from './links.js';
9
12
  import { enqueueJob, runNextJob } from './queue.js';
10
13
 
@@ -20,6 +23,9 @@ import { enqueueJob, runNextJob } from './queue.js';
20
23
  // sales invoices. Exact evidence links the job. A likely invoice is a
21
24
  // suggestion for a person. When the copy is complete and fresh and holds no
22
25
  // invoice that could bill the job, the job has none.
26
+ // 4. Automations (automations.js): the routines that are on, for example
27
+ // marking a job ready to invoice from its work system status, and drafting
28
+ // the invoice of a ready job. Drafts wait in Flow for a person.
23
29
  //
24
30
  // The matcher reads only Flow's copies, so matching years of history costs no
25
31
  // API calls (docs/JOB_INVOICE_LINKS.md).
@@ -129,13 +135,18 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
129
135
  let cursor = restart ? null : await readCursor(db, organisationId, name);
130
136
  const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
131
137
  const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
132
- const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, more: false };
138
+ const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, unmappedLines: 0, more: false };
139
+ // Line mapping (line-mapping.js): each line finds its price item first.
140
+ const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
141
+ const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config) : null;
133
142
  for (let page = 0; page < pages; page += 1) {
134
143
  const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
135
144
  for (const item of items) {
136
145
  const contactId = contactOf.get(String(item.clientId ?? '')) ?? null;
146
+ const job = context ? { ...item.ledgerJob, lines: mapLines(item.ledgerJob.lines ?? [], context) } : item.ledgerJob;
147
+ if (context) result.unmappedLines += job.lines.filter((l) => !l.itemCode && !l.accountCode).length;
137
148
  try {
138
- await observeJob(db, organisationId, { ...item.ledgerJob, contactId });
149
+ await observeJob(db, organisationId, { ...job, contactId });
139
150
  result.observed += 1;
140
151
  if (!contactId) result.withoutCustomer += 1;
141
152
  } catch (error) {
@@ -425,8 +436,10 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
425
436
  system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
426
437
  });
427
438
  }
428
- summary.matching = await matchJobs(db, organisationId, { accounting, now });
439
+ const settings = await readInvoicingSettings(db, organisationId);
440
+ summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
429
441
  }
442
+ if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
430
443
  return summary;
431
444
  }
432
445