@arcforgelabs/flow 2026.11.2 → 2026.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +11 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +51 -2
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +38 -1
- package/server/auth/settings.js +16 -0
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +64 -7
- package/server/core/pipeline.js +6 -2
- package/server/core/sending.js +17 -1
- 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 +7 -4
- package/server/ui/flow.css +68 -1
- package/server/ui/home.js +120 -0
- package/server/ui/invoicing.js +8 -1
- package/server/ui/job-board.js +8 -3
- 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
|
//
|
|
@@ -825,6 +828,23 @@ export function createInvoicingRouter({
|
|
|
825
828
|
res.json({ ok: true, ...await listReconcile(db, organisationId) });
|
|
826
829
|
}));
|
|
827
830
|
|
|
831
|
+
// The open review queue (core/review.js): daily reconcile days, job
|
|
832
|
+
// changes, links Flow could not prove, failed writes. Read only: each item
|
|
833
|
+
// closes when its cause is fixed (or by the screen that owns it).
|
|
834
|
+
router.get('/review', ...signedIn, handle(log, async (req, res) => {
|
|
835
|
+
const organisationId = await organisationOf(req);
|
|
836
|
+
const state = text(req.query.state) || 'open';
|
|
837
|
+
if (!['open', 'resolved', 'dismissed'].includes(state)) throw new FlowError('invalid', 'state must be open, resolved or dismissed');
|
|
838
|
+
const kind = text(req.query.kind) || undefined;
|
|
839
|
+
const limit = Math.min(Math.max(Number.parseInt(text(req.query.limit) || '100', 10) || 100, 1), 500);
|
|
840
|
+
const items = await listReview(db, organisationId, { state, kind, limit });
|
|
841
|
+
res.json({ ok: true, items: items.map((r) => ({
|
|
842
|
+
id: r.id, kind: r.kind, entity: r.entity, flowId: r.flowId, systems: r.systems, evidence: r.evidence,
|
|
843
|
+
moneyAtRiskCents: r.moneyAtRiskCents, seenCount: r.seenCount, state: r.state, resolution: r.resolution,
|
|
844
|
+
createdAt: r.createdAt, updatedAt: r.updatedAt,
|
|
845
|
+
})) });
|
|
846
|
+
}));
|
|
847
|
+
|
|
828
848
|
router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
829
849
|
const obligationId = pathId(req.params.id, 'job');
|
|
830
850
|
const organisationId = await organisationOf(req);
|
|
@@ -848,10 +868,39 @@ export function createInvoicingRouter({
|
|
|
848
868
|
router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
|
|
849
869
|
const obligationId = pathId(req.params.id, 'job');
|
|
850
870
|
const organisationId = await organisationOf(req);
|
|
851
|
-
const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}
|
|
871
|
+
const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}`, system: accounting()?.id ?? null });
|
|
852
872
|
res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
|
|
853
873
|
}));
|
|
854
874
|
|
|
875
|
+
// Holds a job, sends it to review, or releases it (state "none"). Every
|
|
876
|
+
// change needs a reason, kept in the job's activity.
|
|
877
|
+
router.post('/jobs/:id/hold', ...manager, handle(log, async (req, res) => {
|
|
878
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
879
|
+
const organisationId = await organisationOf(req);
|
|
880
|
+
const state = text(req.body?.state);
|
|
881
|
+
const reason = text(req.body?.reason);
|
|
882
|
+
if (!['none', 'held', 'review'].includes(state)) throw invalid('state must be none, held or review');
|
|
883
|
+
if (!reason) throw invalid('a reason is required');
|
|
884
|
+
const [current] = await db.select({ holdState: billingObligations.holdState, holdReason: billingObligations.holdReason }).from(billingObligations)
|
|
885
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
886
|
+
if (state === 'none' && isHistoryHeld(current)) {
|
|
887
|
+
throw invalid('this is a history-import hold: settle it with the history import decisions');
|
|
888
|
+
}
|
|
889
|
+
const job = await setHold(db, organisationId, obligationId, { holdState: state, reason, actor: `person:${req.person.id}` });
|
|
890
|
+
res.json({ ok: true, job: { id: job.id, holdState: job.holdState, holdReason: job.holdReason } });
|
|
891
|
+
}));
|
|
892
|
+
|
|
893
|
+
// Frees a job stuck on a void Flow invoice, or on an accounting invoice that
|
|
894
|
+
// was voided or deleted there. The job goes on hold until a person releases it.
|
|
895
|
+
router.post('/jobs/:id/free', ...manager, handle(log, async (req, res) => {
|
|
896
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
897
|
+
const organisationId = await organisationOf(req);
|
|
898
|
+
const reason = text(req.body?.reason);
|
|
899
|
+
if (!reason) throw invalid('a reason is required');
|
|
900
|
+
const job = await freeVoidedJob(db, organisationId, obligationId, { actor: `person:${req.person.id}`, reason, system: accounting()?.id ?? null });
|
|
901
|
+
res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId, accountingState: job.accountingState, holdState: job.holdState, holdReason: job.holdReason } });
|
|
902
|
+
}));
|
|
903
|
+
|
|
855
904
|
// Links a job to a Flow invoice that already exists.
|
|
856
905
|
router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
|
|
857
906
|
const obligationId = pathId(req.params.id, 'job');
|
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
|
}
|
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
|
@@ -769,7 +769,51 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
|
|
|
769
769
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
770
770
|
const row = await update(tx, obligation, { holdState, holdReason: holdState === 'none' ? null : reason.trim() });
|
|
771
771
|
await addEvent(tx, row, holdState === 'none' ? 'released' : holdState, actor, {
|
|
772
|
-
from: obligation.holdState, reason: row.holdReason,
|
|
772
|
+
from: obligation.holdState, reason: holdState === 'none' ? (reason?.trim() || null) : row.holdReason,
|
|
773
|
+
});
|
|
774
|
+
return row;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const GONE_STATES = ['voided', 'deleted'];
|
|
779
|
+
|
|
780
|
+
// A person (or the assistant, after the lab says yes) frees a job that is
|
|
781
|
+
// stuck on an invoice that bills nothing now: a Flow invoice that was voided,
|
|
782
|
+
// or an accounting invoice that was voided or deleted there. The job lets go
|
|
783
|
+
// of that invoice and goes on hold, so nothing drafts until a person releases
|
|
784
|
+
// it (setHold to 'none'). A Flow invoice that is still live must be voided
|
|
785
|
+
// first: this never voids or changes an invoice.
|
|
786
|
+
export async function freeVoidedJob(db, organisationId, obligationId, { actor, reason, system = null }) {
|
|
787
|
+
requireActor(actor);
|
|
788
|
+
if (!reason?.trim()) throw new FlowError('invalid', 'say why the job is freed');
|
|
789
|
+
return db.transaction(async (tx) => {
|
|
790
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
791
|
+
let flowInvoice = null;
|
|
792
|
+
if (obligation.invoiceId) {
|
|
793
|
+
[flowInvoice] = await tx.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
|
|
794
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, obligation.invoiceId)));
|
|
795
|
+
if (flowInvoice && flowInvoice.status !== 'void') {
|
|
796
|
+
throw new FlowError('invalid', `Flow invoice ${flowInvoice.number ?? flowInvoice.id} is ${flowInvoice.status}: void it first, then free the job`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
|
|
800
|
+
if (!flowInvoice && !gone) throw new FlowError('invalid', 'the job is not linked to a void or deleted invoice');
|
|
801
|
+
const row = await update(tx, obligation, {
|
|
802
|
+
...(flowInvoice ? { invoiceId: null, draftBasis: null } : {}),
|
|
803
|
+
...(gone || GONE_STATES.includes(obligation.accountingState)
|
|
804
|
+
? { accountingState: 'not_found', accountingRef: null, accountingUrl: null, accountingMatchedBy: null, accountingCheckedAt: sql`now()` }
|
|
805
|
+
: {}),
|
|
806
|
+
holdState: 'held',
|
|
807
|
+
holdReason: reason.trim(),
|
|
808
|
+
});
|
|
809
|
+
if (gone && system) {
|
|
810
|
+
await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
|
|
811
|
+
}
|
|
812
|
+
await addEvent(tx, row, 'freed', actor, {
|
|
813
|
+
reason: reason.trim(),
|
|
814
|
+
...(flowInvoice ? { invoiceId: flowInvoice.id } : {}),
|
|
815
|
+
...(gone ? { released: gone } : {}),
|
|
816
|
+
from: obligation.holdState,
|
|
773
817
|
});
|
|
774
818
|
return row;
|
|
775
819
|
});
|
|
@@ -777,9 +821,10 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
|
|
|
777
821
|
|
|
778
822
|
// A job with no invoice of its own whose accounting evidence is ambiguous:
|
|
779
823
|
// the matcher found accounting invoices that may be this job's, but none
|
|
780
|
-
// exactly.
|
|
781
|
-
//
|
|
782
|
-
|
|
824
|
+
// exactly. Or the accounting invoice the matcher linked was voided or deleted
|
|
825
|
+
// there: it bills nothing now. A person settles it on the Reconcile page or
|
|
826
|
+
// the job: "Make invoice", or a link to a Flow invoice (linkJobToInvoice).
|
|
827
|
+
const unlinked = (o) => !o.invoiceId && (GONE_STATES.includes(o.accountingState) || (o.accountingState === 'ambiguous' && !o.accountingRef));
|
|
783
828
|
|
|
784
829
|
// The matcher's open suggestions for one job, best first.
|
|
785
830
|
export async function linkSuggestionsFor(db, organisationId, obligationId) {
|
|
@@ -814,14 +859,26 @@ async function rejectSuggestions(tx, obligation, { actor }) {
|
|
|
814
859
|
// "Make invoice": a person says the job has no invoice yet. Every suggestion
|
|
815
860
|
// is rejected and the job is recorded as not found, so it can draft. The
|
|
816
861
|
// live check before the draft still runs (draftInvoiceForObligation).
|
|
817
|
-
|
|
862
|
+
// `system` is the accounting system's id: a released voided or deleted link
|
|
863
|
+
// becomes that person's "not this one" too, so the matcher never offers it.
|
|
864
|
+
export async function confirmNoInvoice(db, organisationId, obligationId, { actor, system = null }) {
|
|
818
865
|
requireActor(actor);
|
|
819
866
|
return db.transaction(async (tx) => {
|
|
820
867
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
821
868
|
if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
|
|
822
869
|
const rejected = await rejectSuggestions(tx, obligation, { actor });
|
|
823
|
-
|
|
824
|
-
|
|
870
|
+
// A voided or deleted link is let go, so the matcher stops following it.
|
|
871
|
+
const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
|
|
872
|
+
const row = await update(tx, obligation, {
|
|
873
|
+
accountingState: 'not_found', accountingCheckedAt: sql`now()`,
|
|
874
|
+
...(gone ? { accountingRef: null, accountingUrl: null, accountingMatchedBy: null } : {}),
|
|
875
|
+
});
|
|
876
|
+
if (gone && system) {
|
|
877
|
+
await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
|
|
878
|
+
}
|
|
879
|
+
await addEvent(tx, row, 'accounting_checked', actor, {
|
|
880
|
+
reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected, ...(gone ? { released: gone } : {}),
|
|
881
|
+
});
|
|
825
882
|
return row;
|
|
826
883
|
});
|
|
827
884
|
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -342,6 +342,10 @@ export function rankCandidates(obligation, invoices, { prior = null, rivals = []
|
|
|
342
342
|
|
|
343
343
|
const amountsAgree = (obligation, invoice) => totalsFor(obligation).has(invoice.totalCents);
|
|
344
344
|
const withoutReissue = (ref) => String(ref).replace(/R$/i, '');
|
|
345
|
+
// A voided or deleted accounting invoice bills nothing: it never links a job
|
|
346
|
+
// by itself. A lab that reissued work voids the first invoice, and that one
|
|
347
|
+
// keeps the work system's number.
|
|
348
|
+
const live = (invoice) => !['voided', 'deleted'].includes(invoice.state);
|
|
345
349
|
|
|
346
350
|
async function saveSuggestions(db, organisationId, obligationId, system, ranked) {
|
|
347
351
|
const keep = ranked.slice(0, 5);
|
|
@@ -392,14 +396,14 @@ export async function matchJob(db, organisationId, obligation, {
|
|
|
392
396
|
});
|
|
393
397
|
if (prior) {
|
|
394
398
|
const [invoice] = await find({ invoiceIds: [prior.externalId] });
|
|
395
|
-
if (invoice && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
399
|
+
if (invoice && live(invoice) && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
396
400
|
return exact(invoice, 'prior_link');
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
if (obligation.sourceInvoiceRef) {
|
|
400
404
|
const numbers = [obligation.sourceInvoiceRef, withoutReissue(obligation.sourceInvoiceRef)];
|
|
401
405
|
const same = (await find({ numbers }))
|
|
402
|
-
.filter((i) => !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
406
|
+
.filter((i) => live(i) && !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
403
407
|
if (same.length === 1) return exact(same[0], 'source_invoice_ref');
|
|
404
408
|
}
|
|
405
409
|
// Without the customer's accounting contact there is nothing to compare.
|
package/server/core/sending.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
3
|
+
import { billingObligations, contacts, invoiceLines, invoices, people } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
5
|
import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
@@ -430,6 +430,11 @@ export function refusalReport(result) {
|
|
|
430
430
|
return `[sending] the accounting system refused ${count} ${word}${detail ? `: ${detail}` : ''}`;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
async function requesterActive(db, personId) {
|
|
434
|
+
const [row] = await db.select({ status: people.status }).from(people).where(eq(people.id, personId));
|
|
435
|
+
return row?.status === 'active';
|
|
436
|
+
}
|
|
437
|
+
|
|
433
438
|
// One pass of the send worker: up to `limit` queued invoices, oldest first.
|
|
434
439
|
export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
|
|
435
440
|
const rows = await db.select({ id: invoices.id, number: invoices.number, by: invoices.sendRequestedBy }).from(invoices)
|
|
@@ -439,6 +444,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
439
444
|
const summary = { sent: 0, failed: 0, waiting: 0 };
|
|
440
445
|
for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
|
|
441
446
|
const known = new Map(group.map((row) => [row.id, row]));
|
|
447
|
+
// A send waits in the queue. The person who asked for it must still be
|
|
448
|
+
// active when it goes, so turning a person (or an assistant's key) off
|
|
449
|
+
// also stops what they queued.
|
|
450
|
+
if (by && !(await requesterActive(db, by))) {
|
|
451
|
+
for (const row of group) {
|
|
452
|
+
await settle(db, organisationId, row.id, { sendState: 'failed', sendError: { code: 'requester_disabled', message: 'The person who asked to send this is turned off. Send it again.' } });
|
|
453
|
+
summary.failed += 1;
|
|
454
|
+
}
|
|
455
|
+
summary.refused = [...new Set([...(summary.refused ?? []), 'requester_disabled'])];
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
442
458
|
const { invoices: results } = await accounting.capabilities.pushInvoices({
|
|
443
459
|
organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
|
|
444
460
|
});
|
|
@@ -53,6 +53,7 @@ export const SEAZONA_SETUP = {
|
|
|
53
53
|
{ name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
54
54
|
{ name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
|
|
55
55
|
{ name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
|
|
56
|
+
{ name: 'SEAZONA_API_READ_HIDE_PATIENT', label: 'Hide patient details', kind: 'onoff', help: 'On: an AI assistant reading Seazona through Flow gets no patient, note, file or clinical fields.' },
|
|
56
57
|
],
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -85,7 +86,11 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
85
86
|
schema: seazonaSchema,
|
|
86
87
|
setup: SEAZONA_SETUP,
|
|
87
88
|
// The history import's routes (routes.js), under /api/seazona.
|
|
88
|
-
routes: ({ db, personAuth, log }) => createSeazonaRouter({
|
|
89
|
+
routes: ({ db, personAuth, log }) => createSeazonaRouter({
|
|
90
|
+
db: runtime?.db ?? db, personAuth, settings: current().settings, log,
|
|
91
|
+
read: (path, query) => current().client.get(path, query),
|
|
92
|
+
hidePatientFields: () => Boolean(current().settings.sync.hidePatientFields),
|
|
93
|
+
}),
|
|
89
94
|
// Every Seazona call goes through this call guard, inside the rate
|
|
90
95
|
// governor (governor.js; docs/INTEGRATIONS.md, "Outbound calls").
|
|
91
96
|
callGuard: SEAZONA_CALL_GUARD,
|
|
@@ -36,7 +36,16 @@ import { readBackfill, startBackfill, stopBackfill } from './history/backfill.js
|
|
|
36
36
|
// (state: one state, or needs_person)
|
|
37
37
|
// POST /history/ledger/:obligationId/release owner or admin: the job may draft
|
|
38
38
|
//
|
|
39
|
-
// None of these call Seazona
|
|
39
|
+
// None of these call Seazona, except one read:
|
|
40
|
+
//
|
|
41
|
+
// GET /api/<resource>[/<id>]?<query> owner or admin: one GET to the
|
|
42
|
+
// Seazona REST API, through Flow's
|
|
43
|
+
// rate governor, answered as
|
|
44
|
+
// { ok, data }. resource is invoices,
|
|
45
|
+
// orders, clients, products or users.
|
|
46
|
+
// It never writes to Seazona. It is how
|
|
47
|
+
// an AI assistant reads Seazona with its
|
|
48
|
+
// Flow key (docs/AGENT_ACCESS.md).
|
|
40
49
|
|
|
41
50
|
export const MAX_UPLOAD = '25mb';
|
|
42
51
|
|
|
@@ -56,7 +65,40 @@ const handle = (log, fn) => async (req, res) => {
|
|
|
56
65
|
}
|
|
57
66
|
};
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
const API_RESOURCES = ['invoices', 'orders', 'clients', 'products', 'users'];
|
|
69
|
+
|
|
70
|
+
// With "Hide patient details" on (SEAZONA_API_READ_HIDE_PATIENT), any field
|
|
71
|
+
// whose name says patient, note, file, prescription and the like is dropped
|
|
72
|
+
// from the read, at any depth. Off by default.
|
|
73
|
+
const PRIVATE_FIELD = /patient|note|comment|instruction|remark|file|attach|image|photo|prescri|shade|clinical|medical|birth|dob/i;
|
|
74
|
+
// The value is parsed JSON, so it has no cycles.
|
|
75
|
+
export function withoutPrivateFields(value) {
|
|
76
|
+
if (Array.isArray(value)) return value.map(withoutPrivateFields);
|
|
77
|
+
if (!value || typeof value !== 'object') return value;
|
|
78
|
+
return Object.fromEntries(Object.entries(value)
|
|
79
|
+
.filter(([key]) => !PRIVATE_FIELD.test(key))
|
|
80
|
+
.map(([key, item]) => [key, withoutPrivateFields(item)]));
|
|
81
|
+
}
|
|
82
|
+
// A list read must carry the filter Flow's own sync uses. Seazona answers a
|
|
83
|
+
// list without it with an error, and every error counts against the call
|
|
84
|
+
// guard that also protects Flow's sync, so Flow refuses it before the call.
|
|
85
|
+
const SEAZONA_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
|
|
86
|
+
const LIST_FILTERS = {
|
|
87
|
+
orders: { key: 'ordered', hint: 'ordered=2026-11-11T00:00:00 (one day)' },
|
|
88
|
+
invoices: { key: 'lastModified', hint: 'lastModified=2026-11-01T00:00:00' },
|
|
89
|
+
clients: { key: 'lastModified', hint: 'lastModified=2026-11-01T00:00:00' },
|
|
90
|
+
};
|
|
91
|
+
export function listQueryProblem(resource, query) {
|
|
92
|
+
const filter = LIST_FILTERS[resource];
|
|
93
|
+
if (filter && !SEAZONA_TIME.test(query[filter.key] ?? '')) return `A ${resource} list needs ${filter.hint}.`;
|
|
94
|
+
for (const key of ['page', 'pageSize']) {
|
|
95
|
+
if (key in query && !/^[1-9]\d{0,2}$/.test(query[key])) return `${key} must be a whole number from 1 to 999.`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const API_PATH = new RegExp(`^/api/(${API_RESOURCES.join('|')})(?:/([A-Za-z0-9-]{1,64}))?/?$`);
|
|
100
|
+
|
|
101
|
+
export function createSeazonaRouter({ db, personAuth, settings, read = null, hidePatientFields = () => false, log = console }) {
|
|
60
102
|
const router = express.Router();
|
|
61
103
|
// Sign-in off: every route answers 503, as the Xero routes do.
|
|
62
104
|
const signedIn = personAuth
|
|
@@ -72,6 +114,27 @@ export function createSeazonaRouter({ db, personAuth, settings, log = console })
|
|
|
72
114
|
return router;
|
|
73
115
|
}
|
|
74
116
|
|
|
117
|
+
// One read from the Seazona API (above). Only plain query values pass on.
|
|
118
|
+
router.get(API_PATH, ...manager, handle(log, async (req, res) => {
|
|
119
|
+
if (!read) throw Object.assign(new FlowError('seazona_off', 'Seazona is not connected'), { status: 503 });
|
|
120
|
+
const [, resource, id] = API_PATH.exec(req.path);
|
|
121
|
+
const query = {};
|
|
122
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
123
|
+
if (typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(key)) query[key] = value.slice(0, 200);
|
|
124
|
+
}
|
|
125
|
+
const problem = id ? null : listQueryProblem(resource, query);
|
|
126
|
+
if (problem) return res.status(400).json({ ok: false, code: 'invalid', error: problem });
|
|
127
|
+
try {
|
|
128
|
+
const data = await read(id ? `${resource}/${id}` : `${resource}/`, query);
|
|
129
|
+
log.info?.(`[seazona] api read ${resource}${id ? '/:id' : ''} by ${actor(req)}`);
|
|
130
|
+
res.json({ ok: true, data: hidePatientFields() ? withoutPrivateFields(data) : data });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.name !== 'SeazonaApiError' && error?.name !== 'SeazonaCallsStopped') throw error;
|
|
133
|
+
const status = error.statusCode === 404 ? 404 : error.statusCode === 400 ? 400 : error.name === 'SeazonaCallsStopped' || error.statusCode === 429 || error.kind === 'governor_timeout' ? 429 : 502;
|
|
134
|
+
res.status(status).json({ ok: false, code: 'seazona_read_failed', error: error.message });
|
|
135
|
+
}
|
|
136
|
+
}));
|
|
137
|
+
|
|
75
138
|
router.get('/history', ...signedIn, handle(log, async (req, res) => {
|
|
76
139
|
res.json({ ok: true, history: await historySummary(db), backfill: await readBackfill(db), batches: await listBatches(db) });
|
|
77
140
|
}));
|
|
@@ -187,6 +187,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
187
187
|
// print one. Off by default; on, orders and invoices keep Seazona's
|
|
188
188
|
// patient field as the reference.
|
|
189
189
|
keepPatientReference: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_KEEP_PATIENT_REFERENCE).toLowerCase()),
|
|
190
|
+
// Off by default: the Seazona read (routes.js) answers what Seazona gives.
|
|
191
|
+
// On, it drops patient, note, file and clinical fields first.
|
|
192
|
+
hidePatientFields: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_API_READ_HIDE_PATIENT).toLowerCase()),
|
|
190
193
|
};
|
|
191
194
|
|
|
192
195
|
const settings = {
|
|
@@ -119,7 +119,11 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
119
119
|
// 3. The work system's own invoice number, as Xero's number or reference.
|
|
120
120
|
if (obligation.sourceInvoiceRef) {
|
|
121
121
|
const byId = new Map();
|
|
122
|
-
|
|
122
|
+
// Its reissue too (a trailing R): a lab that reissued the work voided the
|
|
123
|
+
// first invoice and bills the R one. One call: Xero takes a list.
|
|
124
|
+
const base = obligation.sourceInvoiceRef.replace(/R$/i, '');
|
|
125
|
+
const numbers = [...new Set([obligation.sourceInvoiceRef, base, `${base}R`])].join(',');
|
|
126
|
+
for (const inv of sales(await client.findInvoicesByNumber(numbers))) byId.set(inv.InvoiceID, inv);
|
|
123
127
|
for (const inv of sales(await client.findInvoicesByReference(obligation.sourceInvoiceRef))) byId.set(inv.InvoiceID, inv);
|
|
124
128
|
const found = [...byId.values()];
|
|
125
129
|
if (found.length === 1) return evidenceFor(runtime, client, found[0], 'source_invoice_ref', checkedAt);
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/home', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|