@arcforgelabs/flow 2026.10.50 → 2026.10.52
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/SECURITY.md +9 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +131 -0
- package/server/api/invoicing.js +1 -1
- package/server/app.js +15 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +145 -0
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +501 -0
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +324 -0
- package/server/core/sending.js +7 -3
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +50 -0
- package/server/index.js +47 -1
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +101 -8
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +283 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/flow.css +92 -17
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
1
4
|
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
5
|
import { invoiceRules, invoicingSettings } from '../db/schema.js';
|
|
3
6
|
import { FlowError, notFound } from './errors.js';
|
|
@@ -26,6 +29,10 @@ const MAX_CONDITIONS = 10;
|
|
|
26
29
|
const invalid = (message) => new FlowError('invalid', message);
|
|
27
30
|
|
|
28
31
|
const MAX_NOTE = 200;
|
|
32
|
+
// A rule's own icon: any Lucide name (the icon picker, server/ui/kit.js).
|
|
33
|
+
// Null shows the mark of what the rule does.
|
|
34
|
+
const ICON_DIR = join(dirname(createRequire(import.meta.url).resolve('lucide-static/package.json')), 'icons');
|
|
35
|
+
const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
29
36
|
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
30
37
|
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
31
38
|
// A line's key: its price item code, else the label line mapping gave it (a
|
|
@@ -107,10 +114,14 @@ function readCondition(raw, index) {
|
|
|
107
114
|
|
|
108
115
|
export function readRule(raw) {
|
|
109
116
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the rule must be an object');
|
|
110
|
-
const unknown = Object.keys(raw).filter((k) => !['name', 'enabled', 'conditions', 'action'].includes(k));
|
|
117
|
+
const unknown = Object.keys(raw).filter((k) => !['name', 'icon', 'enabled', 'conditions', 'action'].includes(k));
|
|
111
118
|
if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
|
|
112
119
|
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
113
120
|
if (!name || name.length > 120) throw invalid('the rule needs a name of 120 characters at most');
|
|
121
|
+
const icon = raw.icon ?? null;
|
|
122
|
+
if (icon !== null && (typeof icon !== 'string' || icon.length > 64 || !ICON_NAME.test(icon) || !existsSync(join(ICON_DIR, `${icon}.svg`)))) {
|
|
123
|
+
throw invalid('the icon must be a Lucide icon name');
|
|
124
|
+
}
|
|
114
125
|
const enabled = raw.enabled ?? true;
|
|
115
126
|
if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
|
|
116
127
|
if (!Array.isArray(raw.conditions) || !raw.conditions.length || raw.conditions.length > MAX_CONDITIONS) {
|
|
@@ -127,7 +138,7 @@ export function readRule(raw) {
|
|
|
127
138
|
const early = conditions.find((c) => FIELDS[c.field].draftOnly);
|
|
128
139
|
if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
|
|
129
140
|
}
|
|
130
|
-
return { name, enabled, conditions, action };
|
|
141
|
+
return { name, icon, enabled, conditions, action };
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
function matches(condition, job, context) {
|
|
@@ -270,7 +281,7 @@ async function seed(db, organisationId) {
|
|
|
270
281
|
}
|
|
271
282
|
|
|
272
283
|
const shape = (r) => ({
|
|
273
|
-
id: r.id, name: r.name, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
284
|
+
id: r.id, name: r.name, icon: r.icon, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
274
285
|
conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
|
|
275
286
|
});
|
|
276
287
|
|
|
@@ -299,12 +310,12 @@ async function loadRule(db, organisationId, ruleId) {
|
|
|
299
310
|
return row;
|
|
300
311
|
}
|
|
301
312
|
|
|
302
|
-
// Changes a rule. `changes` holds any of name, enabled, conditions, action;
|
|
313
|
+
// Changes a rule. `changes` holds any of name, icon, enabled, conditions, action;
|
|
303
314
|
// the result must be a whole valid rule.
|
|
304
315
|
export async function updateRule(db, organisationId, ruleId, changes, { personId = null } = {}) {
|
|
305
316
|
const row = await loadRule(db, organisationId, ruleId);
|
|
306
317
|
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
307
|
-
const rule = readRule({ name: row.name, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
318
|
+
const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
308
319
|
const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
|
|
309
320
|
.where(eq(invoiceRules.id, row.id)).returning();
|
|
310
321
|
return shape(saved);
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
|
3
|
+
import { callCostMicros } from './ai.js';
|
|
4
|
+
import { captures, jobFields } from '../db/schema.js';
|
|
5
|
+
import { FlowError } from './errors.js';
|
|
6
|
+
import { enqueueJob, runNextJob } from './queue.js';
|
|
7
|
+
import { JOB_FIELDS } from '../ui/capture-fields.js';
|
|
8
|
+
|
|
9
|
+
// The vision reader. A small model reads every capture. A larger model reads
|
|
10
|
+
// again only when case number, practice or work is below the sure line.
|
|
11
|
+
// Flow code decides that; the model does not see the jobs list and does not
|
|
12
|
+
// write. The photo and the fields stay out of the log: a failure is a
|
|
13
|
+
// capture id and an error code.
|
|
14
|
+
//
|
|
15
|
+
// A failed call is tried twice more (three calls). The capture is then
|
|
16
|
+
// `problem` with a short reason. Type it still works. The module being off,
|
|
17
|
+
// or the keys missing, is "reader turned off" and is set when the photo is
|
|
18
|
+
// stored (server/core/captures.js), not here.
|
|
19
|
+
|
|
20
|
+
export const CAPTURE_READ_KIND = 'capture.read';
|
|
21
|
+
const CALLS = 3;
|
|
22
|
+
const VALUE_MAX = 2000;
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const NEEDED = new Set(JOB_FIELDS.filter((field) => field.needed).map((field) => field.key));
|
|
26
|
+
|
|
27
|
+
// One object, every property required. Bedrock rejects a schema that leaves
|
|
28
|
+
// a property optional past its limit, or that omits additionalProperties: false.
|
|
29
|
+
const FIELD_SCHEMA = Object.freeze({
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
fields: {
|
|
33
|
+
type: 'array',
|
|
34
|
+
items: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
key: { type: 'string' },
|
|
38
|
+
value: { type: 'string' },
|
|
39
|
+
seen: { type: 'string' },
|
|
40
|
+
sure: { type: 'number' },
|
|
41
|
+
},
|
|
42
|
+
required: ['key', 'value', 'seen', 'sure'],
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
required: ['fields'],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const PROBLEM = new Set([
|
|
52
|
+
'the reader did not answer',
|
|
53
|
+
'could not read the form',
|
|
54
|
+
'photo type cannot be read',
|
|
55
|
+
'the photo is missing',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const READABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
59
|
+
|
|
60
|
+
function problemText(error) {
|
|
61
|
+
return PROBLEM.has(error?.message) ? error.message : 'the reader did not answer';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function blank(key) {
|
|
65
|
+
return { key, value: '', seen: '', sure: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clip(value) {
|
|
69
|
+
return value.trim().slice(0, VALUE_MAX);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sureOf(value) {
|
|
73
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
74
|
+
if (!Number.isFinite(number)) return 0;
|
|
75
|
+
return Math.min(1, Math.max(0, number));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One field per key the lab asked for, in that order. A key that was not
|
|
79
|
+
// asked for is dropped, so a patient name the model adds is not stored.
|
|
80
|
+
// A value that is not text is empty: an unreadable field is never a guess.
|
|
81
|
+
function parseFields(text, fieldList) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
const error = new FlowError('reader_unreadable', 'could not read the form');
|
|
87
|
+
error.retry = true;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const rows = Array.isArray(parsed?.fields) ? parsed.fields : null;
|
|
91
|
+
if (!rows) {
|
|
92
|
+
const error = new FlowError('reader_unreadable', 'could not read the form');
|
|
93
|
+
error.retry = true;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const byKey = new Map();
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
if (!row || typeof row.key !== 'string' || !row.key) continue;
|
|
99
|
+
if (typeof row.value !== 'string' || typeof row.seen !== 'string') {
|
|
100
|
+
byKey.set(row.key, blank(row.key));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
byKey.set(row.key, { key: row.key, value: clip(row.value), seen: clip(row.seen), sure: sureOf(row.sure) });
|
|
104
|
+
}
|
|
105
|
+
return fieldList.map((field) => byKey.get(field.key) ?? blank(field.key));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function belowLine(fields, line) {
|
|
109
|
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
110
|
+
for (const key of NEEDED) {
|
|
111
|
+
if ((byKey.get(key)?.sure ?? 0) < line) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function recorded(fields, calls, model = calls.at(-1).model) {
|
|
117
|
+
const inputTokens = calls.reduce((sum, call) => sum + call.inputTokens, 0);
|
|
118
|
+
const outputTokens = calls.reduce((sum, call) => sum + call.outputTokens, 0);
|
|
119
|
+
const costMicros = calls.some((call) => call.costMicros == null)
|
|
120
|
+
? null
|
|
121
|
+
: calls.reduce((sum, call) => sum + call.costMicros, 0);
|
|
122
|
+
return { fields, model, inputTokens, outputTokens, costMicros, calls };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function instructionFor(fieldList) {
|
|
126
|
+
const lines = fieldList.map((field) => `- ${field.key}: ${field.label}`).join('\n');
|
|
127
|
+
return [
|
|
128
|
+
'Read this photo of a handwritten lab form.',
|
|
129
|
+
'Return one field for each key below.',
|
|
130
|
+
'value is what should be stored. Use an empty string when you cannot read it. Never guess a name, a number or a date.',
|
|
131
|
+
'seen is the exact text you saw, or an empty string when nothing was written.',
|
|
132
|
+
'sure is a number from 0 to 1: how sure you are that value matches what was written.',
|
|
133
|
+
'Ignore printed lab branding.',
|
|
134
|
+
'Do not add a key that is not in this list.',
|
|
135
|
+
lines,
|
|
136
|
+
].join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function captureFields(db, organisationId) {
|
|
140
|
+
const rows = await db.select({ id: jobFields.id, name: jobFields.name }).from(jobFields).where(and(
|
|
141
|
+
eq(jobFields.organisationId, organisationId),
|
|
142
|
+
isNull(jobFields.archivedAt),
|
|
143
|
+
)).orderBy(asc(jobFields.position), asc(jobFields.name));
|
|
144
|
+
const labels = new Set(JOB_FIELDS.map((field) => field.label.toLowerCase()));
|
|
145
|
+
const custom = rows
|
|
146
|
+
.filter((row) => row.name && !labels.has(row.name.trim().toLowerCase()))
|
|
147
|
+
.map((row) => ({ key: `custom_${row.id}`, label: row.name.trim(), needed: false }));
|
|
148
|
+
return [...JOB_FIELDS, ...custom];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Three calls at most. A call that returned token counts is kept on the
|
|
152
|
+
// error when every attempt fails, so the capture still records the cost.
|
|
153
|
+
async function ask(port, { model, price, fieldList, mediaType, bytes, instruction }) {
|
|
154
|
+
const spent = [];
|
|
155
|
+
let last = new FlowError('reader_unavailable', 'the reader did not answer');
|
|
156
|
+
for (let attempt = 0; attempt < CALLS; attempt += 1) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await port.readImage({
|
|
159
|
+
model, mediaType, bytes, instruction, schema: FIELD_SCHEMA, maxTokens: 4096,
|
|
160
|
+
});
|
|
161
|
+
spent.push({
|
|
162
|
+
model,
|
|
163
|
+
inputTokens: result.inputTokens,
|
|
164
|
+
outputTokens: result.outputTokens,
|
|
165
|
+
costMicros: callCostMicros({ inputTokens: result.inputTokens, outputTokens: result.outputTokens, price }),
|
|
166
|
+
});
|
|
167
|
+
return { fields: parseFields(result.text, fieldList), spent };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
last = error;
|
|
170
|
+
if (error?.retry === false) break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
last.spent = spent;
|
|
174
|
+
throw last;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function mark(db, row, fields) {
|
|
178
|
+
const [next] = await db.update(captures).set({ ...fields, updatedAt: sql`now()` }).where(and(
|
|
179
|
+
eq(captures.organisationId, row.organisationId),
|
|
180
|
+
eq(captures.id, row.id),
|
|
181
|
+
eq(captures.state, 'reading'),
|
|
182
|
+
)).returning();
|
|
183
|
+
return next ?? row;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Reads one capture that is still `reading`. A capture that has moved on
|
|
187
|
+
// (typed, decided) is left as it is. The model's answer is stored on the
|
|
188
|
+
// row and is not written to the log.
|
|
189
|
+
export async function readStoredCapture(db, storage, port, settings, { organisationId, captureId, log = console }) {
|
|
190
|
+
const [row] = await db.select().from(captures).where(and(
|
|
191
|
+
eq(captures.organisationId, organisationId),
|
|
192
|
+
eq(captures.id, captureId),
|
|
193
|
+
));
|
|
194
|
+
if (!row || row.state !== 'reading') return row ?? null;
|
|
195
|
+
|
|
196
|
+
let stored;
|
|
197
|
+
try {
|
|
198
|
+
stored = await storage.get(row.fileKey);
|
|
199
|
+
} catch {
|
|
200
|
+
const error = new FlowError('reader_unavailable', 'the reader did not answer');
|
|
201
|
+
error.retry = true;
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
if (!stored) {
|
|
205
|
+
log.error?.(`[reader] ${row.id} reader_missing`);
|
|
206
|
+
return mark(db, row, { state: 'problem', problem: 'the photo is missing' });
|
|
207
|
+
}
|
|
208
|
+
const sha = createHash('sha256').update(stored.body).digest('hex');
|
|
209
|
+
if (sha !== row.fileSha256) {
|
|
210
|
+
log.error?.(`[reader] ${row.id} hash_mismatch`);
|
|
211
|
+
return mark(db, row, { state: 'problem', problem: 'could not read the form' });
|
|
212
|
+
}
|
|
213
|
+
if (!READABLE.has(row.fileType)) {
|
|
214
|
+
log.error?.(`[reader] ${row.id} reader_type`);
|
|
215
|
+
return mark(db, row, { state: 'problem', problem: 'photo type cannot be read' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fieldList = await captureFields(db, organisationId);
|
|
219
|
+
const instruction = instructionFor(fieldList);
|
|
220
|
+
const photo = { fieldList, mediaType: row.fileType, bytes: stored.body, instruction };
|
|
221
|
+
const calls = [];
|
|
222
|
+
let fields = null;
|
|
223
|
+
try {
|
|
224
|
+
const first = await ask(port, { ...photo, model: settings.model, price: settings.prices.small });
|
|
225
|
+
calls.push(...first.spent);
|
|
226
|
+
fields = first.fields;
|
|
227
|
+
if (belowLine(fields, settings.sureLine)) {
|
|
228
|
+
const second = await ask(port, { ...photo, model: settings.large, price: settings.prices.large });
|
|
229
|
+
calls.push(...second.spent);
|
|
230
|
+
fields = second.fields;
|
|
231
|
+
}
|
|
232
|
+
return mark(db, row, { state: 'ready', problem: null, read: recorded(fields, calls) });
|
|
233
|
+
} catch (error) {
|
|
234
|
+
log.error?.(`[reader] ${row.id} ${error?.code ?? 'reader_unavailable'}`);
|
|
235
|
+
calls.push(...(error?.spent ?? []));
|
|
236
|
+
return mark(db, row, {
|
|
237
|
+
state: 'problem',
|
|
238
|
+
problem: problemText(error),
|
|
239
|
+
read: calls.length ? recorded(fields ?? [], calls, fields ? settings.model : calls.at(-1).model) : null,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Queues the read, or runs it in the request when background jobs are off
|
|
245
|
+
// (a Replit workspace). The worker only claims `capture.read` jobs. Two
|
|
246
|
+
// processes can run: the lease keeps a capture to one of them.
|
|
247
|
+
export function createCaptureReader({
|
|
248
|
+
db, storage, port, settings, log = console, jobsOn = true, timers = globalThis,
|
|
249
|
+
}) {
|
|
250
|
+
async function handle(job) {
|
|
251
|
+
const captureId = job.payload?.captureId ?? null;
|
|
252
|
+
try {
|
|
253
|
+
await readStoredCapture(db, storage, port, settings, { organisationId: job.organisationId, captureId, log });
|
|
254
|
+
return { captureId };
|
|
255
|
+
} catch (error) {
|
|
256
|
+
log.error?.(`[reader] ${captureId ?? '-'} ${error?.code ?? 'reader_unavailable'}`);
|
|
257
|
+
const safe = new FlowError('reader_unavailable', 'the reader did not answer');
|
|
258
|
+
safe.retry = error?.retry !== false;
|
|
259
|
+
throw safe;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function schedule(row) {
|
|
264
|
+
if (!settings?.on) return row;
|
|
265
|
+
if (!jobsOn) {
|
|
266
|
+
return readStoredCapture(db, storage, port, settings, { organisationId: row.organisationId, captureId: row.id, log });
|
|
267
|
+
}
|
|
268
|
+
await enqueueJob(db, row.organisationId, {
|
|
269
|
+
kind: CAPTURE_READ_KIND,
|
|
270
|
+
dedupeKey: row.id,
|
|
271
|
+
payload: { captureId: row.id },
|
|
272
|
+
maxAttempts: 2,
|
|
273
|
+
});
|
|
274
|
+
return row;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const workerId = `reader-${randomUUID()}`;
|
|
278
|
+
let timer = null;
|
|
279
|
+
let stopped = true;
|
|
280
|
+
let running = null;
|
|
281
|
+
|
|
282
|
+
function arm(delay) {
|
|
283
|
+
if (stopped) return;
|
|
284
|
+
timer = timers.setTimeout(async () => {
|
|
285
|
+
timer = null;
|
|
286
|
+
running = tick();
|
|
287
|
+
const again = await running;
|
|
288
|
+
running = null;
|
|
289
|
+
arm(again ? 0 : 1000);
|
|
290
|
+
}, delay);
|
|
291
|
+
timer?.unref?.();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function tick() {
|
|
295
|
+
try {
|
|
296
|
+
const ran = await runNextJob(db, {
|
|
297
|
+
workerId,
|
|
298
|
+
leaseMs: 15 * 60 * 1000,
|
|
299
|
+
handlers: { [CAPTURE_READ_KIND]: handle },
|
|
300
|
+
});
|
|
301
|
+
return Boolean(ran);
|
|
302
|
+
} catch {
|
|
303
|
+
log.error?.('[reader] - reader_unavailable');
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
schedule,
|
|
310
|
+
handle,
|
|
311
|
+
worker: {
|
|
312
|
+
start() {
|
|
313
|
+
stopped = false;
|
|
314
|
+
arm(1000);
|
|
315
|
+
},
|
|
316
|
+
async stop() {
|
|
317
|
+
stopped = true;
|
|
318
|
+
if (timer) timers.clearTimeout(timer);
|
|
319
|
+
timer = null;
|
|
320
|
+
await running;
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
package/server/core/sending.js
CHANGED
|
@@ -53,6 +53,10 @@ const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
|
53
53
|
// it. `extra` adds `fields` (the invoice fields to fix) and `line`.
|
|
54
54
|
const problem = (code, message, short = null, extra = {}) => ({ code, message, ...(short ? { short } : {}), ...extra });
|
|
55
55
|
|
|
56
|
+
// An issued invoice the accounting system refused. A refusal stored without
|
|
57
|
+
// its few words (before 2026-10-28) shows "Not sent"; its message stays.
|
|
58
|
+
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : problem('send_failed', 'Sending failed.', 'Not sent'));
|
|
59
|
+
|
|
56
60
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
57
61
|
export function draftProblems(invoice, lines) {
|
|
58
62
|
const problems = [];
|
|
@@ -155,7 +159,7 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
|
|
|
155
159
|
.orderBy(desc(invoices.sendUpdatedAt));
|
|
156
160
|
for (const row of issued) {
|
|
157
161
|
if (row.sendState === 'queued') sending.add(row.id);
|
|
158
|
-
else problems.set(row.id, [row.sendError
|
|
162
|
+
else problems.set(row.id, [refusal(row.sendError)]);
|
|
159
163
|
}
|
|
160
164
|
// A draft whose checks pass is ready (Samuel, 2026-10-20): no person
|
|
161
165
|
// needs to mark it. toCheck stays for callers that still read it.
|
|
@@ -174,7 +178,7 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
|
|
|
174
178
|
if (invoice.status === 'void') return { view: null, problems: [] };
|
|
175
179
|
if (invoice.status === 'issued') {
|
|
176
180
|
if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
|
|
177
|
-
if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError
|
|
181
|
+
if (invoice.sendState === 'failed') return { view: 'problem', problems: [refusal(invoice.sendError)] };
|
|
178
182
|
return { view: null, problems: [] };
|
|
179
183
|
}
|
|
180
184
|
const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
|
|
@@ -435,7 +439,7 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
435
439
|
summary.waiting += 1;
|
|
436
440
|
} else {
|
|
437
441
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
438
|
-
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
442
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}) } });
|
|
439
443
|
summary.failed += 1;
|
|
440
444
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
441
445
|
const fact = refusalFact(known.get(result.invoiceId), error);
|