@arcforgelabs/flow 2026.10.46 → 2026.10.48

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 +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/sentry/index.js +14 -7
  26. package/server/integrations/setup.js +1 -0
  27. package/server/integrations/xero/catalogue.js +82 -14
  28. package/server/integrations/xero/client.js +37 -0
  29. package/server/integrations/xero/create-contact.js +126 -0
  30. package/server/integrations/xero/delete-invoice.js +104 -0
  31. package/server/integrations/xero/drafts.js +19 -8
  32. package/server/integrations/xero/index.js +9 -0
  33. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  34. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  35. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  36. package/server/integrations/xero/routes.js +21 -0
  37. package/server/integrations/xero/settings.js +3 -0
  38. package/server/ui/alerts.js +2 -1
  39. package/server/ui/app.js +113 -28
  40. package/server/ui/bulk-bar.js +18 -5
  41. package/server/ui/columns.js +5 -2
  42. package/server/ui/contacts.js +260 -0
  43. package/server/ui/custom-fields.js +22 -7
  44. package/server/ui/flow.css +275 -24
  45. package/server/ui/invoicing.js +374 -70
  46. package/server/ui/job-board.js +10 -0
  47. package/server/ui/job-change.js +4 -3
  48. package/server/ui/job-view.js +3 -1
  49. package/server/ui/jobs.js +150 -19
  50. package/server/ui/kit.js +220 -45
  51. package/server/ui/list-views.js +50 -10
  52. package/server/ui/mobile.js +729 -0
  53. package/server/ui/monitoring.js +51 -0
  54. package/server/ui/nav-pins.js +44 -0
  55. package/server/ui/pager.js +57 -10
  56. package/server/ui/price-list.js +1 -1
  57. package/server/ui/price-reconcile.js +12 -12
  58. package/server/ui/reconcile.js +18 -17
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/saved-views.js +2 -1
  61. package/server/ui/sidecar.js +106 -6
  62. package/server/ui/view-bar.js +22 -11
  63. package/server/ui/xero-import.js +48 -19
package/README.md CHANGED
@@ -43,6 +43,7 @@ run a lab.
43
43
  | `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
44
44
  | `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
45
45
  | `docs/INVOICE_SENDING.md` | The invoice tabs (Pending, Ready, Draft, Approved, Sent, Void), a changed job, and Send all ready to the accounting system |
46
+ | `docs/CONTACTS.md` | Contacts: Flow's own customers and suppliers, and creating a missing Xero client from one |
46
47
  | `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
47
48
  | `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
