@arcforgelabs/flow 2026.11.1 → 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.1",
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.1",
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.1",
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
  //
@@ -313,7 +316,7 @@ async function jobChangeDetail(db, organisationId, invoiceId, work) {
313
316
  const link = typeof source?.capabilities.jobLink === 'function'
314
317
  ? source.capabilities.jobLink({ sourceJobRef: change.sourceJobRef, sourceInvoiceRef: change.sourceInvoiceRef })
315
318
  : null;
316
- return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
319
+ return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null, canRecheck: typeof source?.capabilities?.readWorkItem === 'function' };
317
320
  }
318
321
 
319
322
  // Each invoice's tab (TABS): Map(id -> tab), the ids on each tab, the
@@ -783,18 +786,35 @@ export function createInvoicingRouter({
783
786
  const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
784
787
  if (typeof source?.capabilities?.readWorkItem !== 'function') throw new FlowError('not_ready', 'the work system cannot read one job');
785
788
  // One read for each job in RECHECK_GAP_MS, whatever the clicks.
786
- const last = rechecked.get(obligationId) ?? 0;
789
+ const last = rechecked.get(obligationId);
790
+ const retryAfter = (entry) => Math.max(1, Math.ceil((RECHECK_GAP_MS - (Date.now() - entry.at)) / 1000));
787
791
  let outcome = 'fresh';
788
- if (Date.now() - last >= RECHECK_GAP_MS) {
789
- rechecked.set(obligationId, Date.now());
790
- const actor = `person:${req.person.id}`;
791
- const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor, force: true })).get(obligationId);
792
- outcome = 'read';
793
- if (read.outcome === 'waiting') throw new FlowError('not_ready', `${source.name} could not be read: ${read.reason ?? 'try again soon'}`);
794
- await settleJobChange(db, organisationId, obligationId, { actor });
792
+ if (last && (last.state === 'reading' || Date.now() - last.at < RECHECK_GAP_MS)) {
793
+ if (last.state !== 'read') {
794
+ const retryAfterSeconds = retryAfter(last);
795
+ throw new FlowError('not_ready', last.state === 'reading'
796
+ ? 'No new source read. The previous recheck is still running. Wait, then recheck again.'
797
+ : 'No new source read. Wait ' + retryAfterSeconds + ' seconds, then recheck again. Previous error: ' + last.error,
798
+ { outcome: last.state === 'reading' ? 'read_in_progress' : 'cached_failed', retryAfterSeconds });
799
+ }
800
+ } else {
801
+ const entry = { at: Date.now(), state: 'reading', error: null };
802
+ rechecked.set(obligationId, entry);
803
+ const actor = 'person:' + req.person.id;
804
+ try {
805
+ const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor, force: true })).get(obligationId);
806
+ if (read.outcome === 'waiting') throw new FlowError('not_ready', source.name + ' could not be read: ' + (read.reason ?? 'the source is unavailable') + '. Wait, then recheck again.');
807
+ await settleJobChange(db, organisationId, obligationId, { actor });
808
+ entry.state = 'read';
809
+ outcome = 'read';
810
+ } catch (error) {
811
+ entry.state = 'failed';
812
+ entry.error = error instanceof FlowError ? error.message : 'The source recheck failed. Wait, then recheck again.';
813
+ throw new FlowError('not_ready', entry.error, { retryAfterSeconds: retryAfter(entry) });
814
+ }
795
815
  }
796
816
  const invoice = obligation.invoiceId ? await invoiceDetail(db, organisationId, obligation.invoiceId, accounting(), work()) : null;
797
- res.json({ ok: true, outcome, invoice });
817
+ res.json({ ok: true, outcome, retryAfterSeconds: retryAfter(rechecked.get(obligationId)), invoice });
798
818
  }));
799
819
 
800
820
  // A job with no invoice whose match is unclear (ledger.js). The linking
@@ -808,6 +828,23 @@ export function createInvoicingRouter({
808
828
  res.json({ ok: true, ...await listReconcile(db, organisationId) });
809
829
  }));
810
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
+
811
848
  router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
812
849
  const obligationId = pathId(req.params.id, 'job');
813
850
  const organisationId = await organisationOf(req);
@@ -831,10 +868,39 @@ export function createInvoicingRouter({
831
868
  router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
832
869
  const obligationId = pathId(req.params.id, 'job');
833
870
  const organisationId = await organisationOf(req);
834
- 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 });
835
872
  res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
836
873
  }));
837
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
+
838
904
  // Links a job to a Flow invoice that already exists.
839
905
  router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
840
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
  }
