@arcforgelabs/flow 2026.11.3 → 2026.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/SECURITY.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +68 -1
  6. package/server/app.js +3 -2
  7. package/server/auth/session.js +13 -7
  8. package/server/auth/settings.js +18 -7
  9. package/server/core/alerts.js +38 -3
  10. package/server/core/automations.js +8 -3
  11. package/server/core/invoice-rules.js +158 -8
  12. package/server/core/invoices.js +9 -1
  13. package/server/core/invoicing-rules.js +31 -6
  14. package/server/core/job-board.js +16 -1
  15. package/server/core/ledger.js +11 -2
  16. package/server/core/reconcile.js +11 -3
  17. package/server/core/sending.js +14 -3
  18. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  19. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  20. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  21. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  22. package/server/db/migrations/meta/_journal.json +14 -0
  23. package/server/db/schema.js +21 -0
  24. package/server/integrations/seazona/feed.js +10 -3
  25. package/server/integrations/xero/drafts.js +1 -1
  26. package/server/integrations/xero/index.js +1 -1
  27. package/server/integrations/xero/ledger.js +12 -7
  28. package/server/integrations/xero/links.js +39 -1
  29. package/server/integrations/xero/lookup.js +3 -1
  30. package/server/integrations/xero/routes.js +18 -1
  31. package/server/ui/app.js +2 -2
  32. package/server/ui/flow.css +38 -62
  33. package/server/ui/home.js +38 -47
  34. package/server/ui/invoice-rules.js +11 -3
  35. package/server/ui/invoicing.js +79 -7
  36. package/server/ui/job-change.js +15 -9
  37. package/server/ui/jobs.js +14 -2
  38. package/server/ui/kit.js +21 -0
  39. package/server/ui/mobile.js +18 -0
  40. package/server/ui/problem-prompt.js +79 -0
  41. package/server/ui/routes.js +1 -1
  42. package/skills/flow/SKILL.md +79 -2
  43. package/skills/flow/flow.sh +32 -1
package/SECURITY.md CHANGED
@@ -32,7 +32,8 @@ paste a credential, in full or in part.
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
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
35
+ `FLOW_AGENT_KEY_SHA256` and `FLOW_AGENT_EMAIL` (and at most one more pair,
36
+ `FLOW_AGENT_KEY_SHA256_2` and `FLOW_AGENT_EMAIL_2`), each bearer key acts as its own
36
37
  Flow person, inside that person's role, on `/api/me`, `/api/invoicing/…`,
