@arcforgelabs/flow 2026.10.37 → 2026.10.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -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 +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- 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 +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- 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 +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
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 |
|
|
@@ -55,6 +55,7 @@ run a lab.
|
|
|
55
55
|
| `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
|
|
56
56
|
| `docs/INTEGRATIONS.md` | How an integration adapter is built, turned on and off, and owns its tables |
|
|
57
57
|
| `docs/XERO.md` | The Xero adapter: modes, settings, rate limits, connecting Xero and draft invoices |
|
|
58
|
+
| `docs/XERO_IMPORT.md` | Xero › Import: each Xero invoice in Flow's copy linked to its Flow invoice, or made into one |
|
|
58
59
|
| `docs/SEAZONA.md` | The Seazona adapter: modes, settings, the rate governor, replay files and its tables |
|
|
59
60
|
| `docs/SENTRY.md` | The Sentry adapter: errors and problem reports to the installation's own Sentry project |
|
|
60
61
|
| `server/integrations/port.js` | The integration port every adapter implements |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.39",
|
|
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.39",
|
|
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.39",
|
|
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}`);
|
|
@@ -71,8 +75,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
71
75
|
router.get('/', ...signedIn, handle(async (req, res) => {
|
|
72
76
|
const organisationId = await organisationOf(req);
|
|
73
77
|
const archived = req.query.archived === '1' || req.query.archived === 'true';
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
await ensureAssignee(db, organisationId);
|
|
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 });
|
|
76
83
|
}));
|
|
77
84
|
|
|
78
85
|
router.post('/', ...manager, handle(async (req, res) => {
|
|
@@ -85,6 +92,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
85
92
|
res.json({ ok: true, fields });
|
|
86
93
|
}));
|
|
87
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
|
+
|
|
88
100
|
router.get('/values', ...signedIn, handle(async (req, res) => {
|
|
89
101
|
res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
|
|
90
102
|
}));
|
|
@@ -111,5 +123,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
111
123
|
res.json({ ok: true, field });
|
|
112
124
|
}));
|
|
113
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
|
+
|
|
114
132
|
return router;
|
|
115
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';
|
|
@@ -17,12 +18,14 @@ import {
|
|
|
17
18
|
} from '../core/invoicing-rules.js';
|
|
18
19
|
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
20
|
import { readBeforeAct } from '../core/read-before-act.js';
|
|
21
|
+
import { invoiceMatchDetail, jobMatchDetail, listReconcile } from '../core/job-match.js';
|
|
20
22
|
import { jobReference } from '../core/references.js';
|
|
21
23
|
import {
|
|
22
|
-
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
24
|
+
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, linkPriceItems, MAX_LINKS_AT_ONCE, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
23
25
|
} from '../core/price-list.js';
|
|
24
26
|
import {
|
|
25
|
-
draftInvoiceForObligation, explainObligation, jobChangeFor,
|
|
27
|
+
confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
|
|
28
|
+
setReady, settleJobChange, useSourceLines,
|
|
26
29
|
} from '../core/ledger.js';
|
|
27
30
|
import { isHistoryHeld } from '../core/history.js';
|
|
28
31
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
@@ -35,8 +38,8 @@ import {
|
|
|
35
38
|
// UI's invoices, customers, price list and jobs pages read and write here.
|
|
36
39
|
//
|
|
37
40
|
// GET /context the organisations, and whether this person may issue
|
|
38
|
-
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by
|
|
39
|
-
// 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)
|
|
40
43
|
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
41
44
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
42
45
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
@@ -79,6 +82,7 @@ import {
|
|
|
79
82
|
// POST /price-items/:id/links owner or admin: { system, externalId } link a record of a system's list
|
|
80
83
|
// POST /price-items/:id/resolve owner or admin: Mark resolved: check again, accept what still differs
|
|
81
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
|
|
82
86
|
// GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
|
|
83
87
|
// PUT /automations/:key owner or admin: { enabled, config }
|
|
84
88
|
// GET /work-statuses the work system's job statuses, with job counts
|
|
@@ -99,7 +103,15 @@ import {
|
|
|
99
103
|
|
|
100
104
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
101
105
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
102
|
-
|
|
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' };
|
|
103
115
|
const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
104
116
|
const MAX_LINES = 200;
|
|
105
117
|
const MAX_TEXT = 500;
|
|
@@ -233,7 +245,7 @@ export async function resolveOrganisation(db, raw) {
|
|
|
233
245
|
}
|
|
234
246
|
|
|
235
247
|
const jobOf = (o) => (o ? {
|
|
236
|
-
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,
|
|
237
249
|
} : null);
|
|
238
250
|
|
|
239
251
|
// A job with its work system's name and its link in that system's web app
|
|
@@ -279,6 +291,46 @@ async function jobChangeDetail(db, organisationId, invoiceId, work) {
|
|
|
279
291
|
return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
|
|
280
292
|
}
|
|
281
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
|
+
|
|
282
334
|
async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
|
|
283
335
|
const invoice = await getInvoice(db, organisationId, invoiceId);
|
|
284
336
|
const [[contact], versions, jobs, jobChange] = await Promise.all([
|
|
@@ -288,10 +340,12 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
|
|
|
288
340
|
jobsByInvoice(db, organisationId, [invoiceId]),
|
|
289
341
|
jobChangeDetail(db, organisationId, invoiceId, work),
|
|
290
342
|
]);
|
|
343
|
+
const viewed = await invoiceView(db, organisationId, invoice, { accounting });
|
|
291
344
|
return {
|
|
292
345
|
jobChange,
|
|
293
346
|
...invoice,
|
|
294
|
-
...
|
|
347
|
+
...viewed,
|
|
348
|
+
tab: await tabOfInvoice(db, organisationId, invoice, viewed.view, accounting),
|
|
295
349
|
// Where a draft goes when it is sent, and how: this customer's send mode.
|
|
296
350
|
sendsTo: accounting?.name ?? null,
|
|
297
351
|
sendMode: accounting && invoice.status === 'draft'
|
|
@@ -305,11 +359,29 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
|
|
|
305
359
|
};
|
|
306
360
|
}
|
|
307
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
|
+
|
|
308
377
|
export function createInvoicingRouter({
|
|
309
378
|
db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [], priceSystems = () => [],
|
|
379
|
+
buttonCooldowns = true, actions = createActionGuard({ integration: 'invoicing', actions: INVOICING_ACTIONS, off: !buttonCooldowns }),
|
|
310
380
|
}) {
|
|
311
381
|
const router = express.Router();
|
|
382
|
+
const act = (name, key) => actions.route(name, { key });
|
|
312
383
|
router.use(express.json({ limit: '64kb' }));
|
|
384
|
+
const rechecked = new Map();
|
|
313
385
|
|
|
314
386
|
const signedIn = [personAuth.requireSession];
|
|
315
387
|
const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
|
|
@@ -325,61 +397,46 @@ export function createInvoicingRouter({
|
|
|
325
397
|
|
|
326
398
|
router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
|
|
327
399
|
const organisationId = await organisationOf(req);
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
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(', ')}`);
|
|
331
404
|
}
|
|
332
405
|
const q = text(req.query.q);
|
|
333
406
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
334
|
-
// Each tab switch runs this, so the independent reads run side by side
|
|
335
|
-
// 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.
|
|
336
408
|
const system = accounting();
|
|
337
|
-
const checking = invoiceViews(db, organisationId, { accounting: system });
|
|
338
|
-
const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
339
|
-
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
340
409
|
const sendMode = system
|
|
341
410
|
? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
|
|
342
411
|
.then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
|
|
343
412
|
: null;
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
360
|
-
.limit(limit);
|
|
361
|
-
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
362
|
-
})();
|
|
363
|
-
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));
|
|
364
428
|
const systems = work();
|
|
365
|
-
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
366
|
-
for (const { status: s, n } of counted) counts[s] = n;
|
|
367
|
-
counts.to_check = views.toCheck.length;
|
|
368
|
-
counts.ready = views.ready.length;
|
|
369
|
-
counts.problem = views.problems.size;
|
|
370
|
-
const ready = new Set(views.ready);
|
|
371
|
-
const toCheck = new Set(views.toCheck);
|
|
372
429
|
res.json({
|
|
373
430
|
ok: true,
|
|
374
431
|
invoices: rows.map(({ invoice, contactName }) => ({
|
|
375
432
|
...invoice,
|
|
376
433
|
contactName,
|
|
377
434
|
job: withLink(jobs.get(invoice.id) ?? null, systems),
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
problems:
|
|
435
|
+
tab: tabs.tabOf.get(invoice.id) ?? null,
|
|
436
|
+
sending: tabs.sending.has(invoice.id),
|
|
437
|
+
problems: tabs.problems.get(invoice.id) ?? [],
|
|
381
438
|
})),
|
|
382
|
-
counts,
|
|
439
|
+
counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
|
|
383
440
|
sendsTo: system?.name ?? null,
|
|
384
441
|
sendMode: mode,
|
|
385
442
|
// The Linked column: one icon for each connected system.
|
|
@@ -409,7 +466,7 @@ export function createInvoicingRouter({
|
|
|
409
466
|
res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
|
|
410
467
|
}));
|
|
411
468
|
|
|
412
|
-
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) => {
|
|
413
470
|
const organisationId = await organisationOf(req);
|
|
414
471
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
415
472
|
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
@@ -420,7 +477,7 @@ export function createInvoicingRouter({
|
|
|
420
477
|
res.json({ ok: true, ...result });
|
|
421
478
|
}));
|
|
422
479
|
|
|
423
|
-
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) => {
|
|
424
481
|
const organisationId = await organisationOf(req);
|
|
425
482
|
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
426
483
|
const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
|
|
@@ -486,7 +543,7 @@ export function createInvoicingRouter({
|
|
|
486
543
|
// Ready and Send do.
|
|
487
544
|
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
488
545
|
const held = problems.find((p) => p.code === 'job_review');
|
|
489
|
-
if (held) throw new FlowError('not_ready', `${held.message} Nothing was
|
|
546
|
+
if (held) throw new FlowError('not_ready', `${held.message} Nothing was approved.`);
|
|
490
547
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
491
548
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
492
549
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
@@ -556,7 +613,7 @@ export function createInvoicingRouter({
|
|
|
556
613
|
res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
|
|
557
614
|
}));
|
|
558
615
|
|
|
559
|
-
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) => {
|
|
560
617
|
const obligationId = pathId(req.params.id, 'job');
|
|
561
618
|
const organisationId = await organisationOf(req);
|
|
562
619
|
// With no accounting system connected, Flow's own records are the check.
|
|
@@ -584,10 +641,85 @@ export function createInvoicingRouter({
|
|
|
584
641
|
router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
|
|
585
642
|
const obligationId = pathId(req.params.id, 'job');
|
|
586
643
|
const organisationId = await organisationOf(req);
|
|
587
|
-
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 });
|
|
588
645
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
|
|
589
646
|
}));
|
|
590
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
|
+
|
|
676
|
+
// A job with no invoice whose match is unclear (ledger.js). The linking
|
|
677
|
+
// tool lists the matcher's suggestions, read from the accounting copy;
|
|
678
|
+
// "Make invoice" says there is none yet; "Link" picks one of them.
|
|
679
|
+
// The Reconcile page: Flow's jobs with no Flow invoice, Flow invoices with
|
|
680
|
+
// no job, and one of them beside its possible other side
|
|
681
|
+
// (core/job-match.js). Flow's own records only; no integration is called.
|
|
682
|
+
router.get('/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
683
|
+
const organisationId = await organisationOf(req);
|
|
684
|
+
res.json({ ok: true, ...await listReconcile(db, organisationId) });
|
|
685
|
+
}));
|
|
686
|
+
|
|
687
|
+
router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
688
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
689
|
+
const organisationId = await organisationOf(req);
|
|
690
|
+
const [obligation] = await db.select().from(billingObligations)
|
|
691
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
692
|
+
if (!obligation) throw new FlowError('not_found', 'job not found');
|
|
693
|
+
// The Reference Flow would give its invoice, from Flow's own copy only.
|
|
694
|
+
const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
|
|
695
|
+
const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
|
|
696
|
+
const reference = await jobReference(db, organisationId, obligation, { template, work: source });
|
|
697
|
+
const detail = await jobMatchDetail(db, organisationId, obligationId, { reference, q: text(req.query.q) ?? '' });
|
|
698
|
+
res.json({ ok: true, ...detail, job: { ...detail.job, sourceName: source?.name ?? null } });
|
|
699
|
+
}));
|
|
700
|
+
|
|
701
|
+
router.get('/invoices/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
702
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
703
|
+
const organisationId = await organisationOf(req);
|
|
704
|
+
res.json({ ok: true, ...await invoiceMatchDetail(db, organisationId, invoiceId, { q: text(req.query.q) ?? '' }) });
|
|
705
|
+
}));
|
|
706
|
+
|
|
707
|
+
router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
|
|
708
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
709
|
+
const organisationId = await organisationOf(req);
|
|
710
|
+
const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
|
|
711
|
+
res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
// Links a job to a Flow invoice that already exists.
|
|
715
|
+
router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
|
|
716
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
717
|
+
const organisationId = await organisationOf(req);
|
|
718
|
+
const invoiceId = pathId(text(req.body?.invoiceId) ?? '', 'invoice');
|
|
719
|
+
const job = await linkJobToInvoice(db, organisationId, obligationId, { invoiceId, actor: `person:${req.person.id}` });
|
|
720
|
+
res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId } });
|
|
721
|
+
}));
|
|
722
|
+
|
|
591
723
|
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
592
724
|
const obligationId = pathId(req.params.id, 'job');
|
|
593
725
|
const organisationId = await organisationOf(req);
|
|
@@ -789,6 +921,21 @@ export function createInvoicingRouter({
|
|
|
789
921
|
res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
|
|
790
922
|
}));
|
|
791
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
|
+
|
|
792
939
|
router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
|
|
793
940
|
const itemId = pathId(req.params.id, 'price item');
|
|
794
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
|
},
|