@arcforgelabs/flow 2026.10.26 → 2026.10.27
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 +5 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +231 -20
- package/server/app.js +28 -2
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +4 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +45 -12
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +8 -1
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +115 -0
- package/server/index.js +12 -4
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +5 -0
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +2 -1
- package/server/ui/app.js +14 -1
- package/server/ui/automations.js +96 -0
- package/server/ui/flow.css +88 -0
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/jobs.js +19 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.27",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
48
48
|
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
49
49
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
50
|
+
"@sentry/node": "11.4.0",
|
|
50
51
|
"drizzle-orm": "0.45.3",
|
|
51
52
|
"express": "5.3.0",
|
|
52
53
|
"express-rate-limit": "8.7.0",
|
package/server/api/invoicing.js
CHANGED
|
@@ -4,7 +4,16 @@ import { FlowError } from '../core/errors.js';
|
|
|
4
4
|
import {
|
|
5
5
|
createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
6
6
|
} from '../core/invoices.js';
|
|
7
|
-
import {
|
|
7
|
+
import { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
|
|
8
|
+
import {
|
|
9
|
+
ACTIONS, createRule, deleteRule, FIELDS, listRules, TESTS, updateRule,
|
|
10
|
+
} from '../core/invoice-rules.js';
|
|
11
|
+
import {
|
|
12
|
+
customerBillingByContact, effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings, ruleBlockers,
|
|
13
|
+
saveCustomerBilling, saveInvoicingSettings,
|
|
14
|
+
} from '../core/invoicing-rules.js';
|
|
15
|
+
import { draftInvoiceForObligation, listObligations, setReady } from '../core/ledger.js';
|
|
16
|
+
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
8
17
|
import {
|
|
9
18
|
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
10
19
|
} from '../db/schema.js';
|
|
@@ -13,7 +22,13 @@ import {
|
|
|
13
22
|
// UI's invoices, customers, price list and jobs pages read and write here.
|
|
14
23
|
//
|
|
15
24
|
// GET /context the organisations, and whether this person may issue
|
|
16
|
-
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status
|
|
25
|
+
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status;
|
|
26
|
+
// status to_check, ready or problem lists those views (server/core/sending.js)
|
|
27
|
+
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
28
|
+
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
29
|
+
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
30
|
+
// accounting system { issueDate }
|
|
31
|
+
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
17
32
|
// GET /invoices/:id one invoice: lines, contact, job, issued versions
|
|
18
33
|
// POST /invoices a new draft
|
|
19
34
|
// PATCH /invoices/:id change a draft
|
|
@@ -22,7 +37,21 @@ import {
|
|
|
22
37
|
// POST /invoices/:id/void owner or admin
|
|
23
38
|
// GET /contacts?q=&limit= customers, by name
|
|
24
39
|
// GET /price-items?q= the price list, by code
|
|
25
|
-
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view
|
|
40
|
+
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
41
|
+
// a job with no invoice lists the invoicing rules it waits on
|
|
42
|
+
// GET /settings the invoicing settings (server/core/invoicing-rules.js)
|
|
43
|
+
// PUT /settings owner or admin: change some of them
|
|
44
|
+
// GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
|
|
45
|
+
// GET /rule-fields what a rule can name, and the menus of values
|
|
46
|
+
// POST /rules owner or admin: a new rule { name, enabled, conditions, action }
|
|
47
|
+
// PUT /rules/:id owner or admin: change some of a rule
|
|
48
|
+
// DELETE /rules/:id owner or admin
|
|
49
|
+
// GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
|
|
50
|
+
// PUT /automations/:key owner or admin: { enabled, config }
|
|
51
|
+
// GET /work-statuses the work system's job statuses, with job counts
|
|
52
|
+
// GET /contacts/:id/billing one customer's own invoicing settings
|
|
53
|
+
// PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
|
|
54
|
+
// POST /jobs/:id/ready { ready }: mark a job ready to invoice, or back in work
|
|
26
55
|
// POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
|
|
27
56
|
// check that the accounting system holds none
|
|
28
57
|
//
|
|
@@ -31,10 +60,12 @@ import {
|
|
|
31
60
|
// may be left out while the installation has exactly one. Money is integer
|
|
32
61
|
// cents. `liveCheckFor(organisationId)` returns the ledger's live check for
|
|
33
62
|
// that organisation, or null when no accounting integration offers one.
|
|
63
|
+
// `accounting()` is the accounting adapter that sends invoices, or null.
|
|
34
64
|
|
|
35
65
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
66
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
37
|
-
const
|
|
67
|
+
const VIEWS_OF_INVOICES = ['to_check', 'ready', 'problem'];
|
|
68
|
+
const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
38
69
|
const MAX_LINES = 200;
|
|
39
70
|
const MAX_TEXT = 500;
|
|
40
71
|
// Each line's amount must fit the database's integer columns with room for
|
|
@@ -45,6 +76,7 @@ const MAX_UNIT_CENTS = 100_000_000;
|
|
|
45
76
|
const ERROR_STATUS = {
|
|
46
77
|
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
47
78
|
never_issued: 409, not_ready: 409, live_check_unavailable: 409,
|
|
79
|
+
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
48
80
|
};
|
|
49
81
|
|
|
50
82
|
function sendError(res, error, log) {
|
|
@@ -176,7 +208,7 @@ async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
|
176
208
|
return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
|
|
177
209
|
}
|
|
178
210
|
|
|
179
|
-
async function invoiceDetail(db, organisationId, invoiceId) {
|
|
211
|
+
async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
|
|
180
212
|
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
181
213
|
const [[contact], versions, jobs] = await Promise.all([
|
|
182
214
|
db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
@@ -186,13 +218,14 @@ async function invoiceDetail(db, organisationId, invoiceId) {
|
|
|
186
218
|
]);
|
|
187
219
|
return {
|
|
188
220
|
...invoice,
|
|
221
|
+
...(await invoiceView(db, organisationId, invoice, { accounting })),
|
|
189
222
|
contact: contact ?? null,
|
|
190
223
|
job: jobs.get(invoiceId) ?? null,
|
|
191
224
|
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
192
225
|
};
|
|
193
226
|
}
|
|
194
227
|
|
|
195
|
-
export function createInvoicingRouter({ db, personAuth, log = console, liveCheckFor = () => null }) {
|
|
228
|
+
export function createInvoicingRouter({ db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null }) {
|
|
196
229
|
const router = express.Router();
|
|
197
230
|
router.use(express.json({ limit: '64kb' }));
|
|
198
231
|
|
|
@@ -210,11 +243,18 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
210
243
|
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
211
244
|
const organisationId = await organisationOf(req);
|
|
212
245
|
const status = text(req.query.status);
|
|
213
|
-
if (status && !INVOICE_STATUSES.includes(status))
|
|
246
|
+
if (status && ![...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].includes(status)) {
|
|
247
|
+
throw invalid(`status must be one of ${[...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].join(', ')}`);
|
|
248
|
+
}
|
|
214
249
|
const q = text(req.query.q);
|
|
215
250
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
251
|
+
const views = await invoiceViews(db, organisationId, { accounting: accounting() });
|
|
216
252
|
const where = [eq(invoices.organisationId, organisationId)];
|
|
217
|
-
|
|
253
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
254
|
+
if (status === 'to_check') where.push(only(views.toCheck));
|
|
255
|
+
else if (status === 'ready') where.push(only(views.ready));
|
|
256
|
+
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
257
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
218
258
|
if (q) {
|
|
219
259
|
const pattern = containing(q);
|
|
220
260
|
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
@@ -229,17 +269,68 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
229
269
|
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
230
270
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
231
271
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
272
|
+
counts.to_check = views.toCheck.length;
|
|
273
|
+
counts.ready = views.ready.length;
|
|
274
|
+
counts.problem = views.problems.size;
|
|
275
|
+
const ready = new Set(views.ready);
|
|
276
|
+
const toCheck = new Set(views.toCheck);
|
|
232
277
|
res.json({
|
|
233
278
|
ok: true,
|
|
234
|
-
invoices: rows.map(({ invoice, contactName }) => ({
|
|
279
|
+
invoices: rows.map(({ invoice, contactName }) => ({
|
|
280
|
+
...invoice,
|
|
281
|
+
contactName,
|
|
282
|
+
job: jobs.get(invoice.id) ?? null,
|
|
283
|
+
view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
|
|
284
|
+
: views.sending.has(invoice.id) ? 'sending' : null,
|
|
285
|
+
problems: views.problems.get(invoice.id) ?? [],
|
|
286
|
+
})),
|
|
235
287
|
counts,
|
|
288
|
+
sendsTo: accounting()?.name ?? null,
|
|
236
289
|
});
|
|
237
290
|
}));
|
|
238
291
|
|
|
292
|
+
const invoiceIdsOf = (body) => {
|
|
293
|
+
const ids = body?.invoiceIds;
|
|
294
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 500 || !ids.every((id) => UUID.test(String(id)))) {
|
|
295
|
+
throw invalid('invoiceIds must list 1 to 500 invoice ids');
|
|
296
|
+
}
|
|
297
|
+
return [...new Set(ids)];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Anyone who drafts may say a draft is checked; issuing and sending stay
|
|
301
|
+
// with an owner or admin.
|
|
302
|
+
router.post('/invoices/mark-ready', ...signedIn, handle(log, async (req, res) => {
|
|
303
|
+
const organisationId = await organisationOf(req);
|
|
304
|
+
const result = await markReady(db, organisationId, invoiceIdsOf(req.body), { personId: req.person.id, accounting: accounting() });
|
|
305
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} marked ${result.marked} invoice(s) ready`);
|
|
306
|
+
res.json({ ok: true, ...result });
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
router.post('/invoices/mark-not-ready', ...signedIn, handle(log, async (req, res) => {
|
|
310
|
+
const organisationId = await organisationOf(req);
|
|
311
|
+
res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
|
|
315
|
+
const organisationId = await organisationOf(req);
|
|
316
|
+
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
317
|
+
const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
|
|
318
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
|
|
319
|
+
res.json({ ok: true, ...result });
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
router.post('/invoices/send-again', ...manager, handle(log, async (req, res) => {
|
|
323
|
+
const organisationId = await organisationOf(req);
|
|
324
|
+
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
325
|
+
const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
|
|
326
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent again: ${JSON.stringify(result)}`);
|
|
327
|
+
res.json({ ok: true, ...result });
|
|
328
|
+
}));
|
|
329
|
+
|
|
239
330
|
router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
240
331
|
const invoiceId = pathId(req.params.id, 'invoice');
|
|
241
332
|
const organisationId = await organisationOf(req);
|
|
242
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
333
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
243
334
|
}));
|
|
244
335
|
|
|
245
336
|
router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -252,7 +343,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
252
343
|
lines: body.lines === undefined ? [] : readLines(body.lines),
|
|
253
344
|
});
|
|
254
345
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made draft invoice ${draft.id}`);
|
|
255
|
-
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id) });
|
|
346
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
|
|
256
347
|
}));
|
|
257
348
|
|
|
258
349
|
router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -266,7 +357,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
266
357
|
if (body.lines !== undefined) changes.lines = readLines(body.lines);
|
|
267
358
|
if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
|
|
268
359
|
await updateDraftInvoice(db, organisationId, invoiceId, changes);
|
|
269
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
360
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
270
361
|
}));
|
|
271
362
|
|
|
272
363
|
router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
|
|
@@ -275,7 +366,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
275
366
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
276
367
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
277
368
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
278
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
369
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
279
370
|
}));
|
|
280
371
|
|
|
281
372
|
router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
|
|
@@ -283,7 +374,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
283
374
|
const organisationId = await organisationOf(req);
|
|
284
375
|
await reviseInvoice(db, organisationId, invoiceId);
|
|
285
376
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} opened invoice ${invoiceId} for revision`);
|
|
286
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
377
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
287
378
|
}));
|
|
288
379
|
|
|
289
380
|
router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
|
|
@@ -291,7 +382,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
291
382
|
const organisationId = await organisationOf(req);
|
|
292
383
|
await voidInvoice(db, organisationId, invoiceId);
|
|
293
384
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
|
|
294
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
385
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
295
386
|
}));
|
|
296
387
|
|
|
297
388
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -320,7 +411,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
320
411
|
const view = text(req.query.view);
|
|
321
412
|
if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
|
|
322
413
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
323
|
-
const all = await listObligations(db, organisationId);
|
|
414
|
+
const all = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
|
|
324
415
|
const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
|
|
325
416
|
for (const entry of all) counts[entry.view] += 1;
|
|
326
417
|
const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
|
|
@@ -338,6 +429,13 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
338
429
|
]);
|
|
339
430
|
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
340
431
|
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
432
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
433
|
+
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
434
|
+
const rules = await listRules(db, organisationId);
|
|
435
|
+
// The invoicing rules a job with no invoice waits on, for every view a
|
|
436
|
+
// draft can still come from.
|
|
437
|
+
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
438
|
+
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
341
439
|
res.json({
|
|
342
440
|
ok: true,
|
|
343
441
|
jobs: listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view: v, reasons }) => ({
|
|
@@ -347,6 +445,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
347
445
|
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
|
348
446
|
view: v,
|
|
349
447
|
reasons,
|
|
448
|
+
waitingFor: waitingFor(o, v),
|
|
350
449
|
})),
|
|
351
450
|
counts,
|
|
352
451
|
});
|
|
@@ -355,15 +454,127 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
355
454
|
router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
|
|
356
455
|
const obligationId = pathId(req.params.id, 'job');
|
|
357
456
|
const organisationId = await organisationOf(req);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
throw new FlowError('live_check_unavailable', 'Flow drafts a job\'s invoice only after checking that the accounting system holds none for it. Turn on and connect Xero first.');
|
|
361
|
-
}
|
|
457
|
+
// With no accounting system connected, Flow's own records are the check.
|
|
458
|
+
const liveCheck = liveCheckFor(organisationId) ?? null;
|
|
362
459
|
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
|
|
363
460
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
364
461
|
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
365
462
|
}));
|
|
366
463
|
|
|
464
|
+
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
465
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
466
|
+
const organisationId = await organisationOf(req);
|
|
467
|
+
const ready = req.body?.ready ?? true;
|
|
468
|
+
if (typeof ready !== 'boolean') throw invalid('ready must be true or false');
|
|
469
|
+
const job = await setReady(db, organisationId, obligationId, { ready, actor: `person:${req.person.id}` });
|
|
470
|
+
res.json({ ok: true, job: { id: job.id, readyAt: job.readyAt, readyBy: job.readyBy } });
|
|
471
|
+
}));
|
|
472
|
+
|
|
473
|
+
router.get('/automations', ...signedIn, handle(log, async (req, res) => {
|
|
474
|
+
const organisationId = await organisationOf(req);
|
|
475
|
+
const place = typeof req.query.place === 'string' ? req.query.place : null;
|
|
476
|
+
const list = await listAutomations(db, organisationId);
|
|
477
|
+
res.json({ ok: true, automations: place ? list.filter((a) => a.place === place) : list });
|
|
478
|
+
}));
|
|
479
|
+
|
|
480
|
+
router.get('/work-statuses', ...signedIn, handle(log, async (req, res) => {
|
|
481
|
+
const organisationId = await organisationOf(req);
|
|
482
|
+
res.json({ ok: true, statuses: await workStatuses(db, organisationId) });
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
router.put('/automations/:key', ...manager, handle(log, async (req, res) => {
|
|
486
|
+
const organisationId = await organisationOf(req);
|
|
487
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
488
|
+
const automation = await saveAutomation(db, organisationId, String(req.params.key), changes, { personId: req.person.id });
|
|
489
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed automation ${automation.key}`);
|
|
490
|
+
res.json({ ok: true, automation });
|
|
491
|
+
}));
|
|
492
|
+
|
|
493
|
+
// The lab's invoice rules (docs/INVOICE_RULES.md).
|
|
494
|
+
router.get('/rules', ...signedIn, handle(log, async (req, res) => {
|
|
495
|
+
const organisationId = await organisationOf(req);
|
|
496
|
+
res.json({ ok: true, rules: await listRules(db, organisationId) });
|
|
497
|
+
}));
|
|
498
|
+
|
|
499
|
+
// What a rule can name, so people pick every value from a menu: the fields
|
|
500
|
+
// with their tests, and the values Flow already holds for each kind of menu.
|
|
501
|
+
router.get('/rule-fields', ...signedIn, handle(log, async (req, res) => {
|
|
502
|
+
const organisationId = await organisationOf(req);
|
|
503
|
+
const [statuses, customers, items] = await Promise.all([
|
|
504
|
+
workStatuses(db, organisationId),
|
|
505
|
+
db.select({ value: contacts.id, label: contacts.name }).from(contacts)
|
|
506
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
|
|
507
|
+
.orderBy(asc(contacts.name)).limit(1000),
|
|
508
|
+
db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
|
|
509
|
+
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
|
510
|
+
.orderBy(asc(priceItems.code)).limit(1000),
|
|
511
|
+
]);
|
|
512
|
+
res.json({
|
|
513
|
+
ok: true,
|
|
514
|
+
fields: Object.entries(FIELDS).map(([key, f]) => ({ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind] })),
|
|
515
|
+
actions: ACTIONS,
|
|
516
|
+
options: {
|
|
517
|
+
statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
|
|
518
|
+
customers,
|
|
519
|
+
items: items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}));
|
|
523
|
+
|
|
524
|
+
router.post('/rules', ...manager, handle(log, async (req, res) => {
|
|
525
|
+
const organisationId = await organisationOf(req);
|
|
526
|
+
const { organisationId: _named, ...raw } = req.body ?? {};
|
|
527
|
+
const rule = await createRule(db, organisationId, raw, { personId: req.person.id });
|
|
528
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added invoice rule ${rule.id}`);
|
|
529
|
+
res.status(201).json({ ok: true, rule });
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
router.put('/rules/:id', ...manager, handle(log, async (req, res) => {
|
|
533
|
+
const ruleId = pathId(req.params.id, 'rule');
|
|
534
|
+
const organisationId = await organisationOf(req);
|
|
535
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
536
|
+
const rule = await updateRule(db, organisationId, ruleId, changes, { personId: req.person.id });
|
|
537
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoice rule ${rule.id}`);
|
|
538
|
+
res.json({ ok: true, rule });
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
router.delete('/rules/:id', ...manager, handle(log, async (req, res) => {
|
|
542
|
+
const ruleId = pathId(req.params.id, 'rule');
|
|
543
|
+
const organisationId = await organisationOf(req);
|
|
544
|
+
await deleteRule(db, organisationId, ruleId);
|
|
545
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted invoice rule ${ruleId}`);
|
|
546
|
+
res.json({ ok: true });
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
router.get('/settings', ...signedIn, handle(log, async (req, res) => {
|
|
550
|
+
const organisationId = await organisationOf(req);
|
|
551
|
+
res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });
|
|
552
|
+
}));
|
|
553
|
+
|
|
554
|
+
router.put('/settings', ...manager, handle(log, async (req, res) => {
|
|
555
|
+
const organisationId = await organisationOf(req);
|
|
556
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
557
|
+
const settings = await saveInvoicingSettings(db, organisationId, changes, { personId: req.person.id });
|
|
558
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoicing settings ${Object.keys(changes).join(', ')}`);
|
|
559
|
+
res.json({ ok: true, settings, defaults: INVOICING_DEFAULTS });
|
|
560
|
+
}));
|
|
561
|
+
|
|
562
|
+
router.get('/contacts/:id/billing', ...signedIn, handle(log, async (req, res) => {
|
|
563
|
+
const contactId = pathId(req.params.id, 'customer');
|
|
564
|
+
const organisationId = await organisationOf(req);
|
|
565
|
+
const [billing, settings] = await Promise.all([readCustomerBilling(db, organisationId, contactId), readInvoicingSettings(db, organisationId)]);
|
|
566
|
+
res.json({ ok: true, billing, effective: effectiveSettings(settings, billing) });
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
router.put('/contacts/:id/billing', ...manager, handle(log, async (req, res) => {
|
|
570
|
+
const contactId = pathId(req.params.id, 'customer');
|
|
571
|
+
const organisationId = await organisationOf(req);
|
|
572
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
573
|
+
const billing = await saveCustomerBilling(db, organisationId, contactId, changes, { personId: req.person.id });
|
|
574
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed billing settings of customer ${contactId}`);
|
|
575
|
+
res.json({ ok: true, billing, effective: effectiveSettings(await readInvoicingSettings(db, organisationId), billing) });
|
|
576
|
+
}));
|
|
577
|
+
|
|
367
578
|
// A body that is not JSON, or too large, answers like every other refusal.
|
|
368
579
|
router.use((error, _req, res, next) => {
|
|
369
580
|
if (error?.type === 'entity.too.large') return res.status(413).json({ ok: false, code: 'too_large', error: 'The request body is too large.' });
|
package/server/app.js
CHANGED
|
@@ -25,7 +25,9 @@ import { createUiAssetsRouter } from './ui/routes.js';
|
|
|
25
25
|
// `integrations` is the started registry (server/integrations/index.js); each
|
|
26
26
|
// adapter's own routes are mounted under /api/<id>, and GET /health reports
|
|
27
27
|
// each one (server/integrations/health.js). `defaultFamily` is the design
|
|
28
|
-
// system every page starts on (FLOW_THEME, server/theme/themes.js).
|
|
28
|
+
// system every page starts on (FLOW_THEME, server/theme/themes.js). `log`
|
|
29
|
+
// is the console, or the monitored log (server/core/monitoring.js) that also
|
|
30
|
+
// sends each error to the installation's error service.
|
|
29
31
|
export function createApp({
|
|
30
32
|
build = deployedBuildStatus(),
|
|
31
33
|
database = null,
|
|
@@ -109,7 +111,21 @@ export function createApp({
|
|
|
109
111
|
const [adapter] = integrations?.with('checkInvoice') ?? [];
|
|
110
112
|
return adapter ? (obligation) => adapter.capabilities.checkInvoice({ organisationId, obligation }) : null;
|
|
111
113
|
};
|
|
112
|
-
|
|
114
|
+
// Send all ready (server/core/sending.js): the first enabled integration
|
|
115
|
+
// that sends invoices (Xero).
|
|
116
|
+
const accounting = () => integrations?.with('pushInvoices')[0] ?? null;
|
|
117
|
+
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting }));
|
|
118
|
+
}
|
|
119
|
+
// What a signed-in browser needs to report its errors and problems to the
|
|
120
|
+
// installation's own error service (docs/SENTRY.md): the first integration
|
|
121
|
+
// that offers `browserMonitoring`, or `{ on: false }`.
|
|
122
|
+
if (auth?.personAuth) {
|
|
123
|
+
app.get('/api/monitoring', auth.personAuth.requireSession, (_req, res) => {
|
|
124
|
+
res.set('Cache-Control', 'no-store');
|
|
125
|
+
const [adapter] = integrations?.with('browserMonitoring') ?? [];
|
|
126
|
+
const settings = adapter?.capabilities.browserMonitoring() ?? null;
|
|
127
|
+
res.json(settings ? { ok: true, on: true, service: adapter.id, ...settings } : { ok: true, on: false });
|
|
128
|
+
});
|
|
113
129
|
}
|
|
114
130
|
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
|
|
115
131
|
app.use(path, handler);
|
|
@@ -125,5 +141,15 @@ export function createApp({
|
|
|
125
141
|
log,
|
|
126
142
|
}));
|
|
127
143
|
|
|
144
|
+
// An error no route answered. A server fault goes to `log` (and from there
|
|
145
|
+
// to the error service, when one is on) and the answer carries no detail;
|
|
146
|
+
// any other error keeps Express's own answer.
|
|
147
|
+
app.use((error, _req, res, next) => {
|
|
148
|
+
const status = error?.status ?? error?.statusCode ?? 500;
|
|
149
|
+
if (status < 500 || res.headersSent) return next(error);
|
|
150
|
+
log.error?.(`[http] ${error?.message ?? error}`, ...(error instanceof Error ? [error] : []));
|
|
151
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
152
|
+
});
|
|
153
|
+
|
|
128
154
|
return app;
|
|
129
155
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { automations, billingObligations } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
import { autoDraftJobs } from './invoicing-rules.js';
|
|
5
|
+
import { setReady } from './ledger.js';
|
|
6
|
+
|
|
7
|
+
// Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
|
|
8
|
+
// own records and moves them on, and each is on or off with its own settings,
|
|
9
|
+
// so no other system's status is baked into Flow's stages. `place` is the
|
|
10
|
+
// screen that shows it: the integration whose data triggers it, or invoicing. The routines are
|
|
11
|
+
// defined here; the `automations` table holds an organisation's choices. The
|
|
12
|
+
// pipeline (pipeline.js) runs them after it reads the integrations' copies.
|
|
13
|
+
//
|
|
14
|
+
// mark_ready_from_work_status: a job whose work system status is one of the
|
|
15
|
+
// chosen statuses becomes ready to invoice (Flow's stage). The statuses
|
|
16
|
+
// are picked from those the work system reported (workStatuses).
|
|
17
|
+
// draft_when_ready: a job that is ready to invoice and passes the invoicing
|
|
18
|
+
// rules (invoicing-rules.js) gets a draft invoice in Flow.
|
|
19
|
+
|
|
20
|
+
const BATCH = 500;
|
|
21
|
+
|
|
22
|
+
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
23
|
+
|
|
24
|
+
// A status matches when it is one of the chosen statuses, in any case.
|
|
25
|
+
export function statusMatches(status, statuses) {
|
|
26
|
+
const have = norm(status);
|
|
27
|
+
return Boolean(have) && statuses.some((wanted) => norm(wanted) === have);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The statuses the work system has reported for this organisation's jobs, with
|
|
31
|
+
// the number of jobs at each, most jobs first. People pick the ready statuses
|
|
32
|
+
// from this list, so it follows each lab's own work system.
|
|
33
|
+
export async function workStatuses(db, organisationId) {
|
|
34
|
+
const rows = await db.select({ status: billingObligations.sourceStatus, jobs: sql`count(*)::int` })
|
|
35
|
+
.from(billingObligations)
|
|
36
|
+
.where(and(eq(billingObligations.organisationId, organisationId), sql`${billingObligations.sourceStatus} is not null`))
|
|
37
|
+
.groupBy(billingObligations.sourceStatus);
|
|
38
|
+
const byName = new Map();
|
|
39
|
+
for (const { status, jobs } of rows) {
|
|
40
|
+
const key = norm(status);
|
|
41
|
+
if (!key) continue;
|
|
42
|
+
const seen = byName.get(key);
|
|
43
|
+
if (seen) seen.jobs += jobs;
|
|
44
|
+
else byName.set(key, { value: key, label: status.trim(), jobs });
|
|
45
|
+
}
|
|
46
|
+
return [...byName.values()].sort((a, b) => b.jobs - a.jobs || a.label.localeCompare(b.label));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readStatuses(value) {
|
|
50
|
+
if (!Array.isArray(value) || !value.length || value.length > 20) throw new FlowError('invalid', 'statuses must list 1 to 20 statuses');
|
|
51
|
+
const statuses = [...new Set(value.map((s) => (typeof s === 'string' ? norm(s) : '')).filter(Boolean))];
|
|
52
|
+
if (!statuses.length || statuses.some((s) => s.length > 100)) throw new FlowError('invalid', 'statuses must be short text');
|
|
53
|
+
return statuses;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const AUTOMATIONS = Object.freeze([
|
|
57
|
+
{
|
|
58
|
+
key: 'mark_ready_from_work_status',
|
|
59
|
+
place: 'seazona',
|
|
60
|
+
name: 'Mark ready to invoice',
|
|
61
|
+
hint: 'When the job has one of the ready statuses.',
|
|
62
|
+
defaults: { enabled: true, config: { statuses: ['shipped', 'delivered'] } },
|
|
63
|
+
readConfig: (config) => ({ statuses: readStatuses(config.statuses) }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'draft_when_ready',
|
|
67
|
+
place: 'invoicing',
|
|
68
|
+
name: 'Draft when ready',
|
|
69
|
+
hint: 'Drafts each ready job that passes the checks.',
|
|
70
|
+
defaults: { enabled: true, config: {} },
|
|
71
|
+
readConfig: () => ({}),
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
|
|
76
|
+
|
|
77
|
+
// Every routine with this organisation's choice, or its default.
|
|
78
|
+
export async function listAutomations(db, organisationId) {
|
|
79
|
+
const rows = await db.select().from(automations).where(eq(automations.organisationId, organisationId));
|
|
80
|
+
const saved = new Map(rows.map((r) => [r.key, r]));
|
|
81
|
+
return AUTOMATIONS.map(({ key, place, name, hint, defaults }) => {
|
|
82
|
+
const row = saved.get(key);
|
|
83
|
+
return { key, place, name, hint, enabled: row?.enabled ?? defaults.enabled, config: { ...defaults.config, ...(row?.config ?? {}) } };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function saveAutomation(db, organisationId, key, changes, { personId = null } = {}) {
|
|
88
|
+
const automation = byKey.get(key);
|
|
89
|
+
if (!automation) throw notFound('automation');
|
|
90
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw new FlowError('invalid', 'the change must be an object');
|
|
91
|
+
const unknown = Object.keys(changes).filter((k) => !['enabled', 'config'].includes(k));
|
|
92
|
+
if (unknown.length) throw new FlowError('invalid', `unknown field: ${unknown[0]}`);
|
|
93
|
+
const current = (await listAutomations(db, organisationId)).find((a) => a.key === key);
|
|
94
|
+
const enabled = changes.enabled ?? current.enabled;
|
|
95
|
+
if (typeof enabled !== 'boolean') throw new FlowError('invalid', 'enabled must be true or false');
|
|
96
|
+
const config = 'config' in changes ? automation.readConfig({ ...current.config, ...changes.config }) : current.config;
|
|
97
|
+
await db.insert(automations)
|
|
98
|
+
.values({ organisationId, key, enabled, config, updatedBy: personId })
|
|
99
|
+
.onConflictDoUpdate({ target: [automations.organisationId, automations.key], set: { enabled, config, updatedBy: personId, updatedAt: sql`now()` } });
|
|
100
|
+
return (await listAutomations(db, organisationId)).find((a) => a.key === key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const actorOf = (key) => `automation:${key}`;
|
|
104
|
+
|
|
105
|
+
// Marks ready each job in work whose work system status is one of the chosen
|
|
106
|
+
// ones. A person who put a job back in work has decided: the routine leaves
|
|
107
|
+
// that job to people from then on.
|
|
108
|
+
async function markReadyFromWorkStatus(db, organisationId, { statuses }) {
|
|
109
|
+
const actor = actorOf('mark_ready_from_work_status');
|
|
110
|
+
const jobs = await db.select().from(billingObligations).where(and(
|
|
111
|
+
eq(billingObligations.organisationId, organisationId),
|
|
112
|
+
sql`${billingObligations.readyAt} is null`,
|
|
113
|
+
sql`${billingObligations.invoiceId} is null`,
|
|
114
|
+
sql`${billingObligations.sourceStatus} is not null`,
|
|
115
|
+
sql`not exists (select 1 from obligation_events e where e.obligation_id = ${billingObligations.id}
|
|
116
|
+
and e.kind = 'in_work' and e.actor not like 'automation:%')`,
|
|
117
|
+
)).limit(BATCH);
|
|
118
|
+
let marked = 0;
|
|
119
|
+
for (const job of jobs) {
|
|
120
|
+
if (!statusMatches(job.sourceStatus, statuses)) continue;
|
|
121
|
+
await setReady(db, organisationId, job.id, { ready: true, actor });
|
|
122
|
+
marked += 1;
|
|
123
|
+
}
|
|
124
|
+
return { marked };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Runs every routine that is on. `accounting` is the integration whose copy
|
|
128
|
+
// the draft routine checks; without one, Flow's own records are the check.
|
|
129
|
+
export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
|
|
130
|
+
const summary = {};
|
|
131
|
+
for (const automation of await listAutomations(db, organisationId)) {
|
|
132
|
+
if (!automation.enabled) continue;
|
|
133
|
+
if (automation.key === 'mark_ready_from_work_status') {
|
|
134
|
+
summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
|
|
135
|
+
} else if (automation.key === 'draft_when_ready') {
|
|
136
|
+
summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return summary;
|
|
140
|
+
}
|