@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.27",
|
|
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": {
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
47
48
|
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
48
49
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
50
|
+
"@sentry/node": "11.4.0",
|
|
49
51
|
"drizzle-orm": "0.45.3",
|
|
50
52
|
"express": "5.3.0",
|
|
51
53
|
"express-rate-limit": "8.7.0",
|
|
@@ -0,0 +1,586 @@
|
|
|
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 { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
|
|
8
|
+
import {
|
|
9
|
+
ACTIONS, createRule, deleteRule, FIELDS, listRules, TESTS, updateRule,
|
|
10
|
+
} from '../core/invoice-rules.js';
|
|
11
|
+
import {
|
|
12
|
+
customerBillingByContact, effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings, ruleBlockers,
|
|
13
|
+
saveCustomerBilling, saveInvoicingSettings,
|
|
14
|
+
} from '../core/invoicing-rules.js';
|
|
15
|
+
import { draftInvoiceForObligation, listObligations, setReady } from '../core/ledger.js';
|
|
16
|
+
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
17
|
+
import {
|
|
18
|
+
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
19
|
+
} from '../db/schema.js';
|
|
20
|
+
|
|
21
|
+
// Invoicing for people signed in to Flow, mounted at /api/invoicing. The web
|
|
22
|
+
// UI's invoices, customers, price list and jobs pages read and write here.
|
|
23
|
+
//
|
|
24
|
+
// GET /context the organisations, and whether this person may issue
|
|
25
|
+
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status;
|
|
26
|
+
// status to_check, ready or problem lists those views (server/core/sending.js)
|
|
27
|
+
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
28
|
+
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
29
|
+
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
30
|
+
// accounting system { issueDate }
|
|
31
|
+
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
32
|
+
// GET /invoices/:id one invoice: lines, contact, job, issued versions
|
|
33
|
+
// POST /invoices a new draft
|
|
34
|
+
// PATCH /invoices/:id change a draft
|
|
35
|
+
// POST /invoices/:id/issue owner or admin: number it and keep the document as sent
|
|
36
|
+
// POST /invoices/:id/revise owner or admin: an issued invoice back to a draft
|
|
37
|
+
// POST /invoices/:id/void owner or admin
|
|
38
|
+
// GET /contacts?q=&limit= customers, by name
|
|
39
|
+
// GET /price-items?q= the price list, by code
|
|
40
|
+
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
41
|
+
// a job with no invoice lists the invoicing rules it waits on
|
|
42
|
+
// GET /settings the invoicing settings (server/core/invoicing-rules.js)
|
|
43
|
+
// PUT /settings owner or admin: change some of them
|
|
44
|
+
// GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
|
|
45
|
+
// GET /rule-fields what a rule can name, and the menus of values
|
|
46
|
+
// POST /rules owner or admin: a new rule { name, enabled, conditions, action }
|
|
47
|
+
// PUT /rules/:id owner or admin: change some of a rule
|
|
48
|
+
// DELETE /rules/:id owner or admin
|
|
49
|
+
// GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
|
|
50
|
+
// PUT /automations/:key owner or admin: { enabled, config }
|
|
51
|
+
// GET /work-statuses the work system's job statuses, with job counts
|
|
52
|
+
// GET /contacts/:id/billing one customer's own invoicing settings
|
|
53
|
+
// PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
|
|
54
|
+
// POST /jobs/:id/ready { ready }: mark a job ready to invoice, or back in work
|
|
55
|
+
// POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
|
|
56
|
+
// check that the accounting system holds none
|
|
57
|
+
//
|
|
58
|
+
// Every route needs a signed-in, active person; reading and drafting are open
|
|
59
|
+
// to every role. `organisationId` (query or body) names the organisation; it
|
|
60
|
+
// may be left out while the installation has exactly one. Money is integer
|
|
61
|
+
// cents. `liveCheckFor(organisationId)` returns the ledger's live check for
|
|
62
|
+
// that organisation, or null when no accounting integration offers one.
|
|
63
|
+
// `accounting()` is the accounting adapter that sends invoices, or null.
|
|
64
|
+
|
|
65
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
66
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
67
|
+
const VIEWS_OF_INVOICES = ['to_check', 'ready', 'problem'];
|
|
68
|
+
const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
69
|
+
const MAX_LINES = 200;
|
|
70
|
+
const MAX_TEXT = 500;
|
|
71
|
+
// Each line's amount must fit the database's integer columns with room for
|
|
72
|
+
// the invoice total.
|
|
73
|
+
const MAX_QUANTITY = 100_000;
|
|
74
|
+
const MAX_UNIT_CENTS = 100_000_000;
|
|
75
|
+
|
|
76
|
+
const ERROR_STATUS = {
|
|
77
|
+
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
78
|
+
never_issued: 409, not_ready: 409, live_check_unavailable: 409,
|
|
79
|
+
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function sendError(res, error, log) {
|
|
83
|
+
// A value PostgreSQL refuses (a number out of range, a date that does not
|
|
84
|
+
// exist) is the caller's mistake, not a server fault.
|
|
85
|
+
const pgCode = error.code ?? error.cause?.code;
|
|
86
|
+
if (!(error instanceof FlowError) && ['22003', '22008', '22007', '22P02'].includes(pgCode)) {
|
|
87
|
+
return res.status(400).json({ ok: false, code: 'invalid', error: 'a value is out of range or not valid' });
|
|
88
|
+
}
|
|
89
|
+
if (!(error instanceof FlowError)) {
|
|
90
|
+
log.error?.(`[invoicing] ${error.message}`);
|
|
91
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
92
|
+
}
|
|
93
|
+
const body = { ok: false, code: error.code, error: error.message };
|
|
94
|
+
if (error.details !== undefined) body.details = error.details;
|
|
95
|
+
return res.status(error.status ?? ERROR_STATUS[error.code] ?? 400).json(body);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const handle = (log, fn) => async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
await fn(req, res);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
sendError(res, error, log);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
107
|
+
const text = (raw) => (typeof raw === 'string' ? raw.trim() : '');
|
|
108
|
+
const pathId = (raw, what) => {
|
|
109
|
+
if (!UUID.test(raw ?? '')) throw new FlowError('not_found', `${what} not found`);
|
|
110
|
+
return raw;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function limitOf(raw, fallback, max) {
|
|
114
|
+
if (raw === undefined) return fallback;
|
|
115
|
+
const n = Number(text(raw));
|
|
116
|
+
if (!Number.isInteger(n) || n < 1) throw invalid(`limit must be a whole number from 1 to ${max}`);
|
|
117
|
+
return Math.min(n, max);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A LIKE pattern that matches the text anywhere, with its wildcards escaped.
|
|
121
|
+
const containing = (q) => `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
122
|
+
|
|
123
|
+
function isRealDate(value) {
|
|
124
|
+
if (!DATE.test(value)) return false;
|
|
125
|
+
const d = new Date(`${value}T00:00:00Z`);
|
|
126
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function optionalText(raw, name) {
|
|
130
|
+
if (raw === undefined || raw === null) return null;
|
|
131
|
+
if (typeof raw !== 'string') throw invalid(`${name} must be text`);
|
|
132
|
+
if (raw.length > MAX_TEXT) throw invalid(`${name} is too long (${MAX_TEXT} characters at most)`);
|
|
133
|
+
return raw.trim() || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function optionalDate(raw, name) {
|
|
137
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
138
|
+
if (typeof raw !== 'string' || !isRealDate(raw)) throw invalid(`${name} must be a date, YYYY-MM-DD`);
|
|
139
|
+
return raw;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function requireBodyId(raw, name) {
|
|
143
|
+
if (typeof raw !== 'string' || !UUID.test(raw)) throw invalid(`${name} must be a Flow id`);
|
|
144
|
+
return raw;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readLines(raw) {
|
|
148
|
+
if (!Array.isArray(raw)) throw invalid('lines must be a list');
|
|
149
|
+
if (raw.length > MAX_LINES) throw invalid(`an invoice has ${MAX_LINES} lines at most`);
|
|
150
|
+
return raw.map((line, index) => {
|
|
151
|
+
const at = `line ${index + 1}`;
|
|
152
|
+
if (!line || typeof line !== 'object' || Array.isArray(line)) throw invalid(`${at} must be an object`);
|
|
153
|
+
const out = {};
|
|
154
|
+
if (line.priceItemId !== undefined && line.priceItemId !== null) out.priceItemId = requireBodyId(line.priceItemId, `${at}: priceItemId`);
|
|
155
|
+
// A blank description takes the price item's name.
|
|
156
|
+
const description = optionalText(line.description, `${at}: description`);
|
|
157
|
+
if (description) out.description = description;
|
|
158
|
+
if (line.quantity !== undefined) {
|
|
159
|
+
if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > MAX_QUANTITY) {
|
|
160
|
+
throw invalid(`${at}: quantity must be a whole number from 1 to ${MAX_QUANTITY}`);
|
|
161
|
+
}
|
|
162
|
+
out.quantity = line.quantity;
|
|
163
|
+
}
|
|
164
|
+
if (line.unitAmountCents !== undefined && line.unitAmountCents !== null) {
|
|
165
|
+
if (!Number.isInteger(line.unitAmountCents) || Math.abs(line.unitAmountCents) > MAX_UNIT_CENTS) {
|
|
166
|
+
throw invalid(`${at}: unitAmountCents must be whole cents`);
|
|
167
|
+
}
|
|
168
|
+
out.unitAmountCents = line.unitAmountCents;
|
|
169
|
+
}
|
|
170
|
+
if (line.taxCode !== undefined && line.taxCode !== null) {
|
|
171
|
+
if (!TAX_CODES.includes(line.taxCode)) throw invalid(`${at}: taxCode must be one of ${TAX_CODES.join(', ')}`);
|
|
172
|
+
out.taxCode = line.taxCode;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The server's own calendar date.
|
|
179
|
+
function today() {
|
|
180
|
+
const d = new Date();
|
|
181
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The organisation a request is about: the one it names, or the only one.
|
|
185
|
+
export async function resolveOrganisation(db, raw) {
|
|
186
|
+
if (raw !== undefined && raw !== null && raw !== '') {
|
|
187
|
+
const organisationId = text(raw);
|
|
188
|
+
if (!UUID.test(organisationId)) throw invalid('organisationId must be a Flow organisation id');
|
|
189
|
+
const [row] = await db.select({ id: organisations.id }).from(organisations).where(eq(organisations.id, organisationId));
|
|
190
|
+
if (!row) throw new FlowError('not_found', 'organisation not found');
|
|
191
|
+
return organisationId;
|
|
192
|
+
}
|
|
193
|
+
const rows = await db.select({ id: organisations.id }).from(organisations).limit(2);
|
|
194
|
+
if (rows.length === 1) return rows[0].id;
|
|
195
|
+
throw new FlowError('organisation_required', rows.length
|
|
196
|
+
? 'This installation has more than one organisation: say which one (organisationId).'
|
|
197
|
+
: 'This installation has no organisation yet.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const jobOf = (o) => (o ? {
|
|
201
|
+
id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
|
|
202
|
+
} : null);
|
|
203
|
+
|
|
204
|
+
async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
205
|
+
if (!invoiceIds.length) return new Map();
|
|
206
|
+
const rows = await db.select().from(billingObligations)
|
|
207
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
|
|
208
|
+
return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
|
|
212
|
+
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
213
|
+
const [[contact], versions, jobs] = await Promise.all([
|
|
214
|
+
db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
215
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, invoice.contactId))),
|
|
216
|
+
listInvoiceVersions(db, organisationId, invoiceId),
|
|
217
|
+
jobsByInvoice(db, organisationId, [invoiceId]),
|
|
218
|
+
]);
|
|
219
|
+
return {
|
|
220
|
+
...invoice,
|
|
221
|
+
...(await invoiceView(db, organisationId, invoice, { accounting })),
|
|
222
|
+
contact: contact ?? null,
|
|
223
|
+
job: jobs.get(invoiceId) ?? null,
|
|
224
|
+
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function createInvoicingRouter({ db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null }) {
|
|
229
|
+
const router = express.Router();
|
|
230
|
+
router.use(express.json({ limit: '64kb' }));
|
|
231
|
+
|
|
232
|
+
const signedIn = [personAuth.requireSession];
|
|
233
|
+
const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
|
|
234
|
+
const organisationOf = (req) => resolveOrganisation(db, req.method === 'GET' ? req.query.organisationId : (req.body?.organisationId ?? req.query.organisationId));
|
|
235
|
+
|
|
236
|
+
router.get('/context', ...signedIn, handle(log, async (req, res) => {
|
|
237
|
+
const rows = await db.select({
|
|
238
|
+
id: organisations.id, name: organisations.name, invoicePrefix: organisations.invoicePrefix, nextInvoiceNumber: organisations.nextInvoiceNumber,
|
|
239
|
+
}).from(organisations).orderBy(asc(organisations.name));
|
|
240
|
+
res.json({ ok: true, organisations: rows, canIssue: ['owner', 'admin'].includes(req.person.role) });
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
244
|
+
const organisationId = await organisationOf(req);
|
|
245
|
+
const status = text(req.query.status);
|
|
246
|
+
if (status && ![...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].includes(status)) {
|
|
247
|
+
throw invalid(`status must be one of ${[...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].join(', ')}`);
|
|
248
|
+
}
|
|
249
|
+
const q = text(req.query.q);
|
|
250
|
+
const limit = limitOf(req.query.limit, 200, 500);
|
|
251
|
+
const views = await invoiceViews(db, organisationId, { accounting: accounting() });
|
|
252
|
+
const where = [eq(invoices.organisationId, organisationId)];
|
|
253
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
254
|
+
if (status === 'to_check') where.push(only(views.toCheck));
|
|
255
|
+
else if (status === 'ready') where.push(only(views.ready));
|
|
256
|
+
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
257
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
258
|
+
if (q) {
|
|
259
|
+
const pattern = containing(q);
|
|
260
|
+
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
261
|
+
}
|
|
262
|
+
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
263
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
264
|
+
.where(and(...where))
|
|
265
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
266
|
+
.limit(limit);
|
|
267
|
+
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
268
|
+
const counted = await db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
269
|
+
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
270
|
+
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
271
|
+
for (const { status: s, n } of counted) counts[s] = n;
|
|
272
|
+
counts.to_check = views.toCheck.length;
|
|
273
|
+
counts.ready = views.ready.length;
|
|
274
|
+
counts.problem = views.problems.size;
|
|
275
|
+
const ready = new Set(views.ready);
|
|
276
|
+
const toCheck = new Set(views.toCheck);
|
|
277
|
+
res.json({
|
|
278
|
+
ok: true,
|
|
279
|
+
invoices: rows.map(({ invoice, contactName }) => ({
|
|
280
|
+
...invoice,
|
|
281
|
+
contactName,
|
|
282
|
+
job: jobs.get(invoice.id) ?? null,
|
|
283
|
+
view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
|
|
284
|
+
: views.sending.has(invoice.id) ? 'sending' : null,
|
|
285
|
+
problems: views.problems.get(invoice.id) ?? [],
|
|
286
|
+
})),
|
|
287
|
+
counts,
|
|
288
|
+
sendsTo: accounting()?.name ?? null,
|
|
289
|
+
});
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
const invoiceIdsOf = (body) => {
|
|
293
|
+
const ids = body?.invoiceIds;
|
|
294
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 500 || !ids.every((id) => UUID.test(String(id)))) {
|
|
295
|
+
throw invalid('invoiceIds must list 1 to 500 invoice ids');
|
|
296
|
+
}
|
|
297
|
+
return [...new Set(ids)];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Anyone who drafts may say a draft is checked; issuing and sending stay
|
|
301
|
+
// with an owner or admin.
|
|
302
|
+
router.post('/invoices/mark-ready', ...signedIn, handle(log, async (req, res) => {
|
|
303
|
+
const organisationId = await organisationOf(req);
|
|
304
|
+
const result = await markReady(db, organisationId, invoiceIdsOf(req.body), { personId: req.person.id, accounting: accounting() });
|
|
305
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} marked ${result.marked} invoice(s) ready`);
|
|
306
|
+
res.json({ ok: true, ...result });
|
|
307
|
+
}));
|
|
308
|
+
|
|
309
|
+
router.post('/invoices/mark-not-ready', ...signedIn, handle(log, async (req, res) => {
|
|
310
|
+
const organisationId = await organisationOf(req);
|
|
311
|
+
res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
|
|
315
|
+
const organisationId = await organisationOf(req);
|
|
316
|
+
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
317
|
+
const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
|
|
318
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
|
|
319
|
+
res.json({ ok: true, ...result });
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
router.post('/invoices/send-again', ...manager, handle(log, async (req, res) => {
|
|
323
|
+
const organisationId = await organisationOf(req);
|
|
324
|
+
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
325
|
+
const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
|
|
326
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent again: ${JSON.stringify(result)}`);
|
|
327
|
+
res.json({ ok: true, ...result });
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
331
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
332
|
+
const organisationId = await organisationOf(req);
|
|
333
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
337
|
+
const organisationId = await organisationOf(req);
|
|
338
|
+
const body = req.body ?? {};
|
|
339
|
+
const draft = await createDraftInvoice(db, organisationId, {
|
|
340
|
+
contactId: requireBodyId(body.contactId, 'contactId'),
|
|
341
|
+
reference: optionalText(body.reference, 'reference'),
|
|
342
|
+
dueDate: optionalDate(body.dueDate, 'dueDate'),
|
|
343
|
+
lines: body.lines === undefined ? [] : readLines(body.lines),
|
|
344
|
+
});
|
|
345
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made draft invoice ${draft.id}`);
|
|
346
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
350
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
351
|
+
const organisationId = await organisationOf(req);
|
|
352
|
+
const body = req.body ?? {};
|
|
353
|
+
const changes = {};
|
|
354
|
+
if (body.contactId !== undefined) changes.contactId = requireBodyId(body.contactId, 'contactId');
|
|
355
|
+
if (body.reference !== undefined) changes.reference = optionalText(body.reference, 'reference');
|
|
356
|
+
if (body.dueDate !== undefined) changes.dueDate = optionalDate(body.dueDate, 'dueDate');
|
|
357
|
+
if (body.lines !== undefined) changes.lines = readLines(body.lines);
|
|
358
|
+
if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
|
|
359
|
+
await updateDraftInvoice(db, organisationId, invoiceId, changes);
|
|
360
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
|
|
364
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
365
|
+
const organisationId = await organisationOf(req);
|
|
366
|
+
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
367
|
+
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
368
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
369
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
|
|
373
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
374
|
+
const organisationId = await organisationOf(req);
|
|
375
|
+
await reviseInvoice(db, organisationId, invoiceId);
|
|
376
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} opened invoice ${invoiceId} for revision`);
|
|
377
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
|
|
381
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
382
|
+
const organisationId = await organisationOf(req);
|
|
383
|
+
await voidInvoice(db, organisationId, invoiceId);
|
|
384
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
|
|
385
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
389
|
+
const organisationId = await organisationOf(req);
|
|
390
|
+
const q = text(req.query.q);
|
|
391
|
+
const where = [eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')];
|
|
392
|
+
if (q) where.push(ilike(contacts.name, containing(q)));
|
|
393
|
+
const rows = await db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
394
|
+
.where(and(...where)).orderBy(asc(contacts.name), asc(contacts.id)).limit(limitOf(req.query.limit, 50, 500));
|
|
395
|
+
res.json({ ok: true, contacts: rows });
|
|
396
|
+
}));
|
|
397
|
+
|
|
398
|
+
router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
|
|
399
|
+
const organisationId = await organisationOf(req);
|
|
400
|
+
const q = text(req.query.q);
|
|
401
|
+
const where = [eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)];
|
|
402
|
+
if (q) where.push(or(ilike(priceItems.code, containing(q)), ilike(priceItems.name, containing(q))));
|
|
403
|
+
const rows = await db.select({
|
|
404
|
+
id: priceItems.id, code: priceItems.code, name: priceItems.name, unitAmountCents: priceItems.unitAmountCents, taxCode: priceItems.taxCode,
|
|
405
|
+
}).from(priceItems).where(and(...where)).orderBy(asc(priceItems.code));
|
|
406
|
+
res.json({ ok: true, priceItems: rows });
|
|
407
|
+
}));
|
|
408
|
+
|
|
409
|
+
router.get('/jobs', ...signedIn, handle(log, async (req, res) => {
|
|
410
|
+
const organisationId = await organisationOf(req);
|
|
411
|
+
const view = text(req.query.view);
|
|
412
|
+
if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
|
|
413
|
+
const limit = limitOf(req.query.limit, 200, 500);
|
|
414
|
+
const all = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
|
|
415
|
+
const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
|
|
416
|
+
for (const entry of all) counts[entry.view] += 1;
|
|
417
|
+
const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
|
|
418
|
+
const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
|
|
419
|
+
const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
|
|
420
|
+
const [names, numbers] = await Promise.all([
|
|
421
|
+
contactIds.length
|
|
422
|
+
? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
423
|
+
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
|
|
424
|
+
: [],
|
|
425
|
+
invoiceIds.length
|
|
426
|
+
? db.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
|
|
427
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
428
|
+
: [],
|
|
429
|
+
]);
|
|
430
|
+
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
431
|
+
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
432
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
433
|
+
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
434
|
+
const rules = await listRules(db, organisationId);
|
|
435
|
+
// The invoicing rules a job with no invoice waits on, for every view a
|
|
436
|
+
// draft can still come from.
|
|
437
|
+
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
438
|
+
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
439
|
+
res.json({
|
|
440
|
+
ok: true,
|
|
441
|
+
jobs: listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view: v, reasons }) => ({
|
|
442
|
+
...o,
|
|
443
|
+
contactName: nameOf.get(o.contactId) ?? null,
|
|
444
|
+
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
445
|
+
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
|
446
|
+
view: v,
|
|
447
|
+
reasons,
|
|
448
|
+
waitingFor: waitingFor(o, v),
|
|
449
|
+
})),
|
|
450
|
+
counts,
|
|
451
|
+
});
|
|
452
|
+
}));
|
|
453
|
+
|
|
454
|
+
router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
|
|
455
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
456
|
+
const organisationId = await organisationOf(req);
|
|
457
|
+
// With no accounting system connected, Flow's own records are the check.
|
|
458
|
+
const liveCheck = liveCheckFor(organisationId) ?? null;
|
|
459
|
+
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
|
|
460
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
461
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
462
|
+
}));
|
|
463
|
+
|
|
464
|
+
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
465
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
466
|
+
const organisationId = await organisationOf(req);
|
|
467
|
+
const ready = req.body?.ready ?? true;
|
|
468
|
+
if (typeof ready !== 'boolean') throw invalid('ready must be true or false');
|
|
469
|
+
const job = await setReady(db, organisationId, obligationId, { ready, actor: `person:${req.person.id}` });
|
|
470
|
+
res.json({ ok: true, job: { id: job.id, readyAt: job.readyAt, readyBy: job.readyBy } });
|
|
471
|
+
}));
|
|
472
|
+
|
|
473
|
+
router.get('/automations', ...signedIn, handle(log, async (req, res) => {
|
|
474
|
+
const organisationId = await organisationOf(req);
|
|
475
|
+
const place = typeof req.query.place === 'string' ? req.query.place : null;
|
|
476
|
+
const list = await listAutomations(db, organisationId);
|
|
477
|
+
res.json({ ok: true, automations: place ? list.filter((a) => a.place === place) : list });
|
|
478
|
+
}));
|
|
479
|
+
|
|
480
|
+
router.get('/work-statuses', ...signedIn, handle(log, async (req, res) => {
|
|
481
|
+
const organisationId = await organisationOf(req);
|
|
482
|
+
res.json({ ok: true, statuses: await workStatuses(db, organisationId) });
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
router.put('/automations/:key', ...manager, handle(log, async (req, res) => {
|
|
486
|
+
const organisationId = await organisationOf(req);
|
|
487
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
488
|
+
const automation = await saveAutomation(db, organisationId, String(req.params.key), changes, { personId: req.person.id });
|
|
489
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed automation ${automation.key}`);
|
|
490
|
+
res.json({ ok: true, automation });
|
|
491
|
+
}));
|
|
492
|
+
|
|
493
|
+
// The lab's invoice rules (docs/INVOICE_RULES.md).
|
|
494
|
+
router.get('/rules', ...signedIn, handle(log, async (req, res) => {
|
|
495
|
+
const organisationId = await organisationOf(req);
|
|
496
|
+
res.json({ ok: true, rules: await listRules(db, organisationId) });
|
|
497
|
+
}));
|
|
498
|
+
|
|
499
|
+
// What a rule can name, so people pick every value from a menu: the fields
|
|
500
|
+
// with their tests, and the values Flow already holds for each kind of menu.
|
|
501
|
+
router.get('/rule-fields', ...signedIn, handle(log, async (req, res) => {
|
|
502
|
+
const organisationId = await organisationOf(req);
|
|
503
|
+
const [statuses, customers, items] = await Promise.all([
|
|
504
|
+
workStatuses(db, organisationId),
|
|
505
|
+
db.select({ value: contacts.id, label: contacts.name }).from(contacts)
|
|
506
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
|
|
507
|
+
.orderBy(asc(contacts.name)).limit(1000),
|
|
508
|
+
db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
|
|
509
|
+
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
|
510
|
+
.orderBy(asc(priceItems.code)).limit(1000),
|
|
511
|
+
]);
|
|
512
|
+
res.json({
|
|
513
|
+
ok: true,
|
|
514
|
+
fields: Object.entries(FIELDS).map(([key, f]) => ({ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind] })),
|
|
515
|
+
actions: ACTIONS,
|
|
516
|
+
options: {
|
|
517
|
+
statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
|
|
518
|
+
customers,
|
|
519
|
+
items: items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}));
|
|
523
|
+
|
|
524
|
+
router.post('/rules', ...manager, handle(log, async (req, res) => {
|
|
525
|
+
const organisationId = await organisationOf(req);
|
|
526
|
+
const { organisationId: _named, ...raw } = req.body ?? {};
|
|
527
|
+
const rule = await createRule(db, organisationId, raw, { personId: req.person.id });
|
|
528
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added invoice rule ${rule.id}`);
|
|
529
|
+
res.status(201).json({ ok: true, rule });
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
router.put('/rules/:id', ...manager, handle(log, async (req, res) => {
|
|
533
|
+
const ruleId = pathId(req.params.id, 'rule');
|
|
534
|
+
const organisationId = await organisationOf(req);
|
|
535
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
536
|
+
const rule = await updateRule(db, organisationId, ruleId, changes, { personId: req.person.id });
|
|
537
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoice rule ${rule.id}`);
|
|
538
|
+
res.json({ ok: true, rule });
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
router.delete('/rules/:id', ...manager, handle(log, async (req, res) => {
|
|
542
|
+
const ruleId = pathId(req.params.id, 'rule');
|
|
543
|
+
const organisationId = await organisationOf(req);
|
|
544
|
+
await deleteRule(db, organisationId, ruleId);
|
|
545
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted invoice rule ${ruleId}`);
|
|
546
|
+
res.json({ ok: true });
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
router.get('/settings', ...signedIn, handle(log, async (req, res) => {
|
|
550
|
+
const organisationId = await organisationOf(req);
|
|
551
|
+
res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });
|
|
552
|
+
}));
|
|
553
|
+
|
|
554
|
+
router.put('/settings', ...manager, handle(log, async (req, res) => {
|
|
555
|
+
const organisationId = await organisationOf(req);
|
|
556
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
557
|
+
const settings = await saveInvoicingSettings(db, organisationId, changes, { personId: req.person.id });
|
|
558
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoicing settings ${Object.keys(changes).join(', ')}`);
|
|
559
|
+
res.json({ ok: true, settings, defaults: INVOICING_DEFAULTS });
|
|
560
|
+
}));
|
|
561
|
+
|
|
562
|
+
router.get('/contacts/:id/billing', ...signedIn, handle(log, async (req, res) => {
|
|
563
|
+
const contactId = pathId(req.params.id, 'customer');
|
|
564
|
+
const organisationId = await organisationOf(req);
|
|
565
|
+
const [billing, settings] = await Promise.all([readCustomerBilling(db, organisationId, contactId), readInvoicingSettings(db, organisationId)]);
|
|
566
|
+
res.json({ ok: true, billing, effective: effectiveSettings(settings, billing) });
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
router.put('/contacts/:id/billing', ...manager, handle(log, async (req, res) => {
|
|
570
|
+
const contactId = pathId(req.params.id, 'customer');
|
|
571
|
+
const organisationId = await organisationOf(req);
|
|
572
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
573
|
+
const billing = await saveCustomerBilling(db, organisationId, contactId, changes, { personId: req.person.id });
|
|
574
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed billing settings of customer ${contactId}`);
|
|
575
|
+
res.json({ ok: true, billing, effective: effectiveSettings(await readInvoicingSettings(db, organisationId), billing) });
|
|
576
|
+
}));
|
|
577
|
+
|
|
578
|
+
// A body that is not JSON, or too large, answers like every other refusal.
|
|
579
|
+
router.use((error, _req, res, next) => {
|
|
580
|
+
if (error?.type === 'entity.too.large') return res.status(413).json({ ok: false, code: 'too_large', error: 'The request body is too large.' });
|
|
581
|
+
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.' });
|
|
582
|
+
return next(error);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return router;
|
|
586
|
+
}
|