@arcforgelabs/flow 2026.10.48 → 2026.10.50

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 (43) hide show
  1. package/VISION.md +18 -9
  2. package/npm-shrinkwrap.json +11 -11
  3. package/package.json +1 -1
  4. package/server/api/invoicing.js +7 -3
  5. package/server/core/automations.js +34 -0
  6. package/server/core/invoice-rules.js +17 -3
  7. package/server/core/job-board.js +1 -1
  8. package/server/core/pipeline.js +46 -6
  9. package/server/core/sending.js +27 -9
  10. package/server/integrations/port.js +1 -1
  11. package/server/integrations/seazona/feed.js +41 -4
  12. package/server/integrations/seazona/sweep.js +11 -8
  13. package/server/integrations/sentry/index.js +2 -2
  14. package/server/integrations/xero/client.js +23 -0
  15. package/server/integrations/xero/connections.js +2 -0
  16. package/server/integrations/xero/contact-sync.js +289 -0
  17. package/server/integrations/xero/create-contact.js +42 -2
  18. package/server/integrations/xero/drafts.js +14 -6
  19. package/server/integrations/xero/import.js +5 -1
  20. package/server/integrations/xero/index.js +16 -2
  21. package/server/integrations/xero/lookup.js +15 -2
  22. package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
  23. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  24. package/server/integrations/xero/oauth.js +10 -3
  25. package/server/integrations/xero/routes.js +74 -42
  26. package/server/integrations/xero/runtime.js +7 -4
  27. package/server/integrations/xero/settings.js +18 -1
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +48 -40
  30. package/server/ui/automations.js +2 -4
  31. package/server/ui/contacts.js +20 -27
  32. package/server/ui/flow.css +125 -41
  33. package/server/ui/invoice-rules.js +3 -5
  34. package/server/ui/invoicing-settings.js +3 -7
  35. package/server/ui/invoicing.js +69 -84
  36. package/server/ui/job-board.js +3 -1
  37. package/server/ui/jobs.js +1 -1
  38. package/server/ui/kit.js +101 -10
  39. package/server/ui/line-mappings.js +2 -4
  40. package/server/ui/mobile.js +49 -4
  41. package/server/ui/price-list.js +3 -5
  42. package/server/ui/routes.js +1 -1
  43. package/server/ui/xero-contact.js +101 -0
package/VISION.md CHANGED
@@ -62,8 +62,9 @@ Two parts follow the board:
62
62
  changes until a person accepts. The model steps are optional and cost money
63
63
  per page, so the manager sees the month's cost.
64
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.
65
+ jobs, send requests, scans and case notes, chat with the lab and manage
66
+ their own team. It works on a phone first and on a desktop. The server
67
+ limits each person to their practice.
67
68
 
68
69
  Job management is a bundled module, built the same way as the integrations,
69
70
  and a lab can remove it. Where the case system cannot take a change through
@@ -110,13 +111,20 @@ record in Flow with who, when and what they saw.
110
111
  What a customer was sent is a commercial commitment. A change makes a new
111
112
  version; the sent one stays as it was, attributed to whoever sent it.
112
113
 
113
- ### Why does Flow keep as little patient data as it can?
114
+ ### Why does Flow treat all its data as health data?
114
115
 
115
- Dental cases carry health information, which Australian privacy law treats as
116
- sensitive. Flow keeps the case number and the practice. It keeps a patient
117
- reference only when a practice needs one printed on its invoice, and it keeps
118
- no clinical notes or images. A form photo the intake reader reads is not kept
119
- after its job is updated.
116
+ Dental work carries health information, which Australian privacy law treats as
117
+ sensitive. A lab or a practice decides what it puts into Flow: case notes,
118
+ scans, photos and patient names can all arrive. So Flow treats every record
119
+ and every file as sensitive health data:
120
+
121
+ - The server limits each person to what their role and practice allow.
122
+ - Files are encrypted at rest. A download uses a short-lived signed link.
123
+ - Flow records each read and change of practice data in an audit log.
124
+ - Each installation sets how long files and notes are kept. Flow deletes them
125
+ after that time.
126
+ - Each installation picks its hosting region. Flow documents what a host needs
127
+ for health data.
120
128
 
121
129
  ### Why does Flow keep its records in PostgreSQL only?
122
130
 
