@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.26",
3
+ "version": "2026.10.29",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "start": "node server/index.js",
33
33
  "test": "node --test --test-concurrency=1 test/*.test.js",
34
34
  "theme:contrast": "node scripts/theme-contrast.mjs",
35
- "db:generate": "drizzle-kit generate"
35
+ "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
36
36
  },
37
37
  "dependencies": {
38
38
  "@clerk/backend": "3.23.2",
@@ -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,20 @@ 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
+ createMapping, deleteMapping, listMappings, STEP_LABELS, STEPS, updateMapping,
13
+ } from '../core/line-mapping.js';
14
+ import {
15
+ effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
16
+ saveCustomerBilling, saveInvoicingSettings,
17
+ } from '../core/invoicing-rules.js';
18
+ import { boardJobs, describeObligations } from '../core/job-board.js';
19
+ import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
20
+ import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
8
21
  import {
9
22
  billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
10
23
  } from '../db/schema.js';
@@ -13,7 +26,13 @@ import {
13
26
  // UI's invoices, customers, price list and jobs pages read and write here.
14
27
  //
15
28
  // GET /context the organisations, and whether this person may issue
16
- // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status
29
+ // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status;
30
+ // status to_check, ready or problem lists those views (server/core/sending.js)
31
+ // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
32
+ // POST /invoices/mark-not-ready { invoiceIds }: back to To check
33
+ // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
34
+ // accounting system { issueDate }
35
+ // POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
17
36
  // GET /invoices/:id one invoice: lines, contact, job, issued versions
18
37
  // POST /invoices a new draft
19
38
  // PATCH /invoices/:id change a draft
@@ -22,7 +41,30 @@ import {
22
41
  // POST /invoices/:id/void owner or admin
23
42
  // GET /contacts?q=&limit= customers, by name
24
43
  // GET /price-items?q= the price list, by code
25
- // GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view
44
+ // GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
45
+ // a job with no invoice lists the invoicing rules it waits on;
46
+ // a job with an invoice has its Invoices page view and problems
47
+ // GET /board the Jobs board: the work system's jobs by its own statuses,
48
+ // each with its billing obligation (server/core/job-board.js)
49
+ // GET /settings the invoicing settings (server/core/invoicing-rules.js)
50
+ // PUT /settings owner or admin: change some of them
51
+ // GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
52
+ // GET /rule-fields what a rule can name, and the menus of values
53
+ // POST /rules owner or admin: a new rule { name, enabled, conditions, action }
54
+ // PUT /rules/:id owner or admin: change some of a rule
55
+ // DELETE /rules/:id owner or admin
56
+ // GET /line-mappings the lab's line mappings and the mapping steps (server/core/line-mapping.js)
57
+ // GET /line-labels the line labels on jobs, with counts and how each mapped
58
+ // POST /line-mappings owner or admin: a new mapping
59
+ // PUT /line-mappings/:id owner or admin: change some of a mapping
60
+ // DELETE /line-mappings/:id owner or admin
61
+ // GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
62
+ // PUT /automations/:key owner or admin: { enabled, config }
63
+ // GET /work-statuses the work system's job statuses, with job counts
64
+ // GET /contacts/:id/billing one customer's own invoicing settings
65
+ // PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
66
+ // POST /jobs/:id/ready { ready }: mark a job ready to invoice, or back in work
67
+ // GET /jobs/:id/activity what happened to a job, newest first
26
68
  // POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
27
69
  // check that the accounting system holds none
28
70
  //
@@ -31,10 +73,13 @@ import {
31
73
  // may be left out while the installation has exactly one. Money is integer
32
74
  // cents. `liveCheckFor(organisationId)` returns the ledger's live check for
33
75
  // that organisation, or null when no accounting integration offers one.
76
+ // `accounting()` is the accounting adapter that sends invoices, or null.
77
+ // `work()` is the work system adapters that offer listWork (the Jobs board).
34
78
 
35
79
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
36
80
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
37
- const VIEWS = ['ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
81
+ const VIEWS_OF_INVOICES = ['to_check', 'ready', 'problem'];
82
+ const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
38
83
  const MAX_LINES = 200;
39
84
  const MAX_TEXT = 500;
40
85
  // Each line's amount must fit the database's integer columns with room for
@@ -44,7 +89,8 @@ const MAX_UNIT_CENTS = 100_000_000;
44
89
 
45
90
  const ERROR_STATUS = {
46
91
  invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
47
- never_issued: 409, not_ready: 409, live_check_unavailable: 409,
92
+ never_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
93
+ accounting_not_connected: 409, accounting_writes_off: 409,
48
94
  };
49
95
 
50
96
  function sendError(res, error, log) {
@@ -176,7 +222,7 @@ async function jobsByInvoice(db, organisationId, invoiceIds) {
176
222
  return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
177
223
  }
178
224
 
179
- async function invoiceDetail(db, organisationId, invoiceId) {
225
+ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
180
226
  const invoice = await getInvoice(db, organisationId, invoiceId);
181
227
  const [[contact], versions, jobs] = await Promise.all([
182
228
  db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
@@ -186,13 +232,16 @@ async function invoiceDetail(db, organisationId, invoiceId) {
186
232
  ]);
187
233
  return {
188
234
  ...invoice,
235
+ ...(await invoiceView(db, organisationId, invoice, { accounting })),
189
236
  contact: contact ?? null,
190
237
  job: jobs.get(invoiceId) ?? null,
191
238
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
192
239
  };
193
240
  }
194
241
 
195
- export function createInvoicingRouter({ db, personAuth, log = console, liveCheckFor = () => null }) {
242
+ export function createInvoicingRouter({
243
+ db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [],
244
+ }) {
196
245
  const router = express.Router();
197
246
  router.use(express.json({ limit: '64kb' }));
198
247
 
@@ -210,11 +259,18 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
210
259
  router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
211
260
  const organisationId = await organisationOf(req);
212
261
  const status = text(req.query.status);
213
- if (status && !INVOICE_STATUSES.includes(status)) throw invalid(`status must be one of ${INVOICE_STATUSES.join(', ')}`);
262
+ if (status && ![...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].includes(status)) {
263
+ throw invalid(`status must be one of ${[...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].join(', ')}`);
264
+ }
214
265
  const q = text(req.query.q);
215
266
  const limit = limitOf(req.query.limit, 200, 500);
267
+ const views = await invoiceViews(db, organisationId, { accounting: accounting() });
216
268
  const where = [eq(invoices.organisationId, organisationId)];
217
- if (status) where.push(eq(invoices.status, status));
269
+ const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
270
+ if (status === 'to_check') where.push(only(views.toCheck));
271
+ else if (status === 'ready') where.push(only(views.ready));
272
+ else if (status === 'problem') where.push(only([...views.problems.keys()]));
273
+ else if (status) where.push(eq(invoices.status, status));
218
274
  if (q) {
219
275
  const pattern = containing(q);
220
276
  where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
@@ -229,17 +285,68 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
229
285
  .where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
230
286
  const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
231
287
  for (const { status: s, n } of counted) counts[s] = n;
288
+ counts.to_check = views.toCheck.length;
289
+ counts.ready = views.ready.length;
290
+ counts.problem = views.problems.size;
291
+ const ready = new Set(views.ready);
292
+ const toCheck = new Set(views.toCheck);
232
293
  res.json({
233
294
  ok: true,
234
- invoices: rows.map(({ invoice, contactName }) => ({ ...invoice, contactName, job: jobs.get(invoice.id) ?? null })),
295
+ invoices: rows.map(({ invoice, contactName }) => ({
296
+ ...invoice,
297
+ contactName,
298
+ job: jobs.get(invoice.id) ?? null,
299
+ view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
300
+ : views.sending.has(invoice.id) ? 'sending' : null,
301
+ problems: views.problems.get(invoice.id) ?? [],
302
+ })),
235
303
  counts,
304
+ sendsTo: accounting()?.name ?? null,
236
305
  });
237
306
  }));
238
307
 
308
+ const invoiceIdsOf = (body) => {
309
+ const ids = body?.invoiceIds;
310
+ if (!Array.isArray(ids) || !ids.length || ids.length > 500 || !ids.every((id) => UUID.test(String(id)))) {
311
+ throw invalid('invoiceIds must list 1 to 500 invoice ids');
312
+ }
313
+ return [...new Set(ids)];
314
+ };
315
+
316
+ // Anyone who drafts may say a draft is checked; issuing and sending stay
317
+ // with an owner or admin.
318
+ router.post('/invoices/mark-ready', ...signedIn, handle(log, async (req, res) => {
319
+ const organisationId = await organisationOf(req);
320
+ const result = await markReady(db, organisationId, invoiceIdsOf(req.body), { personId: req.person.id, accounting: accounting() });
321
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} marked ${result.marked} invoice(s) ready`);
322
+ res.json({ ok: true, ...result });
323
+ }));
324
+
325
+ router.post('/invoices/mark-not-ready', ...signedIn, handle(log, async (req, res) => {
326
+ const organisationId = await organisationOf(req);
327
+ res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
328
+ }));
329
+
330
+ router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
331
+ const organisationId = await organisationOf(req);
332
+ const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
333
+ const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
334
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
335
+ res.json({ ok: true, ...result });
336
+ }));
337
+
338
+ router.post('/invoices/send-again', ...manager, handle(log, async (req, res) => {
339
+ const organisationId = await organisationOf(req);
340
+ const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
341
+ const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
342
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent again: ${JSON.stringify(result)}`);
343
+ res.json({ ok: true, ...result });
344
+ }));
345
+
239
346
  router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
240
347
  const invoiceId = pathId(req.params.id, 'invoice');
241
348
  const organisationId = await organisationOf(req);
242
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
349
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
243
350
  }));
244
351
 
245
352
  router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
@@ -252,7 +359,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
252
359
  lines: body.lines === undefined ? [] : readLines(body.lines),
253
360
  });
