@arcforgelabs/flow 2026.10.26 → 2026.10.29
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 -2
- package/server/api/invoicing.js +313 -46
- package/server/app.js +30 -2
- package/server/core/automations.js +154 -0
- package/server/core/invoice-rules.js +226 -0
- package/server/core/invoices.js +8 -3
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +57 -17
- package/server/core/line-mapping.js +337 -0
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +16 -3
- package/server/core/sending.js +283 -0
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +72 -0
- package/server/db/migrations/0011_invoice_rules.sql +27 -0
- package/server/db/migrations/0012_invoice_sending.sql +18 -0
- package/server/db/migrations/0013_line_mappings.sql +35 -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/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +28 -0
- package/server/db/schema.js +158 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +17 -5
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +127 -17
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +7 -4
- package/server/ui/app.js +122 -6
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +243 -30
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +110 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +232 -84
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +61 -0
- package/server/ui/routes.js +2 -2
- package/server/ui/search.js +145 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.29",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"start": "node server/index.js",
|
|
33
33
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
34
34
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
35
|
-
"db:generate": "drizzle-kit generate"
|
|
35
|
+
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@clerk/backend": "3.23.2",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"@fontsource-variable/playfair-display": "5.3.0",
|
|
48
48
|
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
49
49
|
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
50
|
+
"@sentry/node": "11.4.0",
|
|
50
51
|
"drizzle-orm": "0.45.3",
|
|
51
52
|
"express": "5.3.0",
|
|
52
53
|
"express-rate-limit": "8.7.0",
|
package/server/api/invoicing.js
CHANGED
|
@@ -4,7 +4,20 @@ import { FlowError } from '../core/errors.js';
|
|
|
4
4
|
import {
|
|
5
5
|
createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
6
6
|
} from '../core/invoices.js';
|
|
7
|
-
import {
|
|
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
|
+
createMapping, deleteMapping, listMappings, STEP_LABELS, STEPS, updateMapping,
|
|
13
|
+
} from '../core/line-mapping.js';
|
|
14
|
+
import {
|
|
15
|
+
effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
|
|
16
|
+
saveCustomerBilling, saveInvoicingSettings,
|
|
17
|
+
} from '../core/invoicing-rules.js';
|
|
18
|
+
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
|
+
import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
|
|
20
|
+
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
8
21
|
import {
|
|
9
22
|
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
10
23
|
} from '../db/schema.js';
|
|
@@ -13,7 +26,13 @@ import {
|
|
|
13
26
|
// UI's invoices, customers, price list and jobs pages read and write here.
|
|
14
27
|
//
|
|
15
28
|
// GET /context the organisations, and whether this person may issue
|
|
16
|
-
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status
|
|
29
|
+
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status;
|
|
30
|
+
// status to_check, ready or problem lists those views (server/core/sending.js)
|
|
31
|
+
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
32
|
+
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
33
|
+
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
34
|
+
// accounting system { issueDate }
|
|
35
|
+
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
17
36
|
// GET /invoices/:id one invoice: lines, contact, job, issued versions
|
|
18
37
|
// POST /invoices a new draft
|
|
19
38
|
// PATCH /invoices/:id change a draft
|
|
@@ -22,7 +41,30 @@ import {
|
|
|
22
41
|
// POST /invoices/:id/void owner or admin
|
|
23
42
|
// GET /contacts?q=&limit= customers, by name
|
|
24
43
|
// GET /price-items?q= the price list, by code
|
|
25
|
-
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view
|
|
44
|
+
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
45
|
+
// a job with no invoice lists the invoicing rules it waits on;
|
|
46
|
+
// a job with an invoice has its Invoices page view and problems
|
|
47
|
+
// GET /board the Jobs board: the work system's jobs by its own statuses,
|
|
48
|
+
// each with its billing obligation (server/core/job-board.js)
|
|
49
|
+
// GET /settings the invoicing settings (server/core/invoicing-rules.js)
|
|
50
|
+
// PUT /settings owner or admin: change some of them
|
|
51
|
+
// GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
|
|
52
|
+
// GET /rule-fields what a rule can name, and the menus of values
|
|
53
|
+
// POST /rules owner or admin: a new rule { name, enabled, conditions, action }
|
|
54
|
+
// PUT /rules/:id owner or admin: change some of a rule
|
|
55
|
+
// DELETE /rules/:id owner or admin
|
|
56
|
+
// GET /line-mappings the lab's line mappings and the mapping steps (server/core/line-mapping.js)
|
|
57
|
+
// GET /line-labels the line labels on jobs, with counts and how each mapped
|
|
58
|
+
// POST /line-mappings owner or admin: a new mapping
|
|
59
|
+
// PUT /line-mappings/:id owner or admin: change some of a mapping
|
|
60
|
+
// DELETE /line-mappings/:id owner or admin
|
|
61
|
+
// GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
|
|
62
|
+
// PUT /automations/:key owner or admin: { enabled, config }
|
|
63
|
+
// GET /work-statuses the work system's job statuses, with job counts
|
|
64
|
+
// GET /contacts/:id/billing one customer's own invoicing settings
|
|
65
|
+
// PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
|
|
66
|
+
// POST /jobs/:id/ready { ready }: mark a job ready to invoice, or back in work
|
|
67
|
+
// GET /jobs/:id/activity what happened to a job, newest first
|
|
26
68
|
// POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
|
|
27
69
|
// check that the accounting system holds none
|
|
28
70
|
//
|
|
@@ -31,10 +73,13 @@ import {
|
|
|
31
73
|
// may be left out while the installation has exactly one. Money is integer
|
|
32
74
|
// cents. `liveCheckFor(organisationId)` returns the ledger's live check for
|
|
33
75
|
// that organisation, or null when no accounting integration offers one.
|
|
76
|
+
// `accounting()` is the accounting adapter that sends invoices, or null.
|
|
77
|
+
// `work()` is the work system adapters that offer listWork (the Jobs board).
|
|
34
78
|
|
|
35
79
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
80
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
37
|
-
const
|
|
81
|
+
const VIEWS_OF_INVOICES = ['to_check', 'ready', 'problem'];
|
|
82
|
+
const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
38
83
|
const MAX_LINES = 200;
|
|
39
84
|
const MAX_TEXT = 500;
|
|
40
85
|
// Each line's amount must fit the database's integer columns with room for
|
|
@@ -44,7 +89,8 @@ const MAX_UNIT_CENTS = 100_000_000;
|
|
|
44
89
|
|
|
45
90
|
const ERROR_STATUS = {
|
|
46
91
|
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,
|
|
92
|
+
never_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
93
|
+
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
48
94
|
};
|
|
49
95
|
|
|
50
96
|
function sendError(res, error, log) {
|
|
@@ -176,7 +222,7 @@ async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
|
176
222
|
return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
|
|
177
223
|
}
|
|
178
224
|
|
|
179
|
-
async function invoiceDetail(db, organisationId, invoiceId) {
|
|
225
|
+
async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
|
|
180
226
|
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
181
227
|
const [[contact], versions, jobs] = await Promise.all([
|
|
182
228
|
db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
|
|
@@ -186,13 +232,16 @@ async function invoiceDetail(db, organisationId, invoiceId) {
|
|
|
186
232
|
]);
|
|
187
233
|
return {
|
|
188
234
|
...invoice,
|
|
235
|
+
...(await invoiceView(db, organisationId, invoice, { accounting })),
|
|
189
236
|
contact: contact ?? null,
|
|
190
237
|
job: jobs.get(invoiceId) ?? null,
|
|
191
238
|
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
192
239
|
};
|
|
193
240
|
}
|
|
194
241
|
|
|
195
|
-
export function createInvoicingRouter({
|
|
242
|
+
export function createInvoicingRouter({
|
|
243
|
+
db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [],
|
|
244
|
+
}) {
|
|
196
245
|
const router = express.Router();
|
|
197
246
|
router.use(express.json({ limit: '64kb' }));
|
|
198
247
|
|
|
@@ -210,11 +259,18 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
210
259
|
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
211
260
|
const organisationId = await organisationOf(req);
|
|
212
261
|
const status = text(req.query.status);
|
|
213
|
-
if (status && !INVOICE_STATUSES.includes(status))
|
|
262
|
+
if (status && ![...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].includes(status)) {
|
|
263
|
+
throw invalid(`status must be one of ${[...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].join(', ')}`);
|
|
264
|
+
}
|
|
214
265
|
const q = text(req.query.q);
|
|
215
266
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
267
|
+
const views = await invoiceViews(db, organisationId, { accounting: accounting() });
|
|
216
268
|
const where = [eq(invoices.organisationId, organisationId)];
|
|
217
|
-
|
|
269
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
270
|
+
if (status === 'to_check') where.push(only(views.toCheck));
|
|
271
|
+
else if (status === 'ready') where.push(only(views.ready));
|
|
272
|
+
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
273
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
218
274
|
if (q) {
|
|
219
275
|
const pattern = containing(q);
|
|
220
276
|
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
@@ -229,17 +285,68 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
229
285
|
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
230
286
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
231
287
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
288
|
+
counts.to_check = views.toCheck.length;
|
|
289
|
+
counts.ready = views.ready.length;
|
|
290
|
+
counts.problem = views.problems.size;
|
|
291
|
+
const ready = new Set(views.ready);
|
|
292
|
+
const toCheck = new Set(views.toCheck);
|
|
232
293
|
res.json({
|
|
233
294
|
ok: true,
|
|
234
|
-
invoices: rows.map(({ invoice, contactName }) => ({
|
|
295
|
+
invoices: rows.map(({ invoice, contactName }) => ({
|
|
296
|
+
...invoice,
|
|
297
|
+
contactName,
|
|
298
|
+
job: jobs.get(invoice.id) ?? null,
|
|
299
|
+
view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
|
|
300
|
+
: views.sending.has(invoice.id) ? 'sending' : null,
|
|
301
|
+
problems: views.problems.get(invoice.id) ?? [],
|
|
302
|
+
})),
|
|
235
303
|
counts,
|
|
304
|
+
sendsTo: accounting()?.name ?? null,
|
|
236
305
|
});
|
|
237
306
|
}));
|
|
238
307
|
|
|
308
|
+
const invoiceIdsOf = (body) => {
|
|
309
|
+
const ids = body?.invoiceIds;
|
|
310
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 500 || !ids.every((id) => UUID.test(String(id)))) {
|
|
311
|
+
throw invalid('invoiceIds must list 1 to 500 invoice ids');
|
|
312
|
+
}
|
|
313
|
+
return [...new Set(ids)];
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Anyone who drafts may say a draft is checked; issuing and sending stay
|
|
317
|
+
// with an owner or admin.
|
|
318
|
+
router.post('/invoices/mark-ready', ...signedIn, handle(log, async (req, res) => {
|
|
319
|
+
const organisationId = await organisationOf(req);
|
|
320
|
+
const result = await markReady(db, organisationId, invoiceIdsOf(req.body), { personId: req.person.id, accounting: accounting() });
|
|
321
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} marked ${result.marked} invoice(s) ready`);
|
|
322
|
+
res.json({ ok: true, ...result });
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
router.post('/invoices/mark-not-ready', ...signedIn, handle(log, async (req, res) => {
|
|
326
|
+
const organisationId = await organisationOf(req);
|
|
327
|
+
res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
|
|
331
|
+
const organisationId = await organisationOf(req);
|
|
332
|
+
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
333
|
+
const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
|
|
334
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
|
|
335
|
+
res.json({ ok: true, ...result });
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
router.post('/invoices/send-again', ...manager, handle(log, async (req, res) => {
|
|
339
|
+
const organisationId = await organisationOf(req);
|
|
340
|
+
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
341
|
+
const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
|
|
342
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent again: ${JSON.stringify(result)}`);
|
|
343
|
+
res.json({ ok: true, ...result });
|
|
344
|
+
}));
|
|
345
|
+
|
|
239
346
|
router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
240
347
|
const invoiceId = pathId(req.params.id, 'invoice');
|
|
241
348
|
const organisationId = await organisationOf(req);
|
|
242
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
349
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
243
350
|
}));
|
|
244
351
|
|
|
245
352
|
router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -252,7 +359,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
252
359
|
lines: body.lines === undefined ? [] : readLines(body.lines),
|
|
253
360
|
});
|
|
254
361
|
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) });
|
|
362
|
+
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
|
|
256
363
|
}));
|
|
257
364
|
|
|
258
365
|
router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -266,7 +373,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
266
373
|
if (body.lines !== undefined) changes.lines = readLines(body.lines);
|
|
267
374
|
if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
|
|
268
375
|
await updateDraftInvoice(db, organisationId, invoiceId, changes);
|
|
269
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
376
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
270
377
|
}));
|
|
271
378
|
|
|
272
379
|
router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
|
|
@@ -275,7 +382,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
275
382
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
276
383
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
277
384
|
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) });
|
|
385
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
279
386
|
}));
|
|
280
387
|
|
|
281
388
|
router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
|
|
@@ -283,7 +390,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
283
390
|
const organisationId = await organisationOf(req);
|
|
284
391
|
await reviseInvoice(db, organisationId, invoiceId);
|
|
285
392
|
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) });
|
|
393
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
287
394
|
}));
|
|
288
395
|
|
|
289
396
|
router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
|
|
@@ -291,7 +398,7 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
291
398
|
const organisationId = await organisationOf(req);
|
|
292
399
|
await voidInvoice(db, organisationId, invoiceId);
|
|
293
400
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
|
|
294
|
-
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId) });
|
|
401
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
|
|
295
402
|
}));
|
|
296
403
|
|
|
297
404
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
@@ -320,50 +427,210 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
320
427
|
const view = text(req.query.view);
|
|
321
428
|
if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
|
|
322
429
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
323
|
-
const all = await listObligations(db, organisationId);
|
|
430
|
+
const all = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
|
|
324
431
|
const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
|
|
325
432
|
for (const entry of all) counts[entry.view] += 1;
|
|
326
433
|
const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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,
|
|
434
|
+
res.json({ ok: true, jobs: await describeObligations(db, organisationId, listed, { accounting: accounting() }), counts });
|
|
435
|
+
}));
|
|
436
|
+
|
|
437
|
+
// The Jobs board: the work system's jobs, by its own statuses, each with
|
|
438
|
+
// its billing obligation when it has one (server/core/job-board.js).
|
|
439
|
+
router.get('/board', ...signedIn, handle(log, async (req, res) => {
|
|
440
|
+
const organisationId = await organisationOf(req);
|
|
441
|
+
const { jobs, statuses } = await boardJobs(db, organisationId, {
|
|
442
|
+
work: work(), accounting: accounting(), live: Boolean(liveCheckFor(organisationId)), log,
|
|
352
443
|
});
|
|
444
|
+
res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
|
|
353
445
|
}));
|
|
354
446
|
|
|
355
447
|
router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
|
|
356
448
|
const obligationId = pathId(req.params.id, 'job');
|
|
357
449
|
const organisationId = await organisationOf(req);
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
}
|
|
450
|
+
// With no accounting system connected, Flow's own records are the check.
|
|
451
|
+
const liveCheck = liveCheckFor(organisationId) ?? null;
|
|
362
452
|
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
|
|
363
453
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
364
454
|
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
365
455
|
}));
|
|
366
456
|
|
|
457
|
+
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
458
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
459
|
+
const organisationId = await organisationOf(req);
|
|
460
|
+
const ready = req.body?.ready ?? true;
|
|
461
|
+
if (typeof ready !== 'boolean') throw invalid('ready must be true or false');
|
|
462
|
+
const job = await setReady(db, organisationId, obligationId, { ready, actor: `person:${req.person.id}` });
|
|
463
|
+
res.json({ ok: true, job: { id: job.id, readyAt: job.readyAt, readyBy: job.readyBy } });
|
|
464
|
+
}));
|
|
465
|
+
|
|
466
|
+
// A job's activity for its detail panel: what happened to it, newest
|
|
467
|
+
// first, without the evidence each event keeps.
|
|
468
|
+
router.get('/jobs/:id/activity', ...signedIn, handle(log, async (req, res) => {
|
|
469
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
470
|
+
const organisationId = await organisationOf(req);
|
|
471
|
+
const { events } = await explainObligation(db, organisationId, obligationId);
|
|
472
|
+
res.json({
|
|
473
|
+
ok: true,
|
|
474
|
+
events: events.slice(-50).reverse().map(({ kind, actor, createdAt }) => ({
|
|
475
|
+
kind, by: actor.startsWith('person:') ? 'person' : actor.startsWith('automation:') ? 'automation' : actor.replace(/^system:/, ''), createdAt,
|
|
476
|
+
})),
|
|
477
|
+
});
|
|
478
|
+
}));
|
|
479
|
+
|
|
480
|
+
router.get('/automations', ...signedIn, handle(log, async (req, res) => {
|
|
481
|
+
const organisationId = await organisationOf(req);
|
|
482
|
+
const place = typeof req.query.place === 'string' ? req.query.place : null;
|
|
483
|
+
const list = await listAutomations(db, organisationId);
|
|
484
|
+
res.json({ ok: true, automations: place ? list.filter((a) => a.place === place) : list });
|
|
485
|
+
}));
|
|
486
|
+
|
|
487
|
+
router.get('/work-statuses', ...signedIn, handle(log, async (req, res) => {
|
|
488
|
+
const organisationId = await organisationOf(req);
|
|
489
|
+
res.json({ ok: true, statuses: await workStatuses(db, organisationId) });
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
router.put('/automations/:key', ...manager, handle(log, async (req, res) => {
|
|
493
|
+
const organisationId = await organisationOf(req);
|
|
494
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
495
|
+
const automation = await saveAutomation(db, organisationId, String(req.params.key), changes, { personId: req.person.id });
|
|
496
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed automation ${automation.key}`);
|
|
497
|
+
res.json({ ok: true, automation });
|
|
498
|
+
}));
|
|
499
|
+
|
|
500
|
+
// The lab's invoice rules (docs/INVOICE_RULES.md).
|
|
501
|
+
router.get('/rules', ...signedIn, handle(log, async (req, res) => {
|
|
502
|
+
const organisationId = await organisationOf(req);
|
|
503
|
+
res.json({ ok: true, rules: await listRules(db, organisationId) });
|
|
504
|
+
}));
|
|
505
|
+
|
|
506
|
+
// What a rule can name, so people pick every value from a menu: the fields
|
|
507
|
+
// with their tests, and the values Flow already holds for each kind of menu.
|
|
508
|
+
router.get('/rule-fields', ...signedIn, handle(log, async (req, res) => {
|
|
509
|
+
const organisationId = await organisationOf(req);
|
|
510
|
+
const [statuses, customers, items] = await Promise.all([
|
|
511
|
+
workStatuses(db, organisationId),
|
|
512
|
+
db.select({ value: contacts.id, label: contacts.name }).from(contacts)
|
|
513
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
|
|
514
|
+
.orderBy(asc(contacts.name)).limit(1000),
|
|
515
|
+
db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
|
|
516
|
+
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
|
517
|
+
.orderBy(asc(priceItems.code)).limit(1000),
|
|
518
|
+
]);
|
|
519
|
+
res.json({
|
|
520
|
+
ok: true,
|
|
521
|
+
fields: Object.entries(FIELDS).map(([key, f]) => ({ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind] })),
|
|
522
|
+
actions: ACTIONS,
|
|
523
|
+
options: {
|
|
524
|
+
statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
|
|
525
|
+
customers,
|
|
526
|
+
items: [
|
|
527
|
+
...items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
|
|
528
|
+
...(await listMappings(db, organisationId)).filter((m) => m.kind === 'account')
|
|
529
|
+
.map((m) => ({ value: m.label, label: `${m.label} · ${m.description ?? `account ${m.accountCode}`}` })),
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}));
|
|
534
|
+
|
|
535
|
+
router.post('/rules', ...manager, handle(log, async (req, res) => {
|
|
536
|
+
const organisationId = await organisationOf(req);
|
|
537
|
+
const { organisationId: _named, ...raw } = req.body ?? {};
|
|
538
|
+
const rule = await createRule(db, organisationId, raw, { personId: req.person.id });
|
|
539
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added invoice rule ${rule.id}`);
|
|
540
|
+
res.status(201).json({ ok: true, rule });
|
|
541
|
+
}));
|
|
542
|
+
|
|
543
|
+
router.put('/rules/:id', ...manager, handle(log, async (req, res) => {
|
|
544
|
+
const ruleId = pathId(req.params.id, 'rule');
|
|
545
|
+
const organisationId = await organisationOf(req);
|
|
546
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
547
|
+
const rule = await updateRule(db, organisationId, ruleId, changes, { personId: req.person.id });
|
|
548
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoice rule ${rule.id}`);
|
|
549
|
+
res.json({ ok: true, rule });
|
|
550
|
+
}));
|
|
551
|
+
|
|
552
|
+
router.delete('/rules/:id', ...manager, handle(log, async (req, res) => {
|
|
553
|
+
const ruleId = pathId(req.params.id, 'rule');
|
|
554
|
+
const organisationId = await organisationOf(req);
|
|
555
|
+
await deleteRule(db, organisationId, ruleId);
|
|
556
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted invoice rule ${ruleId}`);
|
|
557
|
+
res.json({ ok: true });
|
|
558
|
+
}));
|
|
559
|
+
|
|
560
|
+
router.get('/line-mappings', ...signedIn, handle(log, async (req, res) => {
|
|
561
|
+
const organisationId = await organisationOf(req);
|
|
562
|
+
res.json({ ok: true, mappings: await listMappings(db, organisationId), steps: STEPS.map((key) => ({ key, label: STEP_LABELS[key] })) });
|
|
563
|
+
}));
|
|
564
|
+
|
|
565
|
+
// Each label the line mapping saw on jobs, how many lines carry it, a
|
|
566
|
+
// sample of the work system's text and price, and whether it mapped.
|
|
567
|
+
router.get('/line-labels', ...signedIn, handle(log, async (req, res) => {
|
|
568
|
+
const organisationId = await organisationOf(req);
|
|
569
|
+
const rows = (await db.execute(sql`
|
|
570
|
+
select l->>'label' as label, count(*)::int as lines,
|
|
571
|
+
count(*) filter (where l->>'priceItemId' is null and l->>'accountCode' is null)::int as unmapped,
|
|
572
|
+
min(l->>'description') as sample, min((l->>'unitAmountCents')::int) as "unitAmountCents",
|
|
573
|
+
min(l->>'mappedBy') as "mappedBy", min(l->>'itemCode') as "itemCode"
|
|
574
|
+
from billing_obligations o, jsonb_array_elements(o.lines) l
|
|
575
|
+
where o.organisation_id = ${organisationId} and l->>'label' is not null
|
|
576
|
+
group by 1 order by 3 desc, 2 desc, 1 limit 1000`)).rows ?? [];
|
|
577
|
+
res.json({ ok: true, labels: rows });
|
|
578
|
+
}));
|
|
579
|
+
|
|
580
|
+
router.post('/line-mappings', ...manager, handle(log, async (req, res) => {
|
|
581
|
+
const organisationId = await organisationOf(req);
|
|
582
|
+
const { organisationId: _named, ...raw } = req.body ?? {};
|
|
583
|
+
const mapping = await createMapping(db, organisationId, raw, { personId: req.person.id });
|
|
584
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added line mapping ${mapping.id}`);
|
|
585
|
+
res.status(201).json({ ok: true, mapping });
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
router.put('/line-mappings/:id', ...manager, handle(log, async (req, res) => {
|
|
589
|
+
const mappingId = pathId(req.params.id, 'mapping');
|
|
590
|
+
const organisationId = await organisationOf(req);
|
|
591
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
592
|
+
const mapping = await updateMapping(db, organisationId, mappingId, changes, { personId: req.person.id });
|
|
593
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed line mapping ${mapping.id}`);
|
|
594
|
+
res.json({ ok: true, mapping });
|
|
595
|
+
}));
|
|
596
|
+
|
|
597
|
+
router.delete('/line-mappings/:id', ...manager, handle(log, async (req, res) => {
|
|
598
|
+
const mappingId = pathId(req.params.id, 'mapping');
|
|
599
|
+
const organisationId = await organisationOf(req);
|
|
600
|
+
await deleteMapping(db, organisationId, mappingId);
|
|
601
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted line mapping ${mappingId}`);
|
|
602
|
+
res.json({ ok: true });
|
|
603
|
+
}));
|
|
604
|
+
|
|
605
|
+
router.get('/settings', ...signedIn, handle(log, async (req, res) => {
|
|
606
|
+
const organisationId = await organisationOf(req);
|
|
607
|
+
res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
router.put('/settings', ...manager, handle(log, async (req, res) => {
|
|
611
|
+
const organisationId = await organisationOf(req);
|
|
612
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
613
|
+
const settings = await saveInvoicingSettings(db, organisationId, changes, { personId: req.person.id });
|
|
614
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed invoicing settings ${Object.keys(changes).join(', ')}`);
|
|
615
|
+
res.json({ ok: true, settings, defaults: INVOICING_DEFAULTS });
|
|
616
|
+
}));
|
|
617
|
+
|
|
618
|
+
router.get('/contacts/:id/billing', ...signedIn, handle(log, async (req, res) => {
|
|
619
|
+
const contactId = pathId(req.params.id, 'customer');
|
|
620
|
+
const organisationId = await organisationOf(req);
|
|
621
|
+
const [billing, settings] = await Promise.all([readCustomerBilling(db, organisationId, contactId), readInvoicingSettings(db, organisationId)]);
|
|
622
|
+
res.json({ ok: true, billing, effective: effectiveSettings(settings, billing) });
|
|
623
|
+
}));
|
|
624
|
+
|
|
625
|
+
router.put('/contacts/:id/billing', ...manager, handle(log, async (req, res) => {
|
|
626
|
+
const contactId = pathId(req.params.id, 'customer');
|
|
627
|
+
const organisationId = await organisationOf(req);
|
|
628
|
+
const { organisationId: _named, ...changes } = req.body ?? {};
|
|
629
|
+
const billing = await saveCustomerBilling(db, organisationId, contactId, changes, { personId: req.person.id });
|
|
630
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed billing settings of customer ${contactId}`);
|
|
631
|
+
res.json({ ok: true, billing, effective: effectiveSettings(await readInvoicingSettings(db, organisationId), billing) });
|
|
632
|
+
}));
|
|
633
|
+
|
|
367
634
|
// A body that is not JSON, or too large, answers like every other refusal.
|
|
368
635
|
router.use((error, _req, res, next) => {
|
|
369
636
|
if (error?.type === 'entity.too.large') return res.status(413).json({ ok: false, code: 'too_large', error: 'The request body is too large.' });
|
package/server/app.js
CHANGED
|
@@ -25,7 +25,9 @@ import { createUiAssetsRouter } from './ui/routes.js';
|
|
|
25
25
|
// `integrations` is the started registry (server/integrations/index.js); each
|
|
26
26
|
// adapter's own routes are mounted under /api/<id>, and GET /health reports
|
|
27
27
|
// each one (server/integrations/health.js). `defaultFamily` is the design
|
|
28
|
-
// system every page starts on (FLOW_THEME, server/theme/themes.js).
|
|
28
|
+
// system every page starts on (FLOW_THEME, server/theme/themes.js). `log`
|
|
29
|
+
// is the console, or the monitored log (server/core/monitoring.js) that also
|
|
30
|
+
// sends each error to the installation's error service.
|
|
29
31
|
export function createApp({
|
|
30
32
|
build = deployedBuildStatus(),
|
|
31
33
|
database = null,
|
|
@@ -109,7 +111,23 @@ export function createApp({
|
|
|
109
111
|
const [adapter] = integrations?.with('checkInvoice') ?? [];
|
|
110
112
|
return adapter ? (obligation) => adapter.capabilities.checkInvoice({ organisationId, obligation }) : null;
|
|
111
113
|
};
|
|
112
|
-
|
|
114
|
+
// Send all ready (server/core/sending.js): the first enabled integration
|
|
115
|
+
// that sends invoices (Xero).
|
|
116
|
+
const accounting = () => integrations?.with('pushInvoices')[0] ?? null;
|
|
117
|
+
// The Jobs board: every enabled integration that lists its jobs (Seazona).
|
|
118
|
+
const work = () => integrations?.with('listWork') ?? [];
|
|
119
|
+
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work }));
|
|
120
|
+
}
|
|
121
|
+
// What a signed-in browser needs to report its errors and problems to the
|
|
122
|
+
// installation's own error service (docs/SENTRY.md): the first integration
|
|
123
|
+
// that offers `browserMonitoring`, or `{ on: false }`.
|
|
124
|
+
if (auth?.personAuth) {
|
|
125
|
+
app.get('/api/monitoring', auth.personAuth.requireSession, (_req, res) => {
|
|
126
|
+
res.set('Cache-Control', 'no-store');
|
|
127
|
+
const [adapter] = integrations?.with('browserMonitoring') ?? [];
|
|
128
|
+
const settings = adapter?.capabilities.browserMonitoring() ?? null;
|
|
129
|
+
res.json(settings ? { ok: true, on: true, service: adapter.id, ...settings } : { ok: true, on: false });
|
|
130
|
+
});
|
|
113
131
|
}
|
|
114
132
|
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
|
|
115
133
|
app.use(path, handler);
|
|
@@ -125,5 +143,15 @@ export function createApp({
|
|
|
125
143
|
log,
|
|
126
144
|
}));
|
|
127
145
|
|
|
146
|
+
// An error no route answered. A server fault goes to `log` (and from there
|
|
147
|
+
// to the error service, when one is on) and the answer carries no detail;
|
|
148
|
+
// any other error keeps Express's own answer.
|
|
149
|
+
app.use((error, _req, res, next) => {
|
|
150
|
+
const status = error?.status ?? error?.statusCode ?? 500;
|
|
151
|
+
if (status < 500 || res.headersSent) return next(error);
|
|
152
|
+
log.error?.(`[http] ${error?.message ?? error}`, ...(error instanceof Error ? [error] : []));
|
|
153
|
+
return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
|
|
154
|
+
});
|
|
155
|
+
|
|
128
156
|
return app;
|
|
129
157
|
}
|