@arcforgelabs/flow 2026.10.24 → 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 +2 -1
- package/VISION.md +56 -15
- package/npm-shrinkwrap.json +12 -2
- package/package.json +3 -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/font-families.js +1 -0
- package/server/theme/themes.js +67 -5
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Flow
|
|
2
2
|
|
|
3
|
-
Flow is invoicing, bill approval and a financial dashboard for labs that make
|
|
3
|
+
Flow is job management, invoicing, bill approval and a financial dashboard for labs that make
|
|
4
4
|
work to order, first for dental labs. Seazona (the lab's case system) is a
|
|
5
5
|
bundled integration, and Xero is an optional one. See `VISION.md`.
|
|
6
6
|
|
|
@@ -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/VISION.md
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
# Flow — Vision
|
|
2
2
|
|
|
3
|
-
Flow is invoicing, bill approval and a financial dashboard
|
|
4
|
-
makes work to order, first for dental labs. It is a complete
|
|
5
|
-
its own database. It is not a sync engine between other
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
Flow is job management, invoicing, bill approval and a financial dashboard
|
|
4
|
+
for a lab that makes work to order, first for dental labs. It is a complete
|
|
5
|
+
application with its own database. It is not a sync engine between other
|
|
6
|
+
products.
|
|
7
|
+
|
|
8
|
+
It is for the lab manager, the staff who move the work, and the person who
|
|
9
|
+
bills it and pays the suppliers. Finished feels like this: Flow opens on a
|
|
10
|
+
job board that shows every job by stage, with due dates and late jobs marked;
|
|
11
|
+
completed jobs become invoices they check and send; supplier bills are
|
|
12
|
+
reviewed and approved; and one dashboard shows cash, what is owed to the lab,
|
|
13
|
+
what the lab owes, and the GST for the BAS period. Every invoice a customer
|
|
14
|
+
was sent can be found again, exactly as it was sent.
|
|
12
15
|
|
|
13
16
|
Flow is open source so any lab can run its own installation.
|
|
14
17
|
|
|
15
18
|
## The core and its integrations
|
|
16
19
|
|
|
17
|
-
The core is invoices, bills, payments, credit notes,
|
|
18
|
-
the
|
|
20
|
+
The core is jobs and the job board, invoices, bills, payments, credit notes,
|
|
21
|
+
contacts, the price list, approvals and the dashboard. The core holds its own copy of
|
|
19
22
|
every record it shows, so it works with no integration connected and an owner
|
|
20
23
|
can build reports and features on its data.
|
|
21
24
|
|
|
@@ -33,6 +36,39 @@ data spine (`docs/DATA_SPINE.md`): its records come into a mirror, get a Flow
|
|
|
33
36
|
identity, and become core records; Flow's writes leave through an outbox; a
|
|
34
37
|
conflict goes to one review queue; one job queue runs the work.
|
|
35
38
|
|
|
39
|
+
## Job management
|
|
40
|
+
|
|
41
|
+
Flow links each job to its client and its invoice. That link is the heart
|
|
42
|
+
of the product: a job card shows what was ordered, who it is for, and how it
|
|
43
|
+
was billed and paid.
|
|
44
|
+
|
|
45
|
+
Flow does a few parts of job management well now, and more later. It does
|
|
46
|
+
not try to replace the lab's case system.
|
|
47
|
+
|
|
48
|
+
- **Now:** update job cards and process a job's information for invoicing,
|
|
49
|
+
and update jobs from paper forms.
|
|
50
|
+
- **Later:** full job management.
|
|
51
|
+
|
|
52
|
+
The job board is Flow's main view: a standard board of jobs by stage (a
|
|
53
|
+
column per stage, a card per job), with a list view of the same jobs. It
|
|
54
|
+
shows every job from the case system and from Flow, with its practice, due
|
|
55
|
+
date and department. Lab staff and the manager work from it.
|
|
56
|
+
|
|
57
|
+
Two parts follow the board (#48):
|
|
58
|
+
|
|
59
|
+
- **An intake reader.** A photo, a form or a note becomes a proposed job
|
|
60
|
+
action: update a job, create one, or add a note. A handwriting reader and a
|
|
61
|
+
low-cost decision model propose it; a person confirms it. Nothing changes
|
|
62
|
+
until a person accepts. The model steps are optional and cost money per
|
|
63
|
+
page, so the manager sees the month's cost.
|
|
64
|
+
- **A practice portal.** Practice staff sign in to follow their own jobs,
|
|
65
|
+
on desktop first, with a simpler view on phones. The server limits each
|
|
66
|
+
person to their practice.
|
|
67
|
+
|
|
68
|
+
Job management is a bundled module, built the same way as the integrations,
|
|
69
|
+
and a lab can remove it. Where the case system cannot take a change through
|
|
70
|
+
its API, the change becomes a short checklist for a person (#49).
|
|
71
|
+
|
|
36
72
|
## Standing decisions
|
|
37
73
|
|
|
38
74
|
### Why does Flow keep its own records?
|
|
@@ -58,7 +94,10 @@ not the product.
|
|
|
58
94
|
The case system, Flow and the accounting system each own their own records,
|
|
59
95
|
and the data moves both ways. Flow records which system each change came
|
|
60
96
|
from. When two systems disagree, Flow never overwrites one silently: it opens
|
|
61
|
-
an
|
|
97
|
+
an alert in the review queue. The alert shows each system's value and writes
|
|
98
|
+
nothing. A person fixes the record where they choose, then presses
|
|
99
|
+
**Resolved, re-check**: Flow checks that one difference again, and the alert
|
|
100
|
+
closes only when the systems agree.
|
|
62
101
|
|
|
63
102
|
### Why are approvals in Flow?
|
|
64
103
|
|
|
@@ -76,7 +115,8 @@ version; the sent one stays as it was, attributed to whoever sent it.
|
|
|
76
115
|
Dental cases carry health information, which Australian privacy law treats as
|
|
77
116
|
sensitive. Flow keeps the case number and the practice. It keeps a patient
|
|
78
117
|
reference only when a practice needs one printed on its invoice, and it keeps
|
|
79
|
-
no clinical notes or images.
|
|
118
|
+
no clinical notes or images. A form photo the intake reader reads is not kept
|
|
119
|
+
after its job is updated.
|
|
80
120
|
|
|
81
121
|
### Why does Flow keep its records in PostgreSQL only?
|
|
82
122
|
|
|
@@ -94,8 +134,9 @@ its own data, configuration and secrets.
|
|
|
94
134
|
|
|
95
135
|
## Out of scope
|
|
96
136
|
|
|
97
|
-
-
|
|
98
|
-
the
|
|
137
|
+
- Replacing the case system, for now. The case system owns the case record.
|
|
138
|
+
Flow's job board shows and updates the work, and does not do clinical
|
|
139
|
+
case management or production scheduling.
|
|
99
140
|
- Patient records, clinical notes, scans and images.
|
|
100
141
|
- Acting as the general ledger. Accounting software owns the ledger and the
|
|
101
142
|
bank feed.
|
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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"@fontsource-variable/lora": "5.3.0",
|
|
20
20
|
"@fontsource-variable/nunito-sans": "5.3.0",
|
|
21
21
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
22
|
+
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
22
23
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
23
24
|
"drizzle-orm": "0.45.3",
|
|
24
25
|
"express": "5.3.0",
|
|
@@ -630,6 +631,15 @@
|
|
|
630
631
|
"url": "https://github.com/sponsors/ayuhito"
|
|
631
632
|
}
|
|
632
633
|
},
|
|
634
|
+
"node_modules/@fontsource-variable/plus-jakarta-sans": {
|
|
635
|
+
"version": "5.3.0",
|
|
636
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz",
|
|
637
|
+
"integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==",
|
|
638
|
+
"license": "OFL-1.1",
|
|
639
|
+
"funding": {
|
|
640
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
641
|
+
}
|
|
642
|
+
},
|
|
633
643
|
"node_modules/@fontsource-variable/space-grotesk": {
|
|
634
644
|
"version": "5.3.0",
|
|
635
645
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz",
|
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": {
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"@fontsource-variable/lora": "5.3.0",
|
|
45
46
|
"@fontsource-variable/nunito-sans": "5.3.0",
|
|
46
47
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
48
|
+
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
47
49
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
48
50
|
"drizzle-orm": "0.45.3",
|
|
49
51
|
"express": "5.3.0",
|
|
@@ -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' }));
|