254
361
  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) });
362
+ res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
256
363
  }));
257
364
 
258
365
  router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
@@ -266,7 +373,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
266
373
  if (body.lines !== undefined) changes.lines = readLines(body.lines);
267
374
  if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
268
375
  await updateDraftInvoice(db, organisationId, invoiceId, changes);
269
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
376
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
270
377
  }));
271
378
 
272
379
  router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
@@ -275,7 +382,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
275
382
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
276
383
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
277
384
  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) });
385
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
279
386
  }));
280
387
 
281
388
  router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
@@ -283,7 +390,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
283
390
  const organisationId = await organisationOf(req);
284
391
  await reviseInvoice(db, organisationId, invoiceId);
285
392
  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) });
393
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
287
394
  }));
288
395
 
289
396
  router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
@@ -291,7 +398,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
291
398
  const organisationId = await organisationOf(req);
292
399
  await voidInvoice(db, organisationId, invoiceId);
293
400
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
294
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
401
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
295
402
  }));
296
403
 
297
404
  router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
@@ -320,50 +427,210 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
320
427
  const view = text(req.query.view);
321
428
  if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
322
429
  const limit = limitOf(req.query.limit, 200, 500);
323
- const all = await listObligations(db, organisationId);
430
+ const all = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
324
431
  const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
