@arcforgelabs/flow 2026.10.34 → 2026.10.35

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 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` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
43
+ | `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Finalise, 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 |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.34",
3
+ "version": "2026.10.35",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.34",
9
+ "version": "2026.10.35",
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.34",
3
+ "version": "2026.10.35",
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,6 +32,7 @@
32
32
  "start": "node server/index.js",
33
33
  "test": "node --test --test-concurrency=1 test/*.test.js",
34
34
  "changelog:check": "node scripts/release/changelog.mjs check",
35
+ "release:stable": "node scripts/release/release-stable.mjs",
35
36
  "theme:contrast": "node scripts/theme-contrast.mjs",
36
37
  "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
37
38
  },
@@ -1,6 +1,6 @@
1
1
  import express from 'express';
2
2
  import {
3
- CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, createField, jobFieldHistory, listFields,
3
+ CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, jobFieldHistory, listFields,
4
4
  listJobFieldValues, orderFields, setJobFieldValue, updateField,
5
5
  } from '../core/custom-fields.js';
6
6
  import { FlowError } from '../core/errors.js';
@@ -8,7 +8,8 @@ import { resolveOrganisation } from './invoicing.js';
8
8
 
9
9
  // Custom fields for jobs (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md).
10
10
  //
11
- // GET /api/custom-fields[?archived=1] the lab's fields, and the work systems' own fields (read only)
11
+ // GET /api/custom-fields[?archived=1] the lab's fields, the work systems' own fields (read only),
12
+ // and the people a person field can name
12
13
  // POST /api/custom-fields { name, type[, options] } owner or admin: make a field
13
14
  // PUT /api/custom-fields/order { ids } owner or admin: the field order
14
15
  // PUT /api/custom-fields/:id { name?, options? } owner or admin: rename, change options
@@ -70,8 +71,8 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
70
71
  router.get('/', ...signedIn, handle(async (req, res) => {
71
72
  const organisationId = await organisationOf(req);
72
73
  const archived = req.query.archived === '1' || req.query.archived === 'true';
73
- const [fields, system] = await Promise.all([listFields(db, organisationId, { archived }), systemFields()]);
74
- res.json({ ok: true, fields, systemFields: system, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
74
+ const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
75
+ res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
75
76
  }));
76
77
 
77
78
  router.post('/', ...manager, handle(async (req, res) => {
@@ -21,7 +21,9 @@ import { jobReference } from '../core/references.js';
21
21
  import {
22
22
  acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
23
23
  } from '../core/price-list.js';
24
- import { draftInvoiceForObligation, explainObligation, listObligations, setReady, useSourceLines } from '../core/ledger.js';
24
+ import {
25
+ draftInvoiceForObligation, explainObligation, jobChangeFor, listObligations, setReady, useSourceLines,
26
+ } from '../core/ledger.js';
25
27
  import { isHistoryHeld } from '../core/history.js';
26
28
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
27
29
  import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
@@ -241,15 +243,37 @@ async function jobsByInvoice(db, organisationId, invoiceIds) {
241
243
  return new Map(rows.map((o) => [o.invoiceId, jobOf(o)]));
242
244
  }
243
245
 
244
- async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
246
+ // The job changed after its invoice was made (ledger.js, jobChangeFor): what
247
+ // changed, with customer names, and the work system's link to the job.
248
+ async function jobChangeDetail(db, organisationId, invoiceId, work) {
249
+ const change = await jobChangeFor(db, organisationId, invoiceId);
250
+ if (!change) return null;
251
+ let contact = null;
252
+ if (change.contact) {
253
+ const ids = [change.contact.was, change.contact.now].filter(Boolean);
254
+ const rows = ids.length ? await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
255
+ .where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, ids))) : [];
256
+ const nameOf = (id) => rows.find((r) => r.id === id)?.name ?? null;
257
+ contact = { was: nameOf(change.contact.was), now: nameOf(change.contact.now) };
258
+ }
259
+ const source = work.find((a) => a.id === change.sourceSystem) ?? null;
260
+ const link = typeof source?.capabilities.jobLink === 'function'
261
+ ? source.capabilities.jobLink({ sourceJobRef: change.sourceJobRef, sourceInvoiceRef: change.sourceInvoiceRef })
262
+ : null;
263
+ return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
264
+ }
265
+
266
+ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
245
267
  const invoice = await getInvoice(db, organisationId, invoiceId);
246
- const [[contact], versions, jobs] = await Promise.all([
268
+ const [[contact], versions, jobs, jobChange] = await Promise.all([
247
269
  db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
248
270
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, invoice.contactId))),
249
271
  listInvoiceVersions(db, organisationId, invoiceId),
250
272
  jobsByInvoice(db, organisationId, [invoiceId]),
273
+ jobChangeDetail(db, organisationId, invoiceId, work),
251
274
  ]);
252
275
  return {
276
+ jobChange,
253
277
  ...invoice,
254
278
  ...(await invoiceView(db, organisationId, invoice, { accounting })),
255
279
  // Where a draft goes when it is sent, and how: this customer's send mode.
@@ -386,7 +410,7 @@ export function createInvoicingRouter({
386
410
  router.get('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
387
411
  const invoiceId = pathId(req.params.id, 'invoice');
388
412
  const organisationId = await organisationOf(req);
389
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
413
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
390
414
  }));
391
415
 
392
416
  router.get('/invoices/:id/pdf', ...signedIn, handle(log, async (req, res) => {
@@ -415,7 +439,7 @@ export function createInvoicingRouter({
415
439
  lines: body.lines === undefined ? [] : readLines(body.lines),
416
440
  });
417
441
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made draft invoice ${draft.id}`);
418
- res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting()) });
442
+ res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, draft.id, accounting(), work()) });
419
443
  }));
420
444
 
421
445
  router.patch('/invoices/:id', ...signedIn, handle(log, async (req, res) => {
@@ -429,16 +453,22 @@ export function createInvoicingRouter({
429
453
  if (body.lines !== undefined) changes.lines = readLines(body.lines);
430
454
  if (!Object.keys(changes).length) throw invalid('nothing to change: send contactId, reference, dueDate or lines');
431
455
  await updateDraftInvoice(db, organisationId, invoiceId, changes);
432
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
456
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
433
457
  }));
434
458
 
435
459
  router.post('/invoices/:id/issue', ...manager, handle(log, async (req, res) => {
436
460
  const invoiceId = pathId(req.params.id, 'invoice');
437
461
  const organisationId = await organisationOf(req);
438
462
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
463
+ // A draft whose job changed after it was made waits for a person to fix
464
+ // the job at its source or adopt the change (ledger.js), as Move to
465
+ // Ready and Send do.
466
+ const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
467
+ const held = problems.find((p) => p.code === 'job_review');
468
+ if (held) throw new FlowError('not_ready', `${held.message} Nothing was finalised.`);
439
469
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
440
470
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
441
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
471
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
442
472
  }));
443
473
 
444
474
  router.post('/invoices/:id/revise', ...manager, handle(log, async (req, res) => {
@@ -446,7 +476,7 @@ export function createInvoicingRouter({
446
476
  const organisationId = await organisationOf(req);
447
477
  await reviseInvoice(db, organisationId, invoiceId);
448
478
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} opened invoice ${invoiceId} for revision`);
449
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
479
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
450
480
  }));
451
481
 
452
482
  router.post('/invoices/:id/void', ...manager, handle(log, async (req, res) => {
@@ -454,7 +484,7 @@ export function createInvoicingRouter({
454
484
  const organisationId = await organisationOf(req);
455
485
  await voidInvoice(db, organisationId, invoiceId);
456
486
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} voided invoice ${invoiceId}`);
457
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
487
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
458
488
  }));
459
489
 
460
490
  router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
@@ -525,7 +555,7 @@ export function createInvoicingRouter({
525
555
  const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: source }) : null;
526
556
  const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
527
557
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
528
- res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
558
+ res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
529
559
  }));
530
560
 
531
561
  // "Use new lines": the job changed in the work system after its draft was
@@ -534,7 +564,7 @@ export function createInvoicingRouter({
534
564
  const obligationId = pathId(req.params.id, 'job');
535
565
  const organisationId = await organisationOf(req);
536
566
  const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
537
- res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
567
+ res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
538
568
  }));
539
569
 
540
570
  router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
@@ -2,6 +2,7 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { people, PERSON_ROLES } from '../db/schema.js';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
+ import { normaliseJobsView } from '../ui/job-view.js';
5
6
  import { isPlainEmail } from './settings.js';
6
7
 
7
8
  // The people who may sign in, and their roles. Only an owner changes this
@@ -50,6 +51,40 @@ export async function setPersonAppearance(db, { id, appearance }) {
50
51
  return row.appearance;
51
52
  }
52
53
 
54
+ // The pages a person can keep their own view of, each with its check.
55
+ // Each page is named in a switch, never looked up by the name a request
56
+ // sends, so a name such as "constructor" can only be refused.
57
+ function viewCheck(page) {
58
+ switch (page) {
59
+ case 'jobs': return normaliseJobsView;
60
+ default: return null;
61
+ }
62
+ }
63
+ const VIEW_PAGE_NAMES = ['jobs'];
64
+
65
+ // A person's saved views as Flow sends them: only the known pages, each
66
+ // checked, so a view saved by an older Flow still loads.
67
+ export function normaliseViews(input) {
68
+ const value = input && typeof input === 'object' ? input : {};
69
+ const out = {};
70
+ for (const page of VIEW_PAGE_NAMES) if (value[page]) out[page] = viewCheck(page)(value[page]);
71
+ return out;
72
+ }
73
+
74
+ // Each person keeps their own view of a page; saving one page leaves the
75
+ // others as they were.
76
+ export async function setPersonView(db, { id, page, view }) {
77
+ const normalise = viewCheck(page);
78
+ if (typeof normalise !== 'function') throw new FlowError('not_found', 'no such page');
79
+ const value = normalise(view);
80
+ const [row] = await db.update(people)
81
+ .set({ views: sql`coalesce(${people.views}, '{}'::jsonb) || ${JSON.stringify({ [page]: value })}::jsonb`, updatedAt: new Date() })
82
+ .where(eq(people.id, id))
83
+ .returning();
84
+ if (!row) throw new FlowError('not_found', 'person not found');
85
+ return value;
86
+ }
87
+
53
88
  export async function findPersonById(db, id) {
54
89
  const [row] = await db.select().from(people).where(eq(people.id, id));
55
90
  return row ?? null;
@@ -8,9 +8,11 @@ import {
8
8
  listPeople,
9
9
  normaliseEmail,
10
10
  normaliseAppearance,
11
+ normaliseViews,
11
12
  publicPerson,
12
13
  removePerson,
13
14
  setPersonAppearance,
15
+ setPersonView,
14
16
  updatePerson,
15
17
  } from './people.js';
16
18
  import { isPlainEmail } from './settings.js';
@@ -99,6 +101,7 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
99
101
  body.ownerActions = { allowed: Boolean(check.verified), by: check.by ?? null };
100
102
  }
101
103
  body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
104
+ body.views = normaliseViews(req.person.views);
102
105
  res.json(body);
103
106
  });
104
107
 
@@ -112,6 +115,17 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
112
115
  }
113
116
  });
114
117
 
118
+ // Each person sets their own view of a page (how Jobs groups, sorts and
119
+ // filters); it follows their account.
120
+ router.put('/api/me/views/:page', requireSession, express.json({ limit: '8kb' }), async (req, res) => {
121
+ try {
122
+ const view = await setPersonView(db, { id: req.person.id, page: req.params.page, view: req.body });
123
+ res.json({ ok: true, view });
124
+ } catch (error) {
125
+ sendError(res, error, log);
126
+ }
127
+ });
128
+
115
129
  // Only an owner manages people.
116
130
  const people = express.Router();
117
131
  people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
@@ -286,6 +286,14 @@ export async function listJobFieldValues(db, organisationId, { jobs = null } = {
286
286
  return rows.filter((r) => !wanted || wanted.has(`${r.sourceSystem}\u0000${r.sourceJobRef}`)).map(publicValue);
287
287
  }
288
288
 
289
+ // The people a person field can name: every active person, by id and name
290
+ // (the part of the email before @ when they have no name), A to Z.
291
+ export async function choosablePeople(db) {
292
+ const rows = await db.select({ id: people.id, name: people.name, email: people.email }).from(people).where(eq(people.status, 'active'));
293
+ return rows.map((r) => ({ id: r.id, name: r.name?.trim() || r.email.replace(/@.*/, '') }))
294
+ .sort((a, b) => a.name.localeCompare(b.name));
295
+ }
296
+
289
297
  // What happened to one job's custom field values, newest first.
290
298
  export async function jobFieldHistory(db, organisationId, raw, { limit = 50 } = {}) {
291
299
  const job = readJob(raw);
@@ -138,6 +138,8 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
138
138
  : (item.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null, mapped: Boolean(l.code && codes.has(l.code)) })),
139
139
  // A stage move the work system has not made yet (moveWork).
140
140
  pendingMove: item.pendingMove ?? null,
141
+ // The link to the job in the work system's own web app ({ url, exact }).
142
+ link: item.link ?? null,
141
143
  obligation: o,
142
144
  });
143
145
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import {
4
- billingObligations, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
4
+ billingObligations, invoices, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
7
  import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
@@ -325,9 +325,16 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
325
325
  ));
326
326
  } else changes[field] = obligation[field];
327
327
  }
328
- const invoice = Object.keys(changes).length
328
+ let invoice = Object.keys(changes).length
329
329
  ? await updateDraftInvoice(tx, organisationId, obligation.invoiceId, changes)
330
330
  : await getInvoice(tx, organisationId, obligation.invoiceId);
331
+ // The draft now matches a job that is ready to invoice, so it goes to
332
+ // Ready on its own; Flow's checks still hold it in Problem if one fails.
333
+ if (obligation.readyAt && !invoice.readyAt) {
334
+ await tx.update(invoices).set({ readyAt: sql`now()`, readyBy: null })
335
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoice.id), eq(invoices.status, 'draft')));
336
+ invoice = await getInvoice(tx, organisationId, invoice.id);
337
+ }
331
338
  const fields = { draftBasis: basisOf(obligation) };
332
339
  if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
333
340
  const row = await update(tx, obligation, fields);
@@ -337,6 +344,79 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
337
344
  });
338
345
  }
339
346
 
347
+ // What changed in a job after its invoice was made, for the invoice page:
348
+ // each line as it was (the draft basis) and as the work system has it now.
349
+ // Lines pair by price item, else item code, account or description; an
350
+ // unpaired old line and an unpaired new line at the same place pair as a
351
+ // replaced item. Answers [{ change, position, was, now, fields }], where
352
+ // `change` is changed, replaced, added or removed and `fields` names what
353
+ // differs on a changed or replaced line (item, description, quantity, price).
354
+ export function diffJobLines(wasLines = [], nowLines = []) {
355
+ const keyOf = (l) => l.priceItemId ?? l.itemCode ?? l.accountCode ?? String(l.description ?? '').trim().toLowerCase();
356
+ const show = (l) => ({
357
+ code: l.itemCode ?? l.accountCode ?? null, description: l.description ?? '', quantity: Number(l.quantity), unitAmountCents: l.unitAmountCents ?? null,
358
+ });
359
+ const differs = (a, b) => [
360
+ keyOf(a) !== keyOf(b) ? 'item' : null,
361
+ String(a.description ?? '').trim() !== String(b.description ?? '').trim() && keyOf(a) === keyOf(b) ? 'description' : null,
362
+ Number(a.quantity) !== Number(b.quantity) ? 'quantity' : null,
363
+ (a.unitAmountCents ?? null) !== (b.unitAmountCents ?? null) ? 'price' : null,
364
+ ].filter(Boolean);
365
+ const wasLeft = wasLines.map((line, index) => ({ line, index }));
366
+ const pairs = [];
367
+ const added = [];
368
+ nowLines.forEach((line, index) => {
369
+ const at = wasLeft.findIndex((w) => keyOf(w.line) === keyOf(line));
370
+ if (at === -1) { added.push({ line, index }); return; }
371
+ pairs.push({ was: wasLeft.splice(at, 1)[0], now: { line, index } });
372
+ });
373
+ // What is left over: an old line and a new line in the same place are one
374
+ // replaced item; the rest were added or removed.
375
+ for (const now of [...added]) {
376
+ const at = wasLeft.findIndex((w) => w.index === now.index);
377
+ if (at === -1) continue;
378
+ pairs.push({ was: wasLeft.splice(at, 1)[0], now });
379
+ added.splice(added.indexOf(now), 1);
380
+ }
381
+ const changes = [];
382
+ for (const { was, now } of pairs) {
383
+ const fields = differs(was.line, now.line);
384
+ if (!fields.length) continue;
385
+ changes.push({ change: fields.includes('item') ? 'replaced' : 'changed', position: now.index + 1, was: show(was.line), now: show(now.line), fields });
386
+ }
387
+ for (const { line, index } of added) changes.push({ change: 'added', position: index + 1, was: null, now: show(line), fields: [] });
388
+ for (const { line, index } of wasLeft) changes.push({ change: 'removed', position: index + 1, was: show(line), now: null, fields: [] });
389
+ return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
390
+ }
391
+
392
+ // The open "job changed" problem of an invoice, for the invoice page, or
393
+ // null: the job as it was when the invoice was made, as the work system has
394
+ // it now, and whether a person can adopt the change into Flow's own draft
395
+ // ("Adopt changes", useSourceLines). An invoice drafted before the basis was
396
+ // kept compares with the invoice's own lines.
397
+ export async function jobChangeFor(db, organisationId, invoiceId) {
398
+ const [obligation] = await db.select().from(billingObligations)
399
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId),
400
+ eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
401
+ if (!obligation) return null;
402
+ const compare = await compareWithDraft(db, organisationId, obligation, obligation);
403
+ const was = obligation.draftBasis
404
+ ?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
405
+ return {
406
+ jobId: obligation.id,
407
+ sourceSystem: obligation.sourceSystem,
408
+ sourceJobRef: obligation.sourceJobRef,
409
+ sourceInvoiceRef: obligation.sourceInvoiceRef,
410
+ seenAt: obligation.sourceSeenAt,
411
+ outcome: compare.outcome,
412
+ sent: compare.sent,
413
+ canAdopt: compare.outcome === 'offer',
414
+ lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
415
+ contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
416
+ total: { was: was.totalCents ?? null, now: obligation.totalCents },
417
+ };
418
+ }
419
+
340
420
  // Records that a job the ledger knows is no longer billable in the work
341
421
  // system: deleted there, or its status moved back (for example from Shipped
342
422
  // to In production). Flow writes nothing to another system:
@@ -0,0 +1 @@
1
+ ALTER TABLE "people" ADD COLUMN IF NOT EXISTS "views" jsonb;