@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +32 -1
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +84 -6
- package/server/core/price-list.js +348 -0
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +16 -4
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +23 -2
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +74 -21
- 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 +51 -2
- package/server/integrations/seazona/lanes.js +251 -62
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- 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/0002_snapshot.json +882 -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 +21 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +116 -0
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +5 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -16
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/monitoring.js +14 -0
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { hashOf, parseSeazonaDate, text } from '../map.js';
|
|
2
|
+
|
|
3
|
+
// Seazona's CSV export, read into Flow fields and grouped into jobs. Pure: no
|
|
4
|
+
// database and no network. docs/SEAZONA.md, "History import", lists the
|
|
5
|
+
// headers Flow knows.
|
|
6
|
+
|
|
7
|
+
// Flow field -> the headers Seazona's export may use for it. Headers compare
|
|
8
|
+
// without case, spaces or punctuation, so "Invoice #" and "invoice no" match.
|
|
9
|
+
// "Client Name" is left out on purpose: in Seazona it can mean the doctor or
|
|
10
|
+
// the practice, so a person maps it with `columns` when the file has it.
|
|
11
|
+
export const INVOICE_LINE_COLUMNS = Object.freeze({
|
|
12
|
+
invoiceNumber: ['invoice', 'invoice #', 'invoice number', 'invoice no', 'inv #', 'inv no'],
|
|
13
|
+
caseRef: ['case', 'case #', 'case number', 'case no', 'case id', 'order', 'order #', 'order number', 'order no', 'order id'],
|
|
14
|
+
jobDate: ['invoice date', 'date', 'date invoiced', 'invoiced'],
|
|
15
|
+
dueDate: ['due date', 'due'],
|
|
16
|
+
shippedDate: ['ship date', 'shipped', 'shipped date', 'date shipped'],
|
|
17
|
+
company: ['company name', 'company', 'practice', 'practice name', 'account name'],
|
|
18
|
+
accountNumber: ['account #', 'account number', 'account no'],
|
|
19
|
+
doctorName: ['doctor name', 'doctor', 'dentist', 'practitioner'],
|
|
20
|
+
patient: ['patient', 'patient name'],
|
|
21
|
+
status: ['status', 'case status'],
|
|
22
|
+
item: ['item', 'product', 'description', 'item description', 'product name', 'service'],
|
|
23
|
+
itemCode: ['item code', 'code', 'product code'],
|
|
24
|
+
quantity: ['qty', 'quantity', 'units'],
|
|
25
|
+
itemAmount: ['item amount', 'amount', 'line amount', 'line total', 'price'],
|
|
26
|
+
itemTaxable: ['item taxable', 'taxable'],
|
|
27
|
+
total: ['total', 'invoice total'],
|
|
28
|
+
tooth: ['tooth', 'teeth', 'tooth #'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// A file is read only when it has these, and at least one date.
|
|
32
|
+
const REQUIRED = ['company', 'item', 'itemAmount'];
|
|
33
|
+
const DATES = ['jobDate', 'dueDate', 'shippedDate'];
|
|
34
|
+
|
|
35
|
+
const squash = (header) => text(header).toLowerCase().replace(/[^a-z0-9#]/g, '').replace(/#/g, 'number');
|
|
36
|
+
|
|
37
|
+
// Answers { columns: field -> header, ignored: [header], missing: [field] }.
|
|
38
|
+
// `override` (field -> header) wins over the guess; a null value unmaps it.
|
|
39
|
+
export function detectColumns(headers, override = {}) {
|
|
40
|
+
const byKey = new Map(headers.map((h) => [squash(h), h]));
|
|
41
|
+
const columns = {};
|
|
42
|
+
for (const [field, aliases] of Object.entries(INVOICE_LINE_COLUMNS)) {
|
|
43
|
+
const found = aliases.map((alias) => byKey.get(squash(alias))).find(Boolean);
|
|
44
|
+
if (found) columns[field] = found;
|
|
45
|
+
}
|
|
46
|
+
for (const [field, header] of Object.entries(override ?? {})) {
|
|
47
|
+
if (!(field in INVOICE_LINE_COLUMNS)) throw Object.assign(new Error(`Unknown field ${field}`), { code: 'invalid' });
|
|
48
|
+
if (header === null) delete columns[field];
|
|
49
|
+
else if (headers.includes(header)) columns[field] = header;
|
|
50
|
+
else throw Object.assign(new Error(`The file has no column "${header}"`), { code: 'invalid' });
|
|
51
|
+
}
|
|
52
|
+
const used = new Set(Object.values(columns));
|
|
53
|
+
const missing = REQUIRED.filter((field) => !columns[field]);
|
|
54
|
+
if (!DATES.some((field) => columns[field])) missing.push('date');
|
|
55
|
+
return { columns, ignored: headers.filter((h) => h && !used.has(h)), missing };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// "$1,234.50", "1234.5", "(12.00)" and "-12" to integer cents.
|
|
59
|
+
export function moneyCents(value) {
|
|
60
|
+
const raw = text(value);
|
|
61
|
+
if (!raw) return null;
|
|
62
|
+
const negative = /^\(.*\)$/.test(raw) || raw.startsWith('-');
|
|
63
|
+
const digits = raw.replace(/[()$,\s-]/g, '').replace(/^[A-Z]{3}/i, '');
|
|
64
|
+
if (!/^\d*(\.\d+)?$/.test(digits) || digits === '' || digits === '.') return undefined;
|
|
65
|
+
const cents = Math.round(Number(digits) * 100);
|
|
66
|
+
return negative ? -cents : cents;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function yesNo(value) {
|
|
70
|
+
const raw = text(value).toLowerCase();
|
|
71
|
+
if (['y', 'yes', 'true', '1', 'taxable', 'gst'].includes(raw)) return true;
|
|
72
|
+
if (['n', 'no', 'false', '0', 'exempt', 'gst free', 'none'].includes(raw)) return false;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One CSV row to Flow fields and the problems that stop it. The patient field
|
|
77
|
+
// is read for grouping; the caller drops it unless the installation keeps a
|
|
78
|
+
// patient reference.
|
|
79
|
+
export function readRow(cells, headers, columns) {
|
|
80
|
+
const cell = (field) => {
|
|
81
|
+
const header = columns[field];
|
|
82
|
+
if (!header) return '';
|
|
83
|
+
return text(cells[headers.indexOf(header)]);
|
|
84
|
+
};
|
|
85
|
+
const problems = [];
|
|
86
|
+
const fields = {
|
|
87
|
+
invoiceNumber: cell('invoiceNumber') || null,
|
|
88
|
+
caseRef: cell('caseRef') || null,
|
|
89
|
+
company: cell('company') || null,
|
|
90
|
+
accountNumber: cell('accountNumber') || null,
|
|
91
|
+
doctorName: cell('doctorName') || null,
|
|
92
|
+
patient: cell('patient') || null,
|
|
93
|
+
status: cell('status') || null,
|
|
94
|
+
item: cell('item') || null,
|
|
95
|
+
itemCode: cell('itemCode') || null,
|
|
96
|
+
tooth: cell('tooth') || null,
|
|
97
|
+
};
|
|
98
|
+
for (const field of DATES) {
|
|
99
|
+
const raw = cell(field);
|
|
100
|
+
fields[field] = raw ? parseSeazonaDate(raw) : null;
|
|
101
|
+
if (raw && !fields[field]) problems.push(`bad_${field}`);
|
|
102
|
+
}
|
|
103
|
+
if (!DATES.some((field) => fields[field])) problems.push('missing_date');
|
|
104
|
+
if (!fields.company) problems.push('missing_company');
|
|
105
|
+
if (!fields.item) problems.push('missing_item');
|
|
106
|
+
const amount = moneyCents(cell('itemAmount'));
|
|
107
|
+
if (amount === undefined || amount === null) problems.push(amount === null ? 'missing_amount' : 'bad_amount');
|
|
108
|
+
fields.itemAmountCents = typeof amount === 'number' ? amount : null;
|
|
109
|
+
const total = moneyCents(cell('total'));
|
|
110
|
+
if (total === undefined) problems.push('bad_total');
|
|
111
|
+
fields.totalCents = typeof total === 'number' ? total : null;
|
|
112
|
+
const quantity = cell('quantity');
|
|
113
|
+
fields.quantity = quantity && Number.isFinite(Number(quantity)) ? Number(quantity) : null;
|
|
114
|
+
fields.itemTaxable = columns.itemTaxable ? yesNo(cell('itemTaxable')) : null;
|
|
115
|
+
return { fields, problems };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sameText = (a, b) => text(a).toLowerCase() === text(b).toLowerCase();
|
|
119
|
+
|
|
120
|
+
// Where a row's job is found: its invoice number, else its case number, else
|
|
121
|
+
// the old app's grouping (date, practice, doctor and patient). The patient
|
|
122
|
+
// only separates jobs inside this file; it is never part of the stored key.
|
|
123
|
+
function groupOf(fields) {
|
|
124
|
+
if (fields.invoiceNumber) return `inv:${fields.invoiceNumber}`;
|
|
125
|
+
if (fields.caseRef) return `case:${fields.caseRef}`;
|
|
126
|
+
const date = fields.jobDate ?? fields.dueDate ?? fields.shippedDate;
|
|
127
|
+
return `grp:${[date, fields.company, fields.doctorName, fields.patient].map((v) => text(v).toLowerCase()).join('|')}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const withGst = (cents) => Math.round(cents * 1.1);
|
|
131
|
+
|
|
132
|
+
// Rows (each { rowNumber, fields, problems }) to jobs. Rows with problems
|
|
133
|
+
// join no job. Answers { jobs, rowKeys: Map(rowNumber -> jobKey) }. Each job
|
|
134
|
+
// carries its findings: { code, blocking }.
|
|
135
|
+
export function groupJobs(rows) {
|
|
136
|
+
const groups = new Map();
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
if (row.problems.length) continue;
|
|
139
|
+
const group = groupOf(row.fields);
|
|
140
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
141
|
+
groups.get(group).push(row);
|
|
142
|
+
}
|
|
143
|
+
const jobs = [];
|
|
144
|
+
const rowKeys = new Map();
|
|
145
|
+
const taken = new Map();
|
|
146
|
+
for (const [group, members] of groups) {
|
|
147
|
+
const first = members[0].fields;
|
|
148
|
+
const findings = [];
|
|
149
|
+
for (const field of ['company', 'doctorName']) {
|
|
150
|
+
if (members.some((m) => !sameText(m.fields[field], first[field]))) findings.push({ code: `mixed_${field}`, blocking: true });
|
|
151
|
+
}
|
|
152
|
+
const totals = [...new Set(members.map((m) => m.fields.totalCents).filter((t) => t !== null))];
|
|
153
|
+
if (totals.length > 1) findings.push({ code: 'conflicting_totals', blocking: true });
|
|
154
|
+
const lines = members.map((m) => ({
|
|
155
|
+
description: m.fields.item,
|
|
156
|
+
code: m.fields.itemCode,
|
|
157
|
+
amountCents: m.fields.itemAmountCents,
|
|
158
|
+
...(m.fields.quantity !== null ? { quantity: m.fields.quantity } : {}),
|
|
159
|
+
...(m.fields.itemTaxable !== null ? { taxable: m.fields.itemTaxable } : {}),
|
|
160
|
+
...(m.fields.tooth ? { tooth: m.fields.tooth } : {}),
|
|
161
|
+
}));
|
|
162
|
+
const subtotalCents = lines.reduce((sum, l) => sum + l.amountCents, 0);
|
|
163
|
+
const totalCents = totals.length === 1 ? totals[0] : null;
|
|
164
|
+
if (totalCents !== null && totalCents !== subtotalCents && totalCents !== withGst(subtotalCents)) {
|
|
165
|
+
const taxable = lines.filter((l) => l.taxable !== false).reduce((sum, l) => sum + l.amountCents, 0);
|
|
166
|
+
if (totalCents !== subtotalCents + Math.round(taxable * 0.1)) findings.push({ code: 'total_differs_from_lines', blocking: false });
|
|
167
|
+
}
|
|
168
|
+
const pick = (field) => members.map((m) => m.fields[field]).find(Boolean) ?? null;
|
|
169
|
+
const job = {
|
|
170
|
+
invoiceNumber: pick('invoiceNumber'),
|
|
171
|
+
caseRef: pick('caseRef'),
|
|
172
|
+
company: first.company,
|
|
173
|
+
accountNumber: pick('accountNumber'),
|
|
174
|
+
doctorName: first.doctorName,
|
|
175
|
+
status: pick('status'),
|
|
176
|
+
jobDate: pick('jobDate'),
|
|
177
|
+
dueDate: pick('dueDate'),
|
|
178
|
+
shippedDate: pick('shippedDate'),
|
|
179
|
+
lines,
|
|
180
|
+
subtotalCents,
|
|
181
|
+
totalCents,
|
|
182
|
+
patientReference: pick('patient'),
|
|
183
|
+
};
|
|
184
|
+
// A job with no Seazona number is keyed by what it is, without the patient.
|
|
185
|
+
let key = group;
|
|
186
|
+
if (group.startsWith('grp:')) {
|
|
187
|
+
const base = `grp:${hashOf({ ...job, patientReference: null }).slice(0, 24)}`;
|
|
188
|
+
const n = (taken.get(base) ?? 0) + 1;
|
|
189
|
+
taken.set(base, n);
|
|
190
|
+
key = n === 1 ? base : `${base}#${n}`;
|
|
191
|
+
}
|
|
192
|
+
jobs.push({ jobKey: key, job, findings });
|
|
193
|
+
for (const m of members) rowKeys.set(m.rowNumber, key);
|
|
194
|
+
}
|
|
195
|
+
return { jobs, rowKeys };
|
|
196
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { guardAlerts } from '../call-guard.js';
|
|
1
2
|
import { createSeazonaClient } from './client.js';
|
|
2
|
-
import { listClients, listWork, readBillableWork } from './feed.js';
|
|
3
|
+
import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
|
|
4
|
+
import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
|
|
3
5
|
import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
|
|
6
|
+
import { SEAZONA_CALL_GUARD } from './governor.js';
|
|
7
|
+
import { createSeazonaRouter } from './routes.js';
|
|
4
8
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
9
|
+
import { resetFailedLanes, resetStoppedLanes } from './store.js';
|
|
5
10
|
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
6
11
|
import { seazonaSchema } from './tables.js';
|
|
7
12
|
|
|
@@ -57,10 +62,27 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
57
62
|
bundled: true,
|
|
58
63
|
schema: seazonaSchema,
|
|
59
64
|
setup: SEAZONA_SETUP,
|
|
65
|
+
// The history import's routes (routes.js), under /api/seazona.
|
|
66
|
+
routes: ({ db, personAuth, log }) => createSeazonaRouter({ db: runtime?.db ?? db, personAuth, settings: current().settings, log }),
|
|
67
|
+
// Every Seazona call goes through this call guard, inside the rate
|
|
68
|
+
// governor (governor.js; docs/INTEGRATIONS.md, "Outbound calls").
|
|
69
|
+
callGuard: SEAZONA_CALL_GUARD,
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
// `reconfigured` is true when a person saved the settings or turned
|
|
72
|
+
// Seazona on (issue #101): that clears a halt after a refused API client,
|
|
73
|
+
// closes the circuit, and runs every failed lane again. A plain start (a
|
|
74
|
+
// restart or a new release) runs again only the lanes stopped by a
|
|
75
|
+
// refused path; a halt still waits for a person.
|
|
76
|
+
async start({ db, log = console, env: settingsEnv, reconfigured = false }) {
|
|
62
77
|
const settings = requireSeazonaSettings(env ?? settingsEnv ?? process.env);
|
|
63
78
|
const client = createSeazonaClient({ settings, db, fetchImpl: fetchImpl ?? undefined, workflow: 'seazona-sync', now });
|
|
79
|
+
const at = new Date(Number(now()));
|
|
80
|
+
if (reconfigured) {
|
|
81
|
+
await client.governor?.resume();
|
|
82
|
+
await resetFailedLanes(db, { at });
|
|
83
|
+
} else {
|
|
84
|
+
await resetStoppedLanes(db, { at });
|
|
85
|
+
}
|
|
64
86
|
const run = (options = {}) => runSeazonaSync(db, client, { settings, now, ...options });
|
|
65
87
|
// The background sync starts in live mode only. A sandbox or replay
|
|
66
88
|
// installation syncs only when someone asks (sync-once.js).
|
|
@@ -89,9 +111,18 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
89
111
|
scheduler: scheduler ? scheduler.status() : null,
|
|
90
112
|
governor: await client.governor.status(),
|
|
91
113
|
...sync,
|
|
114
|
+
history: await historySummary(db),
|
|
92
115
|
};
|
|
93
116
|
},
|
|
94
117
|
|
|
118
|
+
// What only a person can clear, for the Integrations page
|
|
119
|
+
// (server/core/alerts.js): a refused API client halts every call until
|
|
120
|
+
// a person saves the settings. Reads Flow's database only.
|
|
121
|
+
async alerts() {
|
|
122
|
+
if (!runtime?.client.governor) return [];
|
|
123
|
+
return guardAlerts('Seazona', await runtime.client.governor.status(), { fix: 'Check the API client in the Seazona settings, then save them.' });
|
|
124
|
+
},
|
|
125
|
+
|
|
95
126
|
// Billable work since a cursor: complete and shipped orders that
|
|
96
127
|
// changed, with the Seazona invoice that carries each one. Orders
|
|
97
128
|
// deleted in Seazona are never billable; they come back in `withdrawn`
|
|
@@ -100,6 +131,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
100
131
|
return readBillableWork(current().db, { cursor, ...options });
|
|
101
132
|
},
|
|
102
133
|
|
|
134
|
+
// Past work from the history import (docs/SEAZONA.md, "History
|
|
135
|
+
// import"), as ledger jobs marked `historical`. Reads Flow's copy only.
|
|
136
|
+
async pullHistoryJobs(cursor = null, options = {}) {
|
|
137
|
+
return readHistoryWork(current().db, { cursor, ...options });
|
|
138
|
+
},
|
|
139
|
+
|
|
103
140
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
104
141
|
// Flow's copy only.
|
|
105
142
|
async listCustomers() {
|
|
@@ -111,6 +148,18 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
111
148
|
async listWork() {
|
|
112
149
|
return listWork(current().db, { now: now() });
|
|
113
150
|
},
|
|
151
|
+
|
|
152
|
+
// History jobs a person must decide, as issues on their job cards,
|
|
153
|
+
// each with a link to the reconciliation. Reads Flow's copy only.
|
|
154
|
+
async jobIssues({ refs = [] } = {}) {
|
|
155
|
+
return historyJobIssues(current().db, refs);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// Seazona's products, for the price list (server/core/price-list.js).
|
|
159
|
+
// Reads Flow's copy only.
|
|
160
|
+
async listPriceItems() {
|
|
161
|
+
return listProducts(current().db);
|
|
162
|
+
},
|
|
114
163
|
},
|
|
115
164
|
};
|
|
116
165
|
}
|