@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -2
  4. package/server/api/invoicing.js +96 -40
  5. package/server/app.js +3 -1
  6. package/server/core/automations.js +14 -0
  7. package/server/core/invoice-rules.js +4 -2
  8. package/server/core/invoices.js +4 -2
  9. package/server/core/job-board.js +130 -0
  10. package/server/core/ledger.js +12 -5
  11. package/server/core/line-mapping.js +337 -0
  12. package/server/core/pipeline.js +9 -3
  13. package/server/core/sending.js +4 -3
  14. package/server/db/migrate-all.js +26 -0
  15. package/server/db/migrations/0010_invoicing_rules.sql +34 -13
  16. package/server/db/migrations/0011_invoice_rules.sql +11 -5
  17. package/server/db/migrations/0012_invoice_sending.sql +18 -9
  18. package/server/db/migrations/0013_line_mappings.sql +35 -0
  19. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  20. package/server/db/migrations/meta/_journal.json +7 -0
  21. package/server/db/schema.js +43 -0
  22. package/server/import/at-start.js +64 -2
  23. package/server/index.js +5 -1
  24. package/server/integrations/port.js +1 -0
  25. package/server/integrations/seazona/feed.js +62 -4
  26. package/server/integrations/seazona/index.js +7 -1
  27. package/server/integrations/seazona/lanes.js +4 -1
  28. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  29. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  30. package/server/integrations/xero/drafts.js +4 -1
  31. package/server/integrations/xero/lookup.js +6 -2
  32. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  33. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  34. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  35. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  36. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  37. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
  38. package/server/ui/app-page.js +6 -4
  39. package/server/ui/app.js +111 -8
  40. package/server/ui/clerk-appearance.js +21 -0
  41. package/server/ui/flow.css +155 -30
  42. package/server/ui/invoicing-settings.js +2 -0
  43. package/server/ui/job-board.js +155 -0
  44. package/server/ui/jobs.js +220 -91
  45. package/server/ui/line-mappings.js +60 -0
  46. package/server/ui/monitoring.js +15 -7
  47. package/server/ui/routes.js +1 -1
  48. 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`) |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.27",
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.27",
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.27",
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",
@@ -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
- customerBillingByContact, effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings, ruleBlockers,
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 { draftInvoiceForObligation, listObligations, setReady } from '../core/ledger.js';
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
- // GET /settings the invoicing settings (server/core/invoicing-rules.js)
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({ db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null }) {
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
- const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
419
- const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
420
- const [names, numbers] = await Promise.all([
421
- contactIds.length
422
- ? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
423
- .where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
424
- : [],
425
- invoiceIds.length
426
- ? db.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
427
- .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
428
- : [],
429
- ]);
430
- const nameOf = new Map(names.map((c) => [c.id, c.name]));
431
- const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
432
- const settings = await readInvoicingSettings(db, organisationId);
433
- const customers = await customerBillingByContact(db, organisationId, contactIds);
434
- const rules = await listRules(db, organisationId);
435
- // The invoicing rules a job with no invoice waits on, for every view a
436
- // draft can still come from.
437
- const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
438
- ? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
439
- res.json({
440
- ok: true,
441
- jobs: listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view: v, reasons }) => ({
442
- ...o,
443
- contactName: nameOf.get(o.contactId) ?? null,
444
- invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
445
- invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
446
- view: v,
447
- reasons,
448
- waitingFor: waitingFor(o, v),
449
- })),
450
- counts,
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: items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
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
- app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting }));
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
- const code = (line) => norm(line.itemCode);
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 ?? []).map(code) },
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
 
@@ -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
+ }
@@ -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
- normalised.push({ itemCode, priceItemId, description, quantity, unitAmountCents, taxCode, ...totals });
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.