@arcforgelabs/flow 2026.10.25 → 2026.10.26
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 +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +375 -0
- package/server/app.js +11 -0
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/invoices.js +7 -0
- package/server/core/ledger.js +6 -3
- package/server/core/links.js +3 -3
- package/server/core/pipeline.js +488 -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/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +71 -2
- package/server/index.js +9 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +10 -2
- package/server/integrations/xero/lookup.js +94 -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 +5 -10
- package/server/ui/app.js +351 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +246 -22
- package/server/ui/invoicing.js +451 -0
- package/server/ui/jobs.js +153 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/routes.js +5 -1
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ run a lab.
|
|
|
33
33
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
34
34
|
| `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
|
|
35
35
|
| `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
|
|
36
|
+
| `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
|
|
36
37
|
| `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
|
|
37
38
|
| `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
|
|
38
39
|
| `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.26",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.26",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.26",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "node server/index.js",
|
|
33
33
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
34
|
+
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
34
35
|
"db:generate": "drizzle-kit generate"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { FlowError } from '../core/errors.js';
|
|
4
|
+
import {
|
|
5
|
+
createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
6
|
+
} from '../core/invoices.js';
|
|
7
|
+
import { draftInvoiceForObligation, listObligations } from '../core/ledger.js';
|
|
8
|
+
import {
|
|
9
|
+
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
10
|
+
} from '../db/schema.js';
|
|
11
|
+
|
|
12
|
+
// Invoicing for people signed in to Flow, mounted at /api/invoicing. The web
|
|
13
|
+
// UI's invoices, customers, price list and jobs pages read and write here.
|
|
14
|
+
//
|
|
15
|
+
// GET /context the organisations, and whether this person may issue
|
|
16
|
+
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status
|
|
17
|
+
// GET /invoices/:id one invoice: lines, contact, job, issued versions
|
|
18
|
+
// POST /invoices a new draft
|
|
19
|
+
// PATCH /invoices/:id change a draft
|
|
20
|
+
// POST /invoices/:id/issue owner or admin: number it and keep the document as sent
|
|
21
|
+
// POST /invoices/:id/revise owner or admin: an issued invoice back to a draft
|
|
22
|
+
// POST /invoices/:id/void owner or admin
|
|
23
|
+
// GET /contacts?q=&limit= customers, by name
|
|
24
|
+
// GET /price-items?q= the price list, by code
|
|
25
|
+
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view
|
|
26
|
+
// POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
|
|
27
|
+
// check that the accounting system holds none
|
|
28
|
+
//
|
|
29
|
+
// Every route needs a signed-in, active person; reading and drafting are open
|
|
30
|
+
// to every role. `organisationId` (query or body) names the organisation; it
|
|
31
|
+
// may be left out while the installation has exactly one. Money is integer
|
|
32
|
+
// cents. `liveCheckFor(organisationId)` returns the ledger's live check for
|
|
33
|
+
// that organisation, or null when no accounting integration offers one.
|
|
34
|
+
|
|
35
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
37
|
+
const VIEWS = ['ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
38
|
+
const MAX_LINES = 200;
|
|
39
|
+
const MAX_TEXT = 500;
|
|
40
|
+
// Each line's amount must fit the database's integer columns with room for
|
|
41
|
+
// the invoice total.
|
|
42
|
+
const MAX_QUANTITY = 100_000;
|
|
43
|
+
const MAX_UNIT_CENTS = 100_000_000;
|
|
44
|
+
|
|
45
|
+
const ERROR_STATUS = {
|
|
46
|
+
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
47
|
+
never_issued: 409, not_ready: 409, live_check_unavailable: 409,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function sendError(res, error, log) {
|
|
51
|
+
// A value PostgreSQL refuses (a number out of range, a date that does not
|
|
52
|
+
// exist) is the caller's mistake, not a server fault.
|
|
53
|
+
const pgCode = error.code ?? error.cause?.code;
|
|
54
|
+
if (!(error instanceof FlowError) && ['22003', '22008', '22007', '22P02'].includes(pgCode)) {
|
|
55
|
+
return res.status(400).json({ ok: false, code: 'invalid', error: 'a value is out of range or not valid' });
|
|
56
|
+
}
|
|
57
|
+
if (!(error instanceof FlowError)) {
|
|
58
|
+
log.error?.(`[invoicing] ${error.message}`);
|
|
59
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
60
|
+
}
|
|
61
|
+
const body = { ok: false, code: error.code, error: error.message };
|
|
62
|
+
if (error.details !== undefined) body.details = error.details;
|
|
63
|
+
return res.status(error.status ?? ERROR_STATUS[error.code] ?? 400).json(body);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const handle = (log, fn) => async (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
await fn(req, res);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
sendError(res, error, log);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
75
|
+
const text = (raw) => (typeof raw === 'string' ? raw.trim() : '');
|
|
76
|
+
const pathId = (raw, what) => {
|
|
77
|
+
if (!UUID.test(raw ?? '')) throw new FlowError('not_found', `${what} not found`);
|
|
78
|
+
return raw;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function limitOf(raw, fallback, max) {
|
|
82
|
+
if (raw === undefined) return fallback;
|
|
83
|
+
const n = Number(text(raw));
|
|
84
|
+
if (!Number.isInteger(n) || n < 1) throw invalid(`limit must be a whole number from 1 to ${max}`);
|
|
85
|
+
return Math.min(n, max);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A LIKE pattern that matches the text anywhere, with its wildcards escaped.
|
|
89
|
+
const containing = (q) => `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
90
|
+
|
|
91
|
+
function isRealDate(value) {
|
|
92
|
+
if (!DATE.test(value)) return false;
|
|
93
|
+
const d = new Date(`${value}T00:00:00Z`);
|
|
94
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function optionalText(raw, name) {
|
|
98
|
+
if (raw === undefined || raw === null) return null;
|
|
99
|
+
if (typeof raw !== 'string') throw invalid(`${name} must be text`);
|
|
100
|
+
if (raw.length > MAX_TEXT) throw invalid(`${name} is too long (${MAX_TEXT} characters at most)`);
|
|
101
|
+
return raw.trim() || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function optionalDate(raw, name) {
|
|
105
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
106
|
+
if (typeof raw !== 'string' || !isRealDate(raw)) throw invalid(`${name} must be a date, YYYY-MM-DD`);
|
|
107
|
+
return raw;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireBodyId(raw, name) {
|
|
111
|
+
if (typeof raw !== 'string' || !UUID.test(raw)) throw invalid(`${name} must be a Flow id`);
|
|
112
|
+
return raw;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readLines(raw) {
|
|
116
|
+
if (!Array.isArray(raw)) throw invalid('lines must be a list');
|
|
117
|
+
if (raw.length > MAX_LINES) throw invalid(`an invoice has ${MAX_LINES} lines at most`);
|
|
118
|
+
return raw.map((line, index) => {
|
|
119
|
+
const at = `line ${index + 1}`;
|
|
120
|
+
if (!line || typeof line !== 'object' || Array.isArray(line)) throw invalid(`${at} must be an object`);
|
|
121
|
+
const out = {};
|
|
122
|
+
if (line.priceItemId !== undefined && line.priceItemId !== null) out.priceItemId = requireBodyId(line.priceItemId, `${at}: priceItemId`);
|
|
123
|
+
// A blank description takes the price item's name.
|
|
124
|
+
const description = optionalText(line.description, `${at}: description`);
|
|
125
|
+
if (description) out.description = description;
|
|
126
|
+
if (line.quantity !== undefined) {
|
|
127
|
+
if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > MAX_QUANTITY) {
|
|
128
|
+
throw invalid(`${at}: quantity must be a whole number from 1 to ${MAX_QUANTITY}`);
|
|
129
|
+
}
|
|
130
|
+
out.quantity = line.quantity;
|
|
131
|
+
}
|
|
132
|
+
if (line.unitAmountCents !== undefined && line.unitAmountCents !== null) {
|
|
133
|
+
if (!Number.isInteger(line.unitAmountCents) || Math.abs(line.unitAmountCents) > MAX_UNIT_CENTS) {
|
|
134
|
+
throw invalid(`${at}: unitAmountCents must be whole cents`);
|
|
135
|
+
}
|
|
136
|
+
out.unitAmountCents = line.unitAmountCents;
|
|
137
|
+
}
|
|
138
|
+
if (line.taxCode !== undefined && line.taxCode !== null) {
|
|
139
|
+
if (!TAX_CODES.includes(line.taxCode)) throw invalid(`${at}: taxCode must be one of ${TAX_CODES.join(', ')}`);
|
|
140
|
+
out.taxCode = line.taxCode;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The server's own calendar date.
|
|
147
|
+
function today() {
|
|
148
|
+
const d = new Date();
|
|
149
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The organisation a request is about: the one it names, or the only one.
|
|
153
|
+
export async function resolveOrganisation(db, raw) {
|
|
154
|
+
if (raw !== undefined && raw !== null && raw !== '') {
|
|
155
|
+
const organisationId = text(raw);
|
|
156
|
+
if (!UUID.test(organisationId)) throw invalid('organisationId must be a Flow organisation id');
|
|
157
|
+
const [row] = await db.select({ id: organisations.id }).from(organisations).where(eq(organisations.id, organisationId));
|
|
158
|
+
if (!row) throw new FlowError('not_found', 'organisation not found');
|
|
159
|
+
return organisationId;
|
|
160
|
+
}
|
|
161
|
+
const rows = await db.select({ id: organisations.id }).from(organisations).limit(2);
|
|
162
|
+
if (rows.length === 1) return rows[0].id;
|
|
163
|
+
throw new FlowError('organisation_required', rows.length
|
|
164
|
+
? 'This installation has more than one organisation: say which one (organisationId).'
|
|
165
|
+
: 'This installation has no organisation yet.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const jobOf = (o) => (o ? {
|
|
169
|
+
id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
|
|
170
|
+
} : null);
|
|
171
|
+
|
|
172
|
+
async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
173
|
+
if (!invoiceIds.length) return new Map();
|
|
174
|
+
const rows = await db.select().from(billingObligations)
|
|
175
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
|
|
176
|
+
return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function invoiceDetail(db, organisationId, invoiceId) {
|
|
180
|
+
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
181
|
+
const [[contact], versions, jobs] = await Promise.all([
|
|
182
|
+
db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
183
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, invoice.contactId))),
|
|
184
|
+
listInvoiceVersions(db, organisationId, invoiceId),
|
|
185
|
+
jobsByInvoice(db, organisationId, [invoiceId]),
|
|
186
|
+
]);
|
|
187
|
+
return {
|
|
188
|
+
...invoice,
|
|
189
|
+
contact: contact ?? null,
|
|
190
|
+
job: jobs.get(invoiceId) ?? null,
|
|
191
|
+
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createInvoicingRouter({ db, personAuth, log = console, liveCheckFor = () => null }) {
|
|
196
|
+
const router = express.Router();
|
|
197
|
+
router.use(express.json({ limit: '64kb' }));
|
|
198
|
+
|
|
199
|
+
const signedIn = [personAuth.requireSession];
|
|
200
|
+
const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
|
|
201
|
+
const organisationOf = (req) => resolveOrganisation(db, req.method === 'GET' ? req.query.organisationId : (req.body?.organisationId ?? req.query.organisationId));
|
|
202
|
+
|
|
203
|
+
router.get('/context', ...signedIn, handle(log, async (req, res) => {
|
|
204
|
+
const rows = await db.select({
|
|
205
|
+
id: organisations.id, name: organisations.name, invoicePrefix: organisations.invoicePrefix, nextInvoiceNumber: organisations.nextInvoiceNumber,
|
|
206
|
+
}).from(organisations).orderBy(asc(organisations.name));
|
|
207
|
+
res.json({ ok: true, organisations: rows, canIssue: ['owner', 'admin'].includes(req.person.role) });
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
211
|
+
const organisationId = await organisationOf(req);
|
|
212
|
+
const status = text(req.query.status);
|
|
213
|
+
if (status && !INVOICE_STATUSES.includes(status)) throw invalid(`status must be one of ${INVOICE_STATUSES.join(', ')}`);
|
|
214
|
+
const q = text(req.query.q);
|
|
215
|
+
const limit = limitOf(req.query.limit, 200, 500);
|
|
216
|
+
const where = [eq(invoices.organisationId, organisationId)];
|
|
217
|
+
if (status) where.push(eq(invoices.status, status));
|
|
218
|
+
if (q) {
|
|
219
|
+
const pattern = containing(q);
|
|
220
|
+
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
221
|
+
}
|
|
222
|
+
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
223
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
224
|
+
.where(and(...where))
|
|
225
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
226
|
+
.limit(limit);
|
|
227
|
+
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
228
|
+
const counted = await db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
229
|
+
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
230
|
+
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
231
|
+
for (const { status: s, n } of counted) counts[s] = n;
|
|
232
|
+
res.json({
|
|
233
|
+
ok: true,
|
|
234
|
+
invoices: rows.map(({ invoice, contactName }) => ({ ...invoice, contactName, job: jobs.get(invoice.id) ?? null })),
|
|
235
|
+
counts,
|
|
236
|
+
});
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
240
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
241
|
+
const organisationId = await organisationOf(req);
|
|
242
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
246
|
+
const organisationId = await organisationOf(req);
|
|
247
|
+
const body = req.body ?? {};
|
|
248
|
+
const draft = await createDraftInvoice(db, organisationId, {
|
|
249
|
+
contactId: requireBodyId(body.contactId, 'contactId'),
|
|
250
|
+
reference: optionalText(body.reference, 'reference'),
|
|
251
|
+
dueDate: optionalDate(body.dueDate, 'dueDate'),
|
|
252
|
+
lines: body.lines === undefined ? [] : readLines(body.lines),
|
|
253
|
+
});
|
|
254
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made draft invoice ${draft.id}`);
|
|
255
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id) });
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
259
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
260
|
+
const organisationId = await organisationOf(req);
|
|
261
|
+
const body = req.body ?? {};
|
|
262
|
+
const changes = {};
|
|
263
|
+
if (body.contactId !== undefined) changes.contactId = requireBodyId(body.contactId, 'contactId');
|
|
264
|
+
if (body.reference !== undefined) changes.reference = optionalText(body.reference, 'reference');
|
|
265
|
+
if (body.dueDate !== undefined) changes.dueDate = optionalDate(body.dueDate, 'dueDate');
|
|
266
|
+
if (body.lines !== undefined) changes.lines = readLines(body.lines);
|
|
267
|
+
if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
|
|
268
|
+
await updateDraftInvoice(db, organisationId, invoiceId, changes);
|
|
269
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
|
|
273
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
274
|
+
const organisationId = await organisationOf(req);
|
|
275
|
+
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
276
|
+
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
277
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
278
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
|
|
282
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
283
|
+
const organisationId = await organisationOf(req);
|
|
284
|
+
await reviseInvoice(db, organisationId, invoiceId);
|
|
285
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} opened invoice ${invoiceId} for revision`);
|
|
286
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
287
|
+
}));
|
|
288
|
+
|
|
289
|
+
router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
|
|
290
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
291
|
+
const organisationId = await organisationOf(req);
|
|
292
|
+
await voidInvoice(db, organisationId, invoiceId);
|
|
293
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
|
|
294
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
298
|
+
const organisationId = await organisationOf(req);
|
|
299
|
+
const q = text(req.query.q);
|
|
300
|
+
const where = [eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')];
|
|
301
|
+
if (q) where.push(ilike(contacts.name, containing(q)));
|
|
302
|
+
const rows = await db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
303
|
+
.where(and(...where)).orderBy(asc(contacts.name), asc(contacts.id)).limit(limitOf(req.query.limit, 50, 500));
|
|
304
|
+
res.json({ ok: true, contacts: rows });
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
|
|
308
|
+
const organisationId = await organisationOf(req);
|
|
309
|
+
const q = text(req.query.q);
|
|
310
|
+
const where = [eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)];
|
|
311
|
+
if (q) where.push(or(ilike(priceItems.code, containing(q)), ilike(priceItems.name, containing(q))));
|
|
312
|
+
const rows = await db.select({
|
|
313
|
+
id: priceItems.id, code: priceItems.code, name: priceItems.name, unitAmountCents: priceItems.unitAmountCents, taxCode: priceItems.taxCode,
|
|
314
|
+
}).from(priceItems).where(and(...where)).orderBy(asc(priceItems.code));
|
|
315
|
+
res.json({ ok: true, priceItems: rows });
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
router.get('/jobs', ...signedIn, handle(log, async (req, res) => {
|
|
319
|
+
const organisationId = await organisationOf(req);
|
|
320
|
+
const view = text(req.query.view);
|
|
321
|
+
if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
|
|
322
|
+
const limit = limitOf(req.query.limit, 200, 500);
|
|
323
|
+
const all = await listObligations(db, organisationId);
|
|
324
|
+
const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
|
|
325
|
+
for (const entry of all) counts[entry.view] += 1;
|
|
326
|
+
const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
|
|
327
|
+
const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
|
|
328
|
+
const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
|
|
329
|
+
const [names, numbers] = await Promise.all([
|
|
330
|
+
contactIds.length
|
|
331
|
+
? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
332
|
+
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
|
|
333
|
+
: [],
|
|
334
|
+
invoiceIds.length
|
|
335
|
+
? db.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
|
|
336
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
337
|
+
: [],
|
|
338
|
+
]);
|
|
339
|
+
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
340
|
+
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
341
|
+
res.json({
|
|
342
|
+
ok: true,
|
|
343
|
+
jobs: listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view: v, reasons }) => ({
|
|
344
|
+
...o,
|
|
345
|
+
contactName: nameOf.get(o.contactId) ?? null,
|
|
346
|
+
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
347
|
+
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
|
348
|
+
view: v,
|
|
349
|
+
reasons,
|
|
350
|
+
})),
|
|
351
|
+
counts,
|
|
352
|
+
});
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
|
|
356
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
357
|
+
const organisationId = await organisationOf(req);
|
|
358
|
+
const liveCheck = liveCheckFor(organisationId);
|
|
359
|
+
if (typeof liveCheck !== 'function') {
|
|
360
|
+
throw new FlowError('live_check_unavailable', 'Flow drafts a job\'s invoice only after checking that the accounting system holds none for it. Turn on and connect Xero first.');
|
|
361
|
+
}
|
|
362
|
+
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
|
|
363
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
364
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
// A body that is not JSON, or too large, answers like every other refusal.
|
|
368
|
+
router.use((error, _req, res, next) => {
|
|
369
|
+
if (error?.type === 'entity.too.large') return res.status(413).json({ ok: false, code: 'too_large', error: 'The request body is too large.' });
|
|
370
|
+
if (error?.status === 400 || error?.type === 'entity.parse.failed') return res.status(400).json({ ok: false, code: 'invalid', error: 'The request body must be JSON.' });
|
|
371
|
+
return next(error);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return router;
|
|
375
|
+
}
|
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';
|
|
@@ -100,6 +101,16 @@ export function createApp({
|
|
|
100
101
|
if (integrations && database && auth?.personAuth) {
|
|
101
102
|
app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log }));
|
|
102
103
|
}
|
|
104
|
+
// Invoicing for signed-in people (server/api/invoicing.js). A job's draft
|
|
105
|
+
// invoice needs a live check in the accounting system: the first enabled
|
|
106
|
+
// integration that offers `checkInvoice` (Xero) answers it.
|
|
107
|
+
if (database && auth?.personAuth) {
|
|
108
|
+
const liveCheckFor = (organisationId) => {
|
|
109
|
+
const [adapter] = integrations?.with('checkInvoice') ?? [];
|
|
110
|
+
return adapter ? (obligation) => adapter.capabilities.checkInvoice({ organisationId, obligation }) : null;
|
|
111
|
+
};
|
|
112
|
+
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor }));
|
|
113
|
+
}
|
|
103
114
|
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
|
|
104
115
|
app.use(path, handler);
|
|
105
116
|
}
|
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' }));
|
package/server/core/invoices.js
CHANGED
|
@@ -87,6 +87,13 @@ 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;
|
|
92
99
|
let row = invoice;
|
package/server/core/ledger.js
CHANGED
|
@@ -16,8 +16,10 @@ import { invoiceTotals, lineTotals } from './money.js';
|
|
|
16
16
|
// How old the work system's report of a job may be when Flow drafts its invoice.
|
|
17
17
|
export const SOURCE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
18
18
|
|
|
19
|
-
// Ways to tie accounting evidence to a job that are exact, not a guess
|
|
20
|
-
|
|
19
|
+
// Ways to tie accounting evidence to a job that are exact, not a guess:
|
|
20
|
+
// `prior_link` is a link the earlier installation held, checked against the
|
|
21
|
+
// accounting mirror; `person` is a link a person chose.
|
|
22
|
+
export const EXACT_MATCHES = ['flow_invoice', 'source_invoice_ref', 'accounting_ref', 'prior_link', 'person'];
|
|
21
23
|
|
|
22
24
|
const LINKED_STATES = ['drafted', 'authorised', 'part_paid', 'paid', 'voided', 'deleted'];
|
|
23
25
|
|
|
@@ -107,6 +109,7 @@ export async function observeJob(db, organisationId, job) {
|
|
|
107
109
|
contactId: job.contactId ?? null,
|
|
108
110
|
practitioner: job.practitioner ?? null,
|
|
109
111
|
shippedOn: job.shippedOn ?? null,
|
|
112
|
+
jobDate: job.jobDate ?? null,
|
|
110
113
|
lines,
|
|
111
114
|
lineFingerprint: fingerprint(lines),
|
|
112
115
|
...invoiceTotals(lines),
|
|
@@ -135,7 +138,7 @@ export async function observeJob(db, organisationId, job) {
|
|
|
135
138
|
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
|
|
136
139
|
.filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
137
140
|
if (!changed.length) {
|
|
138
|
-
return update(tx, existing, { sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines });
|
|
141
|
+
return update(tx, existing, { sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate });
|
|
139
142
|
}
|
|
140
143
|
const fields = { ...facts, sourceSeenAt: seenAt };
|
|
141
144
|
if (existing.invoiceId && changed.some((key) => ['lineFingerprint', 'totalCents', 'contactId'].includes(key))) {
|
package/server/core/links.js
CHANGED
|
@@ -8,12 +8,12 @@ import { openReview, resolveReviewFor } from './review.js';
|
|
|
8
8
|
// Pushes, imports and the ledger read the link first; matching by name only
|
|
9
9
|
// ever proposes one.
|
|
10
10
|
//
|
|
11
|
-
// A link is exact when Flow made the external record,
|
|
12
|
-
// same id. A name match is a guess: it stays `proposed` and opens a review item
|
|
11
|
+
// A link is exact when Flow made the external record, both sides carry the
|
|
12
|
+
// same id, or one record on each side has the same name. A looser name match is a guess: it stays `proposed` and opens a review item
|
|
13
13
|
// until a person confirms it. A link is never deleted; a replaced one is
|
|
14
14
|
// `superseded`.
|
|
15
15
|
|
|
16
|
-
const EXACT = new Set(['created_by_flow', 'exact_id', 'imported', 'person']);
|
|
16
|
+
const EXACT = new Set(['created_by_flow', 'exact_id', 'exact_name', 'imported', 'person']);
|
|
17
17
|
|
|
18
18
|
function check({ entity, flowId, system, externalId, matchedBy }) {
|
|
19
19
|
if (!LINK_ENTITIES.includes(entity)) throw new FlowError('invalid', `unknown link entity: ${entity}`);
|