@@ -197,15 +197,24 @@ function matches(condition, job, context) {
197
197
  // The reasons the enabled rules give for one job, in rule order. An empty list
198
198
  // means no rule stops its draft. `customer` is the customer's own billing
199
199
  // settings (invoicing-rules.js).
200
- export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}) {
201
- const context = { customer, now };
202
- const reasons = [];
203
- for (const rule of rules) {
204
- if (!rule.enabled || rule.trigger !== 'job_ready' || ['pending', 'non_chargeable'].includes(rule.action.kind)) continue;
205
- if (!rule.conditions.every((c) => matches(c, job, context))) continue;
206
- reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
207
- }
208
- return reasons;
200
+ const waitingRules = (rules, job, { customer = {}, now = new Date() } = {}) => rules
201
+ .filter((rule) => rule.enabled && rule.trigger === 'job_ready' && !['pending', 'non_chargeable'].includes(rule.action.kind))
202
+ .filter((rule) => rule.conditions.every((c) => matches(c, job, { customer, now })));
203
+ const waitMessage = (rule) => rule.action.kind === 'skip'
204
+ ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`;
205
+
206
+ // Keep the text-only reason API for draft checks, including evaluation of
207
+ // starter rules before they are saved. Selection/text have one owner here.
208
+ export function ruleReasons(rules, job, options) {
209
+ return waitingRules(rules, job, options).map(waitMessage);
210
+ }
211
+
212
+ // Pending work needs the saved rule identity, just as a Pending invoice does.
213
+ export function ruleWait(rules, job, options) {
214
+ return waitingRules(rules, job, options).map((rule) => ({ code: 'rule',
215
+ message: waitMessage(rule), short: rule.name.slice(0, 40), rule: rule.id,
216
+ resolution: ruleResolution(rule),
217
+ }));
209
218
  }
210
219
 
211
220
  // The notes of the enabled pending rules that match one draft, as problems:
@@ -213,12 +222,17 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
213
222
  // `reference`, the draft's Reference. A pending rule never stops a draft.
214
223
  // `fields` names the fields the rule reads, so the invoice page marks the
215
224
  // one to fix (the Reference box goes red).
225
+ const ruleResolution = (rule) => {
226
+ if (!rule.id) throw new FlowError('warning_resolution_required', 'A Pending rule needs its saved rule to resolve it.');
227
+ return { kind: 'rule_review' };
228
+ };
229
+
216
230
  export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
217
231
  const context = { customer, now };
218
232
  return rules
219
233
  .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
220
234
  .filter((r) => r.conditions.every((c) => matches(c, job, context)))
221
- .map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))] }));
235
+ .map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))], resolution: ruleResolution(r) }));
222
236
  }
223
237
 
224
238
  // Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
@@ -236,6 +250,7 @@ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
236
250
  short: r.name.slice(0, 40),
237
251
  rule: r.id ?? null,
238
252
  fields: [...new Set(r.conditions.map((c) => c.field))],
253
+ resolution: ruleResolution(r),
239
254
  }));
240
255
  }
241
256
 
@@ -1,6 +1,6 @@
1
1
  import { and, eq, inArray, isNull } from 'drizzle-orm';
2
2
  import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
3
- import { listRules } from './invoice-rules.js';
3
+ import { listRules, ruleWait } from './invoice-rules.js';
4
4
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
5
5
  import { REVIEW_CHANGED, listObligations } from './ledger.js';
6
6
  import { isHistoryHeld } from './history.js';
@@ -54,8 +54,16 @@ export async function describeObligations(db, organisationId, listed, { accounti
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.
57
- const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
58
- ? [] : ruleBlockers(o, { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null }, { rules }));
57
+ const waiting = (o, v) => {
58
+ if (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)) return { waitingFor: [], waitingRules: [] };
59
+ const customer = { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null };
60
+ const waitingRules = ruleWait(rules, o, { customer });
61
+ // Keep the existing text projection for API consumers; links use identity.
62
+ return { waitingRules, waitingFor: [
63
+ ...ruleBlockers(o, customer),
64
+ ...waitingRules.map((problem) => problem.message),
65
+ ] };
66
+ };
59
67
  return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
60
68
  ...o,
61
69
  canUseNewLines: offers.has(o.id),
@@ -66,7 +74,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
66
74
  invoiceProblems: (o.invoiceId && views?.problems.get(o.invoiceId)) || [],
67
75
  view,
68
76
  reasons,
69
- waitingFor: waitingFor(o, view),
77
+ ...waiting(o, view),
70
78
  }));
71
79
  }
72
80
 
@@ -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();
@@ -349,7 +349,7 @@ async function compareWithDraft(tx, organisationId, obligation, facts) {
349
349
  }
350
350
  const flow = { lines: invoice.lines, contactId: invoice.contactId };
351
351
  const compare = compareThreeWays(base, facts, flow, { sent: invoiceSent(invoice, obligation) });
352
- return { ...compare, base, flow, invoiceStatus: invoice.status };
352
+ return { ...compare, base, flow, invoiceStatus: invoice.status, invoiceTotals: { subtotalCents: invoice.subtotalCents, taxCents: invoice.taxCents, totalCents: invoice.totalCents } };
353
353
  }
354
354
 
355
355
  function changedReview(obligation, compare) {
@@ -479,17 +479,29 @@ export function diffJobLines(wasLines = [], nowLines = []) {
479
479
  return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
480
480
  }
481
481
 
482
- // The open "job changed" problem of an invoice, for the invoice page, or
482
+ // The open job-change or source-billing problem, for the invoice page, or
483
483
  // null: the job as it was when the invoice was made, as the work system has
484
484
  // it now, and whether a person can adopt the change into Flow's own draft
485
485
  // ("Adopt changes", useSourceLines). An invoice drafted before the basis was
486
486
  // kept compares with the invoice's own lines.
487
487
  export async function jobChangeFor(db, organisationId, invoiceId) {
488
488
  const [obligation] = await db.select().from(billingObligations)
489
- .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId),
490
- eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
489
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
491
490
  if (!obligation) return null;
491
+ const changed = obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON;
492
+ const sourceProblems = sourceBillingProblems(obligation);
493
+ if (!changed && !sourceProblems.length) return null;
492
494
  const compare = await compareWithDraft(db, organisationId, obligation, obligation);
495
+ // Source review is only for Flow's draft. Issued documents keep the existing
496
+ // job-change/accounting comparison, never a new source-resolution action.
497
+ const sourceReview = sourceProblems.length && compare.invoiceStatus === 'draft' ? {
498
+ sourceTotals: obligation.sourceTotals ?? null,
499
+ reconstructed: { ...invoiceTotals(obligation.lines), ...(obligation.lines.some((line) => line.taxCode === null) ? { taxCents: null, totalCents: null } : {}) },
500
+ unknownTaxLines: obligation.lines.flatMap((line, index) => line.taxCode === null
501
+ ? [{ position: index + 1, description: line.description, itemCode: line.itemCode ?? null }] : []),
502
+ savedInvoice: compare.invoiceTotals,
503
+ } : null;
504
+ if (!changed && !sourceReview) return null;
493
505
  const was = obligation.draftBasis
494
506
  ?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
495
507
  return {
@@ -497,13 +509,15 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
497
509
  sourceSystem: obligation.sourceSystem,
498
510
  sourceJobRef: obligation.sourceJobRef,
499
511
  sourceInvoiceRef: obligation.sourceInvoiceRef,
512
+ caseRef: obligation.caseRef,
513
+ sourceReview,
500
514
  seenAt: obligation.sourceSeenAt,
501
515
  outcome: compare.outcome,
502
516
  sent: compare.sent,
503
- canAdopt: compare.outcome === 'offer',
517
+ canAdopt: !sourceReview && compare.outcome === 'offer',
504
518
  // "Accept change": the draft takes the work system's values, even when
505
519
  // the draft changed too. Not once the invoice has left Flow.
506
- canAccept: !compare.sent && !compare.missing,
520
+ canAccept: !sourceReview && !compare.sent && !compare.missing,
507
521
  lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
508
522
  contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
509
523
  total: { was: was.totalCents ?? null, now: obligation.totalCents },
@@ -755,7 +769,51 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
755
769
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
756
770
  const row = await update(tx, obligation, { holdState, holdReason: holdState === 'none' ? null : reason.trim() });
757
771
  await addEvent(tx, row, holdState === 'none' ? 'released' : holdState, actor, {
758
- 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,
759
817
  });
760
818
  return row;
761
819
  });
@@ -763,9 +821,10 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
763
821
 
764
822
  // A job with no invoice of its own whose accounting evidence is ambiguous:
765
823
  // the matcher found accounting invoices that may be this job's, but none
766
- // exactly. A person settles it on the Reconcile page: "Make invoice", or a
767
- // link to a Flow invoice (linkJobToInvoice).
768
- 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));
769
828
 
770
829
  // The matcher's open suggestions for one job, best first.
771
830
  export async function linkSuggestionsFor(db, organisationId, obligationId) {
@@ -800,14 +859,26 @@ async function rejectSuggestions(tx, obligation, { actor }) {
800
859
  // "Make invoice": a person says the job has no invoice yet. Every suggestion
801
860
  // is rejected and the job is recorded as not found, so it can draft. The
802
861
  // live check before the draft still runs (draftInvoiceForObligation).
803
- 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 }) {
804
865
  requireActor(actor);
805
866
  return db.transaction(async (tx) => {
806
867
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
807
868
  if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
808
869
  const rejected = await rejectSuggestions(tx, obligation, { actor });
809
- const row = await update(tx, obligation, { accountingState: 'not_found', accountingCheckedAt: sql`now()` });
810
- 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
+ });
811
882
  return row;
812
883
  });
813
884
  }