@arcforgelabs/flow 2026.10.46 → 2026.10.47
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/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +56 -16
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/sentry/index.js +14 -7
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +105 -25
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +235 -24
- package/server/ui/invoicing.js +254 -55
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +198 -43
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
package/VISION.md
CHANGED
|
@@ -54,16 +54,16 @@ column per stage, a card per job), with a list view of the same jobs. It
|
|
|
54
54
|
shows every job from the case system and from Flow, with its practice, due
|
|
55
55
|
date and department. Lab staff and the manager work from it.
|
|
56
56
|
|
|
57
|
-
Two parts follow the board
|
|
58
|
-
|
|
59
|
-
- **An intake reader
|
|
60
|
-
action: update a job, create one, or add a note. A handwriting reader
|
|
61
|
-
low-cost decision model propose it; a person confirms it. Nothing
|
|
62
|
-
until a person accepts. The model steps are optional and cost money
|
|
63
|
-
page, so the manager sees the month's cost.
|
|
64
|
-
- **A practice portal
|
|
65
|
-
on desktop first, with a simpler view on phones. The server limits
|
|
66
|
-
person to their practice.
|
|
57
|
+
Two parts follow the board:
|
|
58
|
+
|
|
59
|
+
- **An intake reader** (#267). A photo, a form or a note becomes a proposed
|
|
60
|
+
job action: update a job, create one, or add a note. A handwriting reader
|
|
61
|
+
and a low-cost decision model propose it; a person confirms it. Nothing
|
|
62
|
+
changes until a person accepts. The model steps are optional and cost money
|
|
63
|
+
per page, so the manager sees the month's cost.
|
|
64
|
+
- **A practice portal** (#268). Practice staff sign in to follow their own
|
|
65
|
+
jobs, on desktop first, with a simpler view on phones. The server limits
|
|
66
|
+
each person to their practice.
|
|
67
67
|
|
|
68
68
|
Job management is a bundled module, built the same way as the integrations,
|
|
69
69
|
and a lab can remove it. Where the case system cannot take a change through
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.47",
|
|
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.47",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
|
-
import { invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
|
|
3
|
+
import { invoiceKindOf, invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
|
|
4
|
+
import { fieldIdOf, filterRows } from '../ui/job-view.js';
|
|
5
|
+
import { listFields, listJobFieldValues } from '../core/custom-fields.js';
|
|
4
6
|
import { FlowError } from '../core/errors.js';
|
|
5
7
|
import { createActionGuard } from '../integrations/call-guard.js';
|
|
6
8
|
import {
|
|
7
|
-
createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
9
|
+
createDraftInvoice, deleteDraft, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
|
|
8
10
|
} from '../core/invoices.js';
|
|
9
11
|
import { listAutomations, saveAutomation, workStatuses } from '../core/automations.js';
|
|
10
12
|
import {
|
|
@@ -45,7 +47,9 @@ import {
|
|
|
45
47
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
46
48
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
47
49
|
// accounting system { issueDate, invoiceIds? (only these; one that fails
|
|
48
|
-
// a check now stays on Pending and counts as held
|
|
50
|
+
// a check now stays on Pending and counts as held; one id is all or
|
|
51
|
+
// nothing when its job cannot be confirmed, more than one id sends the
|
|
52
|
+
// rest and counts the unconfirmed in waiting) }
|
|
49
53
|
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
50
54
|
// GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
|
|
51
55
|
// GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
|
|
@@ -56,6 +60,8 @@ import {
|
|
|
56
60
|
// POST /invoices/:id/issue owner or admin: number it and keep the document as sent
|
|
57
61
|
// POST /invoices/:id/revise owner or admin: an issued invoice back to a draft
|
|
58
62
|
// POST /invoices/:id/void owner or admin
|
|
63
|
+
// DELETE /invoices/:id owner or admin: a draft that was never issued.
|
|
64
|
+
// Frees its job, and the lab's rules then decide.
|
|
59
65
|
// GET /contacts?q=&limit= customers, by name
|
|
60
66
|
// GET /price-items?q= the price list, by code
|
|
61
67
|
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
@@ -124,7 +130,7 @@ const MAX_UNIT_CENTS = 100_000_000;
|
|
|
124
130
|
|
|
125
131
|
const ERROR_STATUS = {
|
|
126
132
|
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
127
|
-
never_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
133
|
+
never_issued: 409, was_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
128
134
|
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
129
135
|
};
|
|
130
136
|
|
|
@@ -449,17 +455,43 @@ export function createInvoicingRouter({
|
|
|
449
455
|
const pattern = containing(q);
|
|
450
456
|
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
451
457
|
}
|
|
452
|
-
// First the
|
|
453
|
-
//
|
|
454
|
-
|
|
458
|
+
// First the columns the filter and sort read, for every row; then the
|
|
459
|
+
// whole rows of the one page. A job column and a custom field read the
|
|
460
|
+
// invoice's job, so those values are on every row before the page is cut.
|
|
461
|
+
const lightRows = await db.select({
|
|
455
462
|
id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
|
|
456
|
-
createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
|
|
463
|
+
dueDate: invoices.dueDate, createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
|
|
457
464
|
}).from(invoices)
|
|
458
465
|
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
459
466
|
.where(and(...where))
|
|
460
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
461
|
-
|
|
462
|
-
const
|
|
467
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id));
|
|
468
|
+
const jobs = await jobsByInvoice(db, organisationId, lightRows.map((r) => r.id));
|
|
469
|
+
const customOn = filterRows(view).some((f) => fieldIdOf(f.field));
|
|
470
|
+
const [fieldDefs, fieldValues] = customOn
|
|
471
|
+
? await Promise.all([
|
|
472
|
+
listFields(db, organisationId),
|
|
473
|
+
listJobFieldValues(db, organisationId, { jobs: [...jobs.values()].filter(Boolean) }),
|
|
474
|
+
])
|
|
475
|
+
: [[], []];
|
|
476
|
+
const valuesOf = new Map();
|
|
477
|
+
for (const v of fieldValues) {
|
|
478
|
+
const key = `${v.sourceSystem}\u0000${v.sourceJobRef}`;
|
|
479
|
+
const bag = valuesOf.get(key) ?? {};
|
|
480
|
+
bag[v.fieldId] = v.value;
|
|
481
|
+
valuesOf.set(key, bag);
|
|
482
|
+
}
|
|
483
|
+
const light = lightRows.map((row) => {
|
|
484
|
+
const job = jobs.get(row.id) ?? null;
|
|
485
|
+
return {
|
|
486
|
+
...row,
|
|
487
|
+
problems: tabs.problems.get(row.id) ?? [],
|
|
488
|
+
tab: tabs.tabOf.get(row.id) ?? null,
|
|
489
|
+
sending: tabs.sending.has(row.id),
|
|
490
|
+
job,
|
|
491
|
+
fields: job ? valuesOf.get(`${job.sourceSystem}\u0000${job.sourceJobRef}`) ?? {} : {},
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
const page = invoicePage(light, { view, sort, dir, offset, limit, today: day, kindOf: invoiceKindOf(fieldDefs), me: req.person.id });
|
|
463
495
|
const pageIds = page.rows.map((r) => r.id);
|
|
464
496
|
const full = pageIds.length
|
|
465
497
|
? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
@@ -468,7 +500,6 @@ export function createInvoicingRouter({
|
|
|
468
500
|
: [];
|
|
469
501
|
const byId = new Map(full.map((r) => [r.invoice.id, r]));
|
|
470
502
|
const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
|
|
471
|
-
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
472
503
|
const systems = work();
|
|
473
504
|
res.json({
|
|
474
505
|
ok: true,
|
|
@@ -486,8 +517,9 @@ export function createInvoicingRouter({
|
|
|
486
517
|
limit,
|
|
487
518
|
total: page.total,
|
|
488
519
|
totalCents: page.totalCents,
|
|
489
|
-
// The Filter chip's
|
|
520
|
+
// The Filter chip's choices, from every row of the tab.
|
|
490
521
|
clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
|
522
|
+
practitioners: [...new Set(light.map((r) => r.job?.practitioner).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
|
491
523
|
sendsTo: system?.name ?? null,
|
|
492
524
|
sendMode: mode,
|
|
493
525
|
// The Linked column: one icon for each connected system.
|
|
@@ -593,8 +625,8 @@ export function createInvoicingRouter({
|
|
|
593
625
|
// the job at its source or adopt the change (ledger.js), as Move to
|
|
594
626
|
// Ready and Send do.
|
|
595
627
|
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
596
|
-
// A lab's pending rule (invoice-rules.js)
|
|
597
|
-
// or turn the rule off.
|
|
628
|
+
// A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
|
|
629
|
+
// is a gate too: fix its cause, void the draft, or turn the rule off.
|
|
598
630
|
const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
|
|
599
631
|
if (held) throw new FlowError('not_ready', `${held.message.replace(/\.?$/, '.')} Nothing was approved.`);
|
|
600
632
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
@@ -618,6 +650,14 @@ export function createInvoicingRouter({
|
|
|
618
650
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
619
651
|
}));
|
|
620
652
|
|
|
653
|
+
router.delete('/invoices/:id', ...manager, handle(log, async (req, res) => {
|
|
654
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
655
|
+
const organisationId = await organisationOf(req);
|
|
656
|
+
await deleteDraft(db, organisationId, invoiceId, { actor: `person:${req.person.id}` });
|
|
657
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted draft invoice ${invoiceId}`);
|
|
658
|
+
res.json({ ok: true });
|
|
659
|
+
}));
|
|
660
|
+
|
|
621
661
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
622
662
|
const organisationId = await organisationOf(req);
|
|
623
663
|
const q = text(req.query.q);
|
|
@@ -842,7 +882,7 @@ export function createInvoicingRouter({
|
|
|
842
882
|
res.json({
|
|
843
883
|
ok: true,
|
|
844
884
|
fields: Object.entries(FIELDS).map(([key, f]) => ({
|
|
845
|
-
key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
|
|
885
|
+
key, label: f.label, kind: f.kind, options: f.options ?? null, tests: f.tests ?? TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
|
|
846
886
|
})),
|
|
847
887
|
actions: ACTIONS,
|
|
848
888
|
options: {
|
package/server/auth/people.js
CHANGED
|
@@ -4,6 +4,7 @@ import { FlowError } from '../core/errors.js';
|
|
|
4
4
|
import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
|
|
5
5
|
import { invoiceColumnsView, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
|
|
6
6
|
import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
7
|
+
import { normaliseNavPins } from '../ui/nav-pins.js';
|
|
7
8
|
import { isPlainEmail } from './settings.js';
|
|
8
9
|
|
|
9
10
|
// The people who may sign in, and their roles. Only an owner changes this
|
|
@@ -61,10 +62,11 @@ function viewCheck(page) {
|
|
|
61
62
|
case 'invoices': return invoiceColumnsView;
|
|
62
63
|
case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
|
|
63
64
|
case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
|
|
65
|
+
case 'nav': return normaliseNavPins;
|
|
64
66
|
default: return null;
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
|
-
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list'];
|
|
69
|
+
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list', 'nav'];
|
|
68
70
|
|
|
69
71
|
// A person's saved views as Flow sends them: only the known pages, each
|
|
70
72
|
// checked, so a view saved by an older Flow still loads.
|
package/server/auth/routes.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './people.js';
|
|
20
20
|
import { isPlainEmail } from './settings.js';
|
|
21
21
|
import { APP_PATHS, appPage } from '../ui/app-page.js';
|
|
22
|
+
import { navPinsFromViews } from '../ui/nav-pins.js';
|
|
22
23
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
24
|
const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
|
|
24
25
|
|
|
@@ -104,6 +105,8 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
104
105
|
}
|
|
105
106
|
body.appearance = req.person.appearance ? normaliseAppearance(req.person.appearance) : null;
|
|
106
107
|
body.views = normaliseViews(req.person.views);
|
|
108
|
+
// No pins saved yet: today's sidebar. A saved choice is checked the same way.
|
|
109
|
+
body.views.nav = navPinsFromViews(req.person.views);
|
|
107
110
|
res.json(body);
|
|
108
111
|
});
|
|
109
112
|
|
|
@@ -118,7 +121,7 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
// Each person sets their own view of a page (how Jobs groups, sorts and
|
|
121
|
-
// filters)
|
|
124
|
+
// filters, and which pages are pinned: `nav`). It follows their account.
|
|
122
125
|
router.put('/api/me/views/:page', requireSession, express.json({ limit: '8kb' }), async (req, res) => {
|
|
123
126
|
try {
|
|
124
127
|
const view = await setPersonView(db, { id: req.person.id, page: req.params.page, view: req.body });
|
|
@@ -47,7 +47,14 @@ export const FIELDS = Object.freeze({
|
|
|
47
47
|
practitioner: { label: 'Practitioner', kind: 'text', options: 'practitioners', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
48
48
|
// Only a draft has a Reference, so only a pending rule may read it.
|
|
49
49
|
reference: { label: 'Reference', kind: 'text', draftOnly: true, read: (j) => j.reference || null },
|
|
50
|
-
|
|
50
|
+
// Menu tests read the contact id. Contains and does not contain read the
|
|
51
|
+
// customer's name (matches): a lab can exclude every name that contains
|
|
52
|
+
// one marker, so a new branch needs no new rule.
|
|
53
|
+
customer: {
|
|
54
|
+
label: 'Customer', kind: 'choice', options: 'customers',
|
|
55
|
+
tests: ['is', 'is_not', 'is_one_of', 'contains', 'does_not_contain', 'is_empty'],
|
|
56
|
+
read: (j) => j.contactId,
|
|
57
|
+
},
|
|
51
58
|
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
52
59
|
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
|
|
53
60
|
line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
|
|
@@ -71,7 +78,8 @@ function readCondition(raw, index) {
|
|
|
71
78
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
72
79
|
const field = FIELDS[raw.field];
|
|
73
80
|
if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
|
|
74
|
-
|
|
81
|
+
const allowed = field.tests ?? TESTS[field.kind];
|
|
82
|
+
if (!allowed.includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${allowed.join(', ')}`);
|
|
75
83
|
const out = { field: raw.field, test: raw.test };
|
|
76
84
|
if (NO_VALUE.has(raw.test)) return out;
|
|
77
85
|
const { value } = raw;
|
|
@@ -125,9 +133,13 @@ function matches(condition, job, context) {
|
|
|
125
133
|
const { test, value } = condition;
|
|
126
134
|
if (test === 'is_empty') return have === null || have === '' || have === undefined;
|
|
127
135
|
if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
|
|
128
|
-
|
|
136
|
+
// Customer contains reads the contact's name, in any case and with the
|
|
137
|
+
// spaces trimmed, the same way a practitioner's name is read. is, is not
|
|
138
|
+
// and is one of still compare the contact id. `customer.name` is that name.
|
|
139
|
+
const named = condition.field === 'customer' && (test === 'contains' || test === 'does_not_contain');
|
|
140
|
+
if ((field.kind === 'text' || named) && !['is', 'is_not', 'is_one_of'].includes(test)) {
|
|
129
141
|
// An empty text matches no test that reads its words; is_empty finds it.
|
|
130
|
-
const left = norm(have);
|
|
142
|
+
const left = norm(named ? (context.customer?.name ?? job.customerName ?? null) : have);
|
|
131
143
|
if (!left) return false;
|
|
132
144
|
if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
|
|
133
145
|
if (test === 'has_number') return /\d/.test(left);
|
|
@@ -195,6 +207,24 @@ export function rulePending(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
195
207
|
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))] }));
|
|
196
208
|
}
|
|
197
209
|
|
|
210
|
+
// Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
|
|
211
|
+
// A person drafted the job by hand, or the rule was made after the draft.
|
|
212
|
+
// The list shows the rule's name. The note says to void the draft or change
|
|
213
|
+
// the rule. A wait rule does not hold a draft: a person may still send one.
|
|
214
|
+
export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
215
|
+
const context = { customer, now };
|
|
216
|
+
return rules
|
|
217
|
+
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'skip')
|
|
218
|
+
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
219
|
+
.map((r) => ({
|
|
220
|
+
code: 'rule',
|
|
221
|
+
message: `Rule "${r.name}": not invoiced. Void this draft or change the rule.`,
|
|
222
|
+
short: r.name.slice(0, 40),
|
|
223
|
+
rule: r.id ?? null,
|
|
224
|
+
fields: [...new Set(r.conditions.map((c) => c.field))],
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
198
228
|
// The starter rules made from the earlier fixed settings.
|
|
199
229
|
export function starterRules(settings) {
|
|
200
230
|
const rules = [
|
package/server/core/invoices.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { contacts, invoiceLines, invoices, invoiceVersions, organisations, priceItems } from '../db/schema.js';
|
|
2
|
+
import { billingObligations, contacts, invoiceLines, invoices, invoiceVersions, obligationEvents, organisations, priceItems } from '../db/schema.js';
|
|
3
3
|
import { FlowError, notFound } from './errors.js';
|
|
4
4
|
import { invoiceTotals, lineTotals } from './money.js';
|
|
5
5
|
|
|
@@ -7,6 +7,7 @@ import { invoiceTotals, lineTotals } from './money.js';
|
|
|
7
7
|
// invoice its number and stores the document exactly as sent, as a new
|
|
8
8
|
// version. To change an issued invoice, revise it back to a draft and issue
|
|
9
9
|
// it again: the number stays and the earlier versions stay as they were.
|
|
10
|
+
// A draft that was never issued can be deleted. An issued invoice is voided.
|
|
10
11
|
|
|
11
12
|
async function loadInvoice(tx, organisationId, invoiceId, { lock = false } = {}) {
|
|
12
13
|
let query = tx.select().from(invoices)
|
|
@@ -182,6 +183,44 @@ export async function voidInvoice(db, organisationId, invoiceId) {
|
|
|
182
183
|
});
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// Removes a draft that was never issued. The job it billed is free again:
|
|
187
|
+
// its obligation no longer points at the draft, so the lab's rules decide
|
|
188
|
+
// whether Flow drafts it once more. An invoice that was issued is voided,
|
|
189
|
+
// not deleted (the database holds the same rule).
|
|
190
|
+
export async function deleteDraft(db, organisationId, invoiceId, { actor = null } = {}) {
|
|
191
|
+
return db.transaction(async (tx) => {
|
|
192
|
+
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
193
|
+
if (invoice.status !== 'draft' || invoice.issuedVersion !== 0) {
|
|
194
|
+
throw new FlowError('was_issued', 'an invoice that was issued cannot be deleted; void it');
|
|
195
|
+
}
|
|
196
|
+
const linked = await tx.select().from(billingObligations)
|
|
197
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)))
|
|
198
|
+
.for('update');
|
|
199
|
+
if (linked.length && !actor) throw new FlowError('invalid', 'actor is required');
|
|
200
|
+
for (const obligation of linked) {
|
|
201
|
+
await tx.update(billingObligations)
|
|
202
|
+
.set({ invoiceId: null, draftBasis: null, updatedAt: sql`now()` })
|
|
203
|
+
.where(eq(billingObligations.id, obligation.id));
|
|
204
|
+
await tx.insert(obligationEvents).values({
|
|
205
|
+
createdAt: sql`clock_timestamp()`,
|
|
206
|
+
organisationId,
|
|
207
|
+
obligationId: obligation.id,
|
|
208
|
+
kind: 'draft_deleted',
|
|
209
|
+
actor,
|
|
210
|
+
evidence: { invoiceId: invoice.id },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await tx.delete(invoices).where(eq(invoices.id, invoice.id));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const code = error?.code ?? error?.cause?.code;
|
|
217
|
+
if (code === '23503') throw new FlowError('conflict', 'this draft is still linked; remove the link, then delete it');
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
return invoice;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
185
224
|
export async function getInvoice(db, organisationId, invoiceId) {
|
|
186
225
|
const invoice = await loadInvoice(db, organisationId, invoiceId);
|
|
187
226
|
const lines = await db.select().from(invoiceLines)
|
|
@@ -150,6 +150,16 @@ export async function customerBillingByContact(db, organisationId, contactIds) {
|
|
|
150
150
|
return new Map(rows.map((r) => [r.contactId, pick(r, CUSTOMER_KEYS)]));
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// The customers' names, by contact id. Customer "contains" reads the name
|
|
154
|
+
// (invoice-rules.js), not the contact id the menu tests use.
|
|
155
|
+
async function customerNames(db, organisationId, contactIds) {
|
|
156
|
+
const ids = [...new Set(contactIds.filter(Boolean))];
|
|
157
|
+
if (!ids.length) return new Map();
|
|
158
|
+
const rows = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
159
|
+
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, ids)));
|
|
160
|
+
return new Map(rows.map((r) => [r.id, r.name]));
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
// The settings that apply to one customer: its own, else the organisation's.
|
|
154
164
|
// `autoDraft` is the customer's own switch for the draft automation.
|
|
155
165
|
export function effectiveSettings(settings, customer = CUSTOMER_DEFAULTS) {
|
|
@@ -255,13 +265,16 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
255
265
|
sql`${billingObligations.contactId} is not null`,
|
|
256
266
|
// Newest first: a backlog of jobs that wait on a rule never hides new work.
|
|
257
267
|
)).orderBy(sql`${billingObligations.jobDate} desc nulls last`, billingObligations.id).limit(limit);
|
|
258
|
-
const
|
|
268
|
+
const contactIds = candidates.map((o) => o.contactId);
|
|
269
|
+
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
270
|
+
const names = await customerNames(db, organisationId, contactIds);
|
|
271
|
+
const named = (contactId) => ({ ...effectiveSettings(settings, customers.get(contactId)), name: names.get(contactId) ?? null });
|
|
259
272
|
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
260
273
|
const invoiceDate = isoDay(now);
|
|
261
274
|
let accountingRead = null;
|
|
262
275
|
|
|
263
276
|
for (const candidate of candidates) {
|
|
264
|
-
let effective =
|
|
277
|
+
let effective = named(candidate.contactId);
|
|
265
278
|
if (!effective.autoDraft) {
|
|
266
279
|
result.off += 1;
|
|
267
280
|
continue;
|
|
@@ -302,7 +315,11 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
302
315
|
}
|
|
303
316
|
}
|
|
304
317
|
if (obligation !== candidate) {
|
|
305
|
-
|
|
318
|
+
if (obligation.contactId && !names.has(obligation.contactId)) {
|
|
319
|
+
const extra = await customerNames(db, organisationId, [obligation.contactId]);
|
|
320
|
+
for (const [id, name] of extra) names.set(id, name);
|
|
321
|
+
}
|
|
322
|
+
effective = named(obligation.contactId);
|
|
306
323
|
if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
|
|
307
324
|
result.waiting += 1;
|
|
308
325
|
continue;
|
package/server/core/job-board.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
55
55
|
// The invoicing rules a job with no invoice waits on, for every view a
|
|
56
56
|
// draft can still come from.
|
|
57
57
|
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
58
|
-
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
58
|
+
? [] : ruleBlockers(o, { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null }, { rules }));
|
|
59
59
|
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
|
|
60
60
|
...o,
|
|
61
61
|
canUseNewLines: offers.has(o.id),
|
package/server/core/sending.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
3
|
+
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
-
import { listRules, rulePending } from './invoice-rules.js';
|
|
5
|
+
import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
7
7
|
import { issueInvoice } from './invoices.js';
|
|
8
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
@@ -17,7 +17,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
17
17
|
// charge, an account) on every line. When an accounting
|
|
18
18
|
// system is connected, its own checks are added (for Xero: the customer
|
|
19
19
|
// matches one Xero contact, every item is in Xero), and the lab's pending
|
|
20
|
-
// rules (invoice-rules.js) add their notes.
|
|
20
|
+
// rules (invoice-rules.js) add their notes. An enabled skip rule does too:
|
|
21
|
+
// a draft whose job it matches stays Pending, even when the draft was made
|
|
22
|
+
// by hand or before the rule existed. A draft with a problem is
|
|
21
23
|
// Pending (the "problem" view). A draft without one is Ready.
|
|
22
24
|
//
|
|
23
25
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
@@ -81,19 +83,21 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
81
83
|
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
82
84
|
if (found.length) problems.set(draft.id, found);
|
|
83
85
|
}
|
|
84
|
-
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference }).from(billingObligations)
|
|
86
|
+
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference, customerName: contacts.name }).from(billingObligations)
|
|
85
87
|
.innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
|
|
88
|
+
.leftJoin(contacts, and(eq(contacts.id, billingObligations.contactId), eq(contacts.organisationId, billingObligations.organisationId)))
|
|
86
89
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
87
|
-
// The lab's
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.action.kind === 'pending');
|
|
90
|
+
// The lab's rules (invoice-rules.js). A pending rule puts its note on the
|
|
91
|
+
// draft. A skip rule holds a draft that already exists: void it, or change
|
|
92
|
+
// the rule. Matching stays in invoice-rules.js.
|
|
93
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && (r.action.kind === 'pending' || r.action.kind === 'skip'));
|
|
91
94
|
if (rules.length && jobs.length) {
|
|
92
95
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
93
96
|
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
94
|
-
for (const { obligation, reference } of jobs) {
|
|
95
|
-
const customer = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
96
|
-
const
|
|
97
|
+
for (const { obligation, reference, customerName } of jobs) {
|
|
98
|
+
const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
|
|
99
|
+
const job = { ...obligation, reference };
|
|
100
|
+
const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
97
101
|
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
98
102
|
}
|
|
99
103
|
}
|
|
@@ -196,23 +200,61 @@ async function assertCanSend(accounting, organisationId) {
|
|
|
196
200
|
// Read before act (issue #141): the jobs behind these drafts whose work
|
|
197
201
|
// system report is old are read again first. Answers the drafts that may go
|
|
198
202
|
// now; a draft whose job went to review, or could not be read, waits.
|
|
203
|
+
// `stops` names each held draft: `unread` (read failed or past the cap) or
|
|
204
|
+
// `review` (the job changed).
|
|
199
205
|
async function readJobsFirst(db, organisationId, invoiceIds, { work, now, personId }) {
|
|
200
|
-
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function')
|
|
206
|
+
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') {
|
|
207
|
+
return { go: invoiceIds, waiting: [], stops: [] };
|
|
208
|
+
}
|
|
201
209
|
const jobs = await db.select().from(billingObligations)
|
|
202
210
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
|
|
203
211
|
const old = jobs.filter((job) => needsRead(job, { work, now }));
|
|
204
212
|
const outcomes = await readBeforeAct(db, organisationId, old.slice(0, READS_PER_PASS), { work, now, actor: `person:${personId}` });
|
|
205
|
-
const held = new Set(
|
|
213
|
+
const held = new Set();
|
|
214
|
+
const stops = [];
|
|
215
|
+
for (const job of old.slice(READS_PER_PASS)) {
|
|
216
|
+
held.add(job.invoiceId);
|
|
217
|
+
stops.push({ invoiceId: job.invoiceId, kind: 'deferred' });
|
|
218
|
+
}
|
|
206
219
|
for (const { outcome, obligation } of outcomes.values()) {
|
|
207
|
-
if (outcome === 'waiting'
|
|
220
|
+
if (outcome === 'waiting') {
|
|
221
|
+
held.add(obligation.invoiceId);
|
|
222
|
+
stops.push({ invoiceId: obligation.invoiceId, kind: 'unread' });
|
|
223
|
+
} else if (outcome === 'review') {
|
|
224
|
+
held.add(obligation.invoiceId);
|
|
225
|
+
stops.push({ invoiceId: obligation.invoiceId, kind: 'review' });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)), stops };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The error when a ticked press sends nothing: name the cause (and the count
|
|
232
|
+
// for jobs), never one shared sentence.
|
|
233
|
+
function stoppedSendMessage({ accountingWaiting, accountingReason, stops, waiting }) {
|
|
234
|
+
if (accountingWaiting) {
|
|
235
|
+
if (accountingReason && /more changes than one read takes/i.test(accountingReason)) {
|
|
236
|
+
return 'The accounting copy has more changes than one read takes. Nothing was sent.';
|
|
237
|
+
}
|
|
238
|
+
if (accountingReason) return `${accountingReason.replace(/\.?$/, '')}. Nothing was sent.`;
|
|
239
|
+
return 'The accounting copy could not be read. Nothing was sent.';
|
|
240
|
+
}
|
|
241
|
+
const unread = stops.filter((stop) => stop.kind === 'unread').length;
|
|
242
|
+
const reviews = stops.filter((stop) => stop.kind === 'review').length;
|
|
243
|
+
if (unread) {
|
|
244
|
+
return `${unread} ${unread === 1 ? 'job' : 'jobs'} could not be read from the work system. Nothing was sent.`;
|
|
245
|
+
}
|
|
246
|
+
if (reviews) {
|
|
247
|
+
return `${reviews} ${reviews === 1 ? 'job' : 'jobs'} changed and ${reviews === 1 ? 'needs' : 'need'} a look. Nothing was sent.`;
|
|
208
248
|
}
|
|
209
|
-
|
|
249
|
+
const n = waiting.length || 1;
|
|
250
|
+
return `${n} ${n === 1 ? 'job' : 'jobs'} could not be confirmed with the work system. Nothing was sent.`;
|
|
210
251
|
}
|
|
211
252
|
|
|
212
253
|
// Send all ready: issues each ready draft, then queues it for the accounting
|
|
213
|
-
// system. With `invoiceIds`, only those drafts
|
|
214
|
-
//
|
|
215
|
-
//
|
|
254
|
+
// system. With `invoiceIds`, only those drafts. One id (the invoice page) is
|
|
255
|
+
// all or nothing. More than one id (the bulk bar) sends the ones Flow could
|
|
256
|
+
// confirm and leaves the rest. `work` is the work integration, for the read
|
|
257
|
+
// before act. Answers { issued, queued, remaining, waiting, held }.
|
|
216
258
|
export async function sendAllReady(db, organisationId, { accounting = null, work = null, personId, issueDate, invoiceIds = null, now = new Date() }) {
|
|
217
259
|
await assertCanSend(accounting, organisationId);
|
|
218
260
|
// Issue #45: the accounting copy reads its latest changes first, so the
|
|
@@ -236,11 +278,23 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
236
278
|
: 'Only a ready invoice can be sent. Mark it ready first.');
|
|
237
279
|
}
|
|
238
280
|
}
|
|
239
|
-
const
|
|
240
|
-
|
|
281
|
+
const accountingWaiting = accountingRead.outcome === 'waiting';
|
|
282
|
+
const { go, waiting, stops } = accountingWaiting
|
|
283
|
+
? { go: [], waiting: ready.slice(0, SEND_BATCH), stops: [] }
|
|
241
284
|
: await readJobsFirst(db, organisationId, ready.slice(0, SEND_BATCH), { work, now, personId });
|
|
242
|
-
|
|
243
|
-
|
|
285
|
+
// One invoice (the invoice page) is all or nothing: a job Flow could not
|
|
286
|
+
// confirm stops the send. A bulk press sends the ones it confirmed. The
|
|
287
|
+
// rest stay for the next press (`waiting`), or on Pending when the read
|
|
288
|
+
// sent the job to review. An accounting read that cannot run still stops
|
|
289
|
+
// every ticked invoice: that one read covers the whole press. When nothing
|
|
290
|
+
// can go, the error names the cause and the count.
|
|
291
|
+
if (invoiceIds && waiting.length && go.length === 0) {
|
|
292
|
+
throw new FlowError('not_ready', stoppedSendMessage({
|
|
293
|
+
accountingWaiting,
|
|
294
|
+
accountingReason: accountingRead.reason,
|
|
295
|
+
stops,
|
|
296
|
+
waiting,
|
|
297
|
+
}));
|
|
244
298
|
}
|
|
245
299
|
const batch = go;
|
|
246
300
|
let issued = 0;
|