325
432
  for (const entry of all) counts[entry.view] += 1;
326
433
  const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
327
- const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
328
- const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
329
- const [names, numbers] = await Promise.all([
330
- contactIds.length
331
- ? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
332
- .where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
333
- : [],
334
- invoiceIds.length
335
- ? db.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
336
- .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
337
- : [],
338
- ]);
339
- const nameOf = new Map(names.map((c) => [c.id, c.name]));
340
- const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
341
- res.json({
342
- ok: true,
343
- jobs: listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view: v, reasons }) => ({
344
- ...o,
345
- contactName: nameOf.get(o.contactId) ?? null,
346
- invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
347
- invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
348
- view: v,
349
- reasons,
350
- })),
351
- counts,
434
+ res.json({ ok: true, jobs: await describeObligations(db, organisationId, listed, { accounting: accounting() }), counts });
435
+ }));
436
+
437
+ // The Jobs board: the work system's jobs, by its own statuses, each with
438
+ // its billing obligation when it has one (server/core/job-board.js).
439
+ router.get('/board', ...signedIn, handle(log, async (req, res) => {
440
+ const organisationId = await organisationOf(req);
441
+ const { jobs, statuses } = await boardJobs(db, organisationId, {
442
+ work: work(), accounting: accounting(), live: Boolean(liveCheckFor(organisationId)), log,
352
443
  });
444
+ res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
353
445
  }));
