@arcforgelabs/flow 2026.10.48 → 2026.10.49

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.48",
3
+ "version": "2026.10.49",
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.49",
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.49",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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',
@@ -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);
@@ -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', {