@arcforgelabs/flow 2026.11.3 → 2026.11.4
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 +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/agents/gate.js +25 -5
- package/server/api/invoicing.js +68 -1
- package/server/app.js +3 -2
- package/server/auth/session.js +13 -7
- package/server/auth/settings.js +18 -7
- package/server/core/alerts.js +38 -3
- package/server/core/automations.js +8 -3
- package/server/core/invoice-rules.js +158 -8
- package/server/core/invoices.js +9 -1
- package/server/core/invoicing-rules.js +31 -6
- package/server/core/job-board.js +16 -1
- package/server/core/ledger.js +3 -1
- package/server/core/reconcile.js +11 -3
- package/server/core/sending.js +14 -3
- package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
- package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
- package/server/db/migrations/meta/0027_snapshot.json +5050 -0
- package/server/db/migrations/meta/0028_snapshot.json +5062 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +21 -0
- package/server/integrations/seazona/feed.js +10 -3
- package/server/integrations/xero/drafts.js +1 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +12 -7
- package/server/integrations/xero/links.js +39 -1
- package/server/integrations/xero/lookup.js +3 -1
- package/server/integrations/xero/routes.js +18 -1
- package/server/ui/flow.css +1 -0
- package/server/ui/invoice-rules.js +11 -3
- package/server/ui/invoicing.js +73 -5
- package/server/ui/jobs.js +14 -2
- package/skills/flow/SKILL.md +71 -2
- 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
|
|
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
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
3
|
+
"version": "2026.11.4",
|
|
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.4",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/agents/gate.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
126
|
-
active--;
|
|
146
|
+
unlease();
|
|
127
147
|
}
|
|
128
148
|
};
|
|
129
149
|
return { handle, fail, limits };
|
package/server/api/invoicing.js
CHANGED
|
@@ -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') ?? [])],
|
package/server/auth/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
158
|
-
function
|
|
159
|
-
|
|
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,
|
|
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');
|
package/server/auth/settings.js
CHANGED
|
@@ -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
|
|
137
|
-
const
|
|
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(
|
|
140
|
-
if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(
|
|
141
|
-
if (!isPlainEmail(email)) throw new Error(
|
|
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
|
-
|
|
168
|
+
agentKeys: resolveAgentKeys(env),
|
|
158
169
|
};
|
|
159
170
|
}
|
package/server/core/alerts.js
CHANGED
|
@@ -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
|
|
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) => ({
|
|
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
|
-
|
|
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
|
+
}
|