@arcforgelabs/flow 2026.10.38 → 2026.10.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +74 -6
- package/server/core/ledger.js +29 -7
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +205 -15
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/integrations/xero/settings.js +2 -1
- package/server/ui/app.js +42 -6
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/monitoring.js +13 -12
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ run a lab.
|
|
|
40
40
|
| `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
|
|
41
41
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
42
42
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
43
|
-
| `docs/INVOICE_SENDING.md` |
|
|
43
|
+
| `docs/INVOICE_SENDING.md` | The invoice tabs (Pending, Ready, Draft, Approved, Sent, Void), a changed job, and Send all ready to the accounting system |
|
|
44
44
|
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
45
45
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
46
46
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.40",
|
|
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.40",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.40",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"changelog:check": "node scripts/release/changelog.mjs check",
|
|
35
35
|
"release:stable": "node scripts/release/release-stable.mjs",
|
|
36
36
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
37
|
-
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
37
|
+
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
|
|
38
|
+
"db:follow-production": "node scripts/replit-workspace-db.js sync",
|
|
39
|
+
"db:follow-production:status": "node scripts/replit-workspace-db.js status"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
42
|
"@clerk/backend": "3.25.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import {
|
|
3
|
-
CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField,
|
|
4
|
-
listJobFieldValues, orderFields, setJobFieldValue, updateField,
|
|
3
|
+
CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, deleteField, ensureAssignee, integrationConnectedAt,
|
|
4
|
+
jobFieldHistory, listFields, listJobFieldValues, orderFields, readGroupColors, setGroupColor, setJobFieldValue, updateField,
|
|
5
5
|
} from '../core/custom-fields.js';
|
|
6
6
|
import { FlowError } from '../core/errors.js';
|
|
7
7
|
import { resolveOrganisation } from './invoicing.js';
|
|
@@ -9,11 +9,13 @@ import { resolveOrganisation } from './invoicing.js';
|
|
|
9
9
|
// Custom fields for jobs (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md).
|
|
10
10
|
//
|
|
11
11
|
// GET /api/custom-fields[?archived=1] the lab's fields, the work systems' own fields (read only),
|
|
12
|
-
//
|
|
13
|
-
// POST /api/custom-fields { name, type[, options] }
|
|
12
|
+
// the people a person field can name, and each type group's colour
|
|
13
|
+
// POST /api/custom-fields { name, type[, options, color] } owner or admin: make a field
|
|
14
14
|
// PUT /api/custom-fields/order { ids } owner or admin: the field order
|
|
15
|
-
// PUT /api/custom-fields/:
|
|
15
|
+
// PUT /api/custom-fields/groups/:type { color } owner or admin: a type group's colour (null: its own)
|
|
16
|
+
// PUT /api/custom-fields/:id { name?, options?, color? } owner or admin: rename, change options or colour
|
|
16
17
|
// POST /api/custom-fields/:id/archive { archived } owner or admin: archive or bring back
|
|
18
|
+
// DELETE /api/custom-fields/:id owner or admin: delete a field, its values and history
|
|
17
19
|
// GET /api/custom-fields/values every job's values
|
|
18
20
|
// PUT /api/custom-fields/values { sourceSystem, sourceJobRef, fieldId, value[, expectedUpdatedAt] }
|
|
19
21
|
// set one job's value; a stale expectedUpdatedAt gives 409 with the current value
|
|
@@ -51,15 +53,17 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
51
53
|
|
|
52
54
|
// The work systems' own fields: their job statuses, as the Stage lanes on
|
|
53
55
|
// the Jobs board. Read from Flow's copy; a work system that fails to answer
|
|
54
|
-
// is left out, not an error.
|
|
56
|
+
// is left out, not an error. `createdAt` is when the system was connected.
|
|
55
57
|
async function systemFields() {
|
|
56
58
|
const fields = [];
|
|
57
59
|
for (const adapter of work()) {
|
|
58
60
|
try {
|
|
59
61
|
const { statuses = [] } = await adapter.capabilities.listWork();
|
|
62
|
+
const seen = statuses.map((s) => s.firstSeenAt).filter(Boolean).sort()[0] ?? null;
|
|
60
63
|
fields.push({
|
|
61
64
|
id: `${adapter.id}:stage`, source: adapter.id, sourceLabel: adapter.name ?? adapter.id, name: 'Stage',
|
|
62
|
-
type: 'dropdown', readOnly: true,
|
|
65
|
+
type: 'dropdown', readOnly: true, createdAt: await integrationConnectedAt(db, adapter.id, seen),
|
|
66
|
+
options: statuses.map((s) => ({ id: s.label, name: s.label, final: Boolean(s.final), firstSeenAt: s.firstSeenAt ?? null })),
|
|
63
67
|
});
|
|
64
68
|
} catch (error) {
|
|
65
69
|
log.warn?.(`[custom-fields] ${adapter.id} stages: ${error.message}`);
|
|
@@ -72,8 +76,10 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
72
76
|
const organisationId = await organisationOf(req);
|
|
73
77
|
const archived = req.query.archived === '1' || req.query.archived === 'true';
|
|
74
78
|
await ensureAssignee(db, organisationId);
|
|
75
|
-
const [fields, system, chosen] = await Promise.all([
|
|
76
|
-
|
|
79
|
+
const [fields, system, chosen, groupColors] = await Promise.all([
|
|
80
|
+
listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db), readGroupColors(db, organisationId),
|
|
81
|
+
]);
|
|
82
|
+
res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS, groupColors });
|
|
77
83
|
}));
|
|
78
84
|
|
|
79
85
|
router.post('/', ...manager, handle(async (req, res) => {
|
|
@@ -86,6 +92,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
86
92
|
res.json({ ok: true, fields });
|
|
87
93
|
}));
|
|
88
94
|
|
|
95
|
+
router.put('/groups/:type', ...manager, handle(async (req, res) => {
|
|
96
|
+
const groupColors = await setGroupColor(db, await organisationOf(req), req.params.type, req.body?.color, { personId: req.person.id });
|
|
97
|
+
res.json({ ok: true, groupColors });
|
|
98
|
+
}));
|
|
99
|
+
|
|
89
100
|
router.get('/values', ...signedIn, handle(async (req, res) => {
|
|
90
101
|
res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
|
|
91
102
|
}));
|
|
@@ -112,5 +123,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
112
123
|
res.json({ ok: true, field });
|
|
113
124
|
}));
|
|
114
125
|
|
|
126
|
+
router.delete('/:id', ...manager, handle(async (req, res) => {
|
|
127
|
+
const deleted = await deleteField(db, await organisationOf(req), req.params.id);
|
|
128
|
+
log.info?.(`[custom-fields] deleted field ${deleted.id} with ${deleted.valuesDeleted} values`);
|
|
129
|
+
res.json({ ok: true, deleted });
|
|
130
|
+
}));
|
|
131
|
+
|
|
115
132
|
return router;
|
|
116
133
|
}
|
package/server/api/invoicing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
|
+
import { createActionGuard } from '../integrations/call-guard.js';
|
|
4
5
|
import {
|
|
5
6
|
createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
6
7
|
} from '../core/invoices.js';
|
|
@@ -20,11 +21,11 @@ import { readBeforeAct } from '../core/read-before-act.js';
|
|
|
20
21
|
import { invoiceMatchDetail, jobMatchDetail, listReconcile } from '../core/job-match.js';
|
|
21
22
|
import { jobReference } from '../core/references.js';
|
|
22
23
|
import {
|
|
23
|
-
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
24
|
+
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, linkPriceItems, MAX_LINKS_AT_ONCE, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
24
25
|
} from '../core/price-list.js';
|
|
25
26
|
import {
|
|
26
27
|
confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
|
|
27
|
-
setReady, useSourceLines,
|
|
28
|
+
setReady, settleJobChange, useSourceLines,
|
|
28
29
|
} from '../core/ledger.js';
|
|
29
30
|
import { isHistoryHeld } from '../core/history.js';
|
|
30
31
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
@@ -37,8 +38,8 @@ import {
|
|
|
37
38
|
// UI's invoices, customers, price list and jobs pages read and write here.
|
|
38
39
|
//
|
|
39
40
|
// GET /context the organisations, and whether this person may issue
|
|
40
|
-
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by
|
|
41
|
-
// status
|
|
41
|
+
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by tab;
|
|
42
|
+
// status is a tab: pending, ready, draft, approved, sent or void (TABS)
|
|
42
43
|
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
43
44
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
44
45
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
// POST /price-items/:id/links owner or admin: { system, externalId } link a record of a system's list
|
|
82
83
|
// POST /price-items/:id/resolve owner or admin: Mark resolved: check again, accept what still differs
|
|
83
84
|
// DELETE /price-links/:id owner or admin: remove a link
|
|
85
|
+
// POST /price-links owner or admin: { links: [{ priceItemId, system, externalId }] } many links at once
|
|
84
86
|
// GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
|
|
85
87
|
// PUT /automations/:key owner or admin: { enabled, config }
|
|
86
88
|
// GET /work-statuses the work system's job statuses, with job counts
|
|
@@ -101,7 +103,15 @@ import {
|
|
|
101
103
|
|
|
102
104
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
103
105
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
104
|
-
|
|
106
|
+
// The Invoices page tabs, left to right. Pending: a person must look (a
|
|
107
|
+
// draft with a problem, a refused send, or out of sync with the accounting
|
|
108
|
+
// system). Ready: a draft whose checks pass. Draft, Approved and Sent: an
|
|
109
|
+
// issued invoice, by its stage in the accounting system (its invoiceStages;
|
|
110
|
+
// Draft while it is on its way); with no accounting system, an issued invoice
|
|
111
|
+
// is Approved. Void: void in Flow or in the accounting system.
|
|
112
|
+
const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'void'];
|
|
113
|
+
// Earlier names a saved link may still use.
|
|
114
|
+
const OLD_TABS = { problem: 'pending', to_check: 'ready', issued: 'approved' };
|
|
105
115
|
const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
106
116
|
const MAX_LINES = 200;
|
|
107
117
|
const MAX_TEXT = 500;
|
|
@@ -235,7 +245,7 @@ export async function resolveOrganisation(db, raw) {
|
|
|
235
245
|
}
|
|
236
246
|
|
|
237
247
|
const jobOf = (o) => (o ? {
|
|
238
|
-
id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
|
|
248
|
+
id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, sourceInvoiceRef: o.sourceInvoiceRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
|
|
239
249
|
} : null);
|
|
240
250
|
|
|
241
251
|
// A job with its work system's name and its link in that system's web app
|
|
@@ -281,6 +291,46 @@ async function jobChangeDetail(db, organisationId, invoiceId, work) {
|
|
|
281
291
|
return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
|
|
282
292
|
}
|
|
283
293
|
|
|
294
|
+
// Each invoice's tab (TABS): Map(id -> tab), the ids on each tab, the
|
|
295
|
+
// problems of each pending one and the ones on their way to the accounting
|
|
296
|
+
// system. Drafts come from the draft checks (sending.js); issued invoices
|
|
297
|
+
// from the accounting system's stages, read from Flow's copy (no calls).
|
|
298
|
+
async function invoiceTabs(db, organisationId, system) {
|
|
299
|
+
const [views, issued, voided, stages] = await Promise.all([
|
|
300
|
+
invoiceViews(db, organisationId, { accounting: system }),
|
|
301
|
+
db.select({ id: invoices.id }).from(invoices).where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'))),
|
|
302
|
+
db.select({ id: invoices.id }).from(invoices).where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'void'))),
|
|
303
|
+
typeof system?.capabilities?.invoiceStages === 'function' ? system.capabilities.invoiceStages({ organisationId }) : new Map(),
|
|
304
|
+
]);
|
|
305
|
+
const tabOf = new Map();
|
|
306
|
+
for (const id of [...views.toCheck, ...views.ready]) tabOf.set(id, 'ready');
|
|
307
|
+
for (const { id } of issued) tabOf.set(id, issuedTab(id, { views, stages, system }));
|
|
308
|
+
for (const { id } of voided) tabOf.set(id, 'void');
|
|
309
|
+
for (const id of views.problems.keys()) tabOf.set(id, 'pending');
|
|
310
|
+
const ids = Object.fromEntries(TABS.map((t) => [t, []]));
|
|
311
|
+
for (const [id, tab] of tabOf) ids[tab].push(id);
|
|
312
|
+
return { tabOf, ids, problems: views.problems, sending: views.sending };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function issuedTab(id, { views, stages, system }) {
|
|
316
|
+
if (views.problems.has(id)) return 'pending';
|
|
317
|
+
if (!system) return 'approved';
|
|
318
|
+
if (views.sending.has(id)) return 'draft';
|
|
319
|
+
return stages.get(id) ?? 'draft';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// One invoice's tab, for its page (and its pager).
|
|
323
|
+
async function tabOfInvoice(db, organisationId, invoice, view, accounting) {
|
|
324
|
+
if (invoice.status === 'void') return 'void';
|
|
325
|
+
if (view === 'problem') return 'pending';
|
|
326
|
+
if (invoice.status === 'draft') return 'ready';
|
|
327
|
+
if (!accounting) return 'approved';
|
|
328
|
+
if (view === 'sending') return 'draft';
|
|
329
|
+
const stages = typeof accounting.capabilities.invoiceStages === 'function'
|
|
330
|
+
? await accounting.capabilities.invoiceStages({ organisationId, invoiceIds: [invoice.id] }) : new Map();
|
|
331
|
+
return stages.get(invoice.id) ?? 'draft';
|
|
332
|
+
}
|
|
333
|
+
|
|
284
334
|
async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
|
|
285
335
|
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
286
336
|
const [[contact], versions, jobs, jobChange] = await Promise.all([
|
|
@@ -290,10 +340,12 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
|
|
|
290
340
|
jobsByInvoice(db, organisationId, [invoiceId]),
|
|
291
341
|
jobChangeDetail(db, organisationId, invoiceId, work),
|
|
292
342
|
]);
|
|
343
|
+
const viewed = await invoiceView(db, organisationId, invoice, { accounting });
|
|
293
344
|
return {
|
|
294
345
|
jobChange,
|
|
295
346
|
...invoice,
|
|
296
|
-
...
|
|
347
|
+
...viewed,
|
|
348
|
+
tab: await tabOfInvoice(db, organisationId, invoice, viewed.view, accounting),
|
|
297
349
|
// Where a draft goes when it is sent, and how: this customer's send mode.
|
|
298
350
|
sendsTo: accounting?.name ?? null,
|
|
299
351
|
sendMode: accounting && invoice.status === 'draft'
|
|
@@ -307,11 +359,29 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
|
|
|
307
359
|
};
|
|
308
360
|
}
|
|
309
361
|
|
|
362
|
+
// A person's Recheck reads a job from its work system at most once in this
|
|
363
|
+
// time; a second click inside it answers from Flow's copy.
|
|
364
|
+
const RECHECK_GAP_MS = 10 * 1000;
|
|
365
|
+
|
|
366
|
+
// The buttons here that may call a connected system, and how long each waits
|
|
367
|
+
// before it runs again (the action guard, server/integrations/call-guard.js).
|
|
368
|
+
// A send or a draft is one at a time, and a draft waits per job, so ticking
|
|
369
|
+
// many jobs still works. Check prices reads Flow's copies only, so it has no
|
|
370
|
+
// wait.
|
|
371
|
+
export const INVOICING_ACTIONS = Object.freeze({
|
|
372
|
+
sendReady: { cooldownMs: 0, budget: false },
|
|
373
|
+
sendAgain: { cooldownMs: 0, budget: false },
|
|
374
|
+
draftJob: { cooldownMs: 10_000, budget: false },
|
|
375
|
+
});
|
|
376
|
+
|
|
310
377
|
export function createInvoicingRouter({
|
|
311
378
|
db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [], priceSystems = () => [],
|
|
379
|
+
buttonCooldowns = true, actions = createActionGuard({ integration: 'invoicing', actions: INVOICING_ACTIONS, off: !buttonCooldowns }),
|
|
312
380
|
}) {
|
|
313
381
|
const router = express.Router();
|
|
382
|
+
const act = (name, key) => actions.route(name, { key });
|
|
314
383
|
router.use(express.json({ limit: '64kb' }));
|
|
384
|
+
const rechecked = new Map();
|
|
315
385
|
|
|
316
386
|
const signedIn = [personAuth.requireSession];
|
|
317
387
|
const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
|
|
@@ -327,61 +397,46 @@ export function createInvoicingRouter({
|
|
|
327
397
|
|
|
328
398
|
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
329
399
|
const organisationId = await organisationOf(req);
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
400
|
+
const asked = text(req.query.status);
|
|
401
|
+
const status = OLD_TABS[asked] ?? asked;
|
|
402
|
+
if (status && !TABS.includes(status) && !INVOICE_STATUSES.includes(status)) {
|
|
403
|
+
throw invalid(`status must be one of ${TABS.join(', ')}`);
|
|
333
404
|
}
|
|
334
405
|
const q = text(req.query.q);
|
|
335
406
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
336
|
-
// Each tab switch runs this, so the independent reads run side by side
|
|
337
|
-
// only the to-check, ready and problem lists wait on the draft checks.
|
|
407
|
+
// Each tab switch runs this, so the independent reads run side by side.
|
|
338
408
|
const system = accounting();
|
|
339
|
-
const checking = invoiceViews(db, organisationId, { accounting: system });
|
|
340
|
-
const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
341
|
-
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
342
409
|
const sendMode = system
|
|
343
410
|
? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
|
|
344
411
|
.then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
|
|
345
412
|
: null;
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
362
|
-
.limit(limit);
|
|
363
|
-
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
364
|
-
})();
|
|
365
|
-
const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
|
|
413
|
+
const [tabs, mode] = await Promise.all([invoiceTabs(db, organisationId, system), sendMode]);
|
|
414
|
+
const where = [eq(invoices.organisationId, organisationId)];
|
|
415
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
416
|
+
if (TABS.includes(status)) where.push(only(tabs.ids[status]));
|
|
417
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
418
|
+
if (q) {
|
|
419
|
+
const pattern = containing(q);
|
|
420
|
+
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
421
|
+
}
|
|
422
|
+
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
423
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
424
|
+
.where(and(...where))
|
|
425
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
426
|
+
.limit(limit);
|
|
427
|
+
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
366
428
|
const systems = work();
|
|
367
|
-
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
368
|
-
for (const { status: s, n } of counted) counts[s] = n;
|
|
369
|
-
counts.to_check = views.toCheck.length;
|
|
370
|
-
counts.ready = views.ready.length;
|
|
371
|
-
counts.problem = views.problems.size;
|
|
372
|
-
const ready = new Set(views.ready);
|
|
373
|
-
const toCheck = new Set(views.toCheck);
|
|
374
429
|
res.json({
|
|
375
430
|
ok: true,
|
|
376
431
|
invoices: rows.map(({ invoice, contactName }) => ({
|
|
377
432
|
...invoice,
|
|
378
433
|
contactName,
|
|
379
434
|
job: withLink(jobs.get(invoice.id) ?? null, systems),
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
problems:
|
|
435
|
+
tab: tabs.tabOf.get(invoice.id) ?? null,
|
|
436
|
+
sending: tabs.sending.has(invoice.id),
|
|
437
|
+
problems: tabs.problems.get(invoice.id) ?? [],
|
|
383
438
|
})),
|
|
384
|
-
counts,
|
|
439
|
+
counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
|
|
385
440
|
sendsTo: system?.name ?? null,
|
|
386
441
|
sendMode: mode,
|
|
387
442
|
// The Linked column: one icon for each connected system.
|
|
@@ -411,7 +466,7 @@ export function createInvoicingRouter({
|
|
|
411
466
|
res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
|
|
412
467
|
}));
|
|
413
468
|
|
|
414
|
-
router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
|
|
469
|
+
router.post('/invoices/send-ready', ...manager, act('sendReady'), handle(log, async (req, res) => {
|
|
415
470
|
const organisationId = await organisationOf(req);
|
|
416
471
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
417
472
|
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
@@ -422,7 +477,7 @@ export function createInvoicingRouter({
|
|
|
422
477
|
res.json({ ok: true, ...result });
|
|
423
478
|
}));
|
|
424
479
|
|
|
425
|
-
router.post('/invoices/send-again', ...manager, handle(log, async (req, res) => {
|
|
480
|
+
router.post('/invoices/send-again', ...manager, act('sendAgain'), handle(log, async (req, res) => {
|
|
426
481
|
const organisationId = await organisationOf(req);
|
|
427
482
|
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
428
483
|
const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
|
|
@@ -488,7 +543,7 @@ export function createInvoicingRouter({
|
|
|
488
543
|
// Ready and Send do.
|
|
489
544
|
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
490
545
|
const held = problems.find((p) => p.code === 'job_review');
|
|
491
|
-
if (held) throw new FlowError('not_ready', `${held.message} Nothing was
|
|
546
|
+
if (held) throw new FlowError('not_ready', `${held.message} Nothing was approved.`);
|
|
492
547
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
493
548
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
494
549
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
@@ -558,7 +613,7 @@ export function createInvoicingRouter({
|
|
|
558
613
|
res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
|
|
559
614
|
}));
|
|
560
615
|
|
|
561
|
-
router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
|
|
616
|
+
router.post('/jobs/:id/draft', ...manager, act('draftJob', (req) => req.params.id), handle(log, async (req, res) => {
|
|
562
617
|
const obligationId = pathId(req.params.id, 'job');
|
|
563
618
|
const organisationId = await organisationOf(req);
|
|
564
619
|
// With no accounting system connected, Flow's own records are the check.
|
|
@@ -586,10 +641,38 @@ export function createInvoicingRouter({
|
|
|
586
641
|
router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
|
|
587
642
|
const obligationId = pathId(req.params.id, 'job');
|
|
588
643
|
const organisationId = await organisationOf(req);
|
|
589
|
-
const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}
|
|
644
|
+
const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}`, accept: req.body?.accept === true });
|
|
590
645
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
|
|
591
646
|
}));
|
|
592
647
|
|
|
648
|
+
// "Recheck": a person thinks the work system's copy of this job is old or
|
|
649
|
+
// was wrong and has been fixed there. Flow reads this one job from the work
|
|
650
|
+
// system now (one call, its readWorkItem) into the ledger, as read before
|
|
651
|
+
// act does; a change hold whose job matches the draft again is over. Then
|
|
652
|
+
// the invoice page reloads, so every check runs again. Reads only.
|
|
653
|
+
router.post('/jobs/:id/recheck', ...signedIn, handle(log, async (req, res) => {
|
|
654
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
655
|
+
const organisationId = await organisationOf(req);
|
|
656
|
+
const [obligation] = await db.select().from(billingObligations)
|
|
657
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
658
|
+
if (!obligation) throw new FlowError('not_found', 'job not found');
|
|
659
|
+
const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
|
|
660
|
+
if (typeof source?.capabilities?.readWorkItem !== 'function') throw new FlowError('not_ready', 'the work system cannot read one job');
|
|
661
|
+
// One read for each job in RECHECK_GAP_MS, whatever the clicks.
|
|
662
|
+
const last = rechecked.get(obligationId) ?? 0;
|
|
663
|
+
let outcome = 'fresh';
|
|
664
|
+
if (Date.now() - last >= RECHECK_GAP_MS) {
|
|
665
|
+
rechecked.set(obligationId, Date.now());
|
|
666
|
+
const actor = `person:${req.person.id}`;
|
|
667
|
+
const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor, force: true })).get(obligationId);
|
|
668
|
+
outcome = 'read';
|
|
669
|
+
if (read.outcome === 'waiting') throw new FlowError('not_ready', `${source.name} could not be read: ${read.reason ?? 'try again soon'}`);
|
|
670
|
+
await settleJobChange(db, organisationId, obligationId, { actor });
|
|
671
|
+
}
|
|
672
|
+
const invoice = obligation.invoiceId ? await invoiceDetail(db, organisationId, obligation.invoiceId, accounting(), work()) : null;
|
|
673
|
+
res.json({ ok: true, outcome, invoice });
|
|
674
|
+
}));
|
|
675
|
+
|
|
593
676
|
// A job with no invoice whose match is unclear (ledger.js). The linking
|
|
594
677
|
// tool lists the matcher's suggestions, read from the accounting copy;
|
|
595
678
|
// "Make invoice" says there is none yet; "Link" picks one of them.
|
|
@@ -838,6 +921,21 @@ export function createInvoicingRouter({
|
|
|
838
921
|
res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
|
|
839
922
|
}));
|
|
840
923
|
|
|
924
|
+
router.post('/price-links', ...manager, handle(log, async (req, res) => {
|
|
925
|
+
const organisationId = await organisationOf(req);
|
|
926
|
+
const raw = req.body?.links;
|
|
927
|
+
if (!Array.isArray(raw) || !raw.length || raw.length > MAX_LINKS_AT_ONCE) throw invalid(`links must be a list of 1 to ${MAX_LINKS_AT_ONCE} links`);
|
|
928
|
+
const links = raw.map((l, at) => {
|
|
929
|
+
const system = text(l?.system);
|
|
930
|
+
const externalId = text(l?.externalId);
|
|
931
|
+
if (!system || !externalId) throw invalid(`links[${at}]: system and externalId are required`);
|
|
932
|
+
return { priceItemId: pathId(l?.priceItemId, 'price item'), system, externalId };
|
|
933
|
+
});
|
|
934
|
+
const made = await linkPriceItems(db, organisationId, links, { catalogues: await catalogues(organisationId), personId: req.person.id });
|
|
935
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made ${made.length} price links`);
|
|
936
|
+
res.status(201).json({ ok: true, linked: made.length });
|
|
937
|
+
}));
|
|
938
|
+
|
|
841
939
|
router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
|
|
842
940
|
const itemId = pathId(req.params.id, 'price item');
|
|
843
941
|
const organisationId = await organisationOf(req);
|
package/server/app.js
CHANGED
|
@@ -52,6 +52,9 @@ export function createApp({
|
|
|
52
52
|
signInRateLimitPerMinute = 30,
|
|
53
53
|
clerkProxyRateLimitPerMinute = 300,
|
|
54
54
|
defaultFamily = DEFAULT_FAMILY,
|
|
55
|
+
// Buttons that call Xero or Seazona wait between presses (the action
|
|
56
|
+
// guard, server/integrations/call-guard.js). Off only in tests.
|
|
57
|
+
buttonCooldowns = true,
|
|
55
58
|
log = console,
|
|
56
59
|
tracing = null,
|
|
57
60
|
} = {}) {
|
|
@@ -141,7 +144,7 @@ export function createApp({
|
|
|
141
144
|
// The price list: every enabled integration that keeps its own (Xero
|
|
142
145
|
// items, Seazona products).
|
|
143
146
|
const priceSystems = () => integrations?.with('listPriceItems') ?? [];
|
|
144
|
-
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems }));
|
|
147
|
+
app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems, buttonCooldowns }));
|
|
145
148
|
// Custom fields for jobs (server/core/custom-fields.js): kept in Flow
|
|
146
149
|
// only; the work systems' statuses show beside them, read only.
|
|
147
150
|
app.use('/api/custom-fields', createCustomFieldsRouter({ db: database.db, personAuth: auth.personAuth, work, log }));
|
|
@@ -172,7 +175,7 @@ export function createApp({
|
|
|
172
175
|
res.json(settings ? { ok: true, on: true, service: adapter.id, ...settings } : { ok: true, on: false });
|
|
173
176
|
});
|
|
174
177
|
}
|
|
175
|
-
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
|
|
178
|
+
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page, buttonCooldowns }) ?? []) {
|
|
176
179
|
app.use(path, handler);
|
|
177
180
|
}
|
|
178
181
|
|
package/server/core/alerts.js
CHANGED
|
@@ -55,7 +55,7 @@ export function invoiceProblemsSource(db, { accounting = () => null } = {}) {
|
|
|
55
55
|
page: 'invoices',
|
|
56
56
|
tone: 'warn',
|
|
57
57
|
title: `${plural(ids.length, 'invoice has', 'invoices have')} a problem.`,
|
|
58
|
-
action: { label: 'Open
|
|
58
|
+
action: { label: 'Open Pending', tab: 'pending' },
|
|
59
59
|
moneyAtRiskCents: Number(total),
|
|
60
60
|
members: ids,
|
|
61
61
|
}];
|
|
@@ -91,7 +91,7 @@ export const AUTOMATIONS = Object.freeze([
|
|
|
91
91
|
key: 'ready_when_drafted',
|
|
92
92
|
place: 'invoicing',
|
|
93
93
|
name: 'Ready when drafted',
|
|
94
|
-
hint: '
|
|
94
|
+
hint: 'A draft with no problem goes straight to Ready.',
|
|
95
95
|
defaults: { enabled: true, config: {} },
|
|
96
96
|
readConfig: () => ({}),
|
|
97
97
|
},
|