@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 +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +41 -11
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +8 -0
- package/server/core/job-board.js +2 -0
- package/server/core/ledger.js +82 -2
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +17 -5
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/stage-moves.js +10 -12
- package/server/release-channel.js +8 -6
- package/server/ui/flow.css +81 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +104 -45
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +481 -50
- package/server/ui/routes.js +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ run a lab.
|
|
|
40
40
|
| `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
|
|
41
41
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
42
42
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
43
|
-
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices,
|
|
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 |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
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.
|
|
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,
|
|
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) => {
|
package/server/api/invoicing.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) => {
|
package/server/auth/people.js
CHANGED
|
@@ -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;
|
package/server/auth/routes.js
CHANGED
|
@@ -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);
|
package/server/core/job-board.js
CHANGED
|
@@ -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
|
}
|
package/server/core/ledger.js
CHANGED
|
@@ -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
|
-
|
|
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;
|