48
49
  | `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
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.46",
3
+ "version": "2026.10.48",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.46",
9
+ "version": "2026.10.48",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.46",
3
+ "version": "2026.10.48",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 {
@@ -31,6 +33,7 @@ import {
31
33
  import { isHistoryHeld } from '../core/history.js';
32
34
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
33
35
  import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
36
+ import { createContact, getContact, listContacts, presentContact, updateContact } from '../core/contacts.js';
34
37
  import {
35
38
  billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
36
39
  } from '../db/schema.js';
@@ -45,7 +48,9 @@ import {
45
48
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
46
49
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
47
50
  // accounting system { issueDate, invoiceIds? (only these; one that fails
48
- // a check now stays on Pending and counts as held) }
51
+ // a check now stays on Pending and counts as held; one id is all or
52
+ // nothing when its job cannot be confirmed, more than one id sends the
53
+ // rest and counts the unconfirmed in waiting) }
49
54
  // POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
50
55
  // GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
51
56
  // GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
@@ -56,6 +61,8 @@ import {
56
61
  // POST /invoices/:id/issue owner or admin: number it and keep the document as sent
57
62
  // POST /invoices/:id/revise owner or admin: an issued invoice back to a draft
58
63
  // POST /invoices/:id/void owner or admin
64
+ // DELETE /invoices/:id owner or admin: a draft that was never issued.
65
+ // Frees its job, and the lab's rules then decide.
59
66
  // GET /contacts?q=&limit= customers, by name
60
67
  // GET /price-items?q= the price list, by code
61
68
  // GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
@@ -124,8 +131,9 @@ const MAX_UNIT_CENTS = 100_000_000;
124
131
 
125
132
  const ERROR_STATUS = {
126
133
  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,
128
- accounting_not_connected: 409, accounting_writes_off: 409,
134
+ never_issued: 409, was_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
135
+ accounting_not_connected: 409, accounting_writes_off: 409, accounting_unavailable: 409, accounting_approved: 409,
136
+ conflict: 409,
129
137
  };
130
138
 
131
139
  function sendError(res, error, log) {
@@ -449,17 +457,43 @@ export function createInvoicingRouter({
449
457
  const pattern = containing(q);
450
458
  where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
451
459
  }
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({
460
+ // First the columns the filter and sort read, for every row; then the
461
+ // whole rows of the one page. A job column and a custom field read the
462
+ // invoice's job, so those values are on every row before the page is cut.
463
+ const lightRows = await db.select({
455
464
  id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
456
- createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
465
+ dueDate: invoices.dueDate, createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
457
466
  }).from(invoices)
458
467
  .leftJoin(contacts, eq(contacts.id, invoices.contactId))
459
468
  .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 });
469
+ .orderBy(desc(invoices.createdAt), desc(invoices.id));
470
+ const jobs = await jobsByInvoice(db, organisationId, lightRows.map((r) => r.id));
471
+ const customOn = filterRows(view).some((f) => fieldIdOf(f.field));
472
+ const [fieldDefs, fieldValues] = customOn
473
+ ? await Promise.all([
474
+ listFields(db, organisationId),
475
+ listJobFieldValues(db, organisationId, { jobs: [...jobs.values()].filter(Boolean) }),
476
+ ])
477
+ : [[], []];
478
+ const valuesOf = new Map();
479
+ for (const v of fieldValues) {
480
+ const key = `${v.sourceSystem}\u0000${v.sourceJobRef}`;
481
+ const bag = valuesOf.get(key) ?? {};
482
+ bag[v.fieldId] = v.value;
483
+ valuesOf.set(key, bag);
484
+ }
485
+ const light = lightRows.map((row) => {
486
+ const job = jobs.get(row.id) ?? null;
487
+ return {
488
+ ...row,
489
+ problems: tabs.problems.get(row.id) ?? [],
490
+ tab: tabs.tabOf.get(row.id) ?? null,
491
+ sending: tabs.sending.has(row.id),
492
+ job,
493
+ fields: job ? valuesOf.get(`${job.sourceSystem}\u0000${job.sourceJobRef}`) ?? {} : {},
494
+ };
495
+ });
496
+ const page = invoicePage(light, { view, sort, dir, offset, limit, today: day, kindOf: invoiceKindOf(fieldDefs), me: req.person.id });
463
497
  const pageIds = page.rows.map((r) => r.id);
464
498
  const full = pageIds.length
465
499
  ? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
@@ -468,7 +502,6 @@ export function createInvoicingRouter({
468
502
  : [];
469
503
  const byId = new Map(full.map((r) => [r.invoice.id, r]));
470
504
  const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
471
- const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
472
505
  const systems = work();
473
506
  res.json({
474
507
  ok: true,
@@ -486,8 +519,9 @@ export function createInvoicingRouter({
486
519
  limit,
487
520
  total: page.total,
488
521
  totalCents: page.totalCents,
489
- // The Filter chip's Client choices: every client of the tab.
522
+ // The Filter chip's choices, from every row of the tab.
490
523
  clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
524
+ practitioners: [...new Set(light.map((r) => r.job?.practitioner).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
491
525
  sendsTo: system?.name ?? null,
492
526
  sendMode: mode,
493
527
  // The Linked column: one icon for each connected system.
@@ -593,8 +627,8 @@ export function createInvoicingRouter({
593
627
  // the job at its source or adopt the change (ledger.js), as Move to
594
628
  // Ready and Send do.
595
629
  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.
630
+ // A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
631
+ // is a gate too: fix its cause, void the draft, or turn the rule off.
598
632
  const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
599
633
  if (held) throw new FlowError('not_ready', `${held.message.replace(/\.?$/, '.')} Nothing was approved.`);
600
634
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
@@ -618,16 +652,49 @@ export function createInvoicingRouter({
618
652
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
619
653
  }));
620
654
 
655
+ router.delete('/invoices/:id', ...manager, handle(log, async (req, res) => {
656
+ const invoiceId = pathId(req.params.id, 'invoice');
657
+ const organisationId = await organisationOf(req);
658
+ const removed = await deleteDraft(db, organisationId, invoiceId, { actor: `person:${req.person.id}`, accounting: accounting() });
659
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} ${removed.status === 'void' ? 'voided invoice' : 'deleted draft invoice'} ${invoiceId}`);
660
+ res.json({ ok: true });
661
+ }));
662
+
621
663
  router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
