@arcforgelabs/flow 2026.10.27 → 2026.10.30
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 +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +46 -1
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +18 -6
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +20 -7
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +4 -1
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/governor.js +138 -13
- package/server/integrations/seazona/index.js +21 -2
- package/server/integrations/seazona/lanes.js +69 -13
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +22 -0
- package/server/integrations/seazona/settings.js +10 -0
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/drafts.js +4 -1
- package/server/integrations/xero/import.js +1 -0
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +29 -7
- package/server/ui/routes.js +1 -1
- 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
|
+
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -4,11 +4,12 @@ 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
|
-
import { enqueueJob, runNextJob } from './queue.js';
|
|
12
|
+
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
12
13
|
|
|
13
14
|
// The pipeline (docs/DATA_SPINE.md, issue 58). It joins what the integrations
|
|
14
15
|
// copy into Flow, and never writes to another system:
|
|
@@ -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, { ...
|
|
149
|
+
await observeJob(db, organisationId, { ...job, contactId });
|
|
144
150
|
result.observed += 1;
|
|
145
151
|
if (!contactId) result.withoutCustomer += 1;
|
|
146
152
|
} catch (error) {
|
|
@@ -442,6 +448,7 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
442
448
|
// systems' copies and writes Flow's own records.
|
|
443
449
|
export function createPipelineWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = RUN_EVERY_MS, firstDelayMs = 15_000 }) {
|
|
444
450
|
const workerId = `pipeline-${randomUUID()}`;
|
|
451
|
+
const startedAtMs = Date.now();
|
|
445
452
|
let timer = null;
|
|
446
453
|
let running = null;
|
|
447
454
|
let stopped = true;
|
|
@@ -461,6 +468,9 @@ export function createPipelineWorker({ db, integrations, log = console, timers =
|
|
|
461
468
|
if (!integrations.with('pullWorkItems').length && !integrations.with('findInvoices').length) return;
|
|
462
469
|
const organisationId = await soleOrganisation(db);
|
|
463
470
|
if (!organisationId) return;
|
|
471
|
+
// Issue #101: a run that gave up waits for a person, a restart or the
|
|
472
|
+
// next day; the timer never starts it again by itself.
|
|
473
|
+
if (await deadSince(db, organisationId, { kind: PIPELINE_KIND, dedupeKey: 'all', since: deadHoldSince(startedAtMs) })) return;
|
|
464
474
|
await enqueueJob(db, organisationId, { kind: PIPELINE_KIND, dedupeKey: 'all' });
|
|
465
475
|
const ran = await runNextJob(db, {
|
|
466
476
|
workerId,
|
|
@@ -468,8 +478,10 @@ export function createPipelineWorker({ db, integrations, log = console, timers =
|
|
|
468
478
|
handlers: { [PIPELINE_KIND]: () => runPipelineOnce(db, integrations) },
|
|
469
479
|
});
|
|
470
480
|
const line = ran ? `${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}` : '';
|
|
471
|
-
// Log a run only when its outcome differs from the last one.
|
|
472
|
-
|
|
481
|
+
// Log a run only when its outcome differs from the last one. A run
|
|
482
|
+
// that gave up is reported once to the error services.
|
|
483
|
+
if (ran?.outcome === 'dead') log.error?.('[pipeline] the pipeline run gave up after its last attempt');
|
|
484
|
+
if (line && line !== last) (ran.outcome === 'succeeded' ? log.info : log.warn)?.(`[pipeline] ${line}`);
|
|
473
485
|
last = line || last;
|
|
474
486
|
} catch (error) {
|
|
475
487
|
log.error?.(`[pipeline] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
package/server/core/queue.js
CHANGED
|
@@ -38,6 +38,28 @@ export async function enqueueJob(db, organisationId, {
|
|
|
38
38
|
return { job: open, queued: false };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Issue #101: the newest job of this kind and key that gave up after `since`,
|
|
42
|
+
// or null. A worker on a timer checks this before it queues the next run, so
|
|
43
|
+
// a job that always fails is not started again and again: it waits for a
|
|
44
|
+
// person (the review item), a restart on a new release, or the next day.
|
|
45
|
+
export async function deadSince(db, organisationId, { kind, dedupeKey = '', since }) {
|
|
46
|
+
const [row] = await db.select().from(backgroundJobs).where(and(
|
|
47
|
+
eq(backgroundJobs.organisationId, organisationId),
|
|
48
|
+
eq(backgroundJobs.kind, kind),
|
|
49
|
+
eq(backgroundJobs.dedupeKey, dedupeKey),
|
|
50
|
+
eq(backgroundJobs.state, 'dead'),
|
|
51
|
+
sql`${backgroundJobs.updatedAt} > ${since}`,
|
|
52
|
+
)).orderBy(sql`${backgroundJobs.updatedAt} desc`).limit(1);
|
|
53
|
+
return row ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// When a timer may queue the job again after it gave up: the later of this
|
|
57
|
+
// process's start and one day ago.
|
|
58
|
+
export const DEAD_HOLD_MS = 24 * 60 * 60 * 1000;
|
|
59
|
+
export function deadHoldSince(startedAtMs, nowMs = Date.now()) {
|
|
60
|
+
return new Date(Math.max(startedAtMs, nowMs - DEAD_HOLD_MS));
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
async function giveUp(tx, job, error) {
|
|
42
64
|
const [row] = await tx.update(backgroundJobs)
|
|
43
65
|
.set({ state: 'dead', lockedBy: null, lockedUntil: null, lastError: error, updatedAt: sql`now()` })
|
package/server/core/sending.js
CHANGED
|
@@ -4,12 +4,13 @@ import { invoiceLines, invoices } from '../db/schema.js';
|
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
5
|
import { issueInvoice } from './invoices.js';
|
|
6
6
|
import { soleOrganisation } from './pipeline.js';
|
|
7
|
-
import { enqueueJob, runNextJob } from './queue.js';
|
|
7
|
+
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
8
8
|
|
|
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
|
|
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) {
|
|
@@ -123,9 +124,10 @@ export async function markReady(db, organisationId, invoiceIds, { personId, acco
|
|
|
123
124
|
return { marked: good.length, refused: [...problems].map(([invoiceId, found]) => ({ invoiceId, problems: found })) };
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
// Back to drafts: the drafts need another look before they go.
|
|
127
|
+
// Back to drafts: the drafts need another look before they go. The new
|
|
128
|
+
// updatedAt keeps "Ready when drafted" (automations.js) off them.
|
|
127
129
|
export async function markNotReady(db, organisationId, invoiceIds) {
|
|
128
|
-
const rows = await db.update(invoices).set({ readyAt: null, readyBy: null })
|
|
130
|
+
const rows = await db.update(invoices).set({ readyAt: null, readyBy: null, updatedAt: sql`now()` })
|
|
129
131
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, invoiceIds)))
|
|
130
132
|
.returning({ id: invoices.id });
|
|
131
133
|
return { moved: rows.length };
|
|
@@ -214,6 +216,7 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
214
216
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
215
217
|
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
216
218
|
summary.failed += 1;
|
|
219
|
+
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
217
220
|
}
|
|
218
221
|
}
|
|
219
222
|
}
|
|
@@ -229,6 +232,7 @@ function settle(db, organisationId, invoiceId, fields) {
|
|
|
229
232
|
// the job queue.
|
|
230
233
|
export function createSendWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = WORKER_EVERY_MS }) {
|
|
231
234
|
const workerId = `send-${randomUUID()}`;
|
|
235
|
+
const startedAtMs = Date.now();
|
|
232
236
|
let timer = null;
|
|
233
237
|
let running = null;
|
|
234
238
|
let stopped = true;
|
|
@@ -251,13 +255,22 @@ export function createSendWorker({ db, integrations, log = console, timers = { s
|
|
|
251
255
|
const [waiting] = await db.select({ id: invoices.id }).from(invoices)
|
|
252
256
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.sendState, 'queued'))).limit(1);
|
|
253
257
|
if (!waiting) return;
|
|
258
|
+
// Issue #101: a send run that gave up waits for a person, a restart or
|
|
259
|
+
// the next day; the timer never starts it again by itself.
|
|
260
|
+
if (await deadSince(db, organisationId, { kind: SEND_KIND, dedupeKey: 'all', since: deadHoldSince(startedAtMs) })) return;
|
|
254
261
|
await enqueueJob(db, organisationId, { kind: SEND_KIND, dedupeKey: 'all' });
|
|
255
262
|
const ran = await runNextJob(db, {
|
|
256
263
|
workerId,
|
|
257
264
|
leaseMs: LEASE_MS,
|
|
258
265
|
handlers: { [SEND_KIND]: () => sendQueued(db, organisationId, { accounting }) },
|
|
259
266
|
});
|
|
260
|
-
if (ran)
|
|
267
|
+
if (!ran) return;
|
|
268
|
+
const line = `[sending] ${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}`;
|
|
269
|
+
// An invoice the accounting system refused waits in Problem for a
|
|
270
|
+
// person; it is reported once, when it is refused.
|
|
271
|
+
if (ran.outcome === 'dead') log.error?.('[sending] the send run gave up after its last attempt');
|
|
272
|
+
if (ran.job.result?.failed) log.error?.('[sending] the accounting system refused an invoice');
|
|
273
|
+
(ran.outcome === 'succeeded' && !ran.job.result?.failed ? log.info : log.warn)?.(line);
|
|
261
274
|
} catch (error) {
|
|
262
275
|
log.error?.(`[sending] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
263
276
|
}
|
|
@@ -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
|
-
|
|
46
|
-
ALTER TABLE "automations" ADD CONSTRAINT "
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
ALTER TABLE "
|
|
51
|
-
|
|
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
|
-
|
|
20
|
-
ALTER TABLE "invoice_rules" ADD CONSTRAINT "
|
|
21
|
-
|
|
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");
|