@arcforgelabs/flow 2026.10.49 → 2026.10.51
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/VISION.md +18 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +109 -0
- package/server/api/invoicing.js +7 -3
- package/server/app.js +13 -0
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +144 -0
- package/server/core/captures.js +397 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/reader.js +333 -0
- package/server/core/sending.js +27 -9
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +47 -0
- package/server/index.js +47 -1
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/index.js +1 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +58 -40
- package/server/integrations/xero/runtime.js +7 -4
- package/server/integrations/xero/settings.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +58 -3
- package/server/ui/flow.css +6 -2
- package/server/ui/invoice-rules.js +1 -1
- package/server/ui/invoicing.js +20 -9
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/mobile.js +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
|
|
8
|
+
// The vision reader. A small model reads every capture. A larger model reads
|
|
9
|
+
// again only when case number, practice or work is below the sure line.
|
|
10
|
+
// Flow code decides that; the model does not see the jobs list and does not
|
|
11
|
+
// write. The photo and the fields stay out of the log: a failure is a
|
|
12
|
+
// capture id and an error code.
|
|
13
|
+
//
|
|
14
|
+
// A failed call is tried twice more (three calls). The capture is then
|
|
15
|
+
// `problem` with a short reason. Type it still works. The module being off,
|
|
16
|
+
// or the keys missing, is "reader turned off" and is set when the photo is
|
|
17
|
+
// stored (server/core/captures.js), not here.
|
|
18
|
+
|
|
19
|
+
export const CAPTURE_READ_KIND = 'capture.read';
|
|
20
|
+
const CALLS = 3;
|
|
21
|
+
const VALUE_MAX = 2000;
|
|
22
|
+
|
|
23
|
+
export const JOB_FIELDS = Object.freeze([
|
|
24
|
+
Object.freeze({ key: 'case_number', label: 'Case number', needed: true }),
|
|
25
|
+
Object.freeze({ key: 'practice', label: 'Practice', needed: true }),
|
|
26
|
+
Object.freeze({ key: 'dentist', label: 'Dentist', needed: false }),
|
|
27
|
+
Object.freeze({ key: 'work', label: 'Work', needed: true }),
|
|
28
|
+
Object.freeze({ key: 'teeth', label: 'Teeth', needed: false }),
|
|
29
|
+
Object.freeze({ key: 'shade', label: 'Shade', needed: false }),
|
|
30
|
+
Object.freeze({ key: 'due_date', label: 'Due date', needed: false }),
|
|
31
|
+
Object.freeze({ key: 'notes', label: 'Notes', needed: false }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const NEEDED = new Set(JOB_FIELDS.filter((field) => field.needed).map((field) => field.key));
|
|
35
|
+
|
|
36
|
+
// One object, every property required. Bedrock rejects a schema that leaves
|
|
37
|
+
// a property optional past its limit, or that omits additionalProperties: false.
|
|
38
|
+
const FIELD_SCHEMA = Object.freeze({
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
fields: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
key: { type: 'string' },
|
|
47
|
+
value: { type: 'string' },
|
|
48
|
+
seen: { type: 'string' },
|
|
49
|
+
sure: { type: 'number' },
|
|
50
|
+
},
|
|
51
|
+
required: ['key', 'value', 'seen', 'sure'],
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ['fields'],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const PROBLEM = new Set([
|
|
61
|
+
'the reader did not answer',
|
|
62
|
+
'could not read the form',
|
|
63
|
+
'photo type cannot be read',
|
|
64
|
+
'the photo is missing',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const READABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
68
|
+
|
|
69
|
+
function problemText(error) {
|
|
70
|
+
return PROBLEM.has(error?.message) ? error.message : 'the reader did not answer';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function blank(key) {
|
|
74
|
+
return { key, value: '', seen: '', sure: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clip(value) {
|
|
78
|
+
return value.trim().slice(0, VALUE_MAX);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sureOf(value) {
|
|
82
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
83
|
+
if (!Number.isFinite(number)) return 0;
|
|
84
|
+
return Math.min(1, Math.max(0, number));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One field per key the lab asked for, in that order. A key that was not
|
|
88
|
+
// asked for is dropped, so a patient name the model adds is not stored.
|
|
89
|
+
// A value that is not text is empty: an unreadable field is never a guess.
|
|
90
|
+
function parseFields(text, fieldList) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
const error = new FlowError('reader_unreadable', 'could not read the form');
|
|
96
|
+
error.retry = true;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const rows = Array.isArray(parsed?.fields) ? parsed.fields : null;
|
|
100
|
+
if (!rows) {
|
|
101
|
+
const error = new FlowError('reader_unreadable', 'could not read the form');
|
|
102
|
+
error.retry = true;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const byKey = new Map();
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (!row || typeof row.key !== 'string' || !row.key) continue;
|
|
108
|
+
if (typeof row.value !== 'string' || typeof row.seen !== 'string') {
|
|
109
|
+
byKey.set(row.key, blank(row.key));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
byKey.set(row.key, { key: row.key, value: clip(row.value), seen: clip(row.seen), sure: sureOf(row.sure) });
|
|
113
|
+
}
|
|
114
|
+
return fieldList.map((field) => byKey.get(field.key) ?? blank(field.key));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function belowLine(fields, line) {
|
|
118
|
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
119
|
+
for (const key of NEEDED) {
|
|
120
|
+
if ((byKey.get(key)?.sure ?? 0) < line) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function recorded(fields, calls, model = calls.at(-1).model) {
|
|
126
|
+
const inputTokens = calls.reduce((sum, call) => sum + call.inputTokens, 0);
|
|
127
|
+
const outputTokens = calls.reduce((sum, call) => sum + call.outputTokens, 0);
|
|
128
|
+
const costMicros = calls.some((call) => call.costMicros == null)
|
|
129
|
+
? null
|
|
130
|
+
: calls.reduce((sum, call) => sum + call.costMicros, 0);
|
|
131
|
+
return { fields, model, inputTokens, outputTokens, costMicros, calls };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function instructionFor(fieldList) {
|
|
135
|
+
const lines = fieldList.map((field) => `- ${field.key}: ${field.label}`).join('\n');
|
|
136
|
+
return [
|
|
137
|
+
'Read this photo of a handwritten lab form.',
|
|
138
|
+
'Return one field for each key below.',
|
|
139
|
+
'value is what should be stored. Use an empty string when you cannot read it. Never guess a name, a number or a date.',
|
|
140
|
+
'seen is the exact text you saw, or an empty string when nothing was written.',
|
|
141
|
+
'sure is a number from 0 to 1: how sure you are that value matches what was written.',
|
|
142
|
+
'Ignore printed lab branding.',
|
|
143
|
+
'Do not add a key that is not in this list.',
|
|
144
|
+
lines,
|
|
145
|
+
].join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function captureFields(db, organisationId) {
|
|
149
|
+
const rows = await db.select({ id: jobFields.id, name: jobFields.name }).from(jobFields).where(and(
|
|
150
|
+
eq(jobFields.organisationId, organisationId),
|
|
151
|
+
isNull(jobFields.archivedAt),
|
|
152
|
+
)).orderBy(asc(jobFields.position), asc(jobFields.name));
|
|
153
|
+
const labels = new Set(JOB_FIELDS.map((field) => field.label.toLowerCase()));
|
|
154
|
+
const custom = rows
|
|
155
|
+
.filter((row) => row.name && !labels.has(row.name.trim().toLowerCase()))
|
|
156
|
+
.map((row) => ({ key: `custom_${row.id}`, label: row.name.trim(), needed: false }));
|
|
157
|
+
return [...JOB_FIELDS, ...custom];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Three calls at most. A call that returned token counts is kept on the
|
|
161
|
+
// error when every attempt fails, so the capture still records the cost.
|
|
162
|
+
async function ask(port, { model, price, fieldList, mediaType, bytes, instruction }) {
|
|
163
|
+
const spent = [];
|
|
164
|
+
let last = new FlowError('reader_unavailable', 'the reader did not answer');
|
|
165
|
+
for (let attempt = 0; attempt < CALLS; attempt += 1) {
|
|
166
|
+
try {
|
|
167
|
+
const result = await port.readImage({
|
|
168
|
+
model, mediaType, bytes, instruction, schema: FIELD_SCHEMA, maxTokens: 4096,
|
|
169
|
+
});
|
|
170
|
+
spent.push({
|
|
171
|
+
model,
|
|
172
|
+
inputTokens: result.inputTokens,
|
|
173
|
+
outputTokens: result.outputTokens,
|
|
174
|
+
costMicros: callCostMicros({ inputTokens: result.inputTokens, outputTokens: result.outputTokens, price }),
|
|
175
|
+
});
|
|
176
|
+
return { fields: parseFields(result.text, fieldList), spent };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
last = error;
|
|
179
|
+
if (error?.retry === false) break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
last.spent = spent;
|
|
183
|
+
throw last;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function mark(db, row, fields) {
|
|
187
|
+
const [next] = await db.update(captures).set({ ...fields, updatedAt: sql`now()` }).where(and(
|
|
188
|
+
eq(captures.organisationId, row.organisationId),
|
|
189
|
+
eq(captures.id, row.id),
|
|
190
|
+
eq(captures.state, 'reading'),
|
|
191
|
+
)).returning();
|
|
192
|
+
return next ?? row;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Reads one capture that is still `reading`. A capture that has moved on
|
|
196
|
+
// (typed, decided) is left as it is. The model's answer is stored on the
|
|
197
|
+
// row and is not written to the log.
|
|
198
|
+
export async function readStoredCapture(db, storage, port, settings, { organisationId, captureId, log = console }) {
|
|
199
|
+
const [row] = await db.select().from(captures).where(and(
|
|
200
|
+
eq(captures.organisationId, organisationId),
|
|
201
|
+
eq(captures.id, captureId),
|
|
202
|
+
));
|
|
203
|
+
if (!row || row.state !== 'reading') return row ?? null;
|
|
204
|
+
|
|
205
|
+
let stored;
|
|
206
|
+
try {
|
|
207
|
+
stored = await storage.get(row.fileKey);
|
|
208
|
+
} catch {
|
|
209
|
+
const error = new FlowError('reader_unavailable', 'the reader did not answer');
|
|
210
|
+
error.retry = true;
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
if (!stored) {
|
|
214
|
+
log.error?.(`[reader] ${row.id} reader_missing`);
|
|
215
|
+
return mark(db, row, { state: 'problem', problem: 'the photo is missing' });
|
|
216
|
+
}
|
|
217
|
+
const sha = createHash('sha256').update(stored.body).digest('hex');
|
|
218
|
+
if (sha !== row.fileSha256) {
|
|
219
|
+
log.error?.(`[reader] ${row.id} hash_mismatch`);
|
|
220
|
+
return mark(db, row, { state: 'problem', problem: 'could not read the form' });
|
|
221
|
+
}
|
|
222
|
+
if (!READABLE.has(row.fileType)) {
|
|
223
|
+
log.error?.(`[reader] ${row.id} reader_type`);
|
|
224
|
+
return mark(db, row, { state: 'problem', problem: 'photo type cannot be read' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const fieldList = await captureFields(db, organisationId);
|
|
228
|
+
const instruction = instructionFor(fieldList);
|
|
229
|
+
const photo = { fieldList, mediaType: row.fileType, bytes: stored.body, instruction };
|
|
230
|
+
const calls = [];
|
|
231
|
+
let fields = null;
|
|
232
|
+
try {
|
|
233
|
+
const first = await ask(port, { ...photo, model: settings.model, price: settings.prices.small });
|
|
234
|
+
calls.push(...first.spent);
|
|
235
|
+
fields = first.fields;
|
|
236
|
+
if (belowLine(fields, settings.sureLine)) {
|
|
237
|
+
const second = await ask(port, { ...photo, model: settings.large, price: settings.prices.large });
|
|
238
|
+
calls.push(...second.spent);
|
|
239
|
+
fields = second.fields;
|
|
240
|
+
}
|
|
241
|
+
return mark(db, row, { state: 'ready', problem: null, read: recorded(fields, calls) });
|
|
242
|
+
} catch (error) {
|
|
243
|
+
log.error?.(`[reader] ${row.id} ${error?.code ?? 'reader_unavailable'}`);
|
|
244
|
+
calls.push(...(error?.spent ?? []));
|
|
245
|
+
return mark(db, row, {
|
|
246
|
+
state: 'problem',
|
|
247
|
+
problem: problemText(error),
|
|
248
|
+
read: calls.length ? recorded(fields ?? [], calls, fields ? settings.model : calls.at(-1).model) : null,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Queues the read, or runs it in the request when background jobs are off
|
|
254
|
+
// (a Replit workspace). The worker only claims `capture.read` jobs. Two
|
|
255
|
+
// processes can run: the lease keeps a capture to one of them.
|
|
256
|
+
export function createCaptureReader({
|
|
257
|
+
db, storage, port, settings, log = console, jobsOn = true, timers = globalThis,
|
|
258
|
+
}) {
|
|
259
|
+
async function handle(job) {
|
|
260
|
+
const captureId = job.payload?.captureId ?? null;
|
|
261
|
+
try {
|
|
262
|
+
await readStoredCapture(db, storage, port, settings, { organisationId: job.organisationId, captureId, log });
|
|
263
|
+
return { captureId };
|
|
264
|
+
} catch (error) {
|
|
265
|
+
log.error?.(`[reader] ${captureId ?? '-'} ${error?.code ?? 'reader_unavailable'}`);
|
|
266
|
+
const safe = new FlowError('reader_unavailable', 'the reader did not answer');
|
|
267
|
+
safe.retry = error?.retry !== false;
|
|
268
|
+
throw safe;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function schedule(row) {
|
|
273
|
+
if (!settings?.on) return row;
|
|
274
|
+
if (!jobsOn) {
|
|
275
|
+
return readStoredCapture(db, storage, port, settings, { organisationId: row.organisationId, captureId: row.id, log });
|
|
276
|
+
}
|
|
277
|
+
await enqueueJob(db, row.organisationId, {
|
|
278
|
+
kind: CAPTURE_READ_KIND,
|
|
279
|
+
dedupeKey: row.id,
|
|
280
|
+
payload: { captureId: row.id },
|
|
281
|
+
maxAttempts: 2,
|
|
282
|
+
});
|
|
283
|
+
return row;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const workerId = `reader-${randomUUID()}`;
|
|
287
|
+
let timer = null;
|
|
288
|
+
let stopped = true;
|
|
289
|
+
let running = null;
|
|
290
|
+
|
|
291
|
+
function arm(delay) {
|
|
292
|
+
if (stopped) return;
|
|
293
|
+
timer = timers.setTimeout(async () => {
|
|
294
|
+
timer = null;
|
|
295
|
+
running = tick();
|
|
296
|
+
const again = await running;
|
|
297
|
+
running = null;
|
|
298
|
+
arm(again ? 0 : 1000);
|
|
299
|
+
}, delay);
|
|
300
|
+
timer?.unref?.();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function tick() {
|
|
304
|
+
try {
|
|
305
|
+
const ran = await runNextJob(db, {
|
|
306
|
+
workerId,
|
|
307
|
+
leaseMs: 15 * 60 * 1000,
|
|
308
|
+
handlers: { [CAPTURE_READ_KIND]: handle },
|
|
309
|
+
});
|
|
310
|
+
return Boolean(ran);
|
|
311
|
+
} catch {
|
|
312
|
+
log.error?.('[reader] - reader_unavailable');
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
schedule,
|
|
319
|
+
handle,
|
|
320
|
+
worker: {
|
|
321
|
+
start() {
|
|
322
|
+
stopped = false;
|
|
323
|
+
arm(1000);
|
|
324
|
+
},
|
|
325
|
+
async stop() {
|
|
326
|
+
stopped = true;
|
|
327
|
+
if (timer) timers.clearTimeout(timer);
|
|
328
|
+
timer = null;
|
|
329
|
+
await running;
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
package/server/core/sending.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
-
import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
|
|
5
|
+
import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
7
7
|
import { issueInvoice } from './invoices.js';
|
|
8
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
@@ -20,7 +20,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
20
20
|
// rules (invoice-rules.js) add their notes. An enabled skip rule does too:
|
|
21
21
|
// a draft whose job it matches stays Pending, even when the draft was made
|
|
22
22
|
// by hand or before the rule existed. A draft with a problem is
|
|
23
|
-
// Pending (the "problem" view). A draft without one is Ready.
|
|
23
|
+
// Pending (the "problem" view). A draft without one is Ready. A draft whose
|
|
24
|
+
// job a non-chargeable rule matches is neither: it is Non-chargeable, and
|
|
25
|
+
// nothing sends it.
|
|
24
26
|
//
|
|
25
27
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
26
28
|
// accounting system is connected, queues it to go there. The send worker
|
|
@@ -97,14 +99,14 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
97
99
|
// The lab's rules (invoice-rules.js). A pending rule puts its note on the
|
|
98
100
|
// draft. A skip rule holds a draft that already exists: void it, or change
|
|
99
101
|
// the rule. Matching stays in invoice-rules.js.
|
|
100
|
-
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled &&
|
|
102
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && ['pending', 'skip', 'non_chargeable'].includes(r.action.kind));
|
|
101
103
|
if (rules.length && jobs.length) {
|
|
102
104
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
103
105
|
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
104
106
|
for (const { obligation, reference, customerName } of jobs) {
|
|
105
107
|
const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
|
|
106
108
|
const job = { ...obligation, reference };
|
|
107
|
-
const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
109
|
+
const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
108
110
|
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
109
111
|
}
|
|
110
112
|
}
|
|
@@ -125,17 +127,28 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
125
127
|
return problems;
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// A draft a non-chargeable rule matches has nothing to fix: it is never sent.
|
|
131
|
+
const nonChargeableOf = (found) => found.filter((p) => p.code === 'non_chargeable');
|
|
132
|
+
|
|
128
133
|
// The views of the Invoices page. `accounting` is the accounting adapter, or
|
|
129
|
-
// null. Every draft is in one view:
|
|
130
|
-
//
|
|
131
|
-
// queued, and a Problem when
|
|
132
|
-
// { toCheck: [ids], ready: [ids],
|
|
134
|
+
// null. Every draft is in one view: Non-chargeable (a non-chargeable rule
|
|
135
|
+
// matches its job), Problem (a check failed; the Pending tab) or Ready (its
|
|
136
|
+
// checks pass). An issued invoice is Sending while queued, and a Problem when
|
|
137
|
+
// the accounting system refused it. Answers { toCheck: [ids], ready: [ids],
|
|
138
|
+
// problems: Map(id -> problems), nonChargeable: Map(id -> rules), sending: Set(ids) }.
|
|
133
139
|
export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
|
|
134
140
|
const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents, readyAt: invoices.readyAt }).from(invoices)
|
|
135
141
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft')))
|
|
136
142
|
.orderBy(asc(invoices.createdAt), asc(invoices.id))
|
|
137
143
|
.limit(CHECK_LIMIT);
|
|
138
144
|
const problems = await checkDrafts(db, organisationId, drafts, accounting);
|
|
145
|
+
const nonChargeable = new Map();
|
|
146
|
+
for (const [id, found] of problems) {
|
|
147
|
+
const rules = nonChargeableOf(found);
|
|
148
|
+
if (!rules.length) continue;
|
|
149
|
+
nonChargeable.set(id, rules);
|
|
150
|
+
problems.delete(id);
|
|
151
|
+
}
|
|
139
152
|
const sending = new Set();
|
|
140
153
|
const issued = await db.select({ id: invoices.id, sendState: invoices.sendState, sendError: invoices.sendError }).from(invoices)
|
|
141
154
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), inArray(invoices.sendState, ['queued', 'failed'])))
|
|
@@ -146,11 +159,12 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
|
|
|
146
159
|
}
|
|
147
160
|
// A draft whose checks pass is ready (Samuel, 2026-10-20): no person
|
|
148
161
|
// needs to mark it. toCheck stays for callers that still read it.
|
|
149
|
-
const clean = drafts.filter((d) => !problems.has(d.id));
|
|
162
|
+
const clean = drafts.filter((d) => !problems.has(d.id) && !nonChargeable.has(d.id));
|
|
150
163
|
return {
|
|
151
164
|
toCheck: [],
|
|
152
165
|
ready: clean.map((d) => d.id),
|
|
153
166
|
problems,
|
|
167
|
+
nonChargeable,
|
|
154
168
|
sending,
|
|
155
169
|
};
|
|
156
170
|
}
|
|
@@ -164,6 +178,8 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
|
|
|
164
178
|
return { view: null, problems: [] };
|
|
165
179
|
}
|
|
166
180
|
const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
|
|
181
|
+
const rules = nonChargeableOf(found);
|
|
182
|
+
if (rules.length) return { view: 'non_chargeable', problems: [], nonChargeable: rules };
|
|
167
183
|
return { view: found.length ? 'problem' : 'ready', problems: found };
|
|
168
184
|
}
|
|
169
185
|
|
|
@@ -279,6 +295,8 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
279
295
|
ready = ready.filter((id) => wanted.has(id));
|
|
280
296
|
held = wanted.size - ready.length;
|
|
281
297
|
if (!ready.length) {
|
|
298
|
+
const kept = invoiceIds.map((id) => views.nonChargeable.get(id)?.[0]?.message).find(Boolean);
|
|
299
|
+
if (kept) throw new FlowError('not_ready', `Not sent: ${kept}`);
|
|
282
300
|
const reason = invoiceIds.map((id) => views.problems.get(id)?.[0]?.message).find(Boolean);
|
|
283
301
|
throw new FlowError('not_ready', reason
|
|
284
302
|
? `Not sent: ${reason.replace(/\.?$/, '.')} The invoice is on Pending.`
|