354
446
 
355
447
  router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
356
448
  const obligationId = pathId(req.params.id, 'job');
357
449
  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
- }
450
+ // With no accounting system connected, Flow's own records are the check.
451
+ const liveCheck = liveCheckFor(organisationId) ?? null;
362
452
  const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
363
453
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
364
454
  res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
365
455
  }));
366
456
 
457
+ router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
458
+ const obligationId = pathId(req.params.id, 'job');
459
+ const organisationId = await organisationOf(req);
460
+ const ready = req.body?.ready ?? true;
461
+ if (typeof ready !== 'boolean') throw invalid('ready must be true or false');
462
+ const job = await setReady(db, organisationId, obligationId, { ready, actor: `person:${req.person.id}` });
463
+ res.json({ ok: true, job: { id: job.id, readyAt: job.readyAt, readyBy: job.readyBy } });
464
+ }));
465
+
466
+ // A job's activity for its detail panel: what happened to it, newest
467
+ // first, without the evidence each event keeps.
468
+ router.get('/jobs/:id/activity', ...signedIn, handle(log, async (req, res) => {
469
+ const obligationId = pathId(req.params.id, 'job');
470
+ const organisationId = await organisationOf(req);
471
+ const { events } = await explainObligation(db, organisationId, obligationId);
472
+ res.json({
473
+ ok: true,
474
+ events: events.slice(-50).reverse().map(({ kind, actor, createdAt }) => ({
475
+ kind, by: actor.startsWith('person:') ? 'person' : actor.startsWith('automation:') ? 'automation' : actor.replace(/^system:/, ''), createdAt,
476
+ })),
477
+ });
478
+ }));
479
+
480
+ router.get('/automations', ...signedIn, handle(log, async (req, res) => {
481
+ const organisationId = await organisationOf(req);
482
+ const place = typeof req.query.place === 'string' ? req.query.place : null;
483
+ const list = await listAutomations(db, organisationId);
484
+ res.json({ ok: true, automations: place ? list.filter((a) => a.place === place) : list });
485
+ }));
486
+
487
+ router.get('/work-statuses', ...signedIn, handle(log, async (req, res) => {
488
+ const organisationId = await organisationOf(req);
489
+ res.json({ ok: true, statuses: await workStatuses(db, organisationId) });
490
+ }));
491
+
492
+ router.put('/automations/:key', ...manager, handle(log, async (req, res) => {
493
+ const organisationId = await organisationOf(req);
494
+ const { organisationId: _named, ...changes } = req.body ?? {};
495
+ const automation = await saveAutomation(db, organisationId, String(req.params.key), changes, { personId: req.person.id });
496
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed automation ${automation.key}`);
497
+ res.json({ ok: true, automation });
498
+ }));
499
+
500
+ // The lab's invoice rules (docs/INVOICE_RULES.md).
501
+ router.get('/rules', ...signedIn, handle(log, async (req, res) => {
502
+ const organisationId = await organisationOf(req);
503
+ res.json({ ok: true, rules: await listRules(db, organisationId) });
504
+ }));
505
+
506
+ // What a rule can name, so people pick every value from a menu: the fields
507
+ // with their tests, and the values Flow already holds for each kind of menu.
508
+ router.get('/rule-fields', ...signedIn, handle(log, async (req, res) => {
509
+ const organisationId = await organisationOf(req);
510
+ const [statuses, customers, items] = await Promise.all([
511
+ workStatuses(db, organisationId),
512
+ db.select({ value: contacts.id, label: contacts.name }).from(contacts)
513
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
514
+ .orderBy(asc(contacts.name)).limit(1000),
515
+ db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
516
+ .where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
517
+ .orderBy(asc(priceItems.code)).limit(1000),
518
+ ]);
519
+ res.json({
520
+ ok: true,
521
+ fields: Object.entries(FIELDS).map(([key, f]) => ({ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind] })),
522
+ actions: ACTIONS,
523
+ options: {
524
+ statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
525
+ customers,
526
+ items: [
527
+ ...items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
528
+ ...(await listMappings(db, organisationId)).filter((m) => m.kind === 'account')
529
+ .map((m) => ({ value: m.label, label: `${m.label} · ${m.description ?? `account ${m.accountCode}`}` })),
530
+ ],
531
+ },
532
+ });
533
+ }));
534
+
535
+ router.post('/rules', ...manager, handle(log, async (req, res) => {
536
+ const organisationId = await organisationOf(req);
537
+ const { organisationId: _named, ...raw } = req.body ?? {};
538
+ const rule = await createRule(db, organisationId, raw, { personId: req.person.id });
539
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added invoice rule ${rule.id}`);
540
+ res.status(201).json({ ok: true, rule });
541
+ }));
542
+
543
+ router.put('/rules/:id', ...manager, handle(log, async (req, res) => {
544
+ const ruleId = pathId(req.params.id, 'rule');
545
+ const organisationId = await organisationOf(req);
546
+ const { organisationId: _named, ...changes } = req.body ?? {};
547
+ const rule = await updateRule(db, organisationId, ruleId, changes, { personId: req.person.id });
548
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoice rule ${rule.id}`);
549
+ res.json({ ok: true, rule });
550
+ }));
551
+
552
+ router.delete('/rules/:id', ...manager, handle(log, async (req, res) => {
553
+ const ruleId = pathId(req.params.id, 'rule');
554
+ const organisationId = await organisationOf(req);
555
+ await deleteRule(db, organisationId, ruleId);
556
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted invoice rule ${ruleId}`);
557
+ res.json({ ok: true });
558
+ }));
559
+
560
+ router.get('/line-mappings', ...signedIn, handle(log, async (req, res) => {
561
+ const organisationId = await organisationOf(req);
562
+ res.json({ ok: true, mappings: await listMappings(db, organisationId), steps: STEPS.map((key) => ({ key, label: STEP_LABELS[key] })) });
563
+ }));
564
+
565
+ // Each label the line mapping saw on jobs, how many lines carry it, a
566
+ // sample of the work system's text and price, and whether it mapped.
567
+ router.get('/line-labels', ...signedIn, handle(log, async (req, res) => {
568
+ const organisationId = await organisationOf(req);
569
+ const rows = (await db.execute(sql`
570
+ select l->>'label' as label, count(*)::int as lines,
571
+ count(*) filter (where l->>'priceItemId' is null and l->>'accountCode' is null)::int as unmapped,
572
+ min(l->>'description') as sample, min((l->>'unitAmountCents')::int) as "unitAmountCents",
573
+ min(l->>'mappedBy') as "mappedBy", min(l->>'itemCode') as "itemCode"
574
+ from billing_obligations o, jsonb_array_elements(o.lines) l
575
+ where o.organisation_id = ${organisationId} and l->>'label' is not null
576
+ group by 1 order by 3 desc, 2 desc, 1 limit 1000`)).rows ?? [];
577
+ res.json({ ok: true, labels: rows });
578
+ }));
579
+
580
+ router.post('/line-mappings', ...manager, handle(log, async (req, res) => {
581
+ const organisationId = await organisationOf(req);
582
+ const { organisationId: _named, ...raw } = req.body ?? {};
583
+ const mapping = await createMapping(db, organisationId, raw, { personId: req.person.id });
584
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added line mapping ${mapping.id}`);
585
+ res.status(201).json({ ok: true, mapping });
586
+ }));
587
+
588
+ router.put('/line-mappings/:id', ...manager, handle(log, async (req, res) => {
589
+ const mappingId = pathId(req.params.id, 'mapping');
590
+ const organisationId = await organisationOf(req);
591
+ const { organisationId: _named, ...changes } = req.body ?? {};
592
+ const mapping = await updateMapping(db, organisationId, mappingId, changes, { personId: req.person.id });
593
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed line mapping ${mapping.id}`);
594
+ res.json({ ok: true, mapping });
595
+ }));
596
+
597
+ router.delete('/line-mappings/:id', ...manager, handle(log, async (req, res) => {
598
+ const mappingId = pathId(req.params.id, 'mapping');
599
+ const organisationId = await organisationOf(req);
600
+ await deleteMapping(db, organisationId, mappingId);
601
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted line mapping ${mappingId}`);
602
+ res.json({ ok: true });
603
+ }));
604
+
605
+ router.get('/settings', ...signedIn, handle(log, async (req, res) => {
606
+ const organisationId = await organisationOf(req);
607
+ res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });
608
+ }));
609
+
610
+ router.put('/settings', ...manager, handle(log, async (req, res) => {
611
+ const organisationId = await organisationOf(req);
612
+ const { organisationId: _named, ...changes } = req.body ?? {};
613
+ const settings = await saveInvoicingSettings(db, organisationId, changes, { personId: req.person.id });
614
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoicing settings ${Object.keys(changes).join(', ')}`);
615
+ res.json({ ok: true, settings, defaults: INVOICING_DEFAULTS });
616
+ }));
617
+
618
+ router.get('/contacts/:id/billing', ...signedIn, handle(log, async (req, res) => {
619
+ const contactId = pathId(req.params.id, 'customer');
620
+ const organisationId = await organisationOf(req);
621
+ const [billing, settings] = await Promise.all([readCustomerBilling(db, organisationId, contactId), readInvoicingSettings(db, organisationId)]);
622
+ res.json({ ok: true, billing, effective: effectiveSettings(settings, billing) });
623
+ }));
624
+
625
+ router.put('/contacts/:id/billing', ...manager, handle(log, async (req, res) => {
626
+ const contactId = pathId(req.params.id, 'customer');
627
+ const organisationId = await organisationOf(req);
628
+ const { organisationId: _named, ...changes } = req.body ?? {};
629
+ const billing = await saveCustomerBilling(db, organisationId, contactId, changes, { personId: req.person.id });
630
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed billing settings of customer ${contactId}`);
631
+ res.json({ ok: true, billing, effective: effectiveSettings(await readInvoicingSettings(db, organisationId), billing) });
632
+ }));
633
+
367
634
  // A body that is not JSON, or too large, answers like every other refusal.
368
635
  router.use((error, _req, res, next) => {
369
636
  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,23 @@ 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
+ // The Jobs board: every enabled integration that lists its jobs (Seazona).
118
+ const work = () => integrations?.with('listWork') ?? [];
119
+ app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work }));
120
+ }
121
+ // What a signed-in browser needs to report its errors and problems to the
122
+ // installation's own error service (docs/SENTRY.md): the first integration
123
+ // that offers `browserMonitoring`, or `{ on: false }`.
124
+ if (auth?.personAuth) {
125
+ app.get('/api/monitoring', auth.personAuth.requireSession, (_req, res) => {
126
+ res.set('Cache-Control', 'no-store');
127
+ const [adapter] = integrations?.with('browserMonitoring') ?? [];
128
+ const settings = adapter?.capabilities.browserMonitoring() ?? null;
129
+ res.json(settings ? { ok: true, on: true, service: adapter.id, ...settings } : { ok: true, on: false });
130
+ });
113
131
  }
114
132
  for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
115
133
  app.use(path, handler);
@@ -125,5 +143,15 @@ export function createApp({
125
143
  log,
126
144
  }));
127
145
 
146
+ // An error no route answered. A server fault goes to `log` (and from there
147
+ // to the error service, when one is on) and the answer carries no detail;
148
+ // any other error keeps Express's own answer.
149
+ app.use((error, _req, res, next) => {
150
+ const status = error?.status ?? error?.statusCode ?? 500;
151
+ if (status < 500 || res.headersSent) return next(error);
152
+ log.error?.(`[http] ${error?.message ?? error}`, ...(error instanceof Error ? [error] : []));
153
+ return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
154
+ });
155
+
128
156
  return app;
129
157
  }