@arcforgelabs/flow 2026.10.30 → 2026.10.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +125 -7
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +17 -3
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +26 -1
- package/server/core/job-board.js +25 -3
- package/server/core/ledger.js +3 -2
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +76 -4
- package/server/core/price-list.js +348 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +46 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +21 -1
- package/server/integrations/seazona/feed.js +31 -2
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +46 -1
- package/server/integrations/seazona/lanes.js +187 -51
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +39 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +131 -17
- package/server/integrations/xero/index.js +38 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +27 -10
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +55 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +63 -21
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +78 -9
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +4 -2
- package/server/ui/invoicing.js +241 -49
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
2
|
+
import { priceItemLinks, priceItems, reviewItems, TAX_CODES } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
import { remapJobs } from './line-mapping.js';
|
|
5
|
+
import { openReview, resolveReview } from './review.js';
|
|
6
|
+
|
|
7
|
+
// The price list (docs/PRICE_LIST.md). Flow's own price items are the source
|
|
8
|
+
// of truth: a lab adds, changes and archives them here with no integration
|
|
9
|
+
// connected. Each connected system that keeps its own price list (Xero items,
|
|
10
|
+
// Seazona products) offers it through the port's `listPriceItems`, read from
|
|
11
|
+
// Flow's copy, so nothing here calls another system.
|
|
12
|
+
//
|
|
13
|
+
// A link joins one record of a system's list to one price item:
|
|
14
|
+
// - Flow links by itself when both sides have the same code;
|
|
15
|
+
// - a person links the rest;
|
|
16
|
+
// - an accounting system (Xero) has one record per price item at most; a
|
|
17
|
+
// work system (Seazona) can link many products to one price item.
|
|
18
|
+
// The price check compares each price item with its linked records. A
|
|
19
|
+
// difference is an alert only (review item `price_check`): Flow never changes
|
|
20
|
+
// a price on either side. "Mark resolved" accepts what the check found; the
|
|
21
|
+
// same finding never opens again, a new one does.
|
|
22
|
+
|
|
23
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
24
|
+
const REVIEW_KIND = 'price_check';
|
|
25
|
+
const MAX_ITEMS = 5000;
|
|
26
|
+
const FINDING_KINDS = Object.freeze(['price', 'tax', 'missing', 'not_linked']);
|
|
27
|
+
export { FINDING_KINDS };
|
|
28
|
+
|
|
29
|
+
const codeKey = (value) => String(value ?? '').trim().toLowerCase();
|
|
30
|
+
|
|
31
|
+
const shortText = (value, name, max, { required = false } = {}) => {
|
|
32
|
+
if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) {
|
|
33
|
+
if (required) throw invalid(`${name} is required`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (typeof value !== 'string' || value.trim().length > max) throw invalid(`${name} must be text of ${max} characters at most`);
|
|
37
|
+
return value.trim();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A price item as the API takes it. `partial` reads a change: only the fields
|
|
41
|
+
// it names.
|
|
42
|
+
export function readPriceItem(raw, { partial = false } = {}) {
|
|
43
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the price item must be an object');
|
|
44
|
+
const known = ['code', 'name', 'description', 'unitAmountCents', 'taxCode', 'accountCode'];
|
|
45
|
+
const unknown = Object.keys(raw).filter((k) => !known.includes(k));
|
|
46
|
+
if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
|
|
47
|
+
const out = {};
|
|
48
|
+
const has = (key) => !partial || key in raw;
|
|
49
|
+
if (has('code')) out.code = shortText(raw.code, 'code', 30, { required: true });
|
|
50
|
+
if (has('name')) out.name = shortText(raw.name, 'name', 200, { required: true });
|
|
51
|
+
if (has('description')) out.description = shortText(raw.description, 'description', 1000);
|
|
52
|
+
if (has('unitAmountCents')) {
|
|
53
|
+
const cents = raw.unitAmountCents;
|
|
54
|
+
if (!Number.isInteger(cents) || cents < 0 || cents > 100_000_000) throw invalid('unitAmountCents must be whole cents, 0 or more');
|
|
55
|
+
out.unitAmountCents = cents;
|
|
56
|
+
}
|
|
57
|
+
if (has('taxCode')) {
|
|
58
|
+
const taxCode = raw.taxCode ?? 'GST';
|
|
59
|
+
if (!TAX_CODES.includes(taxCode)) throw invalid(`taxCode must be one of ${TAX_CODES.join(', ')}`);
|
|
60
|
+
out.taxCode = taxCode;
|
|
61
|
+
}
|
|
62
|
+
if (has('accountCode')) out.accountCode = shortText(raw.accountCode, 'accountCode', 20);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const shapeItem = (r) => ({
|
|
67
|
+
id: r.id, code: r.code, name: r.name, description: r.description, unitAmountCents: r.unitAmountCents,
|
|
68
|
+
taxCode: r.taxCode, accountCode: r.accountCode, archivedAt: r.archivedAt, updatedAt: r.updatedAt,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const conflictOf = (error) => error?.code === '23505' || error?.cause?.code === '23505';
|
|
72
|
+
|
|
73
|
+
async function loadItem(db, organisationId, id) {
|
|
74
|
+
const [row] = await db.select().from(priceItems).where(and(eq(priceItems.organisationId, organisationId), eq(priceItems.id, id)));
|
|
75
|
+
if (!row) throw notFound('price item');
|
|
76
|
+
return row;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function createPriceItem(db, organisationId, raw, { personId = null } = {}) {
|
|
80
|
+
const item = readPriceItem(raw);
|
|
81
|
+
const [{ count }] = await db.select({ count: sql`count(*)::int` }).from(priceItems).where(eq(priceItems.organisationId, organisationId));
|
|
82
|
+
if (count >= MAX_ITEMS) throw invalid(`an organisation can have ${MAX_ITEMS} price items at most`);
|
|
83
|
+
try {
|
|
84
|
+
const [row] = await db.insert(priceItems).values({ organisationId, ...item, updatedBy: personId }).returning();
|
|
85
|
+
await remapJobs(db, organisationId);
|
|
86
|
+
return shapeItem(row);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (conflictOf(error)) throw new FlowError('conflict', `the code ${item.code} is already on the price list`);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A change to a price item. Invoice lines keep the price they were made with;
|
|
94
|
+
// jobs not yet invoiced map again on the next pipeline run.
|
|
95
|
+
export async function updatePriceItem(db, organisationId, id, raw, { personId = null } = {}) {
|
|
96
|
+
const row = await loadItem(db, organisationId, id);
|
|
97
|
+
const changes = readPriceItem(raw, { partial: true });
|
|
98
|
+
if (!Object.keys(changes).length) return shapeItem(row);
|
|
99
|
+
try {
|
|
100
|
+
const [saved] = await db.update(priceItems).set({ ...changes, updatedBy: personId, updatedAt: sql`now()` })
|
|
101
|
+
.where(eq(priceItems.id, row.id)).returning();
|
|
102
|
+
await remapJobs(db, organisationId);
|
|
103
|
+
return shapeItem(saved);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (conflictOf(error)) throw new FlowError('conflict', `the code ${changes.code} is already on the price list`);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Archives (or brings back) a price item. An archived item keeps its invoice
|
|
111
|
+
// lines and its links; new lines no longer map to it.
|
|
112
|
+
export async function archivePriceItem(db, organisationId, id, { archived = true, personId = null } = {}) {
|
|
113
|
+
const row = await loadItem(db, organisationId, id);
|
|
114
|
+
const [saved] = await db.update(priceItems)
|
|
115
|
+
.set({ archivedAt: archived ? (row.archivedAt ?? sql`now()`) : null, updatedBy: personId, updatedAt: sql`now()` })
|
|
116
|
+
.where(eq(priceItems.id, row.id)).returning();
|
|
117
|
+
await remapJobs(db, organisationId);
|
|
118
|
+
return shapeItem(saved);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Each connected system's list, by system id: { id, name, kind, syncedAt,
|
|
122
|
+
// items, byId }. A system that cannot answer is left out (it is off, or its
|
|
123
|
+
// copy is not there yet).
|
|
124
|
+
export async function readCatalogues(organisationId, systems = [], { log = console } = {}) {
|
|
125
|
+
const out = new Map();
|
|
126
|
+
for (const adapter of systems) {
|
|
127
|
+
try {
|
|
128
|
+
const answer = await adapter.capabilities.listPriceItems({ organisationId });
|
|
129
|
+
if (!answer || !Array.isArray(answer.items)) continue;
|
|
130
|
+
const items = answer.items.filter((i) => i && i.id);
|
|
131
|
+
out.set(adapter.id, {
|
|
132
|
+
id: adapter.id,
|
|
133
|
+
name: adapter.name,
|
|
134
|
+
kind: answer.kind === 'accounting' ? 'accounting' : 'work',
|
|
135
|
+
syncedAt: answer.syncedAt ?? null,
|
|
136
|
+
items,
|
|
137
|
+
byId: new Map(items.map((i) => [String(i.id), i])),
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log.warn?.(`[price-list] ${adapter.id} price list could not be read: ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readLinks(db, organisationId) {
|
|
147
|
+
return db.select().from(priceItemLinks).where(eq(priceItemLinks.organisationId, organisationId))
|
|
148
|
+
.orderBy(asc(priceItemLinks.system), asc(priceItemLinks.externalCode));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Links each record of a system's list whose code is a price item's code, when
|
|
152
|
+
// neither side is linked yet in a way that stops it. Answers how many it made.
|
|
153
|
+
export async function linkByCode(db, organisationId, catalogue, { items = null, links = null } = {}) {
|
|
154
|
+
const all = items ?? await db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId));
|
|
155
|
+
const existing = links ?? await readLinks(db, organisationId);
|
|
156
|
+
const mine = existing.filter((l) => l.system === catalogue.id);
|
|
157
|
+
const linkedExternal = new Set(mine.map((l) => String(l.externalId)));
|
|
158
|
+
const linkedItem = new Set(mine.map((l) => l.priceItemId));
|
|
159
|
+
const byCode = new Map(all.filter((i) => !i.archivedAt).map((i) => [codeKey(i.code), i]));
|
|
160
|
+
const rows = [];
|
|
161
|
+
for (const record of catalogue.items) {
|
|
162
|
+
const item = byCode.get(codeKey(record.code));
|
|
163
|
+
if (!item || !codeKey(record.code) || linkedExternal.has(String(record.id))) continue;
|
|
164
|
+
if (catalogue.kind === 'accounting' && linkedItem.has(item.id)) continue;
|
|
165
|
+
rows.push({ organisationId, priceItemId: item.id, system: catalogue.id, externalId: String(record.id), externalCode: record.code ?? null, matchedBy: 'code' });
|
|
166
|
+
linkedExternal.add(String(record.id));
|
|
167
|
+
linkedItem.add(item.id);
|
|
168
|
+
}
|
|
169
|
+
if (!rows.length) return 0;
|
|
170
|
+
const made = await db.insert(priceItemLinks).values(rows).onConflictDoNothing().returning({ id: priceItemLinks.id });
|
|
171
|
+
return made.length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A person's link: `externalId` in `system`'s list goes to this price item.
|
|
175
|
+
// For an accounting system it replaces the item's earlier link; a record
|
|
176
|
+
// linked elsewhere moves here.
|
|
177
|
+
export async function linkPriceItem(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId = null }) {
|
|
178
|
+
const item = await loadItem(db, organisationId, priceItemId);
|
|
179
|
+
const catalogue = catalogues.get(system);
|
|
180
|
+
if (!catalogue) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
|
|
181
|
+
const record = catalogue.byId.get(String(externalId ?? ''));
|
|
182
|
+
if (!record) throw notFound(`${catalogue.name} record`);
|
|
183
|
+
const link = await db.transaction(async (tx) => {
|
|
184
|
+
await tx.delete(priceItemLinks).where(and(
|
|
185
|
+
eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.externalId, String(record.id)),
|
|
186
|
+
));
|
|
187
|
+
if (catalogue.kind === 'accounting') {
|
|
188
|
+
await tx.delete(priceItemLinks).where(and(
|
|
189
|
+
eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.priceItemId, item.id),
|
|
190
|
+
));
|
|
191
|
+
}
|
|
192
|
+
const [row] = await tx.insert(priceItemLinks).values({
|
|
193
|
+
organisationId, priceItemId: item.id, system, externalId: String(record.id), externalCode: record.code ?? null, matchedBy: 'person', createdBy: personId,
|
|
194
|
+
}).returning();
|
|
195
|
+
return row;
|
|
196
|
+
});
|
|
197
|
+
await remapJobs(db, organisationId);
|
|
198
|
+
return link;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function unlinkPriceItem(db, organisationId, linkId) {
|
|
202
|
+
const rows = await db.delete(priceItemLinks)
|
|
203
|
+
.where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.id, linkId))).returning();
|
|
204
|
+
if (!rows.length) throw notFound('link');
|
|
205
|
+
await remapJobs(db, organisationId);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// What differs between one price item and its linked records. Pure: the
|
|
209
|
+
// caller passes the item, its links and the catalogues.
|
|
210
|
+
export function findingsFor(item, links, catalogues) {
|
|
211
|
+
const findings = [];
|
|
212
|
+
for (const catalogue of catalogues.values()) {
|
|
213
|
+
const mine = links.filter((l) => l.system === catalogue.id);
|
|
214
|
+
if (!mine.length && catalogue.kind === 'accounting') {
|
|
215
|
+
findings.push({ system: catalogue.id, kind: 'not_linked', code: item.code });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
for (const link of mine) {
|
|
219
|
+
const record = catalogue.byId.get(String(link.externalId));
|
|
220
|
+
const base = { system: catalogue.id, externalId: String(link.externalId), externalCode: record?.code ?? link.externalCode ?? null };
|
|
221
|
+
if (!record) {
|
|
222
|
+
findings.push({ ...base, kind: 'missing' });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (record.unitAmountCents !== null && record.unitAmountCents !== undefined && record.unitAmountCents !== item.unitAmountCents) {
|
|
226
|
+
findings.push({ ...base, kind: 'price', flow: item.unitAmountCents, other: record.unitAmountCents });
|
|
227
|
+
}
|
|
228
|
+
if (record.taxCode && record.taxCode !== item.taxCode) {
|
|
229
|
+
findings.push({ ...base, kind: 'tax', flow: item.taxCode, other: record.taxCode });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return findings;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The same findings always give the same signature, so an accepted finding
|
|
237
|
+
// is never raised again, and a changed one is.
|
|
238
|
+
export function findingsSignature(item, findings) {
|
|
239
|
+
const parts = findings.map((f) => [f.system, f.kind, f.externalId ?? '', f.flow ?? '', f.other ?? ''].join('|')).sort();
|
|
240
|
+
return `${item.unitAmountCents}|${item.taxCode}|${parts.join(';')}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const moneyAtRisk = (findings) => findings.reduce((max, f) => (f.kind === 'price' ? Math.max(max, Math.abs(f.flow - f.other)) : max), 0);
|
|
244
|
+
|
|
245
|
+
async function lastClosedSignatures(db, organisationId, ids) {
|
|
246
|
+
if (!ids.length) return new Map();
|
|
247
|
+
const rows = await db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence, state: reviewItems.state, resolution: reviewItems.resolution })
|
|
248
|
+
.from(reviewItems)
|
|
249
|
+
.where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND), inArray(reviewItems.flowId, ids)))
|
|
250
|
+
.orderBy(desc(reviewItems.updatedAt));
|
|
251
|
+
const out = new Map();
|
|
252
|
+
for (const row of rows) if (!out.has(row.flowId)) out.set(row.flowId, row);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The price check. Links by code first, then compares every live price item
|
|
257
|
+
// with its linked records and keeps one review item per price item in step:
|
|
258
|
+
// opened or refreshed while something differs, closed when nothing does.
|
|
259
|
+
// `only` checks one price item. Reads Flow's copies only.
|
|
260
|
+
export async function checkPrices(db, organisationId, { catalogues, only = null, actor = 'flow:price-check' }) {
|
|
261
|
+
const items = await db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId));
|
|
262
|
+
let linked = 0;
|
|
263
|
+
for (const catalogue of catalogues.values()) linked += await linkByCode(db, organisationId, catalogue, { items });
|
|
264
|
+
const links = await readLinks(db, organisationId);
|
|
265
|
+
const live = items.filter((i) => !i.archivedAt && (!only || i.id === only));
|
|
266
|
+
const latest = await lastClosedSignatures(db, organisationId, live.map((i) => i.id));
|
|
267
|
+
const summary = { linked, checked: live.length, differing: 0, opened: 0, closed: 0, accepted: 0 };
|
|
268
|
+
for (const item of live) {
|
|
269
|
+
const findings = findingsFor(item, links.filter((l) => l.priceItemId === item.id), catalogues);
|
|
270
|
+
const signature = findingsSignature(item, findings);
|
|
271
|
+
const last = latest.get(item.id);
|
|
272
|
+
if (!findings.length) {
|
|
273
|
+
if (last?.state === 'open') {
|
|
274
|
+
await db.update(reviewItems).set({ state: 'resolved', resolution: 'prices_agree', resolvedBy: actor, resolvedAt: sql`now()`, updatedAt: sql`now()` })
|
|
275
|
+
.where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND), eq(reviewItems.flowId, item.id), eq(reviewItems.state, 'open')));
|
|
276
|
+
summary.closed += 1;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
summary.differing += 1;
|
|
281
|
+
if (last && last.resolution === 'accepted' && last.evidence?.signature === signature) {
|
|
282
|
+
summary.accepted += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
await openReview(db, organisationId, {
|
|
286
|
+
kind: REVIEW_KIND,
|
|
287
|
+
entity: 'price_item',
|
|
288
|
+
flowId: item.id,
|
|
289
|
+
systems: [...new Set(findings.map((f) => f.system))],
|
|
290
|
+
evidence: { code: item.code, name: item.name, signature, findings },
|
|
291
|
+
moneyAtRiskCents: moneyAtRisk(findings),
|
|
292
|
+
});
|
|
293
|
+
if (last?.state !== 'open') summary.opened += 1;
|
|
294
|
+
}
|
|
295
|
+
return summary;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// "Mark resolved": checks the one price item again, then accepts what is
|
|
299
|
+
// still different, so the same finding stays closed.
|
|
300
|
+
export async function acceptFindings(db, organisationId, priceItemId, { catalogues, actor }) {
|
|
301
|
+
await loadItem(db, organisationId, priceItemId);
|
|
302
|
+
await checkPrices(db, organisationId, { catalogues, only: priceItemId, actor });
|
|
303
|
+
const [open] = await db.select().from(reviewItems).where(and(
|
|
304
|
+
eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND),
|
|
305
|
+
eq(reviewItems.flowId, priceItemId), eq(reviewItems.state, 'open'),
|
|
306
|
+
));
|
|
307
|
+
if (!open) return { state: 'agree' };
|
|
308
|
+
await resolveReview(db, organisationId, open.id, { resolution: 'accepted', actor });
|
|
309
|
+
return { state: 'accepted' };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Everything the price list screen shows: each price item with its links,
|
|
313
|
+
// the linked records and what differs; each system's records that link to no
|
|
314
|
+
// price item; and the systems themselves. Reads Flow's copies only.
|
|
315
|
+
export async function readPriceList(db, organisationId, { catalogues }) {
|
|
316
|
+
const [items, links, open] = await Promise.all([
|
|
317
|
+
db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId)).orderBy(asc(priceItems.code)),
|
|
318
|
+
readLinks(db, organisationId),
|
|
319
|
+
db.select({ flowId: reviewItems.flowId }).from(reviewItems)
|
|
320
|
+
.where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_KIND), eq(reviewItems.state, 'open'))),
|
|
321
|
+
]);
|
|
322
|
+
const alerting = new Set(open.map((r) => r.flowId));
|
|
323
|
+
const linksOf = new Map();
|
|
324
|
+
for (const link of links) {
|
|
325
|
+
if (!linksOf.has(link.priceItemId)) linksOf.set(link.priceItemId, []);
|
|
326
|
+
linksOf.get(link.priceItemId).push(link);
|
|
327
|
+
}
|
|
328
|
+
const linked = new Set(links.map((l) => `${l.system}:${l.externalId}`));
|
|
329
|
+
return {
|
|
330
|
+
systems: [...catalogues.values()].map((c) => ({ id: c.id, name: c.name, kind: c.kind, syncedAt: c.syncedAt, count: c.items.length })),
|
|
331
|
+
items: items.map((item) => {
|
|
332
|
+
const mine = linksOf.get(item.id) ?? [];
|
|
333
|
+
return {
|
|
334
|
+
...shapeItem(item),
|
|
335
|
+
alert: alerting.has(item.id),
|
|
336
|
+
findings: item.archivedAt ? [] : findingsFor(item, mine, catalogues),
|
|
337
|
+
links: mine.map((l) => {
|
|
338
|
+
const record = catalogues.get(l.system)?.byId.get(String(l.externalId)) ?? null;
|
|
339
|
+
return { id: l.id, system: l.system, externalId: l.externalId, externalCode: l.externalCode, matchedBy: l.matchedBy, record };
|
|
340
|
+
}),
|
|
341
|
+
};
|
|
342
|
+
}),
|
|
343
|
+
unlinked: [...catalogues.values()].map((c) => ({
|
|
344
|
+
system: c.id,
|
|
345
|
+
records: c.items.filter((r) => r.sold !== false && !linked.has(`${c.id}:${r.id}`)),
|
|
346
|
+
})),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { and, eq, inArray, isNotNull } from 'drizzle-orm';
|
|
2
|
+
import { billingObligations, invoices } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
// The invoice Reference (docs/INVOICING_RULES.md, "Reference"). The lab writes it as a
|
|
5
|
+
// rule in Invoice rules, from tokens: {practitioner} is the job's
|
|
6
|
+
// practitioner, {patient} the patient name the work system gives (only when
|
|
7
|
+
// its integration keeps patient names). Flow
|
|
8
|
+
// never puts a work system's ids in the Reference: the customer reads it.
|
|
9
|
+
|
|
10
|
+
export const REFERENCE_TOKENS = Object.freeze(['practitioner', 'patient']);
|
|
11
|
+
export const DEFAULT_REFERENCE = '{practitioner} - {patient}';
|
|
12
|
+
const TOKEN = /\{([a-z]+)\}/g;
|
|
13
|
+
const MAX = 255;
|
|
14
|
+
|
|
15
|
+
// The template's parts: text and tokens, in order.
|
|
16
|
+
function parts(template) {
|
|
17
|
+
const out = [];
|
|
18
|
+
let at = 0;
|
|
19
|
+
for (const match of String(template ?? '').matchAll(TOKEN)) {
|
|
20
|
+
out.push({ text: template.slice(at, match.index) }, { token: match[1] });
|
|
21
|
+
at = match.index + match[0].length;
|
|
22
|
+
}
|
|
23
|
+
out.push({ text: String(template ?? '').slice(at) });
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Unknown tokens are refused, so a typing error shows when it is saved.
|
|
28
|
+
export function checkReferenceTemplate(template) {
|
|
29
|
+
const unknown = parts(template).filter((p) => p.token && !REFERENCE_TOKENS.includes(p.token)).map((p) => `{${p.token}}`);
|
|
30
|
+
return unknown.length ? `unknown token ${unknown[0]}: use ${REFERENCE_TOKENS.map((t) => `{${t}}`).join(', ')}` : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The Reference for some fields. Text between two tokens is a spacer: it
|
|
34
|
+
// shows only when the tokens on both sides have a value, so
|
|
35
|
+
// "{practitioner} - {patient}" with no patient is the practitioner alone.
|
|
36
|
+
// Text before the first token and after the last always shows. Null when no
|
|
37
|
+
// token has a value.
|
|
38
|
+
export function renderReference(template, fields) {
|
|
39
|
+
const list = parts(template);
|
|
40
|
+
let out = list[0].text;
|
|
41
|
+
let filled = false;
|
|
42
|
+
for (let i = 1; i < list.length; i += 2) {
|
|
43
|
+
const value = String(fields[list[i].token] ?? '').trim().replace(/\s+/g, ' ');
|
|
44
|
+
if (!value) continue;
|
|
45
|
+
if (filled) out += list[i - 1].text;
|
|
46
|
+
out += value;
|
|
47
|
+
filled = true;
|
|
48
|
+
}
|
|
49
|
+
if (!filled) return null;
|
|
50
|
+
return `${out}${list.at(-1).text}`.trim().slice(0, MAX) || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The fields of one job. `work` is the work system adapter; it gives the
|
|
54
|
+
// patient name when it keeps one. With `read`, it may ask the work system for
|
|
55
|
+
// a name it has not read yet.
|
|
56
|
+
export async function jobReferenceFields(db, organisationId, obligation, { work = null, read = false } = {}) {
|
|
57
|
+
let found = {};
|
|
58
|
+
if (work?.id === obligation.sourceSystem && typeof work.capabilities.jobReferenceFields === 'function') {
|
|
59
|
+
// A work system that cannot answer gives no name; the rule still works.
|
|
60
|
+
found = (await work.capabilities.jobReferenceFields({ organisationId, sourceJobRef: obligation.sourceJobRef, read }).catch(() => null)) ?? {};
|
|
61
|
+
}
|
|
62
|
+
return { fields: { practitioner: obligation.practitioner, patient: found.patient ?? null }, asked: Boolean(found.asked) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The Reference of a new draft for a job. `template` is the organisation's
|
|
66
|
+
// rule (invoicing settings, referenceTemplate).
|
|
67
|
+
export async function jobReference(db, organisationId, obligation, { template, ...options }) {
|
|
68
|
+
return renderReference(template, (await jobReferenceFields(db, organisationId, obligation, options)).fields);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Drafts Flow made from a job keep the Reference the rule gives, as the rule
|
|
72
|
+
// or the job's fields change. A Reference a person typed is never changed:
|
|
73
|
+
// only one that is empty, a work system id (as earlier releases wrote), or
|
|
74
|
+
// the rule's own text without a field it now has, is updated. The status of
|
|
75
|
+
// the draft does not change. `reads` caps how many jobs may ask the work
|
|
76
|
+
// system for a name in one pass. Answers { updated }.
|
|
77
|
+
export async function refreshReferences(db, organisationId, { template, work = null, reads = 10 }) {
|
|
78
|
+
const rows = await db.select({ obligation: billingObligations, invoiceId: invoices.id, reference: invoices.reference })
|
|
79
|
+
.from(billingObligations)
|
|
80
|
+
.innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
|
|
81
|
+
.where(and(eq(billingObligations.organisationId, organisationId), isNotNull(billingObligations.invoiceId), eq(invoices.status, 'draft')));
|
|
82
|
+
let updated = 0;
|
|
83
|
+
let asked = 0;
|
|
84
|
+
for (const { obligation, invoiceId, reference } of rows) {
|
|
85
|
+
const found = await jobReferenceFields(db, organisationId, obligation, { work, read: asked < reads });
|
|
86
|
+
const { fields } = found;
|
|
87
|
+
if (found.asked) asked += 1;
|
|
88
|
+
const wanted = renderReference(template, fields);
|
|
89
|
+
if ((wanted ?? null) === (reference ?? null)) continue;
|
|
90
|
+
const ours = [null, '', obligation.caseRef, obligation.sourceJobRef,
|
|
91
|
+
renderReference(template, { ...fields, patient: null }), renderReference(DEFAULT_REFERENCE, { ...fields, patient: null }),
|
|
92
|
+
renderReference(DEFAULT_REFERENCE, fields)];
|
|
93
|
+
if (!ours.includes(reference)) continue;
|
|
94
|
+
await db.update(invoices).set({ reference: wanted })
|
|
95
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, [invoiceId]), eq(invoices.status, 'draft')));
|
|
96
|
+
updated += 1;
|
|
97
|
+
}
|
|
98
|
+
return { updated };
|
|
99
|
+
}
|
package/server/core/sending.js
CHANGED
|
@@ -13,8 +13,8 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
13
13
|
// charge, an account) on every line. When an accounting
|
|
14
14
|
// system is connected, its own checks are added (for Xero: the customer
|
|
15
15
|
// matches one Xero contact, every item is in Xero). A draft with a problem is
|
|
16
|
-
// in Problem. A person marks a draft without one ready
|
|
17
|
-
//
|
|
16
|
+
// in Problem. A person marks a draft without one ready. A saved change keeps
|
|
17
|
+
// it ready; only Move to Drafts (markNotReady) takes it back.
|
|
18
18
|
//
|
|
19
19
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
20
20
|
// accounting system is connected, queues it to go there. The send worker
|
|
@@ -147,10 +147,16 @@ async function assertCanSend(accounting, organisationId) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// Send all ready: issues each ready draft, then queues it for the accounting
|
|
150
|
-
// system.
|
|
151
|
-
|
|
150
|
+
// system. With `invoiceIds`, only those drafts, and each must be ready (the
|
|
151
|
+
// Send button on one invoice). Answers { issued, queued, remaining }.
|
|
152
|
+
export async function sendAllReady(db, organisationId, { accounting = null, personId, issueDate, invoiceIds = null }) {
|
|
152
153
|
await assertCanSend(accounting, organisationId);
|
|
153
|
-
|
|
154
|
+
let { ready } = await invoiceViews(db, organisationId, { accounting });
|
|
155
|
+
if (invoiceIds) {
|
|
156
|
+
const wanted = new Set(invoiceIds);
|
|
157
|
+
ready = ready.filter((id) => wanted.has(id));
|
|
158
|
+
if (ready.length !== wanted.size) throw new FlowError('not_ready', 'Only a ready invoice can be sent. Mark it ready first.');
|
|
159
|
+
}
|
|
154
160
|
const batch = ready.slice(0, SEND_BATCH);
|
|
155
161
|
let issued = 0;
|
|
156
162
|
for (const invoiceId of batch) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { adapters } from '../integrations/adapters.js';
|
|
2
|
+
import { diagnoseDatabase, formatReport, liveMigrationCounts, publishVerdict } from './doctor.js';
|
|
3
|
+
import { openDatabase } from './index.js';
|
|
4
|
+
import { migrateEverything } from './migrate-all.js';
|
|
5
|
+
|
|
6
|
+
// `flow-server migrate` and `flow-server doctor` (server/flow-server.js).
|
|
7
|
+
// Each returns the exit code.
|
|
8
|
+
export async function runCommand(command, args, { log = console } = {}) {
|
|
9
|
+
const option = (name) => {
|
|
10
|
+
const at = args.indexOf(name);
|
|
11
|
+
return at === -1 ? undefined : args[at + 1];
|
|
12
|
+
};
|
|
13
|
+
let handle;
|
|
14
|
+
try {
|
|
15
|
+
handle = openDatabase({ max: 1 });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
log.error(`[doctor] ${error.message}`);
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
if (command === 'migrate') {
|
|
22
|
+
await migrateEverything(handle.db, { log: (line) => log.log(`[migrate] ${line}`) });
|
|
23
|
+
log.log('[migrate] the database is at this build');
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
const report = await diagnoseDatabase(handle.pool, { adapters });
|
|
27
|
+
const origin = option('--live');
|
|
28
|
+
const live = origin ? await liveMigrationCounts(origin) : undefined;
|
|
29
|
+
const verdict = origin ? publishVerdict(report, live) : null;
|
|
30
|
+
if (args.includes('--json')) {
|
|
31
|
+
log.log(JSON.stringify({ ...report, live: live ?? null, verdict }, null, 2));
|
|
32
|
+
} else {
|
|
33
|
+
for (const line of formatReport(report, verdict)) log.log(`[doctor] ${line}`);
|
|
34
|
+
}
|
|
35
|
+
return report.ok ? 0 : 1;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
log.error(`[doctor] ${error.message}`);
|
|
38
|
+
return 2;
|
|
39
|
+
} finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
}
|