622
664
  const organisationId = await organisationOf(req);
623
- const q = text(req.query.q);
624
- const where = [eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')];
625
- if (q) where.push(ilike(contacts.name, containing(q)));
626
- const rows = await db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
627
- .where(and(...where)).orderBy(asc(contacts.name), asc(contacts.id)).limit(limitOf(req.query.limit, 50, 500));
665
+ const kind = text(req.query.kind);
666
+ if (kind && !['customer', 'supplier', 'all'].includes(kind)) throw invalid('kind must be customer, supplier or all');
667
+ const active = text(req.query.active);
668
+ if (active && !['true', 'false', 'all'].includes(active)) throw invalid('active must be true, false or all');
669
+ const rows = await listContacts(db, organisationId, {
670
+ q: text(req.query.q),
671
+ kind: kind === 'all' ? null : (kind || 'customer'),
672
+ active: active === 'all' ? null : active === 'false' ? false : true,
673
+ limit: limitOf(req.query.limit, 50, 500),
674
+ offset: offsetOf(req.query.offset),
675
+ });
628
676
  res.json({ ok: true, contacts: rows });
629
677
  }));
630
678
 
679
+ router.post('/contacts', ...manager, handle(log, async (req, res) => {
680
+ const organisationId = await organisationOf(req);
681
+ const contact = presentContact(await createContact(db, organisationId, req.body ?? {}));
682
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} created contact ${contact.id}`);
683
+ res.status(201).json({ ok: true, contact });
684
+ }));
685
+
686
+ router.get('/contacts/:id', ...signedIn, handle(log, async (req, res) => {
687
+ const organisationId = await organisationOf(req);
688
+ res.json({ ok: true, contact: presentContact(await getContact(db, organisationId, pathId(req.params.id, 'contact'))) });
689
+ }));
690
+
691
+ router.patch('/contacts/:id', ...manager, handle(log, async (req, res) => {
692
+ const organisationId = await organisationOf(req);
693
+ const contact = presentContact(await updateContact(db, organisationId, pathId(req.params.id, 'contact'), req.body ?? {}));
694
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} updated contact ${contact.id}`);
695
+ res.json({ ok: true, contact });
696
+ }));
697
+
631
698
  router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
632
699
  const organisationId = await organisationOf(req);
633
700
  const q = text(req.query.q);
@@ -842,7 +909,7 @@ export function createInvoicingRouter({
842
909
  res.json({
843
910
  ok: true,
844
911
  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),
912
+ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: f.tests ?? TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
846
913
  })),
847
914
  actions: ACTIONS,