@@ -137,7 +145,8 @@ its own data, configuration and secrets.
137
145
  - Replacing the case system, for now. The case system owns the case record.
138
146
  Flow's job board shows and updates the work, and does not do clinical
139
147
  case management or production scheduling.
140
- - Patient records, clinical notes, scans and images.
148
+ - Clinical decisions. Flow keeps the notes and files that practices send; it
149
+ does not diagnose or plan treatment.
141
150
  - Acting as the general ledger. Accounting software owns the ledger and the
142
151
  bank feed.
143
152
  - Browser automation of another product. Integrations use published APIs.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.48",
3
+ "version": "2026.10.50",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.48",
9
+ "version": "2026.10.50",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
@@ -1254,18 +1254,18 @@
1254
1254
  "license": "MIT"
1255
1255
  },
1256
1256
  "node_modules/body-parser": {
1257
- "version": "2.3.0",
1258
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
1259
- "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
1257
+ "version": "2.4.0",
1258
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
1259
+ "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
1260
1260
  "license": "MIT",
1261
1261
  "dependencies": {
1262
1262
  "bytes": "^3.1.2",
1263
- "content-type": "^2.0.0",
1263
+ "content-type": "^2.1.0",
1264
1264
  "debug": "^4.4.3",
1265
1265
  "http-errors": "^2.0.1",
1266
- "iconv-lite": "^0.7.2",
1266
+ "iconv-lite": "^0.7.3",
1267
1267
  "on-finished": "^2.4.1",
1268
- "qs": "^6.15.2",
1268
+ "qs": "^6.16.1",
1269
1269
  "raw-body": "^3.0.2",
1270
1270
  "type-is": "^2.1.0"
1271
1271
  },
@@ -2031,9 +2031,9 @@
2031
2031
  "license": "ISC"
2032
2032
  },
2033
2033
  "node_modules/ip-address": {
2034
- "version": "10.7.3",
2035
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
2036
- "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
2034
+ "version": "10.7.5",
2035
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
2036
+ "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
2037
2037
  "license": "MIT",
2038
2038
  "engines": {
2039
2039
  "node": ">= 12"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.48",
3
+ "version": "2026.10.50",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,7 +43,7 @@ import {
43
43
  //
44
44
  // GET /context the organisations, and whether this person may issue
45
45
  // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by tab;
46
- // status is a tab: pending, ready, draft, approved, sent or void (TABS)
46
+ // status is a tab: pending, ready, draft, approved, sent, non_chargeable or void (TABS)
47
47
  // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
48
48
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
49
49
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
@@ -118,7 +118,7 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
118
118
  // issued invoice, by its stage in the accounting system (its invoiceStages;
119
119
  // Draft while it is on its way); with no accounting system, an issued invoice
120
120
  // is Approved. Void: void in Flow or in the accounting system.
121
- const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'void'];
121
+ const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'non_chargeable', 'void'];
122
122
  // Earlier names a saved link may still use.
123
123
  const OLD_TABS = { problem: 'pending', to_check: 'ready', issued: 'approved' };
124
124
  const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
@@ -332,6 +332,7 @@ async function invoiceTabs(db, organisationId, system) {
332
332
  for (const { id } of issued) tabOf.set(id, issuedTab(id, { views, stages, system }));
333
333
  for (const { id } of voided) tabOf.set(id, 'void');
334
334
  for (const id of views.problems.keys()) tabOf.set(id, 'pending');
335
+ for (const id of views.nonChargeable.keys()) tabOf.set(id, 'non_chargeable');
335
336
  const ids = Object.fromEntries(TABS.map((t) => [t, []]));
336
337
  for (const [id, tab] of tabOf) ids[tab].push(id);
337
338
  return { tabOf, ids, problems: views.problems, sending: views.sending };
@@ -348,6 +349,7 @@ function issuedTab(id, { views, stages, system }) {
348
349
  async function tabOfInvoice(db, organisationId, invoice, view, accounting) {
349
350
  if (invoice.status === 'void') return 'void';
350
351
  if (view === 'problem') return 'pending';
352
+ if (view === 'non_chargeable') return 'non_chargeable';
351
353
  if (invoice.status === 'draft') return 'ready';
352
354
  if (!accounting) return 'approved';
353
355
  if (view === 'sending') return 'draft';
@@ -626,7 +628,9 @@ export function createInvoicingRouter({
626
628
  // A draft whose job changed after it was made waits for a person to fix
627
629
  // the job at its source or adopt the change (ledger.js), as Move to
628
630
  // Ready and Send do.
629
- const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
631
+ const { problems, nonChargeable } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
632
+ // A non-chargeable draft is never billed (invoice-rules.js).
633
+ if (nonChargeable?.length) throw new FlowError('not_ready', `${nonChargeable[0].message} Nothing was approved.`);
630
634
  // A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
631
635
  // is a gate too: fix its cause, void the draft, or turn the rule off.
632
636
  const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
@@ -22,6 +22,10 @@ import { refreshReferences } from './references.js';
22
22
  // on the Invoices page, until a person changes it or moves it back.
23
23
  // reference_from_rule: a draft made from a job keeps the Reference the
24
24
  // lab's Reference rule gives (references.js), as the job's fields arrive.
25
+ // add_contacts, add_xero_contacts: a customer of the work system, or of the
26
+ // accounting system, that no Flow contact is named like becomes one.
27
+ // sync_contact_edits: contact edits on either side of the Xero link reach
28
+ // the other (server/integrations/xero/contact-sync.js).
25
29
 
26
30
  const BATCH = 500;
27
31
 
@@ -71,6 +75,36 @@ export const AUTOMATIONS = Object.freeze([
71
75
  readConfig: (config) => readMappingConfig(config),
72
76
  remap: true,
73
77
  },
78
+ {
79
+ // Runs inside the pipeline's read of the work system's customers
80
+ // (pipeline.js, linkCustomers), before its jobs are read.
81
+ key: 'add_contacts',
82
+ place: 'seazona',
83
+ name: 'Add new clients',
84
+ hint: 'A new Seazona client becomes a contact.',
85
+ defaults: { enabled: true, config: {} },
86
+ readConfig: () => ({}),
87
+ },
88
+ {
89
+ // Runs inside the pipeline's read of the accounting system's customers
90
+ // (pipeline.js, linkCustomers). Off until a person turns it on.
91
+ key: 'add_xero_contacts',
92
+ place: 'xero',
93
+ name: 'Add Xero clients',
94
+ hint: 'Each Xero client becomes a contact.',
95
+ defaults: { enabled: false, config: {} },
96
+ readConfig: () => ({}),
97
+ },
98
+ {
99
+ // Runs on the Xero adapter's own worker (xero/contact-sync.js), which
100
+ // writes to Xero only while writes are allowed. Off until a person turns it on.
101
+ key: 'sync_contact_edits',
102
+ place: 'xero',
103
+ name: 'Two-way edits',
104
+ hint: 'A contact edit in Flow or Xero reaches the other. A clash waits for you.',
105
+ defaults: { enabled: false, config: {} },
106
+ readConfig: () => ({}),
107
+ },
74
108
  {
75
109
  key: 'mark_ready_from_work_status',
76
110
  place: 'seazona',
@@ -12,7 +12,10 @@ import { FlowError, notFound } from './errors.js';
12
12
  // Then wait: the job waits for a person; skip: rules never draft it;
13
13
  // pending: Flow drafts it, and the draft is Pending with the rule's
14
14
  // note, which says what to fix (for example a Reference with no
15
- // real practitioner).
15
+ // real practitioner); non_chargeable: Flow drafts it and keeps it on
16
+ // the Non-chargeable tab. The work is done but never billed (Xero's
17
+ // word for such work), so the draft is never sent. Its value stays
18
+ // in Flow, for the lab's output.
16
19
  //
17
20
  // The rules read only Flow's own records, so they work with or without an
18
21
  // accounting integration. The earlier fixed settings (old job limit, required
@@ -70,7 +73,7 @@ export const TESTS = Object.freeze({
70
73
  share: ['is', 'is_not'],
71
74
  });
72
75
 
73
- export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
76
+ export const ACTIONS = Object.freeze(['wait', 'skip', 'pending', 'non_chargeable']);
74
77
  // Tests that take no value.
75
78
  const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word']);
76
79
 
@@ -187,7 +190,7 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
187
190
  const context = { customer, now };
188
191
  const reasons = [];
189
192
  for (const rule of rules) {
190
- if (!rule.enabled || rule.trigger !== 'job_ready' || rule.action.kind === 'pending') continue;
193
+ if (!rule.enabled || rule.trigger !== 'job_ready' || ['pending', 'non_chargeable'].includes(rule.action.kind)) continue;
191
194
  if (!rule.conditions.every((c) => matches(c, job, context))) continue;
192
195
  reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
193
196
  }
@@ -225,6 +228,17 @@ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
225
228
  }));
226
229
  }
227
230
 
231
+ // The enabled non-chargeable rules that match one draft's job (sending.js):
232
+ // the draft is on the Non-chargeable tab, never on Pending or Ready, and
233
+ // Send never sends it.
234
+ export function ruleNonChargeable(rules, job, { customer = {}, now = new Date() } = {}) {
235
+ const context = { customer, now };
236
+ return rules
237
+ .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'non_chargeable')
238
+ .filter((r) => r.conditions.every((c) => matches(c, job, context)))
239
+ .map((r) => ({ code: 'non_chargeable', message: `Rule "${r.name}": non-chargeable. Flow keeps it and never sends it.`, short: r.name.slice(0, 40), rule: r.id ?? null }));
240
+ }
241
+
228
242
  // The starter rules made from the earlier fixed settings.
229
243
  export function starterRules(settings) {
230
244
  const rules = [
@@ -50,7 +50,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
50
50
  const ready = new Set(views?.ready ?? []);
51
51
  const toCheck = new Set(views?.toCheck ?? []);
52
52
  const invoiceView = (id) => (!id || !views ? null
53
- : views.problems.has(id) ? 'problem' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
53
+ : views.problems.has(id) ? 'problem' : views.nonChargeable.has(id) ? 'non_chargeable' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
54
54
  : views.sending.has(id) ? 'sending' : null);
55
55
  // The invoicing rules a job with no invoice waits on, for every view a
56
56
  // draft can still come from.
@@ -10,6 +10,7 @@ import { observeJob, observeWithdrawn, recordAccountingEvidence, setHold } from
10
10
  import { loadMappingContext, mapLines } from './line-mapping.js';
11
11
  import { checkPrices, readCatalogues } from './price-list.js';
12
12
  import { linkRecord, priorLinkFor } from './links.js';
13
+ import { createContact, readContact } from './contacts.js';
13
14
  import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
14
15
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
15
16
 
@@ -88,20 +89,29 @@ function byName(records) {
88
89
  }
89
90
 
90
91
  // Links each customer of one system to the Flow customer with the same name,
91
- // when one record on each side has it and neither side is linked yet.
92
- export async function linkCustomers(db, organisationId, { system, customers }) {
92
+ // when one record on each side has it and neither side is linked yet. With
93
+ // `adopt`, a customer no Flow contact is named like becomes a new Flow
94
+ // contact, linked to it (`imported`), so its jobs find their customer
95
+ // (docs/CONTACTS.md). Two customers of one name stay for a person.
96
+ export async function linkCustomers(db, organisationId, { system, customers, adopt = false }) {
93
97
  const flow = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
94
98
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
95
99
  const links = await activeLinks(db, organisationId, { system, entity: 'contact' });
96
100
  const linkedFlow = new Set(links.map((l) => l.flowId));
97
101
  const linkedExternal = new Set(links.map((l) => l.externalId));
98
102
  const flowByName = byName(flow);
99
- const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0 };
103
+ const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0, added: 0 };
100
104
  for (const [key, external] of byName(customers)) {
101
105
  const own = flowByName.get(key) ?? [];
102
106
  for (const customer of external) {
103
107
  if (linkedExternal.has(String(customer.id))) result.alreadyLinked += 1;
104
108
  }
109
+ if (adopt && own.length === 0 && external.length === 1 && external[0].adoptable !== false && !linkedExternal.has(String(external[0].id))) {
110
+ if (await adoptCustomer(db, organisationId, { system, customer: external[0] })) {
111
+ result.added += 1;
112
+ continue;
113
+ }
114
+ }
105
115
  if (external.length !== 1 || own.length !== 1) {
106
116
  const waiting = external.filter((c) => !linkedExternal.has(String(c.id))).length;
107
117
  if (own.length === 0) result.unmatched += waiting;
@@ -119,6 +129,32 @@ export async function linkCustomers(db, organisationId, { system, customers }) {
119
129
  return result;
120
130
  }
121
131
 
132
+ // The customer's own fields when Flow can keep them all, else its name only.
133
+ async function adoptCustomer(db, organisationId, { system, customer }) {
134
+ const name = String(customer.name ?? '').trim();
135
+ const offered = { ...(customer.contact ?? {}), kind: 'customer', name };
136
+ let values;
137
+ try {
138
+ values = readContact(offered);
139
+ } catch (error) {
140
+ if (!(error instanceof FlowError)) throw error;
141
+ values = readContact({ kind: 'customer', name });
142
+ }
143
+ return db.transaction(async (tx) => {
144
+ let row;
145
+ try {
146
+ row = await createContact(tx, organisationId, values);
147
+ } catch (error) {
148
+ if (error instanceof FlowError && error.code === 'conflict') return false;
149
+ throw error;
150
+ }
151
+ const { outcome } = await linkRecord(tx, organisationId, {
152
+ entity: 'contact', flowId: row.id, system, externalId: String(customer.id), matchedBy: 'imported',
153
+ });
154
+ return outcome === 'linked' || outcome === 'existing';
155
+ });
156
+ }
157
+
122
158
  async function readCursor(db, organisationId, name) {
123
159
  const [row] = await db.select().from(pipelineCursors)
124
160
  .where(and(eq(pipelineCursors.organisationId, organisationId), eq(pipelineCursors.name, name)));
@@ -500,9 +536,12 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
500
536
  if (work) {
501
537
  let restart = (summary.prices?.linked ?? 0) > 0;
502
538
  if (typeof work.capabilities.listCustomers === 'function') {
503
- summary.workCustomers = await linkCustomers(db, organisationId, { system: work.id, customers: await work.capabilities.listCustomers({ organisationId }) });
539
+ const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_contacts');
540
+ summary.workCustomers = await linkCustomers(db, organisationId, {
541
+ system: work.id, customers: await work.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
542
+ });
504
543
  // New customer links reach jobs read before them.
505
- restart ||= summary.workCustomers.linked > 0;
544
+ restart ||= summary.workCustomers.linked + summary.workCustomers.added > 0;
506
545
  }
507
546
  summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
508
547
  // After the live feed, so a job both report is the live one.
@@ -512,8 +551,9 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
512
551
  }
513
552
  if (accounting) {
514
553
  if (typeof accounting.capabilities.listCustomers === 'function') {
554
+ const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_xero_contacts');
515
555
  summary.accountingCustomers = await linkCustomers(db, organisationId, {
516
- system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
556
+ system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
517
557
  });
518
558
  }
519
559
  const settings = await readInvoicingSettings(db, organisationId);
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
- import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
5
+ import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
7
7
  import { issueInvoice } from './invoices.js';
8
8
  import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
@@ -20,7 +20,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
20
20
  // rules (invoice-rules.js) add their notes. An enabled skip rule does too:
21
21
  // a draft whose job it matches stays Pending, even when the draft was made
22
22
  // by hand or before the rule existed. A draft with a problem is
23
- // Pending (the "problem" view). A draft without one is Ready.
23
+ // Pending (the "problem" view). A draft without one is Ready. A draft whose
24
+ // job a non-chargeable rule matches is neither: it is Non-chargeable, and
25
+ // nothing sends it.
24
26
  //
25
27
  // Send all ready issues every ready draft (each gets its number) and, when an
26
28
  // accounting system is connected, queues it to go there. The send worker
@@ -97,14 +99,14 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
97
99
  // The lab's rules (invoice-rules.js). A pending rule puts its note on the
98
100
  // draft. A skip rule holds a draft that already exists: void it, or change
99
101
  // the rule. Matching stays in invoice-rules.js.
100
- const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && (r.action.kind === 'pending' || r.action.kind === 'skip'));
102
+ const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && ['pending', 'skip', 'non_chargeable'].includes(r.action.kind));
101
103
  if (rules.length && jobs.length) {
102
104
  const settings = await readInvoicingSettings(db, organisationId);
103
105
  const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
104
106
  for (const { obligation, reference, customerName } of jobs) {
105
107
  const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
106
108
  const job = { ...obligation, reference };
107
- const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
109
+ const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
108
110
  if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
109
111
  }
110
112
  }
@@ -125,17 +127,28 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
125
127
  return problems;
126
128
  }
127
129
 
130
+ // A draft a non-chargeable rule matches has nothing to fix: it is never sent.
131
+ const nonChargeableOf = (found) => found.filter((p) => p.code === 'non_chargeable');
132
+
128
133
  // The views of the Invoices page. `accounting` is the accounting adapter, or
129
- // null. Every draft is in one view: Problem (a check failed; the Pending tab)
130
- // or Ready (its checks pass). An issued invoice is Sending while
131
- // queued, and a Problem when the accounting system refused it. Answers
132
- // { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
134
+ // null. Every draft is in one view: Non-chargeable (a non-chargeable rule
135
+ // matches its job), Problem (a check failed; the Pending tab) or Ready (its
136
+ // checks pass). An issued invoice is Sending while queued, and a Problem when
137
+ // the accounting system refused it. Answers { toCheck: [ids], ready: [ids],
138
+ // problems: Map(id -> problems), nonChargeable: Map(id -> rules), sending: Set(ids) }.
133
139
  export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
134
140
  const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents, readyAt: invoices.readyAt }).from(invoices)
135
141
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft')))
136
142
  .orderBy(asc(invoices.createdAt), asc(invoices.id))
137
143
  .limit(CHECK_LIMIT);
138
144
  const problems = await checkDrafts(db, organisationId, drafts, accounting);
145
+ const nonChargeable = new Map();
146
+ for (const [id, found] of problems) {
147
+ const rules = nonChargeableOf(found);
148
+ if (!rules.length) continue;
149
+ nonChargeable.set(id, rules);
150
+ problems.delete(id);
151
+ }
139
152
  const sending = new Set();
140
153
  const issued = await db.select({ id: invoices.id, sendState: invoices.sendState, sendError: invoices.sendError }).from(invoices)
141
154
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), inArray(invoices.sendState, ['queued', 'failed'])))
@@ -146,11 +159,12 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
146
159
  }
147
160
  // A draft whose checks pass is ready (Samuel, 2026-10-20): no person
148
161
  // needs to mark it. toCheck stays for callers that still read it.
149
- const clean = drafts.filter((d) => !problems.has(d.id));
162
+ const clean = drafts.filter((d) => !problems.has(d.id) && !nonChargeable.has(d.id));
150
163
  return {
151
164
  toCheck: [],
152
165
  ready: clean.map((d) => d.id),
153
166
  problems,
167
+ nonChargeable,
154
168
  sending,
155
169
  };
156
170
  }
@@ -164,6 +178,8 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
164
178
  return { view: null, problems: [] };
165
179
  }
166
180
  const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
181
+ const rules = nonChargeableOf(found);
182
+ if (rules.length) return { view: 'non_chargeable', problems: [], nonChargeable: rules };
167
183
  return { view: found.length ? 'problem' : 'ready', problems: found };
168
184
  }
169
185
 
@@ -279,6 +295,8 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
279
295
  ready = ready.filter((id) => wanted.has(id));
280
296
  held = wanted.size - ready.length;
281
297
  if (!ready.length) {
298
+ const kept = invoiceIds.map((id) => views.nonChargeable.get(id)?.[0]?.message).find(Boolean);
299
+ if (kept) throw new FlowError('not_ready', `Not sent: ${kept}`);
282
300
  const reason = invoiceIds.map((id) => views.problems.get(id)?.[0]?.message).find(Boolean);
283
301
  throw new FlowError('not_ready', reason
284
302
  ? `Not sent: ${reason.replace(/\.?$/, '.')} The invoice is on Pending.`
@@ -32,7 +32,7 @@ export const CAPABILITIES = Object.freeze([
32
32
  'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
33
33
  'setPersonSetting', // { personId, key, value }: change one, answers the new list
34
34
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
35
- 'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
35
+ 'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts). An optional `contact` is the Flow contact a new customer becomes; `adoptable` false keeps one from becoming a contact (docs/CONTACTS.md)
36
36
  'createCustomer', // { organisationId, contactId, actor }: create the system's customer from the Flow contact. Answers { outcome, contact }. Xero, when Create missing clients is on.
37
37
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
38
38
  'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
@@ -134,12 +134,19 @@ export async function readBillableOrder(db, orderId) {
134
134
  // with no company name is listed by its full name. A client the client list
135
135
  // never gave (the list can be closed to an API client) is listed by the
136
136
  // company name its orders carry, so its jobs still find their customer.
137
+ // `contact` is the Flow contact a new client becomes (docs/CONTACTS.md).
137
138
  export async function listClients(db) {
138
- const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
139
- .from(seazonaClients);
139
+ const rows = await db.select({
140
+ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active,
141
+ accountNumber: seazonaClients.accountNumber, email: seazonaClients.email, phone: seazonaClients.phone,
142
+ address: seazonaClients.address, taxRate: seazonaClients.taxRate, doctors: seazonaClients.doctors,
143
+ }).from(seazonaClients);
140
144
  const listed = rows
141
145
  .filter((r) => r.active !== false)
142
- .map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
146
+ .map((r) => {
147
+ const name = (r.company || r.fullName || '').trim();
148
+ return { id: r.id, name, contact: clientContact(r, name) };
149
+ })
143
150
  .filter((r) => r.name);
144
151
  const known = new Set(rows.map((r) => r.id));
145
152
  const fromOrders = await db.selectDistinctOn([seazonaOrders.clientId], { id: seazonaOrders.clientId, company: seazonaOrders.company })
@@ -148,10 +155,40 @@ export async function listClients(db) {
148
155
  .orderBy(seazonaOrders.clientId, sql`${seazonaOrders.lastChangedAt} desc`);
149
156
  return [
150
157
  ...listed,
151
- ...fromOrders.filter((r) => !known.has(r.id)).map((r) => ({ id: r.id, name: r.company.trim() })),
158
+ ...fromOrders.filter((r) => !known.has(r.id)).map((r) => {
159
+ const name = r.company.trim();
160
+ return { id: r.id, name, contact: { kind: 'customer', name } };
161
+ }),
152
162
  ];
153
163
  }
154
164
 
165
+ // A Seazona client as a Flow contact: the billing fields and the doctors as
166
+ // practitioners. A field Flow cannot keep is left out.
167
+ const cut = (value, max) => {
168
+ const text = String(value ?? '').trim();
169
+ return text && text.length <= max ? text : null;
170
+ };
171
+ export function clientContact(row, name) {
172
+ const address = row.address ?? {};
173
+ const kept = {
174
+ line1: cut(address.address1, 255), line2: cut(address.address2, 255), city: cut(address.city, 255),
175
+ region: cut(address.region, 255), postalCode: cut(address.postalCode, 255), country: cut(address.countryCode, 255),
176
+ };
177
+ const practitioners = (Array.isArray(row.doctors) ? row.doctors : [])
178
+ .map((doctor) => cut(doctor?.fullName, 255)).filter(Boolean).slice(0, 30).map((person) => ({ name: person }));
179
+ const contact = { kind: 'customer', name, practitioners };
180
+ const email = cut(row.email, 255);
181
+ if (email) contact.email = email;
182
+ const phone = cut(row.phone, 50);
183
+ if (phone) contact.phone = phone;
184
+ const account = cut(row.accountNumber, 50);
185
+ if (account) contact.accountNumber = account;
186
+ const taxRate = cut(row.taxRate, 50);
187
+ if (taxRate) contact.taxRate = taxRate;
188
+ if (Object.values(kept).some(Boolean)) contact.address = Object.fromEntries(Object.entries(kept).filter(([, v]) => v));
189
+ return contact;
190
+ }
191
+
155
192
  // Seazona's products, from Flow's copy (the products lane reads them daily),
156
193
  // for the price list's links and price check (server/core/price-list.js).
157
194
  // Seazona says only whether a product is taxable: taxable reads as GST.
@@ -36,7 +36,7 @@ import {
36
36
  // withdraws it, so its invoice goes to review.
37
37
  // clients the whole list (GET clients/?lastModified=), read on from the
38
38
  // last row when it is full.
39
- // invoices every change since the oldest invoice Flow holds, read on the
39
+ // invoices every change since the invoice lane first read, read on the
40
40
  // same way.
41
41
  //
42
42
  // A client or invoice that Flow holds and the whole list leaves out is read
@@ -282,12 +282,15 @@ const WHOLE_LISTS = {
282
282
  list: (client, since) => client.get('invoices/', { lastModified: since }),
283
283
  // The guide: on or after lastModified (inclusive).
284
284
  stepBackMs: 0,
285
- // A day before the oldest change Flow holds: an invoice older than that
286
- // was never in Flow's copy, so it cannot be missing from it.
287
- async firstSince(db) {
288
- const [{ oldest }] = await db.select({ oldest: sql`min(${seazonaInvoices.lastModifiedText})` }).from(seazonaInvoices);
289
- const oldestMs = parseUtc(oldest);
290
- return Number.isFinite(oldestMs) ? new Date(floorUtcDay(oldestMs) - DAY_MS).toISOString() : null;
285
+ // Where the invoice lane first read: the start of the UTC day of its
286
+ // first run, less SEAZONA_INVOICE_LOOKBACK_DAYS (lanes.js). An invoice
287
+ // last changed before that was never the lane's to read, so it is not a
288
+ // miss (FLOW-HORIZON-K: a day's margin before it counted such invoices).
289
+ // The first row Flow stored marks the first run.
290
+ async firstSince(db, sync) {
291
+ const [{ first }] = await db.select({ first: sql`min(${seazonaInvoices.firstSeenAt})` }).from(seazonaInvoices);
292
+ const firstMs = first ? new Date(first).getTime() : NaN;
293
+ return Number.isFinite(firstMs) ? new Date(floorUtcDay(firstMs) - sync.invoiceLookbackDays * DAY_MS).toISOString() : null;
291
294
  },
292
295
  },
293
296
  };
@@ -416,7 +419,7 @@ async function sweepWholeList(ctx, resource, cursor, laneStates, helpers, found)
416
419
  const readsFrom = laneReadsFrom(laneStates.get(resource), sync);
417
420
  if (readsFrom === null) return;
418
421
  if (!cursor[resource]?.cycleStartedAt) {
419
- const since = await spec.firstSince(db);
422
+ const since = await spec.firstSince(db, sync);
420
423
  if (since === null) {
421
424
  cursor[resource] = { lastFinishedAt: at.toISOString(), listed: 0 };
422
425
  return;
@@ -47,8 +47,8 @@ export const SENTRY_SETUP = {
47
47
  { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
48
48
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
49
49
  { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
50
- { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
- { name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
50
+ { name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
+ { name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
52
52
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
53
53
  ],
54
54
  };
@@ -233,6 +233,9 @@ export function createXeroClient({
233
233
  async listContacts({ maxPages } = {}) {
234
234
  return collectPages(async (page) => (await request('GET', 'Contacts', { query: { page } })).Contacts, { maxPages });
235
235
  },
236
+ async getContact(contactId) {
237
+ return first((await request('GET', `Contacts/${encodeURIComponent(contactId)}`)).Contacts);
238
+ },
236
239
  async findContactsByName(name) {
237
240
  return (await request('GET', 'Contacts', { query: { where: `Name==${quote(name)}` } })).Contacts ?? [];
238
241
  },
@@ -329,6 +332,26 @@ export function createXeroClient({
329
332
  }
330
333
  return created;
331
334
  },
335
+ // Changes the fields in `changes` on one contact (contact-sync.js). The
336
+ // body holds only those fields and the id; Xero keeps every other field.
337
+ async updateContact(contactId, changes, { idempotencyKey }) {
338
+ if (!contactId) throw new TypeError('updateContact: contactId is required');
339
+ if (!idempotencyKey) throw new TypeError('updateContact: idempotencyKey is required');
340
+ const payload = await request('POST', `Contacts/${encodeURIComponent(contactId)}`, {
341
+ query: { summarizeErrors: 'false' },
342
+ body: { Contacts: [{ ...changes, ContactID: contactId }] },
343
+ headers: { 'Idempotency-Key': idempotencyKey },
344
+ });
345
+ const updated = first(payload.Contacts);
346
+ const messages = validationMessages(updated);
347
+ if (!updated?.ContactID || updated.HasErrors || updated.StatusAttributeString === 'ERROR' || messages.length) {
348
+ throw xeroError('xero_validation_failed', messages.join(' ') || 'Xero refused the contact change.', {
349
+ status: 422,
350
+ details: messages.length ? { validationErrors: messages } : undefined,
351
+ });
352
+ }
353
+ return updated;
354
+ },
332
355
  async createInvoice(invoice, { idempotencyKey }) {
333
356
  if (!idempotencyKey) throw new TypeError('createInvoice: idempotencyKey is required');
334
357
  const payload = await request('POST', 'Invoices', {