@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 +11 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +78 -12
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +38 -1
- package/server/auth/settings.js +16 -0
- package/server/core/invoice-rules.js +25 -10
- package/server/core/job-board.js +12 -4
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +84 -13
- package/server/core/line-mapping.js +7 -3
- package/server/core/pipeline.js +6 -2
- package/server/core/sending.js +31 -9
- package/server/integrations/seazona/index.js +6 -1
- package/server/integrations/seazona/routes.js +65 -2
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/xero/ledger.js +5 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +9 -6
- package/server/ui/flow.css +75 -4
- package/server/ui/home.js +120 -0
- package/server/ui/invoice-rules.js +24 -3
- package/server/ui/invoicing.js +50 -7
- package/server/ui/job-board.js +20 -10
- package/server/ui/job-change.js +63 -15
- package/server/ui/jobs.js +4 -4
- package/server/ui/nav-pins.js +5 -3
- package/server/ui/reconcile.js +4 -2
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +237 -0
- package/skills/flow/flow.sh +90 -0
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.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
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.
|
|
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.
|
|
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"
|
package/server/api/invoicing.js
CHANGED
|
@@ -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)
|
|
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
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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');
|
package/server/auth/routes.js
CHANGED
|
@@ -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 {
|
package/server/auth/session.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/server/auth/settings.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
package/server/core/job-board.js
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
-
|
|
77
|
+
...waiting(o, view),
|
|
70
78
|
}));
|
|
71
79
|
}
|
|
72
80
|
|
package/server/core/job-match.js
CHANGED
|
@@ -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
|
|
105
|
-
|
|
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
|
|
156
|
-
|
|
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();
|
package/server/core/ledger.js
CHANGED
|
@@ -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
|
|
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.
|
|
767
|
-
//
|
|
768
|
-
|
|
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
|
-
|
|
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
|
-
|
|
810
|
-
|
|
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
|
}
|