37
38
  `/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
39
  With the admin role that includes invoice writes: rules, void, delete draft
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.3",
3
+ "version": "2026.11.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.11.3",
9
+ "version": "2026.11.5",
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.3",
3
+ "version": "2026.11.5",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,7 +6,21 @@ import { agentLimits } from './settings.js';
6
6
  export function createAgentGate({ pool, verify, resource, limits: input, log = console }) {
7
7
  const limits = agentLimits(input);
8
8
  let active = 0, pruneAfter = 0;
9
- const running = new Map();
9
+ const running = new Map(), waiting = new Set();
10
+ const capacity = () => Math.min(limits.fleet, Math.max(0,(pool.options?.max ?? 10)-1));
11
+ // A lease stays held through the audit after a response. A call that comes
12
+ // while a lease is only auditing waits up to a second for it, then is refused.
13
+ const lease = async () => {
14
+ const until = Date.now() + 1000;
15
+ while (active >= capacity()) {
16
+ const left = until - Date.now();
17
+ if (left <= 0) return false;
18
+ await new Promise(resolve => { const timer = setTimeout(done, left); function done() { clearTimeout(timer); waiting.delete(done); resolve(); } waiting.add(done); });
19
+ }
20
+ active++;
21
+ return true;
22
+ };
23
+ const unlease = () => { active--; for (const wake of [...waiting]) wake(); };
10
24
  const fail = (res, status, code) => {
11
25
  if (res.writableEnded || res.destroyed) return;
12
26
  if ([429,503].includes(status) && !res.hasHeader('Retry-After')) res.set('Retry-After', '60');
@@ -35,8 +49,7 @@ export function createAgentGate({ pool, verify, resource, limits: input, log = c
35
49
  }
36
50
  }
37
51
  const handle = handler => async (req,res) => {
38
- if (active >= Math.min(limits.fleet, Math.max(0,(pool.options?.max ?? 10)-1))) return fail(res,429,'agent_capacity');
39
- active++;
52
+ if (!await lease()) return fail(res,429,'agent_capacity');
40
53
  const started = Date.now(), controller = new AbortController();
41
54
  let client, acquiring, grant, ownConcurrency = false, status = 200, count = 0, tool = 'protocol', expired = false;
42
55
  // pg.Pool has no per-acquisition cancellation. Bound only the MCP wait,
@@ -104,6 +117,14 @@ export function createAgentGate({ pool, verify, resource, limits: input, log = c
104
117
  check();
105
118
  await handler(req,res);
106
119
  })();
120
+ // The grant's slot covers its work, not the audit after the response: a
121
+ // client that sends its next call at once must not meet its own last call.
122
+ const freed = () => {
123
+ if (!ownConcurrency) return;
124
+ ownConcurrency = false;
125
+ const n = running.get(grant.id) - 1; n ? running.set(grant.id,n) : running.delete(grant.id);
126
+ };
127
+ work.then(freed, freed);
107
128
  try { await Promise.race([work,deadline]); }
108
129
  catch { if (!expired) { status=503; fail(res,503,'agent_unavailable'); } }
109
130
  finally {
@@ -122,8 +143,7 @@ export function createAgentGate({ pool, verify, resource, limits: input, log = c
122
143
  }
123
144
  } catch { if (client) { client.release(true); client=undefined; } log.warn?.('[mcp] audit unavailable'); }
124
145
  await Promise.allSettled([acquiring]);
125
- if (ownConcurrency) { const n=running.get(grant.id)-1; n ? running.set(grant.id,n) : running.delete(grant.id); }
126
- active--;
146
+ unlease();
127
147
  }
128
148
  };
129
149
  return { handle, fail, limits };
@@ -11,7 +11,7 @@ import {
11
11
  } from '../core/invoices.js';
12
12
  import { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
13
13
  import {
14
- ACTIONS, createRule, deleteRule, FIELDS, listRules, TESTS, updateRule,
14
+ ACTIONS, addRuleItem, createRule, deleteRule, exceptDraft, exceptJob, FIELDS, jobRuleExceptions, listRuleExceptions, listRules, removeRuleException, TESTS, updateRule,
15
15
  } from '../core/invoice-rules.js';
16
16
  import {
17
17
  createMapping, deleteMapping, listMappings, STEP_LABELS, STEPS, updateMapping,
@@ -383,12 +383,34 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
383
383
  : null,
384
384
  contact: contact ?? null,
385
385
  job: withLink(jobs.get(invoiceId) ?? null, work),
386
+ // Lines this invoice has and its job in the work system does not (a
387
+ // rule's added shipping, for example). Flow cannot write lines to the
388
+ // work system, so the page shows the gap until the work system has them.
389
+ sourceGap: await sourceGap(db, organisationId, invoice),
390
+ // The exceptions people gave this invoice's job, current or ended.
391
+ ruleExceptions: await exceptionsOfInvoice(db, organisationId, invoice),
386
392
  // The Linked card: each connected system, and this invoice's link there.
387
393
  linksTo: linkedSystems(work, accounting),
388
394
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
389
395
  };
390
396
  }
391
397
 
398
+ async function exceptionsOfInvoice(db, organisationId, invoice) {
399
+ const [job] = await db.select({ id: billingObligations.id }).from(billingObligations)
400
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)));
401
+ return job ? jobRuleExceptions(db, organisationId, job.id) : [];
402
+ }
403
+
404
+ async function sourceGap(db, organisationId, invoice) {
405
+ if (invoice.status === 'void') return [];
406
+ const [job] = await db.select({ lines: billingObligations.lines }).from(billingObligations)
407
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)));
408
+ if (!job) return [];
409
+ const inSource = new Set((job.lines ?? []).map((l) => l.priceItemId).filter(Boolean));
410
+ return invoice.lines.filter((l) => l.priceItemId && !inSource.has(l.priceItemId))
411
+ .map((l) => ({ description: l.description, quantity: l.quantity, unitAmountCents: l.unitAmountCents }));
412
+ }
413
+
392
414
  // A person's Recheck reads a job from its work system at most once in this
393
415
  // time; a second click inside it answers from Flow's copy.
394
416
  const RECHECK_GAP_MS = 10 * 1000;
@@ -1020,6 +1042,51 @@ export function createInvoicingRouter({
1020
1042
  res.json({ ok: true });
1021
1043
  }));
1022
1044
 
1045
+ // Exceptions (docs/INVOICE_RULES.md): each job a person let through although
1046
+ // the rule matched, with who, when and why.
1047
+ router.get('/rules/:id/exceptions', ...signedIn, handle(log, async (req, res) => {
1048
+ const ruleId = pathId(req.params.id, 'rule');
1049
+ const organisationId = await organisationOf(req);
1050
+ res.json({ ok: true, exceptions: await listRuleExceptions(db, organisationId, ruleId) });
1051
+ }));
1052
+
1053
+ router.delete('/rules/:id/exceptions/:exceptionId', ...manager, handle(log, async (req, res) => {
1054
+ const ruleId = pathId(req.params.id, 'rule');
1055
+ const exceptionId = pathId(req.params.exceptionId, 'exception');
1056
+ const organisationId = await organisationOf(req);
1057
+ await removeRuleException(db, organisationId, ruleId, exceptionId, { actor: `person:${req.person.id}` });
1058
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} removed exception ${exceptionId} from rule ${ruleId}`);
1059
+ res.json({ ok: true });
1060
+ }));
1061
+
1062
+ // A Pending draft's rule fix: add the rule's item, or skip with a reason.
1063
+ router.post('/invoices/:id/rules/:ruleId/add-item', ...manager, handle(log, async (req, res) => {
1064
+ const invoiceId = pathId(req.params.id, 'invoice');
1065
+ const ruleId = pathId(req.params.ruleId, 'rule');
1066
+ const organisationId = await organisationOf(req);
1067
+ await addRuleItem(db, organisationId, invoiceId, ruleId);
1068
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added rule ${ruleId}'s item to invoice ${invoiceId}`);
1069
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
1070
+ }));
1071
+
1072
+ router.post('/invoices/:id/rules/:ruleId/exception', ...manager, handle(log, async (req, res) => {
1073
+ const invoiceId = pathId(req.params.id, 'invoice');
1074
+ const ruleId = pathId(req.params.ruleId, 'rule');
1075
+ const organisationId = await organisationOf(req);
1076
+ await exceptDraft(db, organisationId, invoiceId, ruleId, { reason: req.body?.reason, actor: `person:${req.person.id}` });
1077
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made an exception to rule ${ruleId} for invoice ${invoiceId}`);
1078
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
1079
+ }));
1080
+
1081
+ router.post('/jobs/:id/rules/:ruleId/exception', ...manager, handle(log, async (req, res) => {
1082
+ const obligationId = pathId(req.params.id, 'job');
1083
+ const ruleId = pathId(req.params.ruleId, 'rule');
1084
+ const organisationId = await organisationOf(req);
1085
+ const exception = await exceptJob(db, organisationId, obligationId, ruleId, { reason: req.body?.reason, actor: `person:${req.person.id}` });
1086
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made an exception to rule ${ruleId} for job ${obligationId}`);
1087
+ res.json({ ok: true, exception });
1088
+ }));
1089
+
1023
1090
  router.get('/line-mappings', ...signedIn, handle(log, async (req, res) => {
1024
1091
  const organisationId = await organisationOf(req);
1025
1092
  res.json({ ok: true, mappings: await listMappings(db, organisationId), steps: STEPS.map((key) => ({ key, label: STEP_LABELS[key] })) });
package/server/app.js CHANGED
@@ -8,7 +8,7 @@ import { createWorkMovesRouter } from './api/work-moves.js';
8
8
  import { createConnectorApi } from './api/connector.js';
9
9
  import { createInvoicingRouter } from './api/invoicing.js';
10
10
  import { createSignInRouter } from './auth/routes.js';
11
- import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
11
+ import { integrationAlertsSource, invoiceProblemsSource, jobGapsSource, priceDifferencesSource } from './core/alerts.js';
12
12
  import { boardJobs } from './core/job-board.js';
13
13
  import { reconcileHealth } from './core/reconcile.js';
14
14
  import { appliedMigrationCounts } from './db/doctor.js';
@@ -154,7 +154,7 @@ export function createApp({
154
154
  if (database && auth?.personAuth) {
155
155
  const liveCheckFor = (organisationId) => {
156
156
  const [adapter] = integrations?.with('checkInvoice') ?? [];
157
- return adapter ? (obligation) => adapter.capabilities.checkInvoice({ organisationId, obligation }) : null;
157
+ return adapter ? (obligation, { reference = null } = {}) => adapter.capabilities.checkInvoice({ organisationId, obligation, reference }) : null;
158
158
  };
159
159
  // Send all ready (server/core/sending.js): the first enabled integration
160
160
  // that sends invoices (Xero).
@@ -184,6 +184,7 @@ export function createApp({
184
184
  personAuth: auth.personAuth,
185
185
  log,
186
186
  sources: {
187
+ jobs: [jobGapsSource(database.db)],
187
188
  invoices: [invoiceProblemsSource(database.db, { accounting })],
188
189
  'price-list': [priceDifferencesSource(database.db)],
189
190
  integrations: [integrationAlertsSource(() => integrations?.with('alerts') ?? [])],
@@ -54,6 +54,7 @@ export function createPersonAuth({
54
54
  now = Date.now,
55
55
  clerkUserCacheMs = 60 * 1000,
56
56
  agentKey = null,
57
+ agentKeys = agentKey ? [agentKey] : [],
57
58
  }) {
58
59
  if (!db) throw new Error('createPersonAuth: db is required');
59
60
  if (!clerk?.authenticate || !clerk?.getUser) throw new Error('createPersonAuth: a Clerk adapter is required');
@@ -122,7 +123,8 @@ export function createPersonAuth({
122
123
  async function requireSession(req, res, next) {
123
124
  const token = bearerToken(req);
124
125
  if (!token) return deny(res, 'signed_out');
125
- if (agentKey && isAgentKey(token)) return agentSession(req, res, next);
126
+ const agent = agentKeys.length ? agentKeyFor(token) : null;
127
+ if (agent) return agentSession(req, res, next, agent);
126
128
  let auth;
127
129
  try {
128
130
  auth = await clerk.authenticate(req);
@@ -149,24 +151,28 @@ export function createPersonAuth({
149
151
  return next();
150
152
  }
151
153
 
152
- // The AI assistant key (FLOW_AGENT_KEY). It acts as one Flow person, and
154
+ // The AI assistant keys (FLOW_AGENT_KEY_SHA256, and _2). Each acts as its own Flow person, and
153
155
  // only inside that person's role. Owner actions stay with people: an agent
154
156
  // request is never a verified owner sign-in.
155
157
  // Keys shorter than 32 characters are refused, so a digest of a short
156
158
  // 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);
159
+ const agentDigests = agentKeys.map((key) => ({ email: key.email, digest: Buffer.from(key.digest, 'hex') }));
160
+ function agentKeyFor(token) {
161
+ if (token.length < 32) return null;
162
+ const hash = createHash('sha256').update(token).digest();
163
+ let found = null;
164
+ for (const key of agentDigests) if (timingSafeEqual(hash, key.digest)) found = key;
165
+ return found;
160
166
  }
161
167
  // The key opens only these routes: invoicing, Xero (owner routes refuse it
162
168
  // below), the Seazona read, the Seazona history import, /api/me and its own
163
169
  // skill files.
164
170
  const AGENT_PATHS = /^\/api\/(?:me$|invoicing\/|xero\/|seazona\/(?:api|history)(?:\/|$)|agent-skill\/flow\/)/;
165
- async function agentSession(req, res, next) {
171
+ async function agentSession(req, res, next, agent) {
166
172
  if (!AGENT_PATHS.test(`${req.baseUrl}${req.path}`)) return deny(res, 'role_required');
167
173
  let person;
168
174
  try {
169
- person = await findPersonByEmail(db, agentKey.email);
175
+ person = await findPersonByEmail(db, agent.email);
170
176
  } catch (error) {
171
177
  log.error?.(`[auth] Flow agent person lookup failed: ${error.message}`);
172
178
  return deny(res, 'sign_in_unavailable');
@@ -132,16 +132,27 @@ export function isPlainEmail(email) {
132
132
  // the key's SHA-256 is FLOW_AGENT_KEY_SHA256, acts as the Flow person with
133
133
  // FLOW_AGENT_EMAIL, inside that person's role. Flow holds only the digest, so
134
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();
135
+ export function resolveAgentKey(env = process.env, suffix = '') {
136
+ const digestName = `FLOW_AGENT_KEY_SHA256${suffix}`;
137
+ const emailName = `FLOW_AGENT_EMAIL${suffix}`;
138
+ const digest = readEnv(env, digestName).toLowerCase();
139
+ const email = readEnv(env, emailName).toLowerCase();
138
140
  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');
141
+ if (!digest || !email) throw new Error(`${digestName} and ${emailName} must be set together`);
142
+ if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(`${digestName} must be 64 hex characters (the SHA-256 of the key)`);
143
+ if (!isPlainEmail(email)) throw new Error(`${emailName} must be a plain (ASCII) email address`);
142
144
  return { digest, email };
143
145
  }
144
146
 
147
+ // Up to two assistant keys: the first pair, and a second pair with the
148
+ // suffix _2. Each key acts as its own Flow person.
149
+ export function resolveAgentKeys(env = process.env) {
150
+ const keys = [resolveAgentKey(env), resolveAgentKey(env, '_2')].filter(Boolean);
151
+ if (keys.length === 2 && keys[0].digest === keys[1].digest) throw new Error('FLOW_AGENT_KEY_SHA256_2 must differ from FLOW_AGENT_KEY_SHA256');
152
+ if (keys.length === 2 && keys[0].email === keys[1].email) throw new Error('FLOW_AGENT_EMAIL_2 must differ from FLOW_AGENT_EMAIL');
153
+ return keys;
154
+ }
155
+
145
156
  export function resolveSignInSettings(env = process.env) {
146
157
  const mode = resolveAuthMode(env);
147
158
  if (mode === 'off') return { mode };
@@ -154,6 +165,6 @@ export function resolveSignInSettings(env = process.env) {
154
165
  ownerGoogleDomains: parseOwnerGoogleDomains(readEnv(env, 'FLOW_OWNER_GOOGLE_DOMAINS')),
155
166
  authorizedParties: resolveAuthorizedParties(env),
156
167
  firstOwnerEmail: resolveFirstOwnerEmail(env),
157
- agentKey: resolveAgentKey(env),
168
+ agentKeys: resolveAgentKeys(env),
158
169
  };
159
170
  }
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { and, eq, inArray, sql } from 'drizzle-orm';
3
- import { alertDismissals, invoices } from '../db/schema.js';
2
+ import { and, asc, eq, inArray, isNull, lt, sql } from 'drizzle-orm';
3
+ import { alertDismissals, billingObligations, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
+ import { READY_GRACE_DAYS } from './reconcile.js';
5
6
  import { listReview } from './review.js';
6
7
  import { invoiceViews } from './sending.js';
7
8
 
@@ -19,7 +20,7 @@ import { invoiceViews } from './sending.js';
19
20
  // they never leave the server. `roles` are who may see it. `action`, when
20
21
  // there is one, is { label, href } or { label, tab } for a tab on the page.
21
22
 
22
- export const ALERT_PAGES = Object.freeze(['invoices', 'price-list', 'integrations']);
23
+ export const ALERT_PAGES = Object.freeze(['jobs', 'invoices', 'price-list', 'integrations']);
23
24
  const ALL_ROLES = Object.freeze(['owner', 'admin', 'staff']);
24
25
  const MANAGERS = Object.freeze(['owner', 'admin']);
25
26
  const TONES = new Set(['warn', 'danger']);
@@ -41,6 +42,40 @@ function checkAlert(alert) {
41
42
 
42
43
  // ── Sources ────────────────────────────────────────────────────────────────
43
44
 
45
+ // Jobs: finished jobs that nothing is billing. A job ready longer than the
46
+ // daily reconcile's grace, with no Flow invoice, no accounting invoice and
47
+ // no hold, opens Reconcile, where a person links its invoice or makes one.
48
+ // A hold is how a person says "do not bill".
49
+ export function jobGapsSource(db, { now = () => Date.now() } = {}) {
50
+ return async (organisationId) => {
51
+ const before = new Date(now() - READY_GRACE_DAYS * 86_400_000);
52
+ const unbilled = await db.select({ id: billingObligations.id, totalCents: billingObligations.totalCents })
53
+ .from(billingObligations)
54
+ .where(and(
55
+ eq(billingObligations.organisationId, organisationId),
56
+ isNull(billingObligations.invoiceId),
57
+ isNull(billingObligations.accountingRef),
58
+ eq(billingObligations.holdState, 'none'),
59
+ lt(billingObligations.readyAt, before),
60
+ ))
61
+ .orderBy(asc(billingObligations.readyAt))
62
+ .limit(MAX_MEMBERS);
63
+ const alerts = [];
64
+ if (unbilled.length) {
65
+ alerts.push({
66
+ key: 'jobs:not-invoiced',
67
+ page: 'jobs',
68
+ tone: 'warn',
69
+ title: `${plural(unbilled.length, 'finished job has', 'finished jobs have')} no invoice after ${READY_GRACE_DAYS} days.`,
70
+ action: { label: 'Open Reconcile', href: '/reconcile' },
71
+ moneyAtRiskCents: unbilled.reduce((sum, o) => sum + Number(o.totalCents ?? 0), 0),
72
+ members: unbilled.map((o) => o.id),
73
+ });
74
+ }
75
+ return alerts;
76
+ };
77
+ }
78
+
44
79
  // Invoices: drafts a check stopped and issued invoices the accounting system
45
80
  // refused (server/core/sending.js). One alert for all of them.
46
81
  export function invoiceProblemsSource(db, { accounting = () => null } = {}) {
@@ -1,4 +1,4 @@
1
- import { and, eq, sql } from 'drizzle-orm';
1
+ import { and, asc, eq, sql } from 'drizzle-orm';
2
2
  import { automations, billingObligations, invoices } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
  import { autoDraftJobs, readInvoicingSettings } from './invoicing-rules.js';
@@ -176,14 +176,19 @@ const actorOf = (key) => `automation:${key}`;
176
176
  // that job to people from then on.
177
177
  async function markReadyFromWorkStatus(db, organisationId, { statuses }) {
178
178
  const actor = actorOf('mark_ready_from_work_status');
179
+ const wanted = [...new Set(statuses.map(norm).filter(Boolean))];
180
+ if (!wanted.length) return { marked: 0 };
181
+ // The status test runs in the query, with the same normalising as
182
+ // statusMatches, so the batch holds only jobs to mark, oldest first: jobs
183
+ // still in work never fill it and push a shipped job out.
179
184
  const jobs = await db.select().from(billingObligations).where(and(
180
185
  eq(billingObligations.organisationId, organisationId),
181
186
  sql`${billingObligations.readyAt} is null`,
182
187
  sql`${billingObligations.invoiceId} is null`,
183
- sql`${billingObligations.sourceStatus} is not null`,
188
+ sql`lower(regexp_replace(btrim(${billingObligations.sourceStatus}), '\\s+', ' ', 'g')) in (${sql.join(wanted.map((w) => sql`${w}`), sql`, `)})`,
184
189
  sql`not exists (select 1 from obligation_events e where e.obligation_id = ${billingObligations.id}
185
190
  and e.kind = 'in_work' and e.actor not like 'automation:%')`,
186
- )).limit(BATCH);
191
+ )).orderBy(asc(billingObligations.createdAt)).limit(BATCH);
187
192
  let marked = 0;
188
193
  for (const job of jobs) {
189
194
  if (!statusMatches(job.sourceStatus, statuses)) continue;
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, join } from 'node:path';
4
- import { and, asc, eq, sql } from 'drizzle-orm';
5
- import { invoiceRules, invoicingSettings } from '../db/schema.js';
4
+ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
5
+ import { billingObligations, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
+ import { getInvoice, updateDraftInvoice } from './invoices.js';
7
8
 
8
9
  // Invoice rules (docs/INVOICE_RULES.md): a lab's own checks on a job before
9
10
  // Flow drafts its invoice. Each rule is When, If and Then, and a lab owner
@@ -62,7 +63,7 @@ export const FIELDS = Object.freeze({
62
63
  // one marker, so a new branch needs no new rule.
63
64
  customer: {
64
65
  label: 'Customer', kind: 'choice', options: 'customers',
65
- tests: ['is', 'is_not', 'is_one_of', 'contains', 'does_not_contain', 'is_empty'],
66
+ tests: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'is_empty'],
66
67
  read: (j) => j.contactId,
67
68
  },
68
69
  customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
@@ -73,7 +74,7 @@ export const FIELDS = Object.freeze({
73
74
  export const TESTS = Object.freeze({
74
75
  number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
75
76
  money: ['more_than', 'at_most', 'is', 'is_not'],
76
- choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
77
+ choice: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'is_empty'],
77
78
  text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'number_in_words', 'one_word', 'is_empty', 'is_not_empty'],
78
79
  boolean: ['is'],
79
80
  items: ['has', 'has_not'],
@@ -134,6 +135,13 @@ export function readRule(raw) {
134
135
  const note = typeof raw.action.note === 'string' ? raw.action.note.trim() : '';
135
136
  if (!note || note.length > MAX_NOTE) throw invalid(`a pending rule needs a note of ${MAX_NOTE} characters at most: what a person must fix`);
136
137
  action.note = note;
138
+ // The fix a person can take with one press: add this price list item to
139
+ // the draft (for example a shipping line the work system left off).
140
+ if (raw.action.addItem !== undefined && raw.action.addItem !== null) {
141
+ const item = typeof raw.action.addItem === 'string' ? raw.action.addItem.trim() : '';
142
+ if (!item || item.length > 40) throw invalid('addItem must be a price list code of 40 characters at most');
143
+ action.addItem = item;
144
+ }
137
145
  } else {
138
146
  const early = conditions.find((c) => FIELDS[c.field].draftOnly);
139
147
  if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
@@ -189,6 +197,7 @@ function matches(condition, job, context) {
189
197
  default: {
190
198
  const left = norm(have);
191
199
  if (test === 'is_one_of') return value.some((v) => norm(v) === left);
200
+ if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
192
201
  return test === 'is' ? left === norm(value) : left !== norm(value);
193
202
  }
194
203
  }
@@ -227,12 +236,21 @@ const ruleResolution = (rule) => {
227
236
  return { kind: 'rule_review' };
228
237
  };
229
238
 
239
+ // A rule leaves a job alone when a person made an exception for it
240
+ // (`job.ruleExceptions`, rule ids), or, for a rule with `addItem`, when the
241
+ // draft already has that item (`job.draftItems`, codes): the fix was taken.
230
242
  export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
231
243
  const context = { customer, now };
244
+ const excepted = new Set(job.ruleExceptions ?? []);
245
+ const onDraft = new Set((job.draftItems ?? []).map(norm));
232
246
  return rules
233
247
  .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
248
+ .filter((r) => !excepted.has(r.id) && !(r.action.addItem && onDraft.has(norm(r.action.addItem))))
234
249
  .filter((r) => r.conditions.every((c) => matches(c, job, context)))
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) }));
250
+ .map((r) => ({
251
+ 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))],
252
+ resolution: ruleResolution(r), ...(r.action.addItem ? { addItem: r.action.addItem } : {}),
253
+ }));
236
254
  }
237
255
 
238
256
  // Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
@@ -295,9 +313,9 @@ async function seed(db, organisationId) {
295
313
  });
296
314
  }
297
315
 
298
- const shape = (r) => ({
316
+ const shape = (r, exceptions = 0) => ({
299
317
  id: r.id, name: r.name, icon: r.icon, position: r.position, enabled: r.enabled, trigger: r.trigger,
300
- conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
318
+ conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt, exceptions,
301
319
  });
302
320
 
303
321
  // The organisation's rules, in order. The first read makes the starter rules.
@@ -307,11 +325,101 @@ export async function listRules(db, organisationId) {
307
325
  if (!settings?.seeded) await seed(db, organisationId);
308
326
  const rows = await db.select().from(invoiceRules).where(eq(invoiceRules.organisationId, organisationId))
309
327
  .orderBy(asc(invoiceRules.position), asc(invoiceRules.createdAt));
310
- return rows.map(shape);
328
+ const counts = await db.select({ ruleId: invoiceRuleExceptions.ruleId, count: sql`count(*)::int` }).from(invoiceRuleExceptions)
329
+ .where(eq(invoiceRuleExceptions.organisationId, organisationId)).groupBy(invoiceRuleExceptions.ruleId);
330
+ const byRule = new Map(counts.map((c) => [c.ruleId, Number(c.count)]));
331
+ return rows.map((r) => shape(r, byRule.get(r.id) ?? 0));
332
+ }
333
+
334
+ // ── Exceptions ─────────────────────────────────────────────────────────────
335
+
336
+ // A person says one job is fine although a rule matches it. The rule then
337
+ // leaves the job alone. Only a rule with an item to add takes one: its Skip.
338
+ // Each exception keeps who, when and why, stays listed on the rule, and is
339
+ // in the job's history. It holds while the rule's item and the job's lines
340
+ // stay as they were; a change to either ends it, so a person looks again.
341
+ export async function addRuleException(db, organisationId, ruleId, obligationId, { reason, actor }) {
342
+ const why = typeof reason === 'string' ? reason.trim() : '';
343
+ if (!why || why.length > 200) throw invalid('an exception needs a reason of 1 to 200 characters');
344
+ if (!actor) throw invalid('actor is required');
345
+ const rule = await loadRule(db, organisationId, ruleId);
346
+ if (rule.action?.kind !== 'pending' || !rule.action.addItem) throw invalid('only a pending rule with an item to add takes an exception; change or turn off the rule');
347
+ return db.transaction(async (tx) => {
348
+ const [job] = await tx.select({ id: billingObligations.id, lineFingerprint: billingObligations.lineFingerprint }).from(billingObligations)
349
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId))).for('update');
350
+ if (!job) throw notFound('job');
351
+ const values = { reason: why, actor, itemCode: rule.action.addItem, lineFingerprint: job.lineFingerprint, createdAt: sql`now()` };
352
+ const [row] = await tx.insert(invoiceRuleExceptions).values({ organisationId, ruleId, obligationId, ...values })
353
+ .onConflictDoUpdate({ target: [invoiceRuleExceptions.ruleId, invoiceRuleExceptions.obligationId], set: values })
354
+ .returning();
355
+ await tx.insert(obligationEvents).values({ organisationId, obligationId, kind: 'rule_exception_added', actor,
356
+ evidence: { ruleId, rule: rule.name, item: rule.action.addItem, reason: why } });
357
+ return row;
358
+ });
359
+ }
360
+
361
+ // The rule ids each job has a current exception for, by job.
362
+ export async function ruleExceptionsFor(db, organisationId, obligationIds) {
363
+ if (!obligationIds.length) return new Map();
364
+ const rows = await db.select({ ruleId: invoiceRuleExceptions.ruleId, obligationId: invoiceRuleExceptions.obligationId }).from(invoiceRuleExceptions)
365
+ .innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
366
+ .innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
367
+ .where(and(eq(invoiceRuleExceptions.organisationId, organisationId), inArray(invoiceRuleExceptions.obligationId, obligationIds), CURRENT));
368
+ const out = new Map();
369
+ for (const r of rows) out.set(r.obligationId, [...(out.get(r.obligationId) ?? []), r.ruleId]);
370
+ return out;
371
+ }
372
+ const CURRENT = sql`(${invoiceRuleExceptions.itemCode} is not distinct from (${invoiceRules.action} ->> 'addItem')
373
+ and ${invoiceRuleExceptions.lineFingerprint} is not distinct from ${billingObligations.lineFingerprint})`;
374
+
375
+ // One rule's exceptions, newest first. `current` is false when the rule's
376
+ // item or the job's lines changed since: the rule applies to that job again.
377
+ export async function listRuleExceptions(db, organisationId, ruleId) {
378
+ await loadRule(db, organisationId, ruleId);
379
+ return db.select({ id: invoiceRuleExceptions.id, obligationId: invoiceRuleExceptions.obligationId, reason: invoiceRuleExceptions.reason,
380
+ actor: invoiceRuleExceptions.actor, itemCode: invoiceRuleExceptions.itemCode, createdAt: invoiceRuleExceptions.createdAt, current: sql`${CURRENT}`.mapWith(Boolean) })
381
+ .from(invoiceRuleExceptions)
382
+ .innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
383
+ .innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
384
+ .where(and(eq(invoiceRuleExceptions.organisationId, organisationId), eq(invoiceRuleExceptions.ruleId, ruleId)))
385
+ .orderBy(desc(invoiceRuleExceptions.createdAt));
386
+ }
387
+
388
+ // One job's exceptions, for its invoice page.
389
+ export async function jobRuleExceptions(db, organisationId, obligationId) {
390
+ return db.select({ id: invoiceRuleExceptions.id, ruleId: invoiceRuleExceptions.ruleId, rule: invoiceRules.name, reason: invoiceRuleExceptions.reason,
391
+ actor: invoiceRuleExceptions.actor, itemCode: invoiceRuleExceptions.itemCode, createdAt: invoiceRuleExceptions.createdAt, current: sql`${CURRENT}`.mapWith(Boolean) })
392
+ .from(invoiceRuleExceptions)
393
+ .innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
394
+ .innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
395
+ .where(and(eq(invoiceRuleExceptions.organisationId, organisationId), eq(invoiceRuleExceptions.obligationId, obligationId)))
396
+ .orderBy(desc(invoiceRuleExceptions.createdAt));
397
+ }
398
+
399
+ // Takes an exception back: the rule applies to the job again.
400
+ export async function removeRuleException(db, organisationId, ruleId, exceptionId, { actor = 'system' } = {}) {
401
+ return db.transaction(async (tx) => {
402
+ const [row] = await tx.delete(invoiceRuleExceptions).where(and(eq(invoiceRuleExceptions.organisationId, organisationId),
403
+ eq(invoiceRuleExceptions.ruleId, ruleId), eq(invoiceRuleExceptions.id, exceptionId))).returning();
404
+ if (!row) throw notFound('exception');
405
+ await tx.insert(obligationEvents).values({ organisationId, obligationId: row.obligationId, kind: 'rule_exception_removed', actor,
406
+ evidence: { ruleId, item: row.itemCode, reason: row.reason } });
407
+ });
408
+ }
409
+
410
+ // A rule's item must be on the price list, and not archived.
411
+ async function checkRuleItem(db, organisationId, rule) {
412
+ const code = rule.action.addItem;
413
+ if (!code) return;
414
+ const [item] = await db.select({ archivedAt: priceItems.archivedAt }).from(priceItems)
415
+ .where(and(eq(priceItems.organisationId, organisationId), sql`upper(${priceItems.code}) = upper(${code})`));
416
+ if (!item) throw invalid(`the price list has no item ${code}`);
417
+ if (item.archivedAt) throw invalid(`price item ${code} is archived`);
311
418
  }
312
419
 
313
420
  export async function createRule(db, organisationId, raw, { personId = null } = {}) {
314
421
  const rule = readRule(raw);
422
+ await checkRuleItem(db, organisationId, rule);
315
423
  const existing = await listRules(db, organisationId);
316
424
  if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
317
425
  const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
@@ -331,6 +439,7 @@ export async function updateRule(db, organisationId, ruleId, changes, { personId
331
439
  const row = await loadRule(db, organisationId, ruleId);
332
440
  if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
333
441
  const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
442
+ if (rule.action.addItem && rule.action.addItem !== row.action?.addItem) await checkRuleItem(db, organisationId, rule);
334
443
  const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
335
444
  .where(eq(invoiceRules.id, row.id)).returning();
336
445
  return shape(saved);
@@ -340,3 +449,44 @@ export async function deleteRule(db, organisationId, ruleId) {
340
449
  const row = await loadRule(db, organisationId, ruleId);
341
450
  await db.delete(invoiceRules).where(eq(invoiceRules.id, row.id));
342
451
  }
452
+
453
+ // ── A rule's one-press fix ─────────────────────────────────────────────────
454
+
455
+ // "Add <item>" on a Pending draft: adds one of the rule's price list item to
456
+ // the draft, at the price list's price. The rule then leaves the draft alone.
457
+ // Flow never writes the line to the work system: the invoice page says the
458
+ // job and the work system differ until the work system has it too.
459
+ export async function addRuleItem(db, organisationId, invoiceId, ruleId) {
460
+ const rule = await loadRule(db, organisationId, ruleId);
461
+ const code = rule.action?.addItem;
462
+ if (!code || rule.action.kind !== 'pending') throw invalid('this rule has no item to add');
463
+ if (!rule.enabled) throw invalid('this rule is off');
464
+ const [item] = await db.select().from(priceItems)
465
+ .where(and(eq(priceItems.organisationId, organisationId), sql`upper(${priceItems.code}) = upper(${code})`));
466
+ if (!item) throw invalid(`the price list has no item ${code}`);
467
+ if (item.archivedAt) throw invalid(`price item ${code} is archived`);
468
+ // The lines are read under the draft's lock: a second press, or an edit at
469
+ // the same time, adds nothing twice and loses nothing.
470
+ await updateDraftInvoice(db, organisationId, invoiceId, {
471
+ lines: (current) => (current.some((l) => l.priceItemId === item.id) ? null : [
472
+ ...current.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
473
+ { priceItemId, accountCode, description, quantity, unitAmountCents, taxCode })),
474
+ { priceItemId: item.id, quantity: 1 },
475
+ ]),
476
+ });
477
+ return getInvoice(db, organisationId, invoiceId);
478
+ }
479
+
480
+ // "Skip" on a Pending draft: an exception for the draft's job, with a reason.
481
+ // The same, before the job has a draft: the job board shows the job needs an
482
+ // item, and the person knows it does not (picked up, for example).
483
+ export async function exceptJob(db, organisationId, obligationId, ruleId, { reason, actor }) {
484
+ return addRuleException(db, organisationId, ruleId, obligationId, { reason, actor });
485
+ }
486
+
487
+ export async function exceptDraft(db, organisationId, invoiceId, ruleId, { reason, actor }) {
488
+ const [job] = await db.select({ id: billingObligations.id }).from(billingObligations)
489
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
490
+ if (!job) throw invalid('this invoice has no job; edit the invoice instead');
491
+ return addRuleException(db, organisationId, ruleId, job.id, { reason, actor });
492
+ }