@arcforgelabs/flow 2026.10.25 → 2026.10.27
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/README.md +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +5 -1
package/server/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createConnectorApi } from './api/connector.js';
|
|
3
|
+
import { createInvoicingRouter } from './api/invoicing.js';
|
|
3
4
|
import { createSignInRouter } from './auth/routes.js';
|
|
4
5
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
5
6
|
import { integrationsHealth } from './integrations/health.js';
|
|
@@ -24,7 +25,9 @@ import { createUiAssetsRouter } from './ui/routes.js';
|
|
|
24
25
|
// `integrations` is the started registry (server/integrations/index.js); each
|
|
25
26
|
// adapter's own routes are mounted under /api/<id>, and GET /health reports
|
|
26
27
|
// each one (server/integrations/health.js). `defaultFamily` is the design
|
|
27
|
-
// system every page starts on (FLOW_THEME, server/theme/themes.js).
|
|
28
|
+
// system every page starts on (FLOW_THEME, server/theme/themes.js). `log`
|
|
29
|
+
// is the console, or the monitored log (server/core/monitoring.js) that also
|
|
30
|
+
// sends each error to the installation's error service.
|
|
28
31
|
export function createApp({
|
|
29
32
|
build = deployedBuildStatus(),
|
|
30
33
|
database = null,
|
|
@@ -100,6 +103,30 @@ export function createApp({
|
|
|
100
103
|
if (integrations && database && auth?.personAuth) {
|
|
101
104
|
app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log }));
|
|
102
105
|
}
|
|
106
|
+
// Invoicing for signed-in people (server/api/invoicing.js). A job's draft
|
|
107
|
+
// invoice needs a live check in the accounting system: the first enabled
|
|
108
|
+
// integration that offers `checkInvoice` (Xero) answers it.
|
|
109
|
+
if (database && auth?.personAuth) {
|
|
110
|
+
const liveCheckFor = (organisationId) => {
|
|
111
|
+
const [adapter] = integrations?.with('checkInvoice') ?? [];
|
|
112
|
+
return adapter ? (obligation) => adapter.capabilities.checkInvoice({ organisationId, obligation }) : null;
|
|
113
|
+
};
|
|
114
|
+
// Send all ready (server/core/sending.js): the first enabled integration
|
|
115
|
+
// that sends invoices (Xero).
|
|
116
|
+
const accounting = () => integrations?.with('pushInvoices')[0] ?? null;
|
|
117
|
+
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting }));
|
|
118
|
+
}
|
|
119
|
+
// What a signed-in browser needs to report its errors and problems to the
|
|
120
|
+
// installation's own error service (docs/SENTRY.md): the first integration
|
|
121
|
+
// that offers `browserMonitoring`, or `{ on: false }`.
|
|
122
|
+
if (auth?.personAuth) {
|
|
123
|
+
app.get('/api/monitoring', auth.personAuth.requireSession, (_req, res) => {
|
|
124
|
+
res.set('Cache-Control', 'no-store');
|
|
125
|
+
const [adapter] = integrations?.with('browserMonitoring') ?? [];
|
|
126
|
+
const settings = adapter?.capabilities.browserMonitoring() ?? null;
|
|
127
|
+
res.json(settings ? { ok: true, on: true, service: adapter.id, ...settings } : { ok: true, on: false });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
103
130
|
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
|
|
104
131
|
app.use(path, handler);
|
|
105
132
|
}
|
|
@@ -114,5 +141,15 @@ export function createApp({
|
|
|
114
141
|
log,
|
|
115
142
|
}));
|
|
116
143
|
|
|
144
|
+
// An error no route answered. A server fault goes to `log` (and from there
|
|
145
|
+
// to the error service, when one is on) and the answer carries no detail;
|
|
146
|
+
// any other error keeps Express's own answer.
|
|
147
|
+
app.use((error, _req, res, next) => {
|
|
148
|
+
const status = error?.status ?? error?.statusCode ?? 500;
|
|
149
|
+
if (status < 500 || res.headersSent) return next(error);
|
|
150
|
+
log.error?.(`[http] ${error?.message ?? error}`, ...(error instanceof Error ? [error] : []));
|
|
151
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
152
|
+
});
|
|
153
|
+
|
|
117
154
|
return app;
|
|
118
155
|
}
|
package/server/auth/people.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
|
|
2
2
|
import { people, PERSON_ROLES } from '../db/schema.js';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
|
+
import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
|
|
4
5
|
import { isPlainEmail } from './settings.js';
|
|
5
6
|
|
|
6
7
|
// The people who may sign in, and their roles. Only an owner changes this
|
|
@@ -24,6 +25,31 @@ export function publicPerson(row) {
|
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
const COLOR_MODES = ['light', 'dark', 'system'];
|
|
29
|
+
|
|
30
|
+
// A person's appearance as Flow stores it: only the known keys, each checked.
|
|
31
|
+
// A family Flow does not have, or a malformed accent, is dropped, so the page
|
|
32
|
+
// falls back to the installation's default.
|
|
33
|
+
export function normaliseAppearance(input) {
|
|
34
|
+
const value = input && typeof input === 'object' ? input : {};
|
|
35
|
+
const out = { colorMode: COLOR_MODES.includes(value.colorMode) ? value.colorMode : 'system' };
|
|
36
|
+
if (isThemeFamily(value.themeFamily)) out.themeFamily = value.themeFamily;
|
|
37
|
+
const accent = typeof value.accent === 'string' ? value.accent.trim().toLowerCase() : '';
|
|
38
|
+
if (normalizeAccent(accent)) out.accent = accent;
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Each person keeps their own appearance, so people who share a browser or
|
|
43
|
+
// move between devices each see theirs.
|
|
44
|
+
export async function setPersonAppearance(db, { id, appearance }) {
|
|
45
|
+
const [row] = await db.update(people)
|
|
46
|
+
.set({ appearance: normaliseAppearance(appearance), updatedAt: new Date() })
|
|
47
|
+
.where(eq(people.id, id))
|
|
48
|
+
.returning();
|
|
49
|
+
if (!row) throw new FlowError('not_found', 'person not found');
|
|
50
|
+
return row.appearance;
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
export async function findPersonById(db, id) {
|
|
28
54
|
const [row] = await db.select().from(people).where(eq(people.id, id));
|
|
29
55
|
return row ?? null;
|
package/server/auth/routes.js
CHANGED
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
invitePerson,
|
|
8
8
|
listPeople,
|
|
9
9
|
normaliseEmail,
|
|
10
|
+
normaliseAppearance,
|
|
10
11
|
publicPerson,
|
|
11
12
|
removePerson,
|
|
13
|
+
setPersonAppearance,
|
|
12
14
|
updatePerson,
|
|
13
15
|
} from './people.js';
|
|
14
16
|
import { isPlainEmail } from './settings.js';
|
|
@@ -96,9 +98,20 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
96
98
|
const check = await ownerVerification(req.person, req.clerkAuth);
|
|
97
99
|
body.ownerActions = { allowed: Boolean(check.verified), by: check.by ?? null };
|
|
98
100
|
}
|
|
101
|
+
body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
|
|
99
102
|
res.json(body);
|
|
100
103
|
});
|
|
101
104
|
|
|
105
|
+
// Each person sets their own appearance; it follows their account.
|
|
106
|
+
router.put('/api/me/appearance', requireSession, express.json({ limit: '4kb' }), async (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const appearance = await setPersonAppearance(db, { id: req.person.id, appearance: req.body });
|
|
109
|
+
res.json({ ok: true, appearance });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
sendError(res, error, log);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
102
115
|
// Only an owner manages people.
|
|
103
116
|
const people = express.Router();
|
|
104
117
|
people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { automations, billingObligations } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
import { autoDraftJobs } from './invoicing-rules.js';
|
|
5
|
+
import { setReady } from './ledger.js';
|
|
6
|
+
|
|
7
|
+
// Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
|
|
8
|
+
// own records and moves them on, and each is on or off with its own settings,
|
|
9
|
+
// so no other system's status is baked into Flow's stages. `place` is the
|
|
10
|
+
// screen that shows it: the integration whose data triggers it, or invoicing. The routines are
|
|
11
|
+
// defined here; the `automations` table holds an organisation's choices. The
|
|
12
|
+
// pipeline (pipeline.js) runs them after it reads the integrations' copies.
|
|
13
|
+
//
|
|
14
|
+
// mark_ready_from_work_status: a job whose work system status is one of the
|
|
15
|
+
// chosen statuses becomes ready to invoice (Flow's stage). The statuses
|
|
16
|
+
// are picked from those the work system reported (workStatuses).
|
|
17
|
+
// draft_when_ready: a job that is ready to invoice and passes the invoicing
|
|
18
|
+
// rules (invoicing-rules.js) gets a draft invoice in Flow.
|
|
19
|
+
|
|
20
|
+
const BATCH = 500;
|
|
21
|
+
|
|
22
|
+
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
23
|
+
|
|
24
|
+
// A status matches when it is one of the chosen statuses, in any case.
|
|
25
|
+
export function statusMatches(status, statuses) {
|
|
26
|
+
const have = norm(status);
|
|
27
|
+
return Boolean(have) && statuses.some((wanted) => norm(wanted) === have);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The statuses the work system has reported for this organisation's jobs, with
|
|
31
|
+
// the number of jobs at each, most jobs first. People pick the ready statuses
|
|
32
|
+
// from this list, so it follows each lab's own work system.
|
|
33
|
+
export async function workStatuses(db, organisationId) {
|
|
34
|
+
const rows = await db.select({ status: billingObligations.sourceStatus, jobs: sql`count(*)::int` })
|
|
35
|
+
.from(billingObligations)
|
|
36
|
+
.where(and(eq(billingObligations.organisationId, organisationId), sql`${billingObligations.sourceStatus} is not null`))
|
|
37
|
+
.groupBy(billingObligations.sourceStatus);
|
|
38
|
+
const byName = new Map();
|
|
39
|
+
for (const { status, jobs } of rows) {
|
|
40
|
+
const key = norm(status);
|
|
41
|
+
if (!key) continue;
|
|
42
|
+
const seen = byName.get(key);
|
|
43
|
+
if (seen) seen.jobs += jobs;
|
|
44
|
+
else byName.set(key, { value: key, label: status.trim(), jobs });
|
|
45
|
+
}
|
|
46
|
+
return [...byName.values()].sort((a, b) => b.jobs - a.jobs || a.label.localeCompare(b.label));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readStatuses(value) {
|
|
50
|
+
if (!Array.isArray(value) || !value.length || value.length > 20) throw new FlowError('invalid', 'statuses must list 1 to 20 statuses');
|
|
51
|
+
const statuses = [...new Set(value.map((s) => (typeof s === 'string' ? norm(s) : '')).filter(Boolean))];
|
|
52
|
+
if (!statuses.length || statuses.some((s) => s.length > 100)) throw new FlowError('invalid', 'statuses must be short text');
|
|
53
|
+
return statuses;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const AUTOMATIONS = Object.freeze([
|
|
57
|
+
{
|
|
58
|
+
key: 'mark_ready_from_work_status',
|
|
59
|
+
place: 'seazona',
|
|
60
|
+
name: 'Mark ready to invoice',
|
|
61
|
+
hint: 'When the job has one of the ready statuses.',
|
|
62
|
+
defaults: { enabled: true, config: { statuses: ['shipped', 'delivered'] } },
|
|
63
|
+
readConfig: (config) => ({ statuses: readStatuses(config.statuses) }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'draft_when_ready',
|
|
67
|
+
place: 'invoicing',
|
|
68
|
+
name: 'Draft when ready',
|
|
69
|
+
hint: 'Drafts each ready job that passes the checks.',
|
|
70
|
+
defaults: { enabled: true, config: {} },
|
|
71
|
+
readConfig: () => ({}),
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
|
|
76
|
+
|
|
77
|
+
// Every routine with this organisation's choice, or its default.
|
|
78
|
+
export async function listAutomations(db, organisationId) {
|
|
79
|
+
const rows = await db.select().from(automations).where(eq(automations.organisationId, organisationId));
|
|
80
|
+
const saved = new Map(rows.map((r) => [r.key, r]));
|
|
81
|
+
return AUTOMATIONS.map(({ key, place, name, hint, defaults }) => {
|
|
82
|
+
const row = saved.get(key);
|
|
83
|
+
return { key, place, name, hint, enabled: row?.enabled ?? defaults.enabled, config: { ...defaults.config, ...(row?.config ?? {}) } };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function saveAutomation(db, organisationId, key, changes, { personId = null } = {}) {
|
|
88
|
+
const automation = byKey.get(key);
|
|
89
|
+
if (!automation) throw notFound('automation');
|
|
90
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw new FlowError('invalid', 'the change must be an object');
|
|
91
|
+
const unknown = Object.keys(changes).filter((k) => !['enabled', 'config'].includes(k));
|
|
92
|
+
if (unknown.length) throw new FlowError('invalid', `unknown field: ${unknown[0]}`);
|
|
93
|
+
const current = (await listAutomations(db, organisationId)).find((a) => a.key === key);
|
|
94
|
+
const enabled = changes.enabled ?? current.enabled;
|
|
95
|
+
if (typeof enabled !== 'boolean') throw new FlowError('invalid', 'enabled must be true or false');
|
|
96
|
+
const config = 'config' in changes ? automation.readConfig({ ...current.config, ...changes.config }) : current.config;
|
|
97
|
+
await db.insert(automations)
|
|
98
|
+
.values({ organisationId, key, enabled, config, updatedBy: personId })
|
|
99
|
+
.onConflictDoUpdate({ target: [automations.organisationId, automations.key], set: { enabled, config, updatedBy: personId, updatedAt: sql`now()` } });
|
|
100
|
+
return (await listAutomations(db, organisationId)).find((a) => a.key === key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const actorOf = (key) => `automation:${key}`;
|
|
104
|
+
|
|
105
|
+
// Marks ready each job in work whose work system status is one of the chosen
|
|
106
|
+
// ones. A person who put a job back in work has decided: the routine leaves
|
|
107
|
+
// that job to people from then on.
|
|
108
|
+
async function markReadyFromWorkStatus(db, organisationId, { statuses }) {
|
|
109
|
+
const actor = actorOf('mark_ready_from_work_status');
|
|
110
|
+
const jobs = await db.select().from(billingObligations).where(and(
|
|
111
|
+
eq(billingObligations.organisationId, organisationId),
|
|
112
|
+
sql`${billingObligations.readyAt} is null`,
|
|
113
|
+
sql`${billingObligations.invoiceId} is null`,
|
|
114
|
+
sql`${billingObligations.sourceStatus} is not null`,
|
|
115
|
+
sql`not exists (select 1 from obligation_events e where e.obligation_id = ${billingObligations.id}
|
|
116
|
+
and e.kind = 'in_work' and e.actor not like 'automation:%')`,
|
|
117
|
+
)).limit(BATCH);
|
|
118
|
+
let marked = 0;
|
|
119
|
+
for (const job of jobs) {
|
|
120
|
+
if (!statusMatches(job.sourceStatus, statuses)) continue;
|
|
121
|
+
await setReady(db, organisationId, job.id, { ready: true, actor });
|
|
122
|
+
marked += 1;
|
|
123
|
+
}
|
|
124
|
+
return { marked };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Runs every routine that is on. `accounting` is the integration whose copy
|
|
128
|
+
// the draft routine checks; without one, Flow's own records are the check.
|
|
129
|
+
export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
|
|
130
|
+
const summary = {};
|
|
131
|
+
for (const automation of await listAutomations(db, organisationId)) {
|
|
132
|
+
if (!automation.enabled) continue;
|
|
133
|
+
if (automation.key === 'mark_ready_from_work_status') {
|
|
134
|
+
summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
|
|
135
|
+
} else if (automation.key === 'draft_when_ready') {
|
|
136
|
+
summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return summary;
|
|
140
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { invoiceRules, invoicingSettings } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Invoice rules (docs/INVOICE_RULES.md): a lab's own checks on a job before
|
|
6
|
+
// Flow drafts its invoice. Each rule is When, If and Then, and a lab owner
|
|
7
|
+
// builds it from menus, so no lab's checks are fixed in Flow's code.
|
|
8
|
+
//
|
|
9
|
+
// When job_ready: the job is ready to invoice and has no invoice yet.
|
|
10
|
+
// If every condition matches: a field of the job, its customer or its
|
|
11
|
+
// lines, a test, and a value.
|
|
12
|
+
// Then wait: the job waits for a person; skip: rules never draft it.
|
|
13
|
+
//
|
|
14
|
+
// The rules read only Flow's own records, so they work with or without an
|
|
15
|
+
// accounting integration. The earlier fixed settings (old job limit, required
|
|
16
|
+
// practitioner, rush share) become starter rules, once per organisation.
|
|
17
|
+
|
|
18
|
+
const MAX_RULES = 100;
|
|
19
|
+
const MAX_CONDITIONS = 10;
|
|
20
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
21
|
+
|
|
22
|
+
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
23
|
+
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
24
|
+
const code = (line) => norm(line.itemCode);
|
|
25
|
+
const cents = (lines) => lines.reduce((sum, l) => sum + (l.lineAmountCents ?? 0), 0);
|
|
26
|
+
|
|
27
|
+
// The share of the item's lines in the job's other lines, as the charged and
|
|
28
|
+
// the expected cents for `percent`.
|
|
29
|
+
function share(lines, item, percent) {
|
|
30
|
+
const own = lines.filter((l) => code(l) === norm(item));
|
|
31
|
+
if (!own.length) return null;
|
|
32
|
+
const others = cents(lines.filter((l) => code(l) !== norm(item)));
|
|
33
|
+
return { charged: cents(own), others, expected: Math.round((others * percent) / 100) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Each field: what it reads from a job, its kind, and the tests it allows.
|
|
37
|
+
export const FIELDS = Object.freeze({
|
|
38
|
+
job_age_days: { label: 'Job age (days)', kind: 'number', read: (j, { now }) => (j.jobDate ? Math.floor((now - day(j.jobDate)) / 86_400_000) : null) },
|
|
39
|
+
job_total: { label: 'Job total', kind: 'money', read: (j) => (j.totalCents ?? 0) / 100 },
|
|
40
|
+
job_status: { label: 'Job status', kind: 'choice', options: 'statuses', read: (j) => norm(j.sourceStatus) || null },
|
|
41
|
+
practitioner: { label: 'Practitioner', kind: 'text', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
42
|
+
customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
|
|
43
|
+
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
44
|
+
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).map(code) },
|
|
45
|
+
line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const TESTS = Object.freeze({
|
|
49
|
+
number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
|
|
50
|
+
money: ['more_than', 'at_most', 'is', 'is_not'],
|
|
51
|
+
choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
|
|
52
|
+
text: ['is', 'is_not', 'is_empty', 'is_not_empty'],
|
|
53
|
+
boolean: ['is'],
|
|
54
|
+
items: ['has', 'has_not'],
|
|
55
|
+
share: ['is', 'is_not'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const ACTIONS = Object.freeze(['wait', 'skip']);
|
|
59
|
+
|
|
60
|
+
function readCondition(raw, index) {
|
|
61
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
62
|
+
const field = FIELDS[raw.field];
|
|
63
|
+
if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
|
|
64
|
+
if (!TESTS[field.kind].includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${TESTS[field.kind].join(', ')}`);
|
|
65
|
+
const out = { field: raw.field, test: raw.test };
|
|
66
|
+
if (['is_empty', 'is_not_empty'].includes(raw.test)) return out;
|
|
67
|
+
const { value } = raw;
|
|
68
|
+
if (field.kind === 'boolean') {
|
|
69
|
+
if (typeof value !== 'boolean') throw invalid(`condition ${index + 1}: the value must be true or false`);
|
|
70
|
+
} else if (['number', 'money', 'share'].includes(field.kind)) {
|
|
71
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1e9) throw invalid(`condition ${index + 1}: the value must be a number`);
|
|
72
|
+
} else if (raw.test === 'is_one_of') {
|
|
73
|
+
if (!Array.isArray(value) || !value.length || value.length > 50 || value.some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) {
|
|
74
|
+
throw invalid(`condition ${index + 1}: pick 1 to 50 values`);
|
|
75
|
+
}
|
|
76
|
+
} else if (typeof value !== 'string' || !value.trim() || value.length > 200) {
|
|
77
|
+
throw invalid(`condition ${index + 1}: the value must be short text`);
|
|
78
|
+
}
|
|
79
|
+
out.value = typeof value === 'string' ? value.trim() : value;
|
|
80
|
+
if (field.kind === 'share') {
|
|
81
|
+
if (typeof raw.item !== 'string' || !raw.item.trim() || raw.item.length > 100) throw invalid(`condition ${index + 1}: pick the line item`);
|
|
82
|
+
out.item = raw.item.trim();
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRule(raw) {
|
|
88
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the rule must be an object');
|
|
89
|
+
const unknown = Object.keys(raw).filter((k) => !['name', 'enabled', 'conditions', 'action'].includes(k));
|
|
90
|
+
if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
|
|
91
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
92
|
+
if (!name || name.length > 120) throw invalid('the rule needs a name of 120 characters at most');
|
|
93
|
+
const enabled = raw.enabled ?? true;
|
|
94
|
+
if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
|
|
95
|
+
if (!Array.isArray(raw.conditions) || !raw.conditions.length || raw.conditions.length > MAX_CONDITIONS) {
|
|
96
|
+
throw invalid(`the rule needs 1 to ${MAX_CONDITIONS} conditions`);
|
|
97
|
+
}
|
|
98
|
+
if (!ACTIONS.includes(raw.action?.kind)) throw invalid(`the action must be one of ${ACTIONS.join(', ')}`);
|
|
99
|
+
return { name, enabled, conditions: raw.conditions.map(readCondition), action: { kind: raw.action.kind } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function matches(condition, job, context) {
|
|
103
|
+
const field = FIELDS[condition.field];
|
|
104
|
+
const have = field.read(job, context);
|
|
105
|
+
const { test, value } = condition;
|
|
106
|
+
if (test === 'is_empty') return have === null || have === '' || have === undefined;
|
|
107
|
+
if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
|
|
108
|
+
switch (field.kind) {
|
|
109
|
+
case 'items': {
|
|
110
|
+
const found = have.includes(norm(value));
|
|
111
|
+
return test === 'has' ? found : !found;
|
|
112
|
+
}
|
|
113
|
+
case 'share': {
|
|
114
|
+
const s = share(have, condition.item, value);
|
|
115
|
+
if (!s) return false;
|
|
116
|
+
const right = Math.abs(s.charged - s.expected) <= 1;
|
|
117
|
+
return test === 'is' ? right : !right;
|
|
118
|
+
}
|
|
119
|
+
case 'number':
|
|
120
|
+
case 'money':
|
|
121
|
+
if (have === null) return false;
|
|
122
|
+
if (test === 'more_than') return have > value;
|
|
123
|
+
if (test === 'at_most') return have <= value;
|
|
124
|
+
if (test === 'is') return have === value;
|
|
125
|
+
return have !== value;
|
|
126
|
+
case 'boolean':
|
|
127
|
+
return have === value;
|
|
128
|
+
default: {
|
|
129
|
+
const left = norm(have);
|
|
130
|
+
if (test === 'is_one_of') return value.some((v) => norm(v) === left);
|
|
131
|
+
return test === 'is' ? left === norm(value) : left !== norm(value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The reasons the enabled rules give for one job, in rule order. An empty list
|
|
137
|
+
// means no rule stops its draft. `customer` is the customer's own billing
|
|
138
|
+
// settings (invoicing-rules.js).
|
|
139
|
+
export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
140
|
+
const context = { customer, now };
|
|
141
|
+
const reasons = [];
|
|
142
|
+
for (const rule of rules) {
|
|
143
|
+
if (!rule.enabled || rule.trigger !== 'job_ready') continue;
|
|
144
|
+
if (!rule.conditions.every((c) => matches(c, job, context))) continue;
|
|
145
|
+
reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
|
|
146
|
+
}
|
|
147
|
+
return reasons;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The starter rules made from the earlier fixed settings.
|
|
151
|
+
export function starterRules(settings) {
|
|
152
|
+
const rules = [
|
|
153
|
+
{ starterKey: 'not_billable', name: 'Customer not billable', enabled: true, conditions: [{ field: 'customer_billable', test: 'is', value: false }], action: { kind: 'skip' } },
|
|
154
|
+
{ starterKey: 'zero_total', name: 'No amount', enabled: true, conditions: [{ field: 'job_total', test: 'at_most', value: 0 }], action: { kind: 'wait' } },
|
|
155
|
+
{ starterKey: 'old_job', name: 'Old job', enabled: true, conditions: [{ field: 'job_age_days', test: 'more_than', value: settings.staleDays ?? 90 }], action: { kind: 'wait' } },
|
|
156
|
+
{ starterKey: 'practitioner', name: 'Practitioner required', enabled: Boolean(settings.requirePractitioner), conditions: [{ field: 'practitioner', test: 'is_empty' }], action: { kind: 'wait' } },
|
|
157
|
+
];
|
|
158
|
+
if (settings.rushItemCode) {
|
|
159
|
+
rules.push({
|
|
160
|
+
starterKey: 'rush_share', name: 'Rush charge share', enabled: true,
|
|
161
|
+
conditions: [{ field: 'line_share', item: settings.rushItemCode, test: 'is_not', value: settings.rushPercent ?? 20 }],
|
|
162
|
+
action: { kind: 'wait' },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return rules.map((r) => ({ trigger: 'job_ready', ...r }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Makes this organisation's starter rules once, from its fixed settings.
|
|
169
|
+
async function seed(db, organisationId) {
|
|
170
|
+
await db.transaction(async (tx) => {
|
|
171
|
+
await tx.insert(invoicingSettings).values({ organisationId }).onConflictDoNothing();
|
|
172
|
+
const [settings] = await tx.select().from(invoicingSettings).where(eq(invoicingSettings.organisationId, organisationId)).for('update');
|
|
173
|
+
if (settings.rulesSeeded) return;
|
|
174
|
+
const rules = starterRules(settings);
|
|
175
|
+
await tx.insert(invoiceRules).values(rules.map((r, position) => ({ organisationId, position, ...r }))).onConflictDoNothing();
|
|
176
|
+
await tx.update(invoicingSettings).set({ rulesSeeded: true }).where(eq(invoicingSettings.organisationId, organisationId));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const shape = (r) => ({
|
|
181
|
+
id: r.id, name: r.name, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
182
|
+
conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// The organisation's rules, in order. The first read makes the starter rules.
|
|
186
|
+
export async function listRules(db, organisationId) {
|
|
187
|
+
const [settings] = await db.select({ seeded: invoicingSettings.rulesSeeded }).from(invoicingSettings)
|
|
188
|
+
.where(eq(invoicingSettings.organisationId, organisationId));
|
|
189
|
+
if (!settings?.seeded) await seed(db, organisationId);
|
|
190
|
+
const rows = await db.select().from(invoiceRules).where(eq(invoiceRules.organisationId, organisationId))
|
|
191
|
+
.orderBy(asc(invoiceRules.position), asc(invoiceRules.createdAt));
|
|
192
|
+
return rows.map(shape);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function createRule(db, organisationId, raw, { personId = null } = {}) {
|
|
196
|
+
const rule = readRule(raw);
|
|
197
|
+
const existing = await listRules(db, organisationId);
|
|
198
|
+
if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
|
|
199
|
+
const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
|
|
200
|
+
const [row] = await db.insert(invoiceRules).values({ organisationId, position, ...rule, updatedBy: personId }).returning();
|
|
201
|
+
return shape(row);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function loadRule(db, organisationId, ruleId) {
|
|
205
|
+
const [row] = await db.select().from(invoiceRules).where(and(eq(invoiceRules.organisationId, organisationId), eq(invoiceRules.id, ruleId)));
|
|
206
|
+
if (!row) throw notFound('rule');
|
|
207
|
+
return row;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Changes a rule. `changes` holds any of name, enabled, conditions, action;
|
|
211
|
+
// the result must be a whole valid rule.
|
|
212
|
+
export async function updateRule(db, organisationId, ruleId, changes, { personId = null } = {}) {
|
|
213
|
+
const row = await loadRule(db, organisationId, ruleId);
|
|
214
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
215
|
+
const rule = readRule({ name: row.name, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
216
|
+
const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
|
|
217
|
+
.where(eq(invoiceRules.id, row.id)).returning();
|
|
218
|
+
return shape(saved);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function deleteRule(db, organisationId, ruleId) {
|
|
222
|
+
const row = await loadRule(db, organisationId, ruleId);
|
|
223
|
+
await db.delete(invoiceRules).where(eq(invoiceRules.id, row.id));
|
|
224
|
+
}
|
package/server/core/invoices.js
CHANGED
|
@@ -87,8 +87,17 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
|
|
|
87
87
|
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
88
88
|
if (invoice.status !== 'draft') throw new FlowError('not_draft', 'only a draft invoice can change; revise it first');
|
|
89
89
|
const fields = {};
|
|
90
|
+
if ('contactId' in changes && changes.contactId !== invoice.contactId) {
|
|
91
|
+
const [contact] = await tx.select().from(contacts)
|
|
92
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, changes.contactId)));
|
|
93
|
+
if (!contact) throw new FlowError('invalid', 'contact not found');
|
|
94
|
+
if (contact.kind !== 'customer') throw new FlowError('invalid', 'an invoice goes to a customer contact');
|
|
95
|
+
fields.contactId = changes.contactId;
|
|
96
|
+
}
|
|
90
97
|
if ('reference' in changes) fields.reference = changes.reference;
|
|
91
98
|
if ('dueDate' in changes) fields.dueDate = changes.dueDate;
|
|
99
|
+
// A changed draft needs a person to check it again.
|
|
100
|
+
if (Object.keys(fields).length || 'lines' in changes) Object.assign(fields, { readyAt: null, readyBy: null });
|
|
92
101
|
let row = invoice;
|
|
93
102
|
if (Object.keys(fields).length) {
|
|
94
103
|
[row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })
|
|
@@ -153,7 +162,8 @@ export async function reviseInvoice(db, organisationId, invoiceId) {
|
|
|
153
162
|
return db.transaction(async (tx) => {
|
|
154
163
|
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
155
164
|
if (invoice.status !== 'issued') throw new FlowError('not_issued', 'only an issued invoice can be revised');
|
|
156
|
-
|
|
165
|
+
// A revision is a new version: what happened to the last send no longer applies.
|
|
166
|
+
const [row] = await tx.update(invoices).set({ status: 'draft', readyAt: null, readyBy: null, sendState: null, sendError: null, updatedAt: sql`now()` })
|
|
157
167
|
.where(eq(invoices.id, invoice.id)).returning();
|
|
158
168
|
return row;
|
|
159
169
|
});
|