@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.
Files changed (42) hide show
  1. package/README.md +5 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +231 -20
  5. package/server/app.js +28 -2
  6. package/server/core/automations.js +140 -0
  7. package/server/core/invoice-rules.js +224 -0
  8. package/server/core/invoices.js +4 -1
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/ledger.js +45 -12
  11. package/server/core/monitoring.js +34 -0
  12. package/server/core/pipeline.js +8 -1
  13. package/server/core/sending.js +282 -0
  14. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  15. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  16. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  17. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  18. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  19. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  20. package/server/db/migrations/meta/_journal.json +21 -0
  21. package/server/db/schema.js +115 -0
  22. package/server/index.js +12 -4
  23. package/server/integrations/adapters.js +2 -1
  24. package/server/integrations/port.js +5 -0
  25. package/server/integrations/seazona/ledger.js +2 -0
  26. package/server/integrations/sentry/index.js +105 -0
  27. package/server/integrations/xero/client.js +5 -0
  28. package/server/integrations/xero/drafts.js +123 -16
  29. package/server/integrations/xero/index.js +11 -1
  30. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  31. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  32. package/server/ui/app-page.js +2 -1
  33. package/server/ui/app.js +14 -1
  34. package/server/ui/automations.js +96 -0
  35. package/server/ui/flow.css +88 -0
  36. package/server/ui/invoice-rules.js +237 -0
  37. package/server/ui/invoicing-settings.js +108 -0
  38. package/server/ui/invoicing.js +166 -20
  39. package/server/ui/jobs.js +19 -0
  40. package/server/ui/logos/sentry.svg +1 -0
  41. package/server/ui/monitoring.js +53 -0
  42. 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.26",
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",
@@ -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 { draftInvoiceForObligation, listObligations } from '../core/ledger.js';
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 VIEWS = ['ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
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)) throw invalid(`status must be one of ${INVOICE_STATUSES.join(', ')}`);
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
- if (status) where.push(eq(invoices.status, status));
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 }) => ({ ...invoice, contactName, job: jobs.get(invoice.id) ?? null })),
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
- const liveCheck = liveCheckFor(organisationId);
359
- if (typeof liveCheck !== 'function') {
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
- app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor }));
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
+ }