848
915
  options: {
@@ -4,6 +4,7 @@ import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
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
@@ -61,10 +62,11 @@ function viewCheck(page) {
61
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 });
@@ -0,0 +1,160 @@
1
+ import { and, asc, eq, ilike, or, sql } from 'drizzle-orm';
2
+ import { contacts, CONTACT_KINDS } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+
5
+ // Flow's own contacts (docs/CONTACTS.md). A contact is a customer or a
6
+ // supplier the lab bills or buys from. The fields are what Xero needs to
7
+ // bill them and what Seazona keeps for the practice a job belongs to.
8
+ // Practitioners are people at that practice. Flow stores no patient.
9
+
10
+ const NAME_MAX = 255;
11
+ const TEXT_MAX = 255;
12
+ const SHORT_MAX = 50;
13
+ const PRACTITIONERS_MAX = 30;
14
+ const ADDRESS_KEYS = ['line1', 'line2', 'city', 'region', 'postalCode', 'country'];
15
+
16
+ const invalid = (message) => new FlowError('invalid', message);
17
+
18
+ function text(value, name, max) {
19
+ if (value == null || value === '') return null;
20
+ if (typeof value !== 'string') throw invalid(`${name} must be text`);
21
+ const trimmed = value.trim();
22
+ if (!trimmed) return null;
23
+ if (trimmed.length > max) throw invalid(`${name} is too long (${max} characters at most)`);
24
+ return trimmed;
25
+ }
26
+
27
+ function readAddress(raw) {
28
+ if (raw == null) return null;
29
+ if (typeof raw !== 'object' || Array.isArray(raw)) throw invalid('address must be an object');
30
+ const out = {};
31
+ for (const key of ADDRESS_KEYS) {
32
+ const value = text(raw[key], `address.${key}`, TEXT_MAX);
33
+ if (value) out[key] = value;
34
+ }
35
+ return Object.keys(out).length ? out : null;
36
+ }
37
+
38
+ function readPractitioners(raw) {
39
+ if (raw == null) return [];
40
+ if (!Array.isArray(raw)) throw invalid('practitioners must be a list');
41
+ if (raw.length > PRACTITIONERS_MAX) throw invalid(`a contact has ${PRACTITIONERS_MAX} practitioners at most`);
42
+ return raw.map((item, index) => {
43
+ const at = `practitioner ${index + 1}`;
44
+ const source = typeof item === 'string' ? { name: item } : item;
45
+ if (!source || typeof source !== 'object' || Array.isArray(source)) throw invalid(`${at} must be a name`);
46
+ const name = text(source.name, `${at} name`, NAME_MAX);
47
+ if (!name) throw invalid(`${at} needs a name`);
48
+ const email = text(source.email, `${at} email`, TEXT_MAX);
49
+ return email ? { name, email } : { name };
50
+ });
51
+ }
52
+
53
+ // The fields a create or an update may set. `partial` is an update: only the
54
+ // keys the caller sent are returned.
55
+ export function readContact(input, { partial = false } = {}) {
56
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw invalid('contact must be an object');
57
+ const out = {};
58
+ const has = (key) => Object.prototype.hasOwnProperty.call(input, key);
59
+ if (!partial || has('kind')) {
60
+ if (!CONTACT_KINDS.includes(input.kind)) throw invalid(`kind must be ${CONTACT_KINDS.join(' or ')}`);
61
+ out.kind = input.kind;
62
+ }
63
+ if (!partial || has('name')) {
64
+ const name = text(input.name, 'name', NAME_MAX);
65
+ if (!name) throw invalid('contact name is required');
66
+ out.name = name;
67
+ }
68
+ if (!partial || has('firstName')) out.firstName = text(input.firstName, 'firstName', NAME_MAX);
69
+ if (!partial || has('lastName')) out.lastName = text(input.lastName, 'lastName', NAME_MAX);
70
+ if (!partial || has('email')) out.email = text(input.email, 'email', TEXT_MAX);
71
+ if (!partial || has('phone')) out.phone = text(input.phone, 'phone', SHORT_MAX);
72
+ if (!partial || has('mobile')) out.mobile = text(input.mobile, 'mobile', SHORT_MAX);
73
+ if (!partial || has('accountNumber')) out.accountNumber = text(input.accountNumber, 'accountNumber', SHORT_MAX);
74
+ if (!partial || has('taxNumber')) out.taxNumber = text(input.taxNumber, 'taxNumber', SHORT_MAX);
75
+ if (!partial || has('taxRate')) out.taxRate = text(input.taxRate, 'taxRate', SHORT_MAX);
76
+ if (!partial || has('address')) out.address = readAddress(input.address);
77
+ if (!partial || has('practitioners')) out.practitioners = readPractitioners(input.practitioners);
78
+ if (!partial || has('active')) {
79
+ if (input.active != null && typeof input.active !== 'boolean') throw invalid('active must be true or false');
80
+ out.active = input.active !== false;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function duplicate(error, kind) {
86
+ const code = error?.code ?? error?.cause?.code;
87
+ if (code === '23505') throw new FlowError('conflict', `A ${kind ?? 'contact'} with this name already exists.`);
88
+ throw error;
89
+ }
90
+
91
+ export function presentContact(row) {
92
+ return {
93
+ id: row.id,
94
+ kind: row.kind,
95
+ name: row.name,
96
+ firstName: row.firstName ?? null,
97
+ lastName: row.lastName ?? null,
98
+ email: row.email ?? null,
99
+ phone: row.phone ?? null,
100
+ mobile: row.mobile ?? null,
101
+ accountNumber: row.accountNumber ?? null,
102
+ taxNumber: row.taxNumber ?? null,
103
+ taxRate: row.taxRate ?? null,
104
+ address: row.address ?? null,
105
+ practitioners: row.practitioners ?? [],
106
+ active: row.active !== false,
107
+ createdAt: row.createdAt,
108
+ updatedAt: row.updatedAt,
109
+ };
110
+ }
111
+
112
+ export async function createContact(db, organisationId, input) {
113
+ const values = readContact(input);
114
+ try {
115
+ const [row] = await db.insert(contacts).values({ organisationId, ...values }).returning();
116
+ return row;
117
+ } catch (error) {
118
+ duplicate(error, values.kind);
119
+ }
120
+ }
121
+
122
+ export async function getContact(db, organisationId, id) {
123
+ const [row] = await db.select().from(contacts)
124
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, id)));
125
+ if (!row) throw notFound('contact');
126
+ return row;
127
+ }
128
+
129
+ export async function updateContact(db, organisationId, id, input) {
130
+ const current = await getContact(db, organisationId, id);
131
+ const patch = readContact(input, { partial: true });
132
+ if (!Object.keys(patch).length) return current;
133
+ try {
134
+ const [row] = await db.update(contacts)
135
+ .set({ ...patch, updatedAt: sql`now()` })
136
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, id)))
137
+ .returning();
138
+ return row;
139
+ } catch (error) {
140
+ duplicate(error, patch.kind ?? current.kind);
141
+ }
142
+ }
143
+
144
+ // `kind` null lists customers and suppliers. `active` null lists both.
145
+ export async function listContacts(db, organisationId, { q = '', kind = 'customer', active = true, limit = 200, offset = 0 } = {}) {
146
+ const where = [eq(contacts.organisationId, organisationId)];
147
+ if (kind) where.push(eq(contacts.kind, kind));
148
+ if (active === true) where.push(eq(contacts.active, true));
149
+ if (active === false) where.push(eq(contacts.active, false));
150
+ const needle = String(q ?? '').trim().replace(/[\\%_]/g, (c) => `\\${c}`);
151
+ if (needle) {
152
+ const like = `%${needle}%`;
153
+ where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like)));
154
+ }
155
+ const rows = await db.select().from(contacts).where(and(...where))
156
+ .orderBy(asc(contacts.name), asc(contacts.id))
157
+ .limit(limit)
158
+ .offset(offset);
159
+ return rows.map(presentContact);
160
+ }
@@ -1,5 +1,6 @@
1
1
  import { and, eq, isNull } from 'drizzle-orm';
2
- import { contacts, organisations, priceItems, CONTACT_KINDS, TAX_CODES } from '../db/schema.js';
2
+ import { organisations, priceItems, TAX_CODES } from '../db/schema.js';
3
+ import { createContact as insertContact } from './contacts.js';
3
4
  import { FlowError } from './errors.js';
4
5
 
5
6
  // Organisations, contacts and the price list: the records invoices and bills
@@ -13,14 +14,8 @@ export async function createOrganisation(db, { name, invoicePrefix }) {
13
14
  return row;
14
15
  }
15
16
 
16
- export async function createContact(db, organisationId, { kind, name, email = null }) {
17
- if (!CONTACT_KINDS.includes(kind)) throw new FlowError('invalid', `unknown contact kind: ${kind}`);
18
- if (!name?.trim()) throw new FlowError('invalid', 'contact name is required');
19
- const [row] = await db.insert(contacts)
20
- .values({ organisationId, kind, name: name.trim(), email })
21
- .returning();
22
- return row;
23
- }
17
+ // Contacts live in contacts.js. This name stays so existing callers keep working.
18
+ export const createContact = insertContact;
24
19
 
25
20
  export async function createPriceItem(db, organisationId, { code, name, unitAmountCents, taxCode = 'GST' }) {
26
21
  if (!code?.trim() || !name?.trim()) throw new FlowError('invalid', 'price item code and name are required');
@@ -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 = [