@arcforgelabs/flow 2026.11.2 → 2026.11.3

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.
package/SECURITY.md CHANGED
@@ -31,6 +31,17 @@ paste a credential, in full or in part.
31
31
  person/client/organisation read grant. The first slice exposes four bounded read tools
32
32
  (`docs/AGENT_ACCESS.md`). The reserved `/api/flow/v1` stub still refuses all
33
33
  requests. It does not implement connector keys.
34
+ - **AI assistant key.** Also off by default. When the host sets
35
+ `FLOW_AGENT_KEY_SHA256` and `FLOW_AGENT_EMAIL`, one bearer key acts as one
36
+ Flow person, inside that person's role, on `/api/me`, `/api/invoicing/…`,
37
+ `/api/xero/…` (never connect or disconnect), `GET /api/seazona/api/…` and the Seazona history import `/api/seazona/history…` only (`docs/AGENT_ACCESS.md`, "AI assistant key").
38
+ With the admin role that includes invoice writes: rules, void, delete draft
39
+ and send to the accounting system. Flow stores only the key's SHA-256; the key
40
+ stays with the assistant. Owner actions always refuse the key. Turning the
41
+ person off stops the key on the next request and stops sends it queued. The
42
+ Seazona read answers what Seazona gives, patient fields included; the owner
43
+ can turn on "Hide patient details" to drop them. Use an assistant whose model
44
+ runs locally or with zero data retention.
34
45
  - **Integrations.** Seazona, Xero and every later adapter reach the core only
35
46
  through the integration port. Their credentials are stored per
36
47
  organisation, encrypted, and are write-only in Settings.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.2",
3
+ "version": "2026.11.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.11.2",
9
+ "version": "2026.11.3",
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.11.2",
3
+ "version": "2026.11.3",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "files": [
22
22
  "server/",
23
+ "skills/",
23
24
  "SECURITY.md",
24
25
  "VISION.md",
25
26
  "npm-shrinkwrap.json"
@@ -4,6 +4,7 @@ import { invoiceKindOf, invoicePage, INVOICE_SORTS, normaliseInvoicesView } from
4
4
  import { fieldIdOf, filterRows } from '../ui/job-view.js';
5
5
  import { listFields, listJobFieldValues } from '../core/custom-fields.js';
6
6
  import { FlowError } from '../core/errors.js';
7
+ import { listReview } from '../core/review.js';
7
8
  import { createActionGuard } from '../integrations/call-guard.js';
8
9
  import {
9
10
  createDraftInvoice, deleteDraft, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
@@ -27,7 +28,7 @@ import {
27
28
  acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, linkPriceItems, MAX_LINKS_AT_ONCE, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
28
29
  } from '../core/price-list.js';
29
30
  import {
30
- confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
31
+ confirmNoInvoice, draftInvoiceForObligation, freeVoidedJob, setHold, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
31
32
  setReady, settleJobChange, useSourceLines,
32
33
  } from '../core/ledger.js';
33
34
  import { isHistoryHeld } from '../core/history.js';
@@ -99,6 +100,8 @@ import {
99
100
  // PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
100
101
  // POST /jobs/:id/ready { ready }: mark a job ready to invoice, or back in work
101
102
  // GET /jobs/:id/activity what happened to a job, newest first
103
+ // POST /jobs/:id/hold owner or admin: { state: none|held|review, reason }
104
+ // POST /jobs/:id/free owner or admin: { reason }: let go of a void invoice, then hold
102
105
  // POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
103
106
  // check that the accounting system holds none
104
107
  //
@@ -825,6 +828,23 @@ export function createInvoicingRouter({
825
828
  res.json({ ok: true, ...await listReconcile(db, organisationId) });
826
829
  }));
827
830
 
831
+ // The open review queue (core/review.js): daily reconcile days, job
832
+ // changes, links Flow could not prove, failed writes. Read only: each item
833
+ // closes when its cause is fixed (or by the screen that owns it).
834
+ router.get('/review', ...signedIn, handle(log, async (req, res) => {
835
+ const organisationId = await organisationOf(req);
836
+ const state = text(req.query.state) || 'open';
837
+ if (!['open', 'resolved', 'dismissed'].includes(state)) throw new FlowError('invalid', 'state must be open, resolved or dismissed');
838
+ const kind = text(req.query.kind) || undefined;
839
+ const limit = Math.min(Math.max(Number.parseInt(text(req.query.limit) || '100', 10) || 100, 1), 500);
840
+ const items = await listReview(db, organisationId, { state, kind, limit });
841
+ res.json({ ok: true, items: items.map((r) => ({
842
+ id: r.id, kind: r.kind, entity: r.entity, flowId: r.flowId, systems: r.systems, evidence: r.evidence,
843
+ moneyAtRiskCents: r.moneyAtRiskCents, seenCount: r.seenCount, state: r.state, resolution: r.resolution,
844
+ createdAt: r.createdAt, updatedAt: r.updatedAt,
845
+ })) });
846
+ }));
847
+
828
848
  router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
829
849
  const obligationId = pathId(req.params.id, 'job');
830
850
  const organisationId = await organisationOf(req);
@@ -848,10 +868,39 @@ export function createInvoicingRouter({
848
868
  router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
849
869
  const obligationId = pathId(req.params.id, 'job');
850
870
  const organisationId = await organisationOf(req);
851
- const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
871
+ const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}`, system: accounting()?.id ?? null });
852
872
  res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
853
873
  }));
854
874
 
875
+ // Holds a job, sends it to review, or releases it (state "none"). Every
876
+ // change needs a reason, kept in the job's activity.
877
+ router.post('/jobs/:id/hold', ...manager, handle(log, async (req, res) => {
878
+ const obligationId = pathId(req.params.id, 'job');
879
+ const organisationId = await organisationOf(req);
880
+ const state = text(req.body?.state);
881
+ const reason = text(req.body?.reason);
882
+ if (!['none', 'held', 'review'].includes(state)) throw invalid('state must be none, held or review');
883
+ if (!reason) throw invalid('a reason is required');
884
+ const [current] = await db.select({ holdState: billingObligations.holdState, holdReason: billingObligations.holdReason }).from(billingObligations)
885
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
886
+ if (state === 'none' && isHistoryHeld(current)) {
887
+ throw invalid('this is a history-import hold: settle it with the history import decisions');
888
+ }
889
+ const job = await setHold(db, organisationId, obligationId, { holdState: state, reason, actor: `person:${req.person.id}` });
890
+ res.json({ ok: true, job: { id: job.id, holdState: job.holdState, holdReason: job.holdReason } });
891
+ }));
892
+
893
+ // Frees a job stuck on a void Flow invoice, or on an accounting invoice that
894
+ // was voided or deleted there. The job goes on hold until a person releases it.
895
+ router.post('/jobs/:id/free', ...manager, handle(log, async (req, res) => {
896
+ const obligationId = pathId(req.params.id, 'job');
897
+ const organisationId = await organisationOf(req);
898
+ const reason = text(req.body?.reason);
899
+ if (!reason) throw invalid('a reason is required');
900
+ const job = await freeVoidedJob(db, organisationId, obligationId, { actor: `person:${req.person.id}`, reason, system: accounting()?.id ?? null });
901
+ res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId, accountingState: job.accountingState, holdState: job.holdState, holdReason: job.holdReason } });
902
+ }));
903
+
855
904
  // Links a job to a Flow invoice that already exists.
856
905
  router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
857
906
  const obligationId = pathId(req.params.id, 'job');
@@ -1,3 +1,4 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import express from 'express';
2
3
  import { FlowError } from '../core/errors.js';
3
4
  import { rateLimit } from '../rate-limit.js';
@@ -18,6 +19,9 @@ import {
18
19
  updatePerson,
19
20
  } from './people.js';
20
21
  import { isPlainEmail } from './settings.js';
22
+
23
+ // The files of skills/flow that GET /api/agent-skill/flow/<file> serves.
24
+ const SKILL_FILES = { 'SKILL.md': 'text/markdown', 'flow.sh': 'text/x-shellscript' };
21
25
  import { APP_PATHS, appPage } from '../ui/app-page.js';
22
26
  import { navPinsFromViews } from '../ui/nav-pins.js';
23
27
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -110,6 +114,22 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
110
114
  res.json(body);
111
115
  });
112
116
 
117
+ // The AI assistant's own skill, from the Flow it talks to, so the skill
118
+ // always matches this Flow's routes (`flow.sh update`). Only the two skill
119
+ // files; the release that runs here is the one they come from.
120
+ router.get('/api/agent-skill/flow/:file', requireSession, async (req, res) => {
121
+ const file = SKILL_FILES[req.params.file];
122
+ if (!file) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such skill file.' });
123
+ try {
124
+ const text = await readFile(new URL(`../../skills/flow/${req.params.file}`, import.meta.url), 'utf8');
125
+ res.set('Cache-Control', 'no-store');
126
+ res.type(file).send(text);
127
+ } catch (error) {
128
+ log.error?.(`[auth] skill file ${req.params.file} unreadable: ${error.message}`);
129
+ res.status(503).json({ ok: false, code: 'skill_unavailable', error: 'This Flow has no skill files.' });
130
+ }
131
+ });
132
+
113
133
  // Each person sets their own appearance; it follows their account.
114
134
  router.put('/api/me/appearance', requireSession, express.json({ limit: '4kb' }), async (req, res) => {
115
135
  try {
@@ -1,3 +1,4 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
1
2
  import { displayNameOf, googleWorkspaceOwner, primaryEmailOf, secondFactorVerified } from './clerk.js';
2
3
  import { fillPersonName, findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
3
4
  import { DEFAULT_OWNER_MFA_MAX_AGE_MINUTES, isPlainEmail } from './settings.js';
@@ -52,6 +53,7 @@ export function createPersonAuth({
52
53
  log = console,
53
54
  now = Date.now,
54
55
  clerkUserCacheMs = 60 * 1000,
56
+ agentKey = null,
55
57
  }) {
56
58
  if (!db) throw new Error('createPersonAuth: db is required');
57
59
  if (!clerk?.authenticate || !clerk?.getUser) throw new Error('createPersonAuth: a Clerk adapter is required');
@@ -118,7 +120,9 @@ export function createPersonAuth({
118
120
  // Every person route starts here. The session token must be in the
119
121
  // Authorization header; Clerk verifies it, and Flow checks its expiry too.
120
122
  async function requireSession(req, res, next) {
121
- if (!bearerToken(req)) return deny(res, 'signed_out');
123
+ const token = bearerToken(req);
124
+ if (!token) return deny(res, 'signed_out');
125
+ if (agentKey && isAgentKey(token)) return agentSession(req, res, next);
122
126
  let auth;
123
127
  try {
124
128
  auth = await clerk.authenticate(req);
@@ -145,6 +149,37 @@ export function createPersonAuth({
145
149
  return next();
146
150
  }
147
151
 
152
+ // The AI assistant key (FLOW_AGENT_KEY). It acts as one Flow person, and
153
+ // only inside that person's role. Owner actions stay with people: an agent
154
+ // request is never a verified owner sign-in.
155
+ // Keys shorter than 32 characters are refused, so a digest of a short
156
+ // guess never matches.
157
+ const agentDigest = agentKey ? Buffer.from(agentKey.digest, 'hex') : null;
158
+ function isAgentKey(token) {
159
+ return token.length >= 32 && timingSafeEqual(createHash('sha256').update(token).digest(), agentDigest);
160
+ }
161
+ // The key opens only these routes: invoicing, Xero (owner routes refuse it
162
+ // below), the Seazona read, the Seazona history import, /api/me and its own
163
+ // skill files.
164
+ const AGENT_PATHS = /^\/api\/(?:me$|invoicing\/|xero\/|seazona\/(?:api|history)(?:\/|$)|agent-skill\/flow\/)/;
165
+ async function agentSession(req, res, next) {
166
+ if (!AGENT_PATHS.test(`${req.baseUrl}${req.path}`)) return deny(res, 'role_required');
167
+ let person;
168
+ try {
169
+ person = await findPersonByEmail(db, agentKey.email);
170
+ } catch (error) {
171
+ log.error?.(`[auth] Flow agent person lookup failed: ${error.message}`);
172
+ return deny(res, 'sign_in_unavailable');
173
+ }
174
+ if (!person) return deny(res, 'not_invited');
175
+ if (person.status !== 'active') return deny(res, 'access_disabled');
176
+ req.person = person;
177
+ req.clerkAuth = null;
178
+ req.agent = true;
179
+ log.info?.(`[auth] agent key: ${req.method} ${req.baseUrl}${req.path} as Flow person ${person.id} (${person.role})`);
180
+ return next();
181
+ }
182
+
148
183
  function requireRole(...roles) {
149
184
  return (req, res, next) => (roles.includes(req.person?.role) ? next() : deny(res, 'role_required'));
150
185
  }
@@ -165,6 +200,7 @@ export function createPersonAuth({
165
200
  // (Replit's managed Clerk, which has no MFA).
166
201
  // A Clerk error fails closed. Returns { verified, by } or { unavailable }.
167
202
  async function ownerVerification(person, auth) {
203
+ if (!auth) return { verified: false, by: null };
168
204
  if (secondFactorVerified(auth, ownerMfaMaxAgeMinutes)) return { verified: true, by: 'second_factor' };
169
205
  if (!ownerGoogleDomains.length) return { verified: false, by: null };
170
206
  try {
@@ -182,6 +218,7 @@ export function createPersonAuth({
182
218
  // requireSession.
183
219
  async function requireOwner(req, res, next) {
184
220
  if (req.person?.role !== 'owner') return deny(res, 'owner_required');
221
+ if (req.agent) return deny(res, 'owner_verification_required', ownerMessage);
185
222
  const check = await ownerVerification(req.person, req.clerkAuth);
186
223
  if (check.unavailable) return deny(res, 'sign_in_unavailable');
187
224
  if (!check.verified) return deny(res, 'owner_verification_required', ownerMessage);
@@ -127,6 +127,21 @@ export function isPlainEmail(email) {
127
127
  return typeof email === 'string' && /^[\x21-\x3f\x41-\x7e]+@[a-z0-9.-]+\.[a-z0-9-]+$/i.test(email);
128
128
  }
129
129
 
130
+ // FLOW_AGENT_KEY_SHA256 and FLOW_AGENT_EMAIL: one key for one AI assistant
131
+ // (an OpenClaw Gateway). A request with `Authorization: Bearer <key>`, where
132
+ // the key's SHA-256 is FLOW_AGENT_KEY_SHA256, acts as the Flow person with
133
+ // FLOW_AGENT_EMAIL, inside that person's role. Flow holds only the digest, so
134
+ // the key itself never leaves the assistant's machine. Both or neither.
135
+ export function resolveAgentKey(env = process.env) {
136
+ const digest = readEnv(env, 'FLOW_AGENT_KEY_SHA256').toLowerCase();
137
+ const email = readEnv(env, 'FLOW_AGENT_EMAIL').toLowerCase();
138
+ if (!digest && !email) return null;
139
+ if (!digest || !email) throw new Error('FLOW_AGENT_KEY_SHA256 and FLOW_AGENT_EMAIL must be set together');
140
+ if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error('FLOW_AGENT_KEY_SHA256 must be 64 hex characters (the SHA-256 of the key)');
141
+ if (!isPlainEmail(email)) throw new Error('FLOW_AGENT_EMAIL must be a plain (ASCII) email address');
142
+ return { digest, email };
143
+ }
144
+
130
145
  export function resolveSignInSettings(env = process.env) {
131
146
  const mode = resolveAuthMode(env);
132
147
  if (mode === 'off') return { mode };
@@ -139,5 +154,6 @@ export function resolveSignInSettings(env = process.env) {
139
154
  ownerGoogleDomains: parseOwnerGoogleDomains(readEnv(env, 'FLOW_OWNER_GOOGLE_DOMAINS')),
140
155
  authorizedParties: resolveAuthorizedParties(env),
141
156
  firstOwnerEmail: resolveFirstOwnerEmail(env),
157
+ agentKey: resolveAgentKey(env),
142
158
  };
143
159
  }
@@ -101,8 +101,9 @@ export async function listReconcile(db, organisationId) {
101
101
  status: billingObligations.sourceStatus,
102
102
  sourceSystem: billingObligations.sourceSystem,
103
103
  sourceJobRef: billingObligations.sourceJobRef,
104
- // An unclear accounting match: "Make invoice" settles it first.
105
- unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
104
+ // An unclear accounting match, or a matched accounting invoice that was
105
+ // voided or deleted there: "Make invoice" settles it first.
106
+ unclear: sql`${billingObligations.accountingState} in ('ambiguous', 'voided', 'deleted') and ${billingObligations.invoiceId} is null`.mapWith(Boolean),
106
107
  possible: sql`(select count(*) from invoices i
107
108
  where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
108
109
  and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
@@ -152,8 +153,9 @@ export async function jobMatchDetail(db, organisationId, obligationId, { referen
152
153
  lines: jobLines(o, codes),
153
154
  totalCents: o.totalCents,
154
155
  open: !o.invoiceId && !o.accountingRef,
155
- // An unclear accounting match: "Make invoice" settles it first.
156
- unclear: o.accountingState === 'ambiguous',
156
+ // An unclear accounting match, or a matched accounting invoice that was
157
+ // voided or deleted there: "Make invoice" settles it first.
158
+ unclear: !o.invoiceId && ['ambiguous', 'voided', 'deleted'].includes(o.accountingState),
157
159
  };
158
160
 
159
161
  const search = String(q ?? '').trim();
@@ -769,7 +769,51 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
769
769
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
770
770
  const row = await update(tx, obligation, { holdState, holdReason: holdState === 'none' ? null : reason.trim() });
771
771
  await addEvent(tx, row, holdState === 'none' ? 'released' : holdState, actor, {
772
- from: obligation.holdState, reason: row.holdReason,
772
+ from: obligation.holdState, reason: holdState === 'none' ? (reason?.trim() || null) : row.holdReason,
773
+ });
774
+ return row;
775
+ });
776
+ }
777
+
778
+ const GONE_STATES = ['voided', 'deleted'];
779
+
780
+ // A person (or the assistant, after the lab says yes) frees a job that is
781
+ // stuck on an invoice that bills nothing now: a Flow invoice that was voided,
782
+ // or an accounting invoice that was voided or deleted there. The job lets go
783
+ // of that invoice and goes on hold, so nothing drafts until a person releases
784
+ // it (setHold to 'none'). A Flow invoice that is still live must be voided
785
+ // first: this never voids or changes an invoice.
786
+ export async function freeVoidedJob(db, organisationId, obligationId, { actor, reason, system = null }) {
787
+ requireActor(actor);
788
+ if (!reason?.trim()) throw new FlowError('invalid', 'say why the job is freed');
789
+ return db.transaction(async (tx) => {
790
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
791
+ let flowInvoice = null;
792
+ if (obligation.invoiceId) {
793
+ [flowInvoice] = await tx.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
794
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, obligation.invoiceId)));
795
+ if (flowInvoice && flowInvoice.status !== 'void') {
796
+ throw new FlowError('invalid', `Flow invoice ${flowInvoice.number ?? flowInvoice.id} is ${flowInvoice.status}: void it first, then free the job`);
797
+ }
798
+ }
799
+ const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
800
+ if (!flowInvoice && !gone) throw new FlowError('invalid', 'the job is not linked to a void or deleted invoice');
801
+ const row = await update(tx, obligation, {
802
+ ...(flowInvoice ? { invoiceId: null, draftBasis: null } : {}),
803
+ ...(gone || GONE_STATES.includes(obligation.accountingState)
804
+ ? { accountingState: 'not_found', accountingRef: null, accountingUrl: null, accountingMatchedBy: null, accountingCheckedAt: sql`now()` }
805
+ : {}),
806
+ holdState: 'held',
807
+ holdReason: reason.trim(),
808
+ });
809
+ if (gone && system) {
810
+ await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
811
+ }
812
+ await addEvent(tx, row, 'freed', actor, {
813
+ reason: reason.trim(),
814
+ ...(flowInvoice ? { invoiceId: flowInvoice.id } : {}),
815
+ ...(gone ? { released: gone } : {}),
816
+ from: obligation.holdState,
773
817
  });
774
818
  return row;
775
819
  });
@@ -777,9 +821,10 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
777
821
 
778
822
  // A job with no invoice of its own whose accounting evidence is ambiguous:
779
823
  // the matcher found accounting invoices that may be this job's, but none
780
- // exactly. A person settles it on the Reconcile page: "Make invoice", or a
781
- // link to a Flow invoice (linkJobToInvoice).
782
- const unlinked = (o) => o.accountingState === 'ambiguous' && !o.invoiceId && !o.accountingRef;
824
+ // exactly. Or the accounting invoice the matcher linked was voided or deleted
825
+ // there: it bills nothing now. A person settles it on the Reconcile page or
826
+ // the job: "Make invoice", or a link to a Flow invoice (linkJobToInvoice).
827
+ const unlinked = (o) => !o.invoiceId && (GONE_STATES.includes(o.accountingState) || (o.accountingState === 'ambiguous' && !o.accountingRef));
783
828
 
784
829
  // The matcher's open suggestions for one job, best first.
785
830
  export async function linkSuggestionsFor(db, organisationId, obligationId) {
@@ -814,14 +859,26 @@ async function rejectSuggestions(tx, obligation, { actor }) {
814
859
  // "Make invoice": a person says the job has no invoice yet. Every suggestion
815
860
  // is rejected and the job is recorded as not found, so it can draft. The
816
861
  // live check before the draft still runs (draftInvoiceForObligation).
817
- export async function confirmNoInvoice(db, organisationId, obligationId, { actor }) {
862
+ // `system` is the accounting system's id: a released voided or deleted link
863
+ // becomes that person's "not this one" too, so the matcher never offers it.
864
+ export async function confirmNoInvoice(db, organisationId, obligationId, { actor, system = null }) {
818
865
  requireActor(actor);
819
866
  return db.transaction(async (tx) => {
820
867
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
821
868
  if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
822
869
  const rejected = await rejectSuggestions(tx, obligation, { actor });
823
- const row = await update(tx, obligation, { accountingState: 'not_found', accountingCheckedAt: sql`now()` });
824
- await addEvent(tx, row, 'accounting_checked', actor, { reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected });
870
+ // A voided or deleted link is let go, so the matcher stops following it.
871
+ const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
872
+ const row = await update(tx, obligation, {
873
+ accountingState: 'not_found', accountingCheckedAt: sql`now()`,
874
+ ...(gone ? { accountingRef: null, accountingUrl: null, accountingMatchedBy: null } : {}),
875
+ });
876
+ if (gone && system) {
877
+ await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
878
+ }
879
+ await addEvent(tx, row, 'accounting_checked', actor, {
880
+ reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected, ...(gone ? { released: gone } : {}),
881
+ });
825
882
  return row;
826
883
  });
827
884
  }
@@ -342,6 +342,10 @@ export function rankCandidates(obligation, invoices, { prior = null, rivals = []
342
342
 
343
343
  const amountsAgree = (obligation, invoice) => totalsFor(obligation).has(invoice.totalCents);
344
344
  const withoutReissue = (ref) => String(ref).replace(/R$/i, '');
345
+ // A voided or deleted accounting invoice bills nothing: it never links a job
346
+ // by itself. A lab that reissued work voids the first invoice, and that one
347
+ // keeps the work system's number.
348
+ const live = (invoice) => !['voided', 'deleted'].includes(invoice.state);
345
349
 
346
350
  async function saveSuggestions(db, organisationId, obligationId, system, ranked) {
347
351
  const keep = ranked.slice(0, 5);
@@ -392,14 +396,14 @@ export async function matchJob(db, organisationId, obligation, {
392
396
  });
393
397
  if (prior) {
394
398
  const [invoice] = await find({ invoiceIds: [prior.externalId] });
395
- if (invoice && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
399
+ if (invoice && live(invoice) && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
396
400
  return exact(invoice, 'prior_link');
397
401
  }
398
402
  }
399
403
  if (obligation.sourceInvoiceRef) {
400
404
  const numbers = [obligation.sourceInvoiceRef, withoutReissue(obligation.sourceInvoiceRef)];
401
405
  const same = (await find({ numbers }))
402
- .filter((i) => !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
406
+ .filter((i) => live(i) && !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
403
407
  if (same.length === 1) return exact(same[0], 'source_invoice_ref');
404
408
  }
405
409
  // Without the customer's accounting contact there is nothing to compare.
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
- import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
3
+ import { billingObligations, contacts, invoiceLines, invoices, people } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
5
  import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
@@ -430,6 +430,11 @@ export function refusalReport(result) {
430
430
  return `[sending] the accounting system refused ${count} ${word}${detail ? `: ${detail}` : ''}`;
431
431
  }
432
432
 
433
+ async function requesterActive(db, personId) {
434
+ const [row] = await db.select({ status: people.status }).from(people).where(eq(people.id, personId));
435
+ return row?.status === 'active';
436
+ }
437
+
433
438
  // One pass of the send worker: up to `limit` queued invoices, oldest first.
434
439
  export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
435
440
  const rows = await db.select({ id: invoices.id, number: invoices.number, by: invoices.sendRequestedBy }).from(invoices)
@@ -439,6 +444,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
439
444
  const summary = { sent: 0, failed: 0, waiting: 0 };
440
445
  for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
441
446
  const known = new Map(group.map((row) => [row.id, row]));
447
+ // A send waits in the queue. The person who asked for it must still be
448
+ // active when it goes, so turning a person (or an assistant's key) off
449
+ // also stops what they queued.
450
+ if (by && !(await requesterActive(db, by))) {
451
+ for (const row of group) {
452
+ await settle(db, organisationId, row.id, { sendState: 'failed', sendError: { code: 'requester_disabled', message: 'The person who asked to send this is turned off. Send it again.' } });
453
+ summary.failed += 1;
454
+ }
455
+ summary.refused = [...new Set([...(summary.refused ?? []), 'requester_disabled'])];
456
+ continue;
457
+ }
442
458
  const { invoices: results } = await accounting.capabilities.pushInvoices({
443
459
  organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
444
460
  });
@@ -53,6 +53,7 @@ export const SEAZONA_SETUP = {
53
53
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
54
54
  { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
55
55
  { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
56
+ { name: 'SEAZONA_API_READ_HIDE_PATIENT', label: 'Hide patient details', kind: 'onoff', help: 'On: an AI assistant reading Seazona through Flow gets no patient, note, file or clinical fields.' },
56
57
  ],
57
58
  };
58
59
 
@@ -85,7 +86,11 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
85
86
  schema: seazonaSchema,
86
87
  setup: SEAZONA_SETUP,
87
88
  // The history import's routes (routes.js), under /api/seazona.
88
- routes: ({ db, personAuth, log }) => createSeazonaRouter({ db: runtime?.db ?? db, personAuth, settings: current().settings, log }),
89
+ routes: ({ db, personAuth, log }) => createSeazonaRouter({
90
+ db: runtime?.db ?? db, personAuth, settings: current().settings, log,
91
+ read: (path, query) => current().client.get(path, query),
92
+ hidePatientFields: () => Boolean(current().settings.sync.hidePatientFields),
93
+ }),
89
94
  // Every Seazona call goes through this call guard, inside the rate
90
95
  // governor (governor.js; docs/INTEGRATIONS.md, "Outbound calls").
91
96
  callGuard: SEAZONA_CALL_GUARD,
@@ -36,7 +36,16 @@ import { readBackfill, startBackfill, stopBackfill } from './history/backfill.js
36
36
  // (state: one state, or needs_person)
37
37
  // POST /history/ledger/:obligationId/release owner or admin: the job may draft
38
38
  //
39
- // None of these call Seazona.
39
+ // None of these call Seazona, except one read:
40
+ //
41
+ // GET /api/<resource>[/<id>]?<query> owner or admin: one GET to the
42
+ // Seazona REST API, through Flow's
43
+ // rate governor, answered as
44
+ // { ok, data }. resource is invoices,
45
+ // orders, clients, products or users.
46
+ // It never writes to Seazona. It is how
47
+ // an AI assistant reads Seazona with its
48
+ // Flow key (docs/AGENT_ACCESS.md).
40
49
 
41
50
  export const MAX_UPLOAD = '25mb';
42
51
 
@@ -56,7 +65,40 @@ const handle = (log, fn) => async (req, res) => {
56
65
  }
57
66
  };
58
67
 
59
- export function createSeazonaRouter({ db, personAuth, settings, log = console }) {
68
+ const API_RESOURCES = ['invoices', 'orders', 'clients', 'products', 'users'];
69
+
70
+ // With "Hide patient details" on (SEAZONA_API_READ_HIDE_PATIENT), any field
71
+ // whose name says patient, note, file, prescription and the like is dropped
72
+ // from the read, at any depth. Off by default.
73
+ const PRIVATE_FIELD = /patient|note|comment|instruction|remark|file|attach|image|photo|prescri|shade|clinical|medical|birth|dob/i;
74
+ // The value is parsed JSON, so it has no cycles.
75
+ export function withoutPrivateFields(value) {
76
+ if (Array.isArray(value)) return value.map(withoutPrivateFields);
77
+ if (!value || typeof value !== 'object') return value;
78
+ return Object.fromEntries(Object.entries(value)
79
+ .filter(([key]) => !PRIVATE_FIELD.test(key))
80
+ .map(([key, item]) => [key, withoutPrivateFields(item)]));
81
+ }
82
+ // A list read must carry the filter Flow's own sync uses. Seazona answers a
83
+ // list without it with an error, and every error counts against the call
84
+ // guard that also protects Flow's sync, so Flow refuses it before the call.
85
+ const SEAZONA_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
86
+ const LIST_FILTERS = {
87
+ orders: { key: 'ordered', hint: 'ordered=2026-11-11T00:00:00 (one day)' },
88
+ invoices: { key: 'lastModified', hint: 'lastModified=2026-11-01T00:00:00' },
89
+ clients: { key: 'lastModified', hint: 'lastModified=2026-11-01T00:00:00' },
90
+ };
91
+ export function listQueryProblem(resource, query) {
92
+ const filter = LIST_FILTERS[resource];
93
+ if (filter && !SEAZONA_TIME.test(query[filter.key] ?? '')) return `A ${resource} list needs ${filter.hint}.`;
94
+ for (const key of ['page', 'pageSize']) {
95
+ if (key in query && !/^[1-9]\d{0,2}$/.test(query[key])) return `${key} must be a whole number from 1 to 999.`;
96
+ }
97
+ return null;
98
+ }
99
+ const API_PATH = new RegExp(`^/api/(${API_RESOURCES.join('|')})(?:/([A-Za-z0-9-]{1,64}))?/?$`);
100
+
101
+ export function createSeazonaRouter({ db, personAuth, settings, read = null, hidePatientFields = () => false, log = console }) {
60
102
  const router = express.Router();
61
103
  // Sign-in off: every route answers 503, as the Xero routes do.
62
104
  const signedIn = personAuth
@@ -72,6 +114,27 @@ export function createSeazonaRouter({ db, personAuth, settings, log = console })
72
114
  return router;
73
115
  }
74
116
 
117
+ // One read from the Seazona API (above). Only plain query values pass on.
118
+ router.get(API_PATH, ...manager, handle(log, async (req, res) => {
119
+ if (!read) throw Object.assign(new FlowError('seazona_off', 'Seazona is not connected'), { status: 503 });
120
+ const [, resource, id] = API_PATH.exec(req.path);
121
+ const query = {};
122
+ for (const [key, value] of Object.entries(req.query)) {
123
+ if (typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(key)) query[key] = value.slice(0, 200);
124
+ }
125
+ const problem = id ? null : listQueryProblem(resource, query);
126
+ if (problem) return res.status(400).json({ ok: false, code: 'invalid', error: problem });
127
+ try {
128
+ const data = await read(id ? `${resource}/${id}` : `${resource}/`, query);
129
+ log.info?.(`[seazona] api read ${resource}${id ? '/:id' : ''} by ${actor(req)}`);
130
+ res.json({ ok: true, data: hidePatientFields() ? withoutPrivateFields(data) : data });
131
+ } catch (error) {
132
+ if (error?.name !== 'SeazonaApiError' && error?.name !== 'SeazonaCallsStopped') throw error;
133
+ const status = error.statusCode === 404 ? 404 : error.statusCode === 400 ? 400 : error.name === 'SeazonaCallsStopped' || error.statusCode === 429 || error.kind === 'governor_timeout' ? 429 : 502;
134
+ res.status(status).json({ ok: false, code: 'seazona_read_failed', error: error.message });
135
+ }
136
+ }));
137
+
75
138
  router.get('/history', ...signedIn, handle(log, async (req, res) => {
76
139
  res.json({ ok: true, history: await historySummary(db), backfill: await readBackfill(db), batches: await listBatches(db) });
77
140
  }));
@@ -187,6 +187,9 @@ export function resolveSeazonaSettings(env = process.env) {
187
187
  // print one. Off by default; on, orders and invoices keep Seazona's
188
188
  // patient field as the reference.
189
189
  keepPatientReference: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_KEEP_PATIENT_REFERENCE).toLowerCase()),
190
+ // Off by default: the Seazona read (routes.js) answers what Seazona gives.
191
+ // On, it drops patient, note, file and clinical fields first.
192
+ hidePatientFields: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_API_READ_HIDE_PATIENT).toLowerCase()),
190
193
  };
191
194
 
192
195
  const settings = {
@@ -119,7 +119,11 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
119
119
  // 3. The work system's own invoice number, as Xero's number or reference.
120
120
  if (obligation.sourceInvoiceRef) {
121
121
  const byId = new Map();
122
- for (const inv of sales(await client.findInvoicesByNumber(obligation.sourceInvoiceRef))) byId.set(inv.InvoiceID, inv);
122
+ // Its reissue too (a trailing R): a lab that reissued the work voided the
123
+ // first invoice and bills the R one. One call: Xero takes a list.
124
+ const base = obligation.sourceInvoiceRef.replace(/R$/i, '');
125
+ const numbers = [...new Set([obligation.sourceInvoiceRef, base, `${base}R`])].join(',');
126
+ for (const inv of sales(await client.findInvoicesByNumber(numbers))) byId.set(inv.InvoiceID, inv);
123
127
  for (const inv of sales(await client.findInvoicesByReference(obligation.sourceInvoiceRef))) byId.set(inv.InvoiceID, inv);
124
128
  const found = [...byId.values()];
125
129
  if (found.length === 1) return evidenceFor(runtime, client, found[0], 'source_invoice_ref', checkedAt);
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
54
+ export const APP_PATHS = ['/sign-in', '/home', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({