@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
package/README.md CHANGED
@@ -36,6 +36,7 @@ run a lab.
36
36
  | `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
37
37
  | `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
38
38
  | `docs/DESIGN_SYSTEM.md` | The Flow Design System artifact: how `npm run design-system` builds it from the code, the card per shared component, publishing and review |
39
+ | `docs/MOBILE_TESTS.md` | The phone suite: every screen at phone, tablet and desk sizes in WebKit and Chromium, its checks and budget, the three widths, saved images |
39
40
  | `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
40
41
  | `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
41
42
  | `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
package/VISION.md CHANGED
@@ -54,16 +54,16 @@ column per stage, a card per job), with a list view of the same jobs. It
54
54
  shows every job from the case system and from Flow, with its practice, due
55
55
  date and department. Lab staff and the manager work from it.
56
56
 
57
- Two parts follow the board (#48):
58
-
59
- - **An intake reader.** A photo, a form or a note becomes a proposed job
60
- action: update a job, create one, or add a note. A handwriting reader and a
61
- low-cost decision model propose it; a person confirms it. Nothing changes
62
- until a person accepts. The model steps are optional and cost money per
63
- page, so the manager sees the month's cost.
64
- - **A practice portal.** Practice staff sign in to follow their own jobs,
65
- on desktop first, with a simpler view on phones. The server limits each
66
- person to their practice.
57
+ Two parts follow the board:
58
+
59
+ - **An intake reader** (#267). A photo, a form or a note becomes a proposed
60
+ job action: update a job, create one, or add a note. A handwriting reader
61
+ and a low-cost decision model propose it; a person confirms it. Nothing
62
+ changes until a person accepts. The model steps are optional and cost money
63
+ per page, so the manager sees the month's cost.
64
+ - **A practice portal** (#268). Practice staff sign in to follow their own
65
+ jobs, on desktop first, with a simpler view on phones. The server limits
66
+ each person to their practice.
67
67
 
68
68
  Job management is a bundled module, built the same way as the integrations,
69
69
  and a lab can remove it. Where the case system cannot take a change through
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.45",
3
+ "version": "2026.10.47",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.45",
9
+ "version": "2026.10.47",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
@@ -33,6 +33,8 @@
33
33
  "flow-server": "server/flow-server.js"
34
34
  },
35
35
  "devDependencies": {
36
+ "@axe-core/playwright": "4.13.0",
37
+ "@playwright/test": "1.56.1",
36
38
  "drizzle-kit": "0.31.11",
37
39
  "esbuild": "0.25.12"
38
40
  },
@@ -40,6 +42,19 @@
40
42
  "node": ">=24"
41
43
  }
42
44
  },
45
+ "node_modules/@axe-core/playwright": {
46
+ "version": "4.13.0",
47
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
48
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
49
+ "dev": true,
50
+ "license": "MPL-2.0",
51
+ "dependencies": {
52
+ "axe-core": "~4.13.0"
53
+ },
54
+ "peerDependencies": {
55
+ "playwright-core": ">= 1.0.0"
56
+ }
57
+ },
43
58
  "node_modules/@clerk/backend": {
44
59
  "version": "3.25.0",
45
60
  "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.25.0.tgz",
@@ -1029,6 +1044,22 @@
1029
1044
  "url": "https://github.com/sponsors/oxc-project"
1030
1045
  }
1031
1046
  },
1047
+ "node_modules/@playwright/test": {
1048
+ "version": "1.56.1",
1049
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
1050
+ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
1051
+ "dev": true,
1052
+ "license": "Apache-2.0",
1053
+ "dependencies": {
1054
+ "playwright": "1.56.1"
1055
+ },
1056
+ "bin": {
1057
+ "playwright": "cli.js"
1058
+ },
1059
+ "engines": {
1060
+ "node": ">=18"
1061
+ }
1062
+ },
1032
1063
  "node_modules/@sentry/bundler-plugins": {
1033
1064
  "version": "11.8.0",
1034
1065
  "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.8.0.tgz",
@@ -1183,6 +1214,16 @@
1183
1214
  "node": ">= 0.6"
1184
1215
  }
1185
1216
  },
1217
+ "node_modules/axe-core": {
1218
+ "version": "4.13.0",
1219
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
1220
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
1221
+ "dev": true,
1222
+ "license": "MPL-2.0",
1223
+ "engines": {
1224
+ "node": ">=4"
1225
+ }
1226
+ },
1186
1227
  "node_modules/balanced-match": {
1187
1228
  "version": "4.0.4",
1188
1229
  "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -2380,6 +2421,67 @@
2380
2421
  "split2": "^4.1.0"
2381
2422
  }
2382
2423
  },
2424
+ "node_modules/playwright": {
2425
+ "version": "1.56.1",
2426
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
2427
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
2428
+ "dev": true,
2429
+ "license": "Apache-2.0",
2430
+ "dependencies": {
2431
+ "playwright-core": "1.56.1"
2432
+ },
2433
+ "bin": {
2434
+ "playwright": "cli.js"
2435
+ },
2436
+ "engines": {
2437
+ "node": ">=18"
2438
+ },
2439
+ "optionalDependencies": {
2440
+ "fsevents": "2.3.2"
2441
+ }
2442
+ },
2443
+ "node_modules/playwright-core": {
2444
+ "version": "1.64.1",
2445
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
2446
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
2447
+ "dev": true,
2448
+ "license": "Apache-2.0",
2449
+ "peer": true,
2450
+ "bin": {
2451
+ "playwright-core": "cli.js"
2452
+ },
2453
+ "engines": {
2454
+ "node": ">=20"
2455
+ }
2456
+ },
2457
+ "node_modules/playwright/node_modules/fsevents": {
2458
+ "version": "2.3.2",
2459
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
2460
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
2461
+ "dev": true,
2462
+ "hasInstallScript": true,
2463
+ "license": "MIT",
2464
+ "optional": true,
2465
+ "os": [
2466
+ "darwin"
2467
+ ],
2468
+ "engines": {
2469
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
2470
+ }
2471
+ },
2472
+ "node_modules/playwright/node_modules/playwright-core": {
2473
+ "version": "1.56.1",
2474
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
2475
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
2476
+ "dev": true,
2477
+ "license": "Apache-2.0",
2478
+ "bin": {
2479
+ "playwright-core": "cli.js"
2480
+ },
2481
+ "engines": {
2482
+ "node": ">=18"
2483
+ }
2484
+ },
2383
2485
  "node_modules/png-js": {
2384
2486
  "version": "2.0.0",
2385
2487
  "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.45",
3
+ "version": "2026.10.47",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,9 @@
37
37
  "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
38
38
  "db:follow-production": "node scripts/replit-workspace-db.js sync",
39
39
  "db:follow-production:status": "node scripts/replit-workspace-db.js status",
40
- "design-system": "node scripts/design-system/build.mjs"
40
+ "design-system": "node scripts/design-system/build.mjs",
41
+ "test:mobile": "playwright test -c test/mobile/playwright.config.js",
42
+ "test:mobile:budget": "node test/mobile/budget.mjs"
41
43
  },
42
44
  "dependencies": {
43
45
  "@clerk/backend": "3.25.0",
@@ -61,6 +63,8 @@
61
63
  "pg": "8.23.1"
62
64
  },
63
65
  "devDependencies": {
66
+ "@axe-core/playwright": "4.13.0",
67
+ "@playwright/test": "1.56.1",
64
68
  "drizzle-kit": "0.31.11",
65
69
  "esbuild": "0.25.12"
66
70
  },
@@ -1,10 +1,12 @@
1
1
  import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
2
2
  import express from 'express';
3
- import { invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
3
+ import { invoiceKindOf, invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
4
+ import { fieldIdOf, filterRows } from '../ui/job-view.js';
5
+ import { listFields, listJobFieldValues } from '../core/custom-fields.js';
4
6
  import { FlowError } from '../core/errors.js';
5
7
  import { createActionGuard } from '../integrations/call-guard.js';
6
8
  import {
7
- createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
9
+ createDraftInvoice, deleteDraft, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
8
10
  } from '../core/invoices.js';
9
11
  import { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
10
12
  import {
@@ -45,7 +47,9 @@ import {
45
47
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
46
48
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
47
49
  // accounting system { issueDate, invoiceIds? (only these; one that fails
48
- // a check now stays on Pending and counts as held) }
50
+ // a check now stays on Pending and counts as held; one id is all or
51
+ // nothing when its job cannot be confirmed, more than one id sends the
52
+ // rest and counts the unconfirmed in waiting) }
49
53
  // POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
50
54
  // GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
51
55
  // GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
@@ -56,6 +60,8 @@ import {
56
60
  // POST /invoices/:id/issue owner or admin: number it and keep the document as sent
57
61
  // POST /invoices/:id/revise owner or admin: an issued invoice back to a draft
58
62
  // POST /invoices/:id/void owner or admin
63
+ // DELETE /invoices/:id owner or admin: a draft that was never issued.
64
+ // Frees its job, and the lab's rules then decide.
59
65
  // GET /contacts?q=&limit= customers, by name
60
66
  // GET /price-items?q= the price list, by code
61
67
  // GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
@@ -124,7 +130,7 @@ const MAX_UNIT_CENTS = 100_000_000;
124
130
 
125
131
  const ERROR_STATUS = {
126
132
  invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
127
- never_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
133
+ never_issued: 409, was_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
128
134
  accounting_not_connected: 409, accounting_writes_off: 409,
129
135
  };
130
136
 
@@ -449,17 +455,43 @@ export function createInvoicingRouter({
449
455
  const pattern = containing(q);
450
456
  where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
451
457
  }
452
- // First the few columns the filter and sort read, for every row; then
453
- // the whole rows of the one page.
454
- const light = (await db.select({
458
+ // First the columns the filter and sort read, for every row; then the
459
+ // whole rows of the one page. A job column and a custom field read the
460
+ // invoice's job, so those values are on every row before the page is cut.
461
+ const lightRows = await db.select({
455
462
  id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
456
- createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
463
+ dueDate: invoices.dueDate, createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
457
464
  }).from(invoices)
458
465
  .leftJoin(contacts, eq(contacts.id, invoices.contactId))
459
466
  .where(and(...where))
460
- .orderBy(desc(invoices.createdAt), desc(invoices.id)))
461
- .map((row) => ({ ...row, problems: tabs.problems.get(row.id) ?? [] }));
462
- const page = invoicePage(light, { view, sort, dir, offset, limit, today: day });
467
+ .orderBy(desc(invoices.createdAt), desc(invoices.id));
468
+ const jobs = await jobsByInvoice(db, organisationId, lightRows.map((r) => r.id));
469
+ const customOn = filterRows(view).some((f) => fieldIdOf(f.field));
470
+ const [fieldDefs, fieldValues] = customOn
471
+ ? await Promise.all([
472
+ listFields(db, organisationId),
473
+ listJobFieldValues(db, organisationId, { jobs: [...jobs.values()].filter(Boolean) }),
474
+ ])
475
+ : [[], []];
476
+ const valuesOf = new Map();
477
+ for (const v of fieldValues) {
478
+ const key = `${v.sourceSystem}\u0000${v.sourceJobRef}`;
479
+ const bag = valuesOf.get(key) ?? {};
480
+ bag[v.fieldId] = v.value;
481
+ valuesOf.set(key, bag);
482
+ }
483
+ const light = lightRows.map((row) => {
484
+ const job = jobs.get(row.id) ?? null;
485
+ return {
486
+ ...row,
487
+ problems: tabs.problems.get(row.id) ?? [],
488
+ tab: tabs.tabOf.get(row.id) ?? null,
489
+ sending: tabs.sending.has(row.id),
490
+ job,
491
+ fields: job ? valuesOf.get(`${job.sourceSystem}\u0000${job.sourceJobRef}`) ?? {} : {},
492
+ };
493
+ });
494
+ const page = invoicePage(light, { view, sort, dir, offset, limit, today: day, kindOf: invoiceKindOf(fieldDefs), me: req.person.id });
463
495
  const pageIds = page.rows.map((r) => r.id);
464
496
  const full = pageIds.length
465
497
  ? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
@@ -468,7 +500,6 @@ export function createInvoicingRouter({
468
500
  : [];
469
501
  const byId = new Map(full.map((r) => [r.invoice.id, r]));
470
502
  const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
471
- const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
472
503
  const systems = work();
473
504
  res.json({
474
505
  ok: true,
@@ -486,8 +517,9 @@ export function createInvoicingRouter({
486
517
  limit,
487
518
  total: page.total,
488
519
  totalCents: page.totalCents,
489
- // The Filter chip's Client choices: every client of the tab.
520
+ // The Filter chip's choices, from every row of the tab.
490
521
  clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
522
+ practitioners: [...new Set(light.map((r) => r.job?.practitioner).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
491
523
  sendsTo: system?.name ?? null,
492
524
  sendMode: mode,
493
525
  // The Linked column: one icon for each connected system.
@@ -593,8 +625,8 @@ export function createInvoicingRouter({
593
625
  // the job at its source or adopt the change (ledger.js), as Move to
594
626
  // Ready and Send do.
595
627
  const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
596
- // A lab's pending rule (invoice-rules.js) is a gate too: fix its cause
597
- // or turn the rule off.
628
+ // A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
629
+ // is a gate too: fix its cause, void the draft, or turn the rule off.
598
630
  const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
599
631
  if (held) throw new FlowError('not_ready', `${held.message.replace(/\.?$/, '.')} Nothing was approved.`);
600
632
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
@@ -618,6 +650,14 @@ export function createInvoicingRouter({
618
650
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
619
651
  }));
620
652
 
653
+ router.delete('/invoices/:id', ...manager, handle(log, async (req, res) => {
654
+ const invoiceId = pathId(req.params.id, 'invoice');
655
+ const organisationId = await organisationOf(req);
656
+ await deleteDraft(db, organisationId, invoiceId, { actor: `person:${req.person.id}` });
657
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted draft invoice ${invoiceId}`);
658
+ res.json({ ok: true });
659
+ }));
660
+
621
661
  router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
622
662
  const organisationId = await organisationOf(req);
623
663
  const q = text(req.query.q);
@@ -842,7 +882,7 @@ export function createInvoicingRouter({
842
882
  res.json({
843
883
  ok: true,
844
884
  fields: Object.entries(FIELDS).map(([key, f]) => ({
845
- key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
885
+ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: f.tests ?? TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
846
886
  })),
847
887
  actions: ACTIONS,
848
888
  options: {
package/server/app.js CHANGED
@@ -26,7 +26,8 @@ import { createUiAssetsRouter } from './ui/routes.js';
26
26
  // per client, sized for a lab of a few people: `rateLimitPerMinute` for /api,
27
27
  // `signInRateLimitPerMinute` for the sign-in page and its config, and
28
28
  // `clerkProxyRateLimitPerMinute` for Clerk's Frontend API, which a signed-in
29
- // browser calls often. `build` is what GET /health reports
29
+ // browser calls often, and `assetRateLimitPerMinute` for the scripts, styles
30
+ // and faces every page loads. `build` is what GET /health reports
30
31
  // (server/deployed-build.js); `updates` is a server/update-check.js checker.
31
32
  // `integrations` is the started registry (server/integrations/index.js); each
32
33
  // adapter's own routes are mounted under /api/<id>, and GET /health reports
@@ -51,6 +52,7 @@ export function createApp({
51
52
  rateLimitPerMinute = 120,
52
53
  signInRateLimitPerMinute = 30,
53
54
  clerkProxyRateLimitPerMinute = 300,
55
+ assetRateLimitPerMinute = 600,
54
56
  defaultFamily = DEFAULT_FAMILY,
55
57
  // Buttons that call Xero or Seazona wait between presses (the action
56
58
  // guard, server/integrations/call-guard.js). Off only in tests.
@@ -95,7 +97,7 @@ export function createApp({
95
97
  }
96
98
 
97
99
  // The design systems, Flow's components and the faces every page loads.
98
- app.use(createUiAssetsRouter());
100
+ app.use(createUiAssetsRouter({ rateLimitPerMinute: assetRateLimitPerMinute }));
99
101
 
100
102
  app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
101
103
 
@@ -2,8 +2,9 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { people, PERSON_ROLES } from '../db/schema.js';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
- import { INVOICE_COLUMNS, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
5
+ import { invoiceColumnsView, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
6
6
  import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
7
+ import { normaliseNavPins } from '../ui/nav-pins.js';
7
8
  import { isPlainEmail } from './settings.js';
8
9
 
9
10
  // The people who may sign in, and their roles. Only an owner changes this
@@ -58,13 +59,14 @@ export async function setPersonAppearance(db, { id, appearance }) {
58
59
  function viewCheck(page) {
59
60
  switch (page) {
60
61
  case 'jobs': return normaliseJobsView;
61
- case 'invoices': return (view) => normaliseListView(view, INVOICE_COLUMNS);
62
+ case 'invoices': return invoiceColumnsView;
62
63
  case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
63
64
  case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
65
+ case 'nav': return normaliseNavPins;
64
66
  default: return null;
65
67
  }
66
68
  }
67
- const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list'];
69
+ const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list', 'nav'];
68
70
 
69
71
  // A person's saved views as Flow sends them: only the known pages, each
70
72
  // checked, so a view saved by an older Flow still loads.
@@ -19,6 +19,7 @@ import {
19
19
  } from './people.js';
20
20
  import { isPlainEmail } from './settings.js';
21
21
  import { APP_PATHS, appPage } from '../ui/app-page.js';
22
+ import { navPinsFromViews } from '../ui/nav-pins.js';
22
23
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
24
  const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
24
25
 
@@ -104,6 +105,8 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
104
105
  }
105
106
  body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
106
107
  body.views = normaliseViews(req.person.views);
108
+ // No pins saved yet: today's sidebar. A saved choice is checked the same way.
109
+ body.views.nav = navPinsFromViews(req.person.views);
107
110
  res.json(body);
108
111
  });
109
112
 
@@ -118,7 +121,7 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
118
121
  });
119
122
 
120
123
  // Each person sets their own view of a page (how Jobs groups, sorts and
121
- // filters); it follows their account.
124
+ // filters, and which pages are pinned: `nav`). It follows their account.
122
125
  router.put('/api/me/views/:page', requireSession, express.json({ limit: '8kb' }), async (req, res) => {
123
126
  try {
124
127
  const view = await setPersonView(db, { id: req.person.id, page: req.params.page, view: req.body });
@@ -47,7 +47,14 @@ export const FIELDS = Object.freeze({
47
47
  practitioner: { label: 'Practitioner', kind: 'text', options: 'practitioners', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
48
48
  // Only a draft has a Reference, so only a pending rule may read it.
49
49
  reference: { label: 'Reference', kind: 'text', draftOnly: true, read: (j) => j.reference || null },
50
- customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
50
+ // Menu tests read the contact id. Contains and does not contain read the
51
+ // customer's name (matches): a lab can exclude every name that contains
52
+ // one marker, so a new branch needs no new rule.
53
+ customer: {
54
+ label: 'Customer', kind: 'choice', options: 'customers',
55
+ tests: ['is', 'is_not', 'is_one_of', 'contains', 'does_not_contain', 'is_empty'],
56
+ read: (j) => j.contactId,
57
+ },
51
58
  customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
52
59
  line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
53
60
  line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
@@ -71,7 +78,8 @@ function readCondition(raw, index) {
71
78
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
72
79
  const field = FIELDS[raw.field];
73
80
  if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
74
- if (!TESTS[field.kind].includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${TESTS[field.kind].join(', ')}`);
81
+ const allowed = field.tests ?? TESTS[field.kind];
82
+ if (!allowed.includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${allowed.join(', ')}`);
75
83
  const out = { field: raw.field, test: raw.test };
76
84
  if (NO_VALUE.has(raw.test)) return out;
77
85
  const { value } = raw;
@@ -125,9 +133,13 @@ function matches(condition, job, context) {
125
133
  const { test, value } = condition;
126
134
  if (test === 'is_empty') return have === null || have === '' || have === undefined;
127
135
  if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
128
- if (field.kind === 'text' && !['is', 'is_not', 'is_one_of'].includes(test)) {
136
+ // Customer contains reads the contact's name, in any case and with the
137
+ // spaces trimmed, the same way a practitioner's name is read. is, is not
138
+ // and is one of still compare the contact id. `customer.name` is that name.
139
+ const named = condition.field === 'customer' && (test === 'contains' || test === 'does_not_contain');
140
+ if ((field.kind === 'text' || named) && !['is', 'is_not', 'is_one_of'].includes(test)) {
129
141
  // An empty text matches no test that reads its words; is_empty finds it.
130
- const left = norm(have);
142
+ const left = norm(named ? (context.customer?.name ?? job.customerName ?? null) : have);
131
143
  if (!left) return false;
132
144
  if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
133
145
  if (test === 'has_number') return /\d/.test(left);
@@ -195,6 +207,24 @@ export function rulePending(rules, job, { customer = {}, now = new Date() } = {}
195
207
  .map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))] }));
196
208
  }
197
209
 
210
+ // Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
211
+ // A person drafted the job by hand, or the rule was made after the draft.
212
+ // The list shows the rule's name. The note says to void the draft or change
213
+ // the rule. A wait rule does not hold a draft: a person may still send one.
214
+ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
215
+ const context = { customer, now };
216
+ return rules
217
+ .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'skip')
218
+ .filter((r) => r.conditions.every((c) => matches(c, job, context)))
219
+ .map((r) => ({
220
+ code: 'rule',
221
+ message: `Rule "${r.name}": not invoiced. Void this draft or change the rule.`,
222
+ short: r.name.slice(0, 40),
223
+ rule: r.id ?? null,
224
+ fields: [...new Set(r.conditions.map((c) => c.field))],
225
+ }));
226
+ }
227
+
198
228
  // The starter rules made from the earlier fixed settings.
199
229
  export function starterRules(settings) {
200
230
  const rules = [
@@ -1,5 +1,5 @@
1
1
  import { and, asc, desc, eq, sql } from 'drizzle-orm';
2
- import { contacts, invoiceLines, invoices, invoiceVersions, organisations, priceItems } from '../db/schema.js';
2
+ import { billingObligations, contacts, invoiceLines, invoices, invoiceVersions, obligationEvents, organisations, priceItems } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
  import { invoiceTotals, lineTotals } from './money.js';
5
5
 
@@ -7,6 +7,7 @@ import { invoiceTotals, lineTotals } from './money.js';
7
7
  // invoice its number and stores the document exactly as sent, as a new
8
8
  // version. To change an issued invoice, revise it back to a draft and issue
9
9
  // it again: the number stays and the earlier versions stay as they were.
10
+ // A draft that was never issued can be deleted. An issued invoice is voided.
10
11
 
11
12
  async function loadInvoice(tx, organisationId, invoiceId, { lock = false } = {}) {
12
13
  let query = tx.select().from(invoices)
@@ -182,6 +183,44 @@ export async function voidInvoice(db, organisationId, invoiceId) {
182
183
  });
183
184
  }
184
185
 
186
+ // Removes a draft that was never issued. The job it billed is free again:
187
+ // its obligation no longer points at the draft, so the lab's rules decide
188
+ // whether Flow drafts it once more. An invoice that was issued is voided,
189
+ // not deleted (the database holds the same rule).
190
+ export async function deleteDraft(db, organisationId, invoiceId, { actor = null } = {}) {
191
+ return db.transaction(async (tx) => {
192
+ const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
193
+ if (invoice.status !== 'draft' || invoice.issuedVersion !== 0) {
194
+ throw new FlowError('was_issued', 'an invoice that was issued cannot be deleted; void it');
195
+ }
196
+ const linked = await tx.select().from(billingObligations)
197
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)))
198
+ .for('update');
199
+ if (linked.length && !actor) throw new FlowError('invalid', 'actor is required');
200
+ for (const obligation of linked) {
201
+ await tx.update(billingObligations)
202
+ .set({ invoiceId: null, draftBasis: null, updatedAt: sql`now()` })
203
+ .where(eq(billingObligations.id, obligation.id));
204
+ await tx.insert(obligationEvents).values({
205
+ createdAt: sql`clock_timestamp()`,
206
+ organisationId,
207
+ obligationId: obligation.id,
208
+ kind: 'draft_deleted',
209
+ actor,
210
+ evidence: { invoiceId: invoice.id },
211
+ });
212
+ }
213
+ try {
214
+ await tx.delete(invoices).where(eq(invoices.id, invoice.id));
215
+ } catch (error) {
216
+ const code = error?.code ?? error?.cause?.code;
217
+ if (code === '23503') throw new FlowError('conflict', 'this draft is still linked; remove the link, then delete it');
218
+ throw error;
219
+ }
220
+ return invoice;
221
+ });
222
+ }
223
+
185
224
  export async function getInvoice(db, organisationId, invoiceId) {
186
225
  const invoice = await loadInvoice(db, organisationId, invoiceId);
187
226
  const lines = await db.select().from(invoiceLines)
@@ -150,6 +150,16 @@ export async function customerBillingByContact(db, organisationId, contactIds) {
150
150
  return new Map(rows.map((r) => [r.contactId, pick(r, CUSTOMER_KEYS)]));
151
151
  }
152
152
 
153
+ // The customers' names, by contact id. Customer "contains" reads the name
154
+ // (invoice-rules.js), not the contact id the menu tests use.
155
+ async function customerNames(db, organisationId, contactIds) {
156
+ const ids = [...new Set(contactIds.filter(Boolean))];
157
+ if (!ids.length) return new Map();
158
+ const rows = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
159
+ .where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, ids)));
160
+ return new Map(rows.map((r) => [r.id, r.name]));
161
+ }
162
+
153
163
  // The settings that apply to one customer: its own, else the organisation's.
154
164
  // `autoDraft` is the customer's own switch for the draft automation.
155
165
  export function effectiveSettings(settings, customer = CUSTOMER_DEFAULTS) {
@@ -255,13 +265,16 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
255
265
  sql`${billingObligations.contactId} is not null`,
256
266
  // Newest first: a backlog of jobs that wait on a rule never hides new work.
257
267
  )).orderBy(sql`${billingObligations.jobDate} desc nulls last`, billingObligations.id).limit(limit);
258
- const customers = await customerBillingByContact(db, organisationId, candidates.map((o) => o.contactId));
268
+ const contactIds = candidates.map((o) => o.contactId);
269
+ const customers = await customerBillingByContact(db, organisationId, contactIds);
270
+ const names = await customerNames(db, organisationId, contactIds);
271
+ const named = (contactId) => ({ ...effectiveSettings(settings, customers.get(contactId)), name: names.get(contactId) ?? null });
259
272
  const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
260
273
  const invoiceDate = isoDay(now);
261
274
  let accountingRead = null;
262
275
 
263
276
  for (const candidate of candidates) {
264
- let effective = effectiveSettings(settings, customers.get(candidate.contactId));
277
+ let effective = named(candidate.contactId);
265
278
  if (!effective.autoDraft) {
266
279
  result.off += 1;
267
280
  continue;
@@ -302,7 +315,11 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
302
315
  }
303
316
  }
304
317
  if (obligation !== candidate) {
305
- effective = effectiveSettings(settings, customers.get(obligation.contactId));
318
+ if (obligation.contactId && !names.has(obligation.contactId)) {
319
+ const extra = await customerNames(db, organisationId, [obligation.contactId]);
320
+ for (const [id, name] of extra) names.set(id, name);
321
+ }
322
+ effective = named(obligation.contactId);
306
323
  if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
307
324
  result.waiting += 1;
308
325
  continue;
@@ -55,7 +55,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
55
55
  // The invoicing rules a job with no invoice waits on, for every view a
56
56
  // draft can still come from.
57
57
  const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
58
- ? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
58
+ ? [] : ruleBlockers(o, { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null }, { rules }));
59
59
  return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
60
60
  ...o,
61
61
  canUseNewLines: offers.has(o.id),