@arcforgelabs/flow 2026.10.27 → 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 +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +14 -0
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +9 -3
- package/server/core/sending.js +4 -3
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +1 -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/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/xero/drafts.js +4 -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 +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +15 -7
- package/server/ui/routes.js +1 -1
- package/server/ui/search.js +145 -0
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ run a lab.
|
|
|
38
38
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
39
39
|
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
|
|
40
40
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
|
41
|
+
| `docs/LINE_MAPPING.md` | Line mapping: how each work system line finds its price item, and Price codes in Settings |
|
|
41
42
|
| `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
|
|
42
43
|
| `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
|
|
43
44
|
| `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.29",
|
|
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.29",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
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",
|
package/server/api/invoicing.js
CHANGED
|
@@ -9,10 +9,14 @@ import {
|
|
|
9
9
|
ACTIONS, createRule, deleteRule, FIELDS, listRules, TESTS, updateRule,
|
|
10
10
|
} from '../core/invoice-rules.js';
|
|
11
11
|
import {
|
|
12
|
-
|
|
12
|
+
createMapping, deleteMapping, listMappings, STEP_LABELS, STEPS, updateMapping,
|
|
13
|
+
} from '../core/line-mapping.js';
|
|
14
|
+
import {
|
|
15
|
+
effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
|
|
13
16
|
saveCustomerBilling, saveInvoicingSettings,
|
|
14
17
|
} from '../core/invoicing-rules.js';
|
|
15
|
-
import {
|
|
18
|
+
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
|
+
import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
|
|
16
20
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
17
21
|
import {
|
|
18
22
|
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
@@ -38,20 +42,29 @@ import {
|
|
|
38
42
|
// GET /contacts?q=&limit= customers, by name
|
|
39
43
|
// GET /price-items?q= the price list, by code
|
|
40
44
|
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
41
|
-
// a job with no invoice lists the invoicing rules it waits on
|
|
42
|
-
//
|
|
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)
|
|
43
50
|
// PUT /settings owner or admin: change some of them
|
|
44
51
|
// GET /rules the lab's invoice rules, in order (server/core/invoice-rules.js)
|
|
45
52
|
// GET /rule-fields what a rule can name, and the menus of values
|
|
46
53
|
// POST /rules owner or admin: a new rule { name, enabled, conditions, action }
|
|
47
54
|
// PUT /rules/:id owner or admin: change some of a rule
|
|
48
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
|
|
49
61
|
// GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
|
|
50
62
|
// PUT /automations/:key owner or admin: { enabled, config }
|
|
51
63
|
// GET /work-statuses the work system's job statuses, with job counts
|
|
52
64
|
// GET /contacts/:id/billing one customer's own invoicing settings
|
|
53
65
|
// PUT /contacts/:id/billing owner or admin: change them (null takes the organisation's)
|
|
54
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
|
|
55
68
|
// POST /jobs/:id/draft owner or admin: a job's draft invoice, after a live
|
|
56
69
|
// check that the accounting system holds none
|
|
57
70
|
//
|
|
@@ -61,6 +74,7 @@ import {
|
|
|
61
74
|
// cents. `liveCheckFor(organisationId)` returns the ledger's live check for
|
|
62
75
|
// that organisation, or null when no accounting integration offers one.
|
|
63
76
|
// `accounting()` is the accounting adapter that sends invoices, or null.
|
|
77
|
+
// `work()` is the work system adapters that offer listWork (the Jobs board).
|
|
64
78
|
|
|
65
79
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
66
80
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -75,7 +89,7 @@ const MAX_UNIT_CENTS = 100_000_000;
|
|
|
75
89
|
|
|
76
90
|
const ERROR_STATUS = {
|
|
77
91
|
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
78
|
-
never_issued: 409, not_ready: 409, live_check_unavailable: 409,
|
|
92
|
+
never_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
79
93
|
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
80
94
|
};
|
|
81
95
|
|
|
@@ -225,7 +239,9 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
|
|
|
225
239
|
};
|
|
226
240
|
}
|
|
227
241
|
|
|
228
|
-
export function createInvoicingRouter({
|
|
242
|
+
export function createInvoicingRouter({
|
|
243
|
+
db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [],
|
|
244
|
+
}) {
|
|
229
245
|
const router = express.Router();
|
|
230
246
|
router.use(express.json({ limit: '64kb' }));
|
|
231
247
|
|
|
@@ -415,40 +431,17 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
415
431
|
const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
|
|
416
432
|
for (const entry of all) counts[entry.view] += 1;
|
|
417
433
|
const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
428
|
-
: [],
|
|
429
|
-
]);
|
|
430
|
-
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
431
|
-
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
432
|
-
const settings = await readInvoicingSettings(db, organisationId);
|
|
433
|
-
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
434
|
-
const rules = await listRules(db, organisationId);
|
|
435
|
-
// The invoicing rules a job with no invoice waits on, for every view a
|
|
436
|
-
// draft can still come from.
|
|
437
|
-
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
438
|
-
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
439
|
-
res.json({
|
|
440
|
-
ok: true,
|
|
441
|
-
jobs: listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view: v, reasons }) => ({
|
|
442
|
-
...o,
|
|
443
|
-
contactName: nameOf.get(o.contactId) ?? null,
|
|
444
|
-
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
445
|
-
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
|
446
|
-
view: v,
|
|
447
|
-
reasons,
|
|
448
|
-
waitingFor: waitingFor(o, v),
|
|
449
|
-
})),
|
|
450
|
-
counts,
|
|
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,
|
|
451
443
|
});
|
|
444
|
+
res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
|
|
452
445
|
}));
|
|
453
446
|
|
|
454
447
|
router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
|
|
@@ -470,6 +463,20 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
470
463
|
res.json({ ok: true, job: { id: job.id, readyAt: job.readyAt, readyBy: job.readyBy } });
|
|
471
464
|
}));
|
|
472
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
|
+
|
|
473
480
|
router.get('/automations', ...signedIn, handle(log, async (req, res) => {
|
|
474
481
|
const organisationId = await organisationOf(req);
|
|
475
482
|
const place = typeof req.query.place === 'string' ? req.query.place : null;
|
|
@@ -516,7 +523,11 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
516
523
|
options: {
|
|
517
524
|
statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
|
|
518
525
|
customers,
|
|
519
|
-
items:
|
|
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
|
+
],
|
|
520
531
|
},
|
|
521
532
|
});
|
|
522
533
|
}));
|
|
@@ -546,6 +557,51 @@ export function createInvoicingRouter({ db, personAuth, log = console, liveCheck
|
|
|
546
557
|
res.json({ ok: true });
|
|
547
558
|
}));
|
|
548
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
|
+
|
|
549
605
|
router.get('/settings', ...signedIn, handle(log, async (req, res) => {
|
|
550
606
|
const organisationId = await organisationOf(req);
|
|
551
607
|
res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });
|
package/server/app.js
CHANGED
|
@@ -114,7 +114,9 @@ export function createApp({
|
|
|
114
114
|
// Send all ready (server/core/sending.js): the first enabled integration
|
|
115
115
|
// that sends invoices (Xero).
|
|
116
116
|
const accounting = () => integrations?.with('pushInvoices')[0] ?? null;
|
|
117
|
-
|
|
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 }));
|
|
118
120
|
}
|
|
119
121
|
// What a signed-in browser needs to report its errors and problems to the
|
|
120
122
|
// installation's own error service (docs/SENTRY.md): the first integration
|
|
@@ -2,6 +2,7 @@ import { and, eq, sql } from 'drizzle-orm';
|
|
|
2
2
|
import { automations, billingObligations } from '../db/schema.js';
|
|
3
3
|
import { FlowError, notFound } from './errors.js';
|
|
4
4
|
import { autoDraftJobs } from './invoicing-rules.js';
|
|
5
|
+
import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
|
|
5
6
|
import { setReady } from './ledger.js';
|
|
6
7
|
|
|
7
8
|
// Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
|
|
@@ -54,6 +55,17 @@ function readStatuses(value) {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export const AUTOMATIONS = Object.freeze([
|
|
58
|
+
{
|
|
59
|
+
// Runs inside the pipeline's read of the work system (pipeline.js,
|
|
60
|
+
// ingestWork), so every job reaches the ledger already mapped.
|
|
61
|
+
key: 'map_lines',
|
|
62
|
+
place: 'seazona',
|
|
63
|
+
name: 'Map lines to price items',
|
|
64
|
+
hint: 'Each Seazona line finds its price item.',
|
|
65
|
+
defaults: { enabled: true, config: { ...MAPPING_DEFAULTS } },
|
|
66
|
+
readConfig: (config) => readMappingConfig(config),
|
|
67
|
+
remap: true,
|
|
68
|
+
},
|
|
57
69
|
{
|
|
58
70
|
key: 'mark_ready_from_work_status',
|
|
59
71
|
place: 'seazona',
|
|
@@ -97,6 +109,8 @@ export async function saveAutomation(db, organisationId, key, changes, { personI
|
|
|
97
109
|
await db.insert(automations)
|
|
98
110
|
.values({ organisationId, key, enabled, config, updatedBy: personId })
|
|
99
111
|
.onConflictDoUpdate({ target: [automations.organisationId, automations.key], set: { enabled, config, updatedBy: personId, updatedAt: sql`now()` } });
|
|
112
|
+
// A routine that shapes jobs as they come in applies to the jobs already read.
|
|
113
|
+
if (automation.remap) await remapJobs(db, organisationId);
|
|
100
114
|
return (await listAutomations(db, organisationId)).find((a) => a.key === key);
|
|
101
115
|
}
|
|
102
116
|
|
|
@@ -21,7 +21,9 @@ const invalid = (message) => new FlowError('invalid', message);
|
|
|
21
21
|
|
|
22
22
|
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
23
23
|
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
24
|
-
|
|
24
|
+
// A line's key: its price item code, else the label line mapping gave it (a
|
|
25
|
+
// line booked to an account, such as a surcharge, has no code).
|
|
26
|
+
const code = (line) => norm(line.itemCode || line.label);
|
|
25
27
|
const cents = (lines) => lines.reduce((sum, l) => sum + (l.lineAmountCents ?? 0), 0);
|
|
26
28
|
|
|
27
29
|
// The share of the item's lines in the job's other lines, as the charged and
|
|
@@ -41,7 +43,7 @@ export const FIELDS = Object.freeze({
|
|
|
41
43
|
practitioner: { label: 'Practitioner', kind: 'text', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
42
44
|
customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
|
|
43
45
|
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
44
|
-
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).
|
|
46
|
+
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
|
|
45
47
|
line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
|
|
46
48
|
});
|
|
47
49
|
|
package/server/core/invoices.js
CHANGED
|
@@ -39,9 +39,11 @@ async function resolveLines(tx, organisationId, lines) {
|
|
|
39
39
|
} catch (error) {
|
|
40
40
|
throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
|
|
41
41
|
}
|
|
42
|
+
const accountCode = line.priceItemId ? null : (typeof line.accountCode === 'string' && line.accountCode.trim() ? line.accountCode.trim().slice(0, 20) : null);
|
|
42
43
|
resolved.push({
|
|
43
44
|
position: index + 1,
|
|
44
45
|
priceItemId: line.priceItemId ?? null,
|
|
46
|
+
accountCode,
|
|
45
47
|
description: description.trim(),
|
|
46
48
|
quantity,
|
|
47
49
|
unitAmountCents,
|
|
@@ -141,8 +143,8 @@ export async function issueInvoice(db, organisationId, invoiceId, { issuedBy, is
|
|
|
141
143
|
reference: invoice.reference,
|
|
142
144
|
currency: invoice.currency,
|
|
143
145
|
contact: { id: contact.id, name: contact.name, email: contact.email },
|
|
144
|
-
lines: lines.map(({ line: { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }, itemCode }) => (
|
|
145
|
-
{ position, itemCode: itemCode ?? null, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
|
|
146
|
+
lines: lines.map(({ line: { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents, accountCode }, itemCode }) => (
|
|
147
|
+
{ position, itemCode: itemCode ?? null, ...(accountCode ? { accountCode } : {}), description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
|
|
146
148
|
)),
|
|
147
149
|
subtotalCents: invoice.subtotalCents,
|
|
148
150
|
taxCents: invoice.taxCents,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
|
+
import { contacts, invoices, priceItems } from '../db/schema.js';
|
|
3
|
+
import { listRules } from './invoice-rules.js';
|
|
4
|
+
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
|
+
import { listObligations } from './ledger.js';
|
|
6
|
+
import { invoiceViews } from './sending.js';
|
|
7
|
+
|
|
8
|
+
// The Jobs board (/jobs, server/ui/jobs.js). Its columns are the work
|
|
9
|
+
// system's own job statuses, one to one. Its jobs come from the work
|
|
10
|
+
// system's copy in Flow (the port's listWork: Seazona's open orders and the
|
|
11
|
+
// ones finished lately), so jobs still in the lab show. A job that has a
|
|
12
|
+
// billing obligation (ledger.js) carries it, with the state of its invoice,
|
|
13
|
+
// for the job's detail panel. With no work system that offers listWork, the
|
|
14
|
+
// board shows the ledger's jobs by the status the work system last reported.
|
|
15
|
+
|
|
16
|
+
// The ledger's jobs with what the Jobs page shows: the customer's name, the
|
|
17
|
+
// invoice's number, status, Invoices page view (sending.js) and problems,
|
|
18
|
+
// and the invoicing rules a job with no invoice waits on. `listed` is
|
|
19
|
+
// listObligations output. `accounting` is the accounting adapter, or null.
|
|
20
|
+
export async function describeObligations(db, organisationId, listed, { accounting = null } = {}) {
|
|
21
|
+
const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
|
|
22
|
+
const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
|
|
23
|
+
const [names, numbers, views] = await Promise.all([
|
|
24
|
+
contactIds.length
|
|
25
|
+
? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
26
|
+
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
|
|
27
|
+
: [],
|
|
28
|
+
invoiceIds.length
|
|
29
|
+
? db.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
|
|
30
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
31
|
+
: [],
|
|
32
|
+
invoiceIds.length ? invoiceViews(db, organisationId, { accounting }) : null,
|
|
33
|
+
]);
|
|
34
|
+
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
35
|
+
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
36
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
37
|
+
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
38
|
+
const rules = await listRules(db, organisationId);
|
|
39
|
+
const ready = new Set(views?.ready ?? []);
|
|
40
|
+
const toCheck = new Set(views?.toCheck ?? []);
|
|
41
|
+
const invoiceView = (id) => (!id || !views ? null
|
|
42
|
+
: views.problems.has(id) ? 'problem' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
|
|
43
|
+
: views.sending.has(id) ? 'sending' : null);
|
|
44
|
+
// The invoicing rules a job with no invoice waits on, for every view a
|
|
45
|
+
// draft can still come from.
|
|
46
|
+
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
47
|
+
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
48
|
+
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view, reasons }) => ({
|
|
49
|
+
...o,
|
|
50
|
+
contactName: nameOf.get(o.contactId) ?? null,
|
|
51
|
+
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
52
|
+
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
|
53
|
+
invoiceView: invoiceView(o.invoiceId),
|
|
54
|
+
invoiceProblems: (o.invoiceId && views?.problems.get(o.invoiceId)) || [],
|
|
55
|
+
view,
|
|
56
|
+
reasons,
|
|
57
|
+
waitingFor: waitingFor(o, view),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const keyOf = (system, ref) => `${system}\u0000${ref}`;
|
|
62
|
+
|
|
63
|
+
// The ledger's lines as the board shows them: whether each maps to a price item.
|
|
64
|
+
const ledgerLines = (lines) => (lines ?? []).map((l) => ({
|
|
65
|
+
code: l.itemCode ?? null, description: l.description, amountCents: l.lineAmountCents ?? null, mapped: Boolean(l.priceItemId),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Answers { jobs, statuses }. `work` is the adapters that offer listWork;
|
|
69
|
+
// `live` says an accounting system checks the ledger (ledgerView).
|
|
70
|
+
export async function boardJobs(db, organisationId, { work = [], accounting = null, live = false, log = console } = {}) {
|
|
71
|
+
const listed = await listObligations(db, organisationId, { accounting: live });
|
|
72
|
+
const obligations = await describeObligations(db, organisationId, listed, { accounting });
|
|
73
|
+
const byJob = new Map(obligations.map((o) => [keyOf(o.sourceSystem, o.sourceJobRef), o]));
|
|
74
|
+
|
|
75
|
+
const reports = [];
|
|
76
|
+
for (const adapter of work) {
|
|
77
|
+
try {
|
|
78
|
+
reports.push(await adapter.capabilities.listWork({ organisationId }));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// The board still shows the ledger's jobs when the copy cannot be read.
|
|
81
|
+
log.error?.(`[jobs] ${adapter.id} listWork: ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!reports.length) {
|
|
85
|
+
const statuses = new Map();
|
|
86
|
+
const jobs = obligations.map((o) => {
|
|
87
|
+
const status = o.sourceStatus?.trim() || null;
|
|
88
|
+
if (status && !statuses.has(status)) statuses.set(status, { label: status, final: false, firstSeenAt: null });
|
|
89
|
+
return {
|
|
90
|
+
sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, status,
|
|
91
|
+
clientName: o.contactName, workClientName: null, practitioner: o.practitioner, department: null,
|
|
92
|
+
orderedOn: null, dueOn: null, jobDate: o.jobDate, shippedOn: o.shippedOn,
|
|
93
|
+
totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
return { jobs, statuses: [...statuses.values()] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const codes = new Set((await db.select({ code: priceItems.code }).from(priceItems)
|
|
100
|
+
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))).map((p) => p.code));
|
|
101
|
+
const jobs = [];
|
|
102
|
+
const statuses = [];
|
|
103
|
+
for (const report of reports) {
|
|
104
|
+
statuses.push(...(report.statuses ?? []));
|
|
105
|
+
for (const item of report.items ?? []) {
|
|
106
|
+
const o = byJob.get(keyOf(item.sourceSystem, item.sourceJobRef)) ?? null;
|
|
107
|
+
jobs.push({
|
|
108
|
+
sourceSystem: item.sourceSystem,
|
|
109
|
+
sourceJobRef: item.sourceJobRef,
|
|
110
|
+
caseRef: item.caseRef ?? o?.caseRef ?? null,
|
|
111
|
+
status: item.status ?? null,
|
|
112
|
+
// The Flow customer once the work system's client is linked to one;
|
|
113
|
+
// until then the work system's own name for the client.
|
|
114
|
+
clientName: o?.contactName ?? item.clientName ?? null,
|
|
115
|
+
workClientName: item.clientName ?? null,
|
|
116
|
+
practitioner: item.practitioner ?? o?.practitioner ?? null,
|
|
117
|
+
department: item.department ?? null,
|
|
118
|
+
orderedOn: item.orderedOn ?? null,
|
|
119
|
+
dueOn: item.dueOn ?? null,
|
|
120
|
+
jobDate: o?.jobDate ?? null,
|
|
121
|
+
shippedOn: o?.shippedOn ?? null,
|
|
122
|
+
totalCents: o?.totalCents ?? item.totalCents ?? null,
|
|
123
|
+
lines: o ? ledgerLines(o.lines)
|
|
124
|
+
: (item.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null, mapped: Boolean(l.code && codes.has(l.code)) })),
|
|
125
|
+
obligation: o,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { jobs, statuses };
|
|
130
|
+
}
|
package/server/core/ledger.js
CHANGED
|
@@ -85,14 +85,21 @@ async function normaliseLines(tx, organisationId, lines) {
|
|
|
85
85
|
} catch (error) {
|
|
86
86
|
throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
// A line mapped to an account (line-mapping.js) needs no price item.
|
|
89
|
+
const accountCode = priceItemId ? null : (line.accountCode?.trim() || null);
|
|
90
|
+
const extra = {
|
|
91
|
+
...(accountCode ? { accountCode } : {}),
|
|
92
|
+
...(line.label ? { label: line.label } : {}),
|
|
93
|
+
...(line.mappedBy ? { mappedBy: line.mappedBy } : {}),
|
|
94
|
+
};
|
|
95
|
+
normalised.push({ itemCode, priceItemId, description, quantity, unitAmountCents, taxCode, ...extra, ...totals });
|
|
89
96
|
}
|
|
90
97
|
return normalised;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
function fingerprint(lines) {
|
|
94
101
|
const keys = lines
|
|
95
|
-
.map((l) => [l.itemCode ?? l.description.toLowerCase(), l.quantity, l.unitAmountCents, l.taxCode])
|
|
102
|
+
.map((l) => [l.itemCode ?? l.accountCode ?? l.description.toLowerCase(), l.quantity, l.unitAmountCents, l.taxCode])
|
|
96
103
|
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
97
104
|
return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
|
|
98
105
|
}
|
|
@@ -117,7 +124,7 @@ export async function observeJob(db, organisationId, job) {
|
|
|
117
124
|
lines,
|
|
118
125
|
lineFingerprint: fingerprint(lines),
|
|
119
126
|
...invoiceTotals(lines),
|
|
120
|
-
mappingComplete: lines.every((l) => l.priceItemId),
|
|
127
|
+
mappingComplete: lines.every((l) => l.priceItemId || l.accountCode),
|
|
121
128
|
};
|
|
122
129
|
const [existing] = await tx.select().from(billingObligations)
|
|
123
130
|
.where(and(
|
|
@@ -418,8 +425,8 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
|
|
|
418
425
|
contactId: obligation.contactId,
|
|
419
426
|
reference: obligation.caseRef ?? obligation.sourceJobRef,
|
|
420
427
|
dueDate,
|
|
421
|
-
lines: obligation.lines.map(({ priceItemId, description, quantity, unitAmountCents, taxCode }) => (
|
|
422
|
-
{ priceItemId, description, quantity, unitAmountCents, taxCode }
|
|
428
|
+
lines: obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
|
|
429
|
+
{ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
|
|
423
430
|
)),
|
|
424
431
|
});
|
|
425
432
|
// Drafting the invoice says the job is ready, whoever asked.
|