@arcforgelabs/flow 2026.10.27 → 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 (48) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -2
  4. package/server/api/invoicing.js +96 -40
  5. package/server/app.js +3 -1
  6. package/server/core/automations.js +14 -0
  7. package/server/core/invoice-rules.js +4 -2
  8. package/server/core/invoices.js +4 -2
  9. package/server/core/job-board.js +130 -0
  10. package/server/core/ledger.js +12 -5
  11. package/server/core/line-mapping.js +337 -0
  12. package/server/core/pipeline.js +9 -3
  13. package/server/core/sending.js +4 -3
  14. package/server/db/migrate-all.js +26 -0
  15. package/server/db/migrations/0010_invoicing_rules.sql +34 -13
  16. package/server/db/migrations/0011_invoice_rules.sql +11 -5
  17. package/server/db/migrations/0012_invoice_sending.sql +18 -9
  18. package/server/db/migrations/0013_line_mappings.sql +35 -0
  19. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  20. package/server/db/migrations/meta/_journal.json +7 -0
  21. package/server/db/schema.js +43 -0
  22. package/server/import/at-start.js +64 -2
  23. package/server/index.js +5 -1
  24. package/server/integrations/port.js +1 -0
  25. package/server/integrations/seazona/feed.js +62 -4
  26. package/server/integrations/seazona/index.js +7 -1
  27. package/server/integrations/seazona/lanes.js +4 -1
  28. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  29. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  30. package/server/integrations/xero/drafts.js +4 -1
  31. package/server/integrations/xero/lookup.js +6 -2
  32. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  33. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  34. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  35. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  36. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  37. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
  38. package/server/ui/app-page.js +6 -4
  39. package/server/ui/app.js +111 -8
  40. package/server/ui/clerk-appearance.js +21 -0
  41. package/server/ui/flow.css +155 -30
  42. package/server/ui/invoicing-settings.js +2 -0
  43. package/server/ui/job-board.js +155 -0
  44. package/server/ui/jobs.js +220 -91
  45. package/server/ui/line-mappings.js +60 -0
  46. package/server/ui/monitoring.js +15 -7
  47. package/server/ui/routes.js +1 -1
  48. 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
+ }
@@ -4,9 +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 { runAutomations } from './automations.js';
7
+ import { listAutomations, runAutomations } from './automations.js';
8
8
  import { readInvoicingSettings } from './invoicing-rules.js';
9
9
  import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
10
+ import { loadMappingContext, mapLines } from './line-mapping.js';
10
11
  import { linkRecord, priorLinkFor } from './links.js';
11
12
  import { enqueueJob, runNextJob } from './queue.js';
12
13
 
@@ -134,13 +135,18 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
134
135
  let cursor = restart ? null : await readCursor(db, organisationId, name);
135
136
  const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
136
137
  const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
137
- 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;
138
142
  for (let page = 0; page < pages; page += 1) {
139
143
  const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
140
144
  for (const item of items) {
141
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;
142
148
  try {
143
- await observeJob(db, organisationId, { ...item.ledgerJob, contactId });
149
+ await observeJob(db, organisationId, { ...job, contactId });
144
150
  result.observed += 1;
145
151
  if (!contactId) result.withoutCustomer += 1;
146
152
  } catch (error) {
@@ -9,7 +9,8 @@ import { enqueueJob, runNextJob } from './queue.js';
9
9
  // Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
10
10
  //
11
11
  // Flow checks every draft. It checks its own data first: the draft has lines,
12
- // a total above zero, and a price item on every line. When an accounting
12
+ // a total above zero, and a price item (or, for a line such as a rush
13
+ // charge, an account) on every line. When an accounting
13
14
  // system is connected, its own checks are added (for Xero: the customer
14
15
  // matches one Xero contact, every item is in Xero). A draft with a problem is
15
16
  // in Problem. A person marks a draft without one ready; any later change to
@@ -41,7 +42,7 @@ export function draftProblems(invoice, lines) {
41
42
  if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.'));
42
43
  else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.'));
43
44
  for (const line of lines) {
44
- if (!line.priceItemId) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`));
45
+ if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`));
45
46
  }
46
47
  return problems;
47
48
  }
@@ -52,7 +53,7 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
52
53
  const ids = drafts.map((d) => d.id);
53
54
  const problems = new Map();
54
55
  if (!ids.length) return problems;
55
- const lines = await db.select({ invoiceId: invoiceLines.invoiceId, position: invoiceLines.position, priceItemId: invoiceLines.priceItemId })
56
+ const lines = await db.select({ invoiceId: invoiceLines.invoiceId, position: invoiceLines.position, priceItemId: invoiceLines.priceItemId, accountCode: invoiceLines.accountCode })
56
57
  .from(invoiceLines).where(inArray(invoiceLines.invoiceId, ids)).orderBy(asc(invoiceLines.position));
57
58
  const linesOf = Map.groupBy(lines, (l) => l.invoiceId);
58
59
  for (const draft of drafts) {
@@ -0,0 +1,26 @@
1
+ import { migrateDatabase, openDatabase } from './index.js';
2
+ import { adapters as shipped } from '../integrations/adapters.js';
3
+ import { migrateAdapterSchema } from '../integrations/schema.js';
4
+
5
+ // Brings a database up to this build: the core migrations, then the tables
6
+ // of every adapter this build ships, whether or not it is on. On Replit the
7
+ // workspace's database runs this before a publish (scripts/replit-deploy.sh
8
+ // update-workspace), so it holds every table production can hold and
9
+ // publishing never offers to drop one (docs/REPLIT.md).
10
+ export async function migrateEverything(db, { adapters = shipped, log = () => {} } = {}) {
11
+ await migrateDatabase(db);
12
+ for (const adapter of adapters) {
13
+ if (!adapter.schema) continue;
14
+ await migrateAdapterSchema(db, adapter);
15
+ log(`${adapter.id} tables are up to date`);
16
+ }
17
+ }
18
+
19
+ if (import.meta.url === `file://${process.argv[1]}`) {
20
+ const handle = openDatabase({ max: 1 });
21
+ try {
22
+ await migrateEverything(handle.db, { log: (line) => console.log(`[deploy] ${line}`) });
23
+ } finally {
24
+ await handle.close();
25
+ }
26
+ }
@@ -1,4 +1,4 @@
1
- CREATE TABLE "automations" (
1
+ CREATE TABLE IF NOT EXISTS "automations" (
2
2
  "organisation_id" uuid NOT NULL,
3
3
  "key" text NOT NULL,
4
4
  "enabled" boolean NOT NULL,
@@ -8,7 +8,7 @@ CREATE TABLE "automations" (
8
8
  CONSTRAINT "automations_pk" PRIMARY KEY("organisation_id","key")
9
9
  );
10
10
  --> statement-breakpoint
11
- CREATE TABLE "customer_billing_settings" (
11
+ CREATE TABLE IF NOT EXISTS "customer_billing_settings" (
12
12
  "organisation_id" uuid NOT NULL,
13
13
  "contact_id" uuid NOT NULL,
14
14
  "billable" boolean DEFAULT true NOT NULL,
@@ -23,7 +23,7 @@ CREATE TABLE "customer_billing_settings" (
23
23
  CONSTRAINT "customer_billing_settings_terms_valid" CHECK ("customer_billing_settings"."payment_terms_days" is null or "customer_billing_settings"."payment_terms_days" between 0 and 365)
24
24
  );
25
25
  --> statement-breakpoint
26
- CREATE TABLE "invoicing_settings" (
26
+ CREATE TABLE IF NOT EXISTS "invoicing_settings" (
27
27
  "organisation_id" uuid PRIMARY KEY NOT NULL,
28
28
  "send_mode" text DEFAULT 'accounting_draft' NOT NULL,
29
29
  "stale_days" integer DEFAULT 90 NOT NULL,
@@ -39,13 +39,34 @@ CREATE TABLE "invoicing_settings" (
39
39
  CONSTRAINT "invoicing_settings_rush_valid" CHECK ("invoicing_settings"."rush_percent" between 0 and 100)
40
40
  );
41
41
  --> statement-breakpoint
42
- ALTER TABLE "billing_obligations" ADD COLUMN "source_status" text;--> statement-breakpoint
43
- ALTER TABLE "billing_obligations" ADD COLUMN "ready_at" timestamp with time zone;--> statement-breakpoint
44
- ALTER TABLE "billing_obligations" ADD COLUMN "ready_by" text;--> statement-breakpoint
45
- ALTER TABLE "automations" ADD CONSTRAINT "automations_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
46
- ALTER TABLE "automations" ADD CONSTRAINT "automations_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
47
- ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
48
- ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
49
- ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_contact_same_org" FOREIGN KEY ("organisation_id","contact_id") REFERENCES "public"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
50
- ALTER TABLE "invoicing_settings" ADD CONSTRAINT "invoicing_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
51
- ALTER TABLE "invoicing_settings" ADD CONSTRAINT "invoicing_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
42
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "source_status" text;--> statement-breakpoint
43
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "ready_at" timestamp with time zone;--> statement-breakpoint
44
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "ready_by" text;--> statement-breakpoint
45
+ DO $flow_guard$ BEGIN
46
+ ALTER TABLE "automations" ADD CONSTRAINT "automations_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
47
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
48
+ END $flow_guard$;--> statement-breakpoint
49
+ DO $flow_guard$ BEGIN
50
+ ALTER TABLE "automations" ADD CONSTRAINT "automations_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
51
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
52
+ END $flow_guard$;--> statement-breakpoint
53
+ DO $flow_guard$ BEGIN
54
+ ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
55
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
56
+ END $flow_guard$;--> statement-breakpoint
57
+ DO $flow_guard$ BEGIN
58
+ ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
59
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
60
+ END $flow_guard$;--> statement-breakpoint
61
+ DO $flow_guard$ BEGIN
62
+ ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_contact_same_org" FOREIGN KEY ("organisation_id","contact_id") REFERENCES "public"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;
63
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
64
+ END $flow_guard$;--> statement-breakpoint
65
+ DO $flow_guard$ BEGIN
66
+ ALTER TABLE "invoicing_settings" ADD CONSTRAINT "invoicing_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
67
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
68
+ END $flow_guard$;--> statement-breakpoint
69
+ DO $flow_guard$ BEGIN
70
+ ALTER TABLE "invoicing_settings" ADD CONSTRAINT "invoicing_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
71
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
72
+ END $flow_guard$;
@@ -1,4 +1,4 @@
1
- CREATE TABLE "invoice_rules" (
1
+ CREATE TABLE IF NOT EXISTS "invoice_rules" (
2
2
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
3
  "organisation_id" uuid NOT NULL,
4
4
  "name" text NOT NULL,
@@ -15,7 +15,13 @@ CREATE TABLE "invoice_rules" (
15
15
  CONSTRAINT "invoice_rules_name_valid" CHECK (length("invoice_rules"."name") between 1 and 120)
16
16
  );
17
17
  --> statement-breakpoint
18
- ALTER TABLE "invoicing_settings" ADD COLUMN "rules_seeded" boolean DEFAULT false NOT NULL;--> statement-breakpoint
19
- ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
20
- ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
21
- CREATE UNIQUE INDEX "invoice_rules_starter_key" ON "invoice_rules" USING btree ("organisation_id","starter_key");
18
+ ALTER TABLE "invoicing_settings" ADD COLUMN IF NOT EXISTS "rules_seeded" boolean DEFAULT false NOT NULL;--> statement-breakpoint
19
+ DO $flow_guard$ BEGIN
20
+ ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
21
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
22
+ END $flow_guard$;--> statement-breakpoint
23
+ DO $flow_guard$ BEGIN
24
+ ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
25
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
26
+ END $flow_guard$;--> statement-breakpoint
27
+ CREATE UNIQUE INDEX IF NOT EXISTS "invoice_rules_starter_key" ON "invoice_rules" USING btree ("organisation_id","starter_key");
@@ -1,9 +1,18 @@
1
- ALTER TABLE "invoices" ADD COLUMN "ready_at" timestamp with time zone;--> statement-breakpoint
2
- ALTER TABLE "invoices" ADD COLUMN "ready_by" uuid;--> statement-breakpoint
3
- ALTER TABLE "invoices" ADD COLUMN "send_state" text;--> statement-breakpoint
4
- ALTER TABLE "invoices" ADD COLUMN "send_error" jsonb;--> statement-breakpoint
5
- ALTER TABLE "invoices" ADD COLUMN "send_requested_by" uuid;--> statement-breakpoint
6
- ALTER TABLE "invoices" ADD COLUMN "send_updated_at" timestamp with time zone;--> statement-breakpoint
7
- ALTER TABLE "invoices" ADD CONSTRAINT "invoices_ready_by_people_id_fk" FOREIGN KEY ("ready_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
8
- ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_requested_by_people_id_fk" FOREIGN KEY ("send_requested_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
9
- ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_state_valid" CHECK ("invoices"."send_state" is null or "invoices"."send_state" in ('queued', 'sent', 'failed'));
1
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ready_at" timestamp with time zone;--> statement-breakpoint
2
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ready_by" uuid;--> statement-breakpoint
3
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_state" text;--> statement-breakpoint
4
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_error" jsonb;--> statement-breakpoint
5
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_requested_by" uuid;--> statement-breakpoint
6
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_updated_at" timestamp with time zone;--> statement-breakpoint
7
+ DO $flow_guard$ BEGIN
8
+ ALTER TABLE "invoices" ADD CONSTRAINT "invoices_ready_by_people_id_fk" FOREIGN KEY ("ready_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
9
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
10
+ END $flow_guard$;--> statement-breakpoint
11
+ DO $flow_guard$ BEGIN
12
+ ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_requested_by_people_id_fk" FOREIGN KEY ("send_requested_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
13
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
14
+ END $flow_guard$;--> statement-breakpoint
15
+ DO $flow_guard$ BEGIN
16
+ ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_state_valid" CHECK ("invoices"."send_state" is null or "invoices"."send_state" in ('queued', 'sent', 'failed'));
17
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
18
+ END $flow_guard$;
@@ -0,0 +1,35 @@
1
+ CREATE TABLE IF NOT EXISTS "line_mappings" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "kind" text NOT NULL,
5
+ "match" text DEFAULT 'exact' NOT NULL,
6
+ "label" text NOT NULL,
7
+ "unit_amount_cents" integer,
8
+ "item_code" text,
9
+ "account_code" text,
10
+ "tax_code" text,
11
+ "description" text,
12
+ "enabled" boolean DEFAULT true NOT NULL,
13
+ "origin" text DEFAULT 'person' NOT NULL,
14
+ "updated_by" uuid,
15
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
16
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
17
+ CONSTRAINT "line_mappings_kind_valid" CHECK ("line_mappings"."kind" in ('alias', 'price_alias', 'saved', 'account')),
18
+ CONSTRAINT "line_mappings_match_valid" CHECK ("line_mappings"."match" in ('exact', 'starts_with')),
19
+ CONSTRAINT "line_mappings_tax_code_valid" CHECK ("line_mappings"."tax_code" is null or "line_mappings"."tax_code" in ('GST', 'GST_FREE')),
20
+ CONSTRAINT "line_mappings_target" CHECK (("line_mappings"."kind" = 'account' and "line_mappings"."account_code" is not null) or ("line_mappings"."kind" <> 'account' and "line_mappings"."item_code" is not null)),
21
+ CONSTRAINT "line_mappings_price" CHECK ("line_mappings"."kind" not in ('price_alias', 'saved') or "line_mappings"."unit_amount_cents" is not null),
22
+ CONSTRAINT "line_mappings_label_valid" CHECK (length("line_mappings"."label") between 1 and 200)
23
+ );
24
+ --> statement-breakpoint
25
+ ALTER TABLE "invoice_lines" ADD COLUMN IF NOT EXISTS "account_code" text;--> statement-breakpoint
26
+ ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "description" text;--> statement-breakpoint
27
+ DO $flow_guard$ BEGIN
28
+ ALTER TABLE "line_mappings" ADD CONSTRAINT "line_mappings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
29
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
30
+ END $flow_guard$;--> statement-breakpoint
31
+ DO $flow_guard$ BEGIN
32
+ ALTER TABLE "line_mappings" ADD CONSTRAINT "line_mappings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
33
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
34
+ END $flow_guard$;--> statement-breakpoint
35
+ CREATE UNIQUE INDEX IF NOT EXISTS "line_mappings_unique" ON "line_mappings" USING btree ("organisation_id","kind","match","label",coalesce("unit_amount_cents", -1));