@arcforgelabs/flow 2026.10.43 → 2026.10.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/api/invoicing.js +53 -3
- package/server/auth/people.js +41 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/release-channel.js +5 -2
- package/server/ui/app.js +53 -197
- package/server/ui/columns.js +286 -0
- package/server/ui/flow.css +230 -43
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +219 -72
- package/server/ui/job-view.js +276 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +279 -0
- package/server/ui/list-views.js +115 -0
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/view-bar.js +176 -70
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ run a lab.
|
|
|
35
35
|
| `docs/index.md` | The docs site home page |
|
|
36
36
|
| `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
|
|
37
37
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
38
|
+
| `docs/DESIGN_SYSTEM.md` | The Flow Design System artifact: how `npm run design-system` builds it from the code, the card per shared component, publishing and review |
|
|
38
39
|
| `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
|
|
39
40
|
| `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
|
|
40
41
|
| `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.45",
|
|
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.45",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"flow-server": "server/flow-server.js"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"drizzle-kit": "0.31.11"
|
|
36
|
+
"drizzle-kit": "0.31.11",
|
|
37
|
+
"esbuild": "0.25.12"
|
|
37
38
|
},
|
|
38
39
|
"engines": {
|
|
39
40
|
"node": ">=24"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.45",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
37
37
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
|
|
38
38
|
"db:follow-production": "node scripts/replit-workspace-db.js sync",
|
|
39
|
-
"db:follow-production:status": "node scripts/replit-workspace-db.js status"
|
|
39
|
+
"db:follow-production:status": "node scripts/replit-workspace-db.js status",
|
|
40
|
+
"design-system": "node scripts/design-system/build.mjs"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@clerk/backend": "3.25.0",
|
|
@@ -60,7 +61,8 @@
|
|
|
60
61
|
"pg": "8.23.1"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
|
-
"drizzle-kit": "0.31.11"
|
|
64
|
+
"drizzle-kit": "0.31.11",
|
|
65
|
+
"esbuild": "0.25.12"
|
|
64
66
|
},
|
|
65
67
|
"overrides": {
|
|
66
68
|
"@esbuild-kit/core-utils": {
|
package/server/api/invoicing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
4
|
import { FlowError } from '../core/errors.js';
|
|
4
5
|
import { createActionGuard } from '../integrations/call-guard.js';
|
|
5
6
|
import {
|
|
@@ -165,6 +166,21 @@ function limitOf(raw, fallback, max) {
|
|
|
165
166
|
return Math.min(n, max);
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
function offsetOf(raw) {
|
|
170
|
+
if (raw === undefined) return 0;
|
|
171
|
+
const n = Number(text(raw));
|
|
172
|
+
if (!Number.isInteger(n) || n < 0) throw invalid('offset must be a whole number from 0');
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The Filter chip's view, sent as JSON (list-views.js keeps only what it knows).
|
|
177
|
+
function filterViewOf(raw) {
|
|
178
|
+
if (raw === undefined || text(raw) === '') return normaliseInvoicesView({});
|
|
179
|
+
let value;
|
|
180
|
+
try { value = JSON.parse(String(raw)); } catch { throw invalid('filter must be JSON'); }
|
|
181
|
+
return normaliseInvoicesView(value);
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
// A LIKE pattern that matches the text anywhere, with its wildcards escaped.
|
|
169
185
|
const containing = (q) => `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
170
186
|
|
|
@@ -404,7 +420,20 @@ export function createInvoicingRouter({
|
|
|
404
420
|
throw invalid(`status must be one of ${TABS.join(', ')}`);
|
|
405
421
|
}
|
|
406
422
|
const q = text(req.query.q);
|
|
423
|
+
// The list pages on the server, so it works with any number of invoices:
|
|
424
|
+
// the tab and the search pick rows, the Filter chip's view and the sort
|
|
425
|
+
// (job-view.js, the page's own rules) run over all of them, and one page
|
|
426
|
+
// goes back with the total.
|
|
407
427
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
428
|
+
const offset = offsetOf(req.query.offset);
|
|
429
|
+
const view = filterViewOf(req.query.filter);
|
|
430
|
+
const sort = text(req.query.sort) || null;
|
|
431
|
+
if (sort && !INVOICE_SORTS.includes(sort)) throw invalid(`sort must be one of ${INVOICE_SORTS.join(', ')}`);
|
|
432
|
+
const dir = text(req.query.dir) || 'asc';
|
|
433
|
+
if (!['asc', 'desc'].includes(dir)) throw invalid('dir must be asc or desc');
|
|
434
|
+
// The person's own day, for date filters such as This week.
|
|
435
|
+
const day = text(req.query.today) || new Date().toISOString().slice(0, 10);
|
|
436
|
+
if (!isRealDate(day)) throw invalid('today must be a date (yyyy-mm-dd)');
|
|
408
437
|
// Each tab switch runs this, so the independent reads run side by side.
|
|
409
438
|
const system = accounting();
|
|
410
439
|
const sendMode = system
|
|
@@ -420,11 +449,25 @@ export function createInvoicingRouter({
|
|
|
420
449
|
const pattern = containing(q);
|
|
421
450
|
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
422
451
|
}
|
|
423
|
-
|
|
452
|
+
// First the few columns the filter and sort read, for every row; then
|
|
453
|
+
// the whole rows of the one page.
|
|
454
|
+
const light = (await db.select({
|
|
455
|
+
id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
|
|
456
|
+
createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
|
|
457
|
+
}).from(invoices)
|
|
424
458
|
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
425
459
|
.where(and(...where))
|
|
426
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
427
|
-
.
|
|
460
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id)))
|
|
461
|
+
.map((row) => ({ ...row, problems: tabs.problems.get(row.id) ?? [] }));
|
|
462
|
+
const page = invoicePage(light, { view, sort, dir, offset, limit, today: day });
|
|
463
|
+
const pageIds = page.rows.map((r) => r.id);
|
|
464
|
+
const full = pageIds.length
|
|
465
|
+
? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
466
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
467
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, pageIds)))
|
|
468
|
+
: [];
|
|
469
|
+
const byId = new Map(full.map((r) => [r.invoice.id, r]));
|
|
470
|
+
const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
|
|
428
471
|
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
429
472
|
const systems = work();
|
|
430
473
|
res.json({
|
|
@@ -438,6 +481,13 @@ export function createInvoicingRouter({
|
|
|
438
481
|
problems: tabs.problems.get(invoice.id) ?? [],
|
|
439
482
|
})),
|
|
440
483
|
counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
|
|
484
|
+
// The page: where it starts, its size, and every row the filter keeps.
|
|
485
|
+
offset,
|
|
486
|
+
limit,
|
|
487
|
+
total: page.total,
|
|
488
|
+
totalCents: page.totalCents,
|
|
489
|
+
// The Filter chip's Client choices: every client of the tab.
|
|
490
|
+
clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
|
441
491
|
sendsTo: system?.name ?? null,
|
|
442
492
|
sendMode: mode,
|
|
443
493
|
// The Linked column: one icon for each connected system.
|
package/server/auth/people.js
CHANGED
|
@@ -2,7 +2,8 @@ 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
|
+
import { INVOICE_COLUMNS, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
|
|
6
|
+
import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
6
7
|
import { isPlainEmail } from './settings.js';
|
|
7
8
|
|
|
8
9
|
// The people who may sign in, and their roles. Only an owner changes this
|
|
@@ -57,10 +58,13 @@ export async function setPersonAppearance(db, { id, appearance }) {
|
|
|
57
58
|
function viewCheck(page) {
|
|
58
59
|
switch (page) {
|
|
59
60
|
case 'jobs': return normaliseJobsView;
|
|
61
|
+
case 'invoices': return (view) => normaliseListView(view, INVOICE_COLUMNS);
|
|
62
|
+
case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
|
|
63
|
+
case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
|
|
60
64
|
default: return null;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
const VIEW_PAGE_NAMES = ['jobs'];
|
|
67
|
+
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list'];
|
|
64
68
|
|
|
65
69
|
// A person's saved views as Flow sends them: only the known pages, each
|
|
66
70
|
// checked, so a view saved by an older Flow still loads.
|
|
@@ -68,9 +72,31 @@ export function normaliseViews(input) {
|
|
|
68
72
|
const value = input && typeof input === 'object' ? input : {};
|
|
69
73
|
const out = {};
|
|
70
74
|
for (const page of VIEW_PAGE_NAMES) if (value[page]) out[page] = viewCheck(page)(value[page]);
|
|
75
|
+
// The named views a person saved on each page, and their default
|
|
76
|
+
// (ui/list-views.js), kept under `saved`.
|
|
77
|
+
const saved = value.saved && typeof value.saved === 'object' ? value.saved : {};
|
|
78
|
+
const lists = {};
|
|
79
|
+
for (const page of SAVED_VIEW_PAGES) if (saved[page]) lists[page] = normaliseSavedViews(page, saved[page]);
|
|
80
|
+
if (Object.keys(lists).length) out.saved = lists;
|
|
71
81
|
return out;
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
// Each person keeps their own named views of a page and picks their
|
|
85
|
+
// default; saving one page's list leaves the other pages as they were.
|
|
86
|
+
export async function setPersonSavedViews(db, { id, page, saved }) {
|
|
87
|
+
if (!SAVED_VIEW_PAGES.includes(page)) throw new FlowError('not_found', 'no such page');
|
|
88
|
+
const value = normaliseSavedViews(page, saved);
|
|
89
|
+
const [row] = await db.update(people)
|
|
90
|
+
.set({
|
|
91
|
+
views: sql`coalesce(${people.views}, '{}'::jsonb) || jsonb_build_object('saved', coalesce(${people.views}->'saved', '{}'::jsonb) || ${JSON.stringify({ [page]: value })}::jsonb)`,
|
|
92
|
+
updatedAt: new Date(),
|
|
93
|
+
})
|
|
94
|
+
.where(eq(people.id, id))
|
|
95
|
+
.returning();
|
|
96
|
+
if (!row) throw new FlowError('not_found', 'person not found');
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
// Each person keeps their own view of a page; saving one page leaves the
|
|
75
101
|
// others as they were.
|
|
76
102
|
export async function setPersonView(db, { id, page, view }) {
|
|
@@ -141,6 +167,19 @@ export async function linkPerson(db, { id, clerkUserId }) {
|
|
|
141
167
|
return row ?? null;
|
|
142
168
|
}
|
|
143
169
|
|
|
170
|
+
// Fills a person's name from their sign-in account (Clerk) when Flow has
|
|
171
|
+
// none, so pickers show "Jane Citizen", not an email. A name Flow already
|
|
172
|
+
// holds stays.
|
|
173
|
+
export async function fillPersonName(db, { id, name }) {
|
|
174
|
+
const value = String(name ?? '').trim();
|
|
175
|
+
if (!value) return null;
|
|
176
|
+
const [row] = await db.update(people)
|
|
177
|
+
.set({ name: value, updatedAt: new Date() })
|
|
178
|
+
.where(and(eq(people.id, id), isNull(people.name)))
|
|
179
|
+
.returning();
|
|
180
|
+
return row ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
// While the installation has no owner, invite `email` as its owner (or make
|
|
145
184
|
// that person the owner if they are already listed). Returns the row, or null
|
|
146
185
|
// when an owner already exists.
|
package/server/auth/routes.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
publicPerson,
|
|
14
14
|
removePerson,
|
|
15
15
|
setPersonAppearance,
|
|
16
|
+
setPersonSavedViews,
|
|
16
17
|
setPersonView,
|
|
17
18
|
updatePerson,
|
|
18
19
|
} from './people.js';
|
|
@@ -127,6 +128,16 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
127
128
|
}
|
|
128
129
|
});
|
|
129
130
|
|
|
131
|
+
// Each person's named views of a page and their default (ui/list-views.js).
|
|
132
|
+
router.put('/api/me/saved-views/:page', requireSession, express.json({ limit: '64kb' }), async (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
const saved = await setPersonSavedViews(db, { id: req.person.id, page: req.params.page, saved: req.body });
|
|
135
|
+
res.json({ ok: true, saved });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
sendError(res, error, log);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
130
141
|
// Only an owner manages people.
|
|
131
142
|
const people = express.Router();
|
|
132
143
|
people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
|
package/server/auth/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { displayNameOf, googleWorkspaceOwner, primaryEmailOf, secondFactorVerified } from './clerk.js';
|
|
2
|
-
import { findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
|
|
2
|
+
import { fillPersonName, findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
|
|
3
3
|
import { DEFAULT_OWNER_MFA_MAX_AGE_MINUTES, isPlainEmail } from './settings.js';
|
|
4
4
|
|
|
5
5
|
// Checks the Clerk session on every person request, then maps the Clerk user
|
|
@@ -60,10 +60,29 @@ export function createPersonAuth({
|
|
|
60
60
|
// owner requests costs one Clerk call. Failures are never cached.
|
|
61
61
|
const clerkUsers = new Map();
|
|
62
62
|
|
|
63
|
+
// Clerk users already checked for a name in this run, so a person whose
|
|
64
|
+
// sign-in has no name costs one Clerk call, not one per request.
|
|
65
|
+
const nameChecked = new Set();
|
|
66
|
+
|
|
67
|
+
// The person, with their Clerk name filled in when Flow has none. Never
|
|
68
|
+
// stops a sign-in: a Clerk error keeps the person as they are.
|
|
69
|
+
async function withName(person, clerkUser = null) {
|
|
70
|
+
if (person.name?.trim() || nameChecked.has(person.clerkUserId)) return person;
|
|
71
|
+
if (nameChecked.size > 1000) nameChecked.clear();
|
|
72
|
+
nameChecked.add(person.clerkUserId);
|
|
73
|
+
try {
|
|
74
|
+
const name = displayNameOf(clerkUser ?? await cachedClerkUser(person.clerkUserId));
|
|
75
|
+
return (name && await fillPersonName(db, { id: person.id, name })) || person;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
log.warn?.(`[auth] could not read the name of Flow person ${person.id}: ${error.message}`);
|
|
78
|
+
return person;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
// { person } or { denial }.
|
|
64
83
|
async function personFor(clerkUserId) {
|
|
65
84
|
const linked = await findPersonByClerkId(db, clerkUserId);
|
|
66
|
-
if (linked) return { person: linked };
|
|
85
|
+
if (linked) return { person: await withName(linked) };
|
|
67
86
|
|
|
68
87
|
const clerkUser = await clerk.getUser(clerkUserId);
|
|
69
88
|
const { email, verified } = primaryEmailOf(clerkUser);
|
|
@@ -76,7 +95,7 @@ export function createPersonAuth({
|
|
|
76
95
|
const person = await linkPerson(db, { id: invited.id, clerkUserId });
|
|
77
96
|
if (person) {
|
|
78
97
|
log.info?.(`[auth] linked Clerk user ${clerkUserId} to Flow person ${person.id} (${person.role})`);
|
|
79
|
-
return { person };
|
|
98
|
+
return { person: await withName(person, clerkUser) };
|
|
80
99
|
}
|
|
81
100
|
const again = await findPersonByClerkId(db, clerkUserId);
|
|
82
101
|
return again ? { person: again } : { denial: 'email_linked_elsewhere' };
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
CUSTOM_FIELD_TYPES, integrationSettingEvents, integrationSettings, jobFieldEvents, jobFields, jobFieldSettings, jobFieldValues, people,
|
|
5
5
|
} from '../db/schema.js';
|
|
6
6
|
import { FlowError, notFound } from './errors.js';
|
|
7
|
+
import { personLabel } from '../ui/person.js';
|
|
7
8
|
|
|
8
9
|
// Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
|
|
9
10
|
// Flow, like ClickUp's custom fields. An owner or admin makes them in
|
|
@@ -372,11 +373,11 @@ export async function listJobFieldValues(db, organisationId, { jobs = null } = {
|
|
|
372
373
|
return rows.filter((r) => !wanted || wanted.has(`${r.sourceSystem}\u0000${r.sourceJobRef}`)).map(publicValue);
|
|
373
374
|
}
|
|
374
375
|
|
|
375
|
-
// The people a person field can name: every active person, by id and
|
|
376
|
-
// (
|
|
376
|
+
// The people a person field can name: every active person, by id and label
|
|
377
|
+
// (full name, else the whole email: server/ui/person.js), A to Z.
|
|
377
378
|
export async function choosablePeople(db) {
|
|
378
379
|
const rows = await db.select({ id: people.id, name: people.name, email: people.email }).from(people).where(eq(people.status, 'active'));
|
|
379
|
-
return rows.map((r) => ({ id: r.id, name:
|
|
380
|
+
return rows.map((r) => ({ id: r.id, name: personLabel(r) }))
|
|
380
381
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
381
382
|
}
|
|
382
383
|
|
|
@@ -57,7 +57,7 @@ export const TESTS = Object.freeze({
|
|
|
57
57
|
number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
|
|
58
58
|
money: ['more_than', 'at_most', 'is', 'is_not'],
|
|
59
59
|
choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
|
|
60
|
-
text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'one_word', 'is_empty', 'is_not_empty'],
|
|
60
|
+
text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'number_in_words', 'one_word', 'is_empty', 'is_not_empty'],
|
|
61
61
|
boolean: ['is'],
|
|
62
62
|
items: ['has', 'has_not'],
|
|
63
63
|
share: ['is', 'is_not'],
|
|
@@ -65,7 +65,7 @@ export const TESTS = Object.freeze({
|
|
|
65
65
|
|
|
66
66
|
export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
|
|
67
67
|
// Tests that take no value.
|
|
68
|
-
const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'one_word']);
|
|
68
|
+
const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word']);
|
|
69
69
|
|
|
70
70
|
function readCondition(raw, index) {
|
|
71
71
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
@@ -131,6 +131,9 @@ function matches(condition, job, context) {
|
|
|
131
131
|
if (!left) return false;
|
|
132
132
|
if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
|
|
133
133
|
if (test === 'has_number') return /\d/.test(left);
|
|
134
|
+
// A part (between " - ") that is only a number is a client's reference,
|
|
135
|
+
// such as a patient number, so only a number beside words matches.
|
|
136
|
+
if (test === 'number_in_words') return left.split(' - ').some((part) => /\d/.test(part) && /\p{L}/u.test(part));
|
|
134
137
|
if (test === 'one_word') return !left.includes(' ');
|
|
135
138
|
if (test === 'contains') return left.includes(norm(value));
|
|
136
139
|
if (test === 'does_not_contain') return !left.includes(norm(value));
|
|
@@ -182,12 +185,14 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
182
185
|
// The notes of the enabled pending rules that match one draft, as problems:
|
|
183
186
|
// they put the draft on Pending (sending.js). `job` is the draft's job with
|
|
184
187
|
// `reference`, the draft's Reference. A pending rule never stops a draft.
|
|
188
|
+
// `fields` names the fields the rule reads, so the invoice page marks the
|
|
189
|
+
// one to fix (the Reference box goes red).
|
|
185
190
|
export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
186
191
|
const context = { customer, now };
|
|
187
192
|
return rules
|
|
188
193
|
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
|
|
189
194
|
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
190
|
-
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null }));
|
|
195
|
+
.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))] }));
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
// The starter rules made from the earlier fixed settings.
|
package/server/core/job-match.js
CHANGED
|
@@ -87,8 +87,9 @@ function jobLines(o, codes) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// The two queues, oldest first. jobs: { id, clientName, practitioner,
|
|
90
|
-
// jobDate, totalCents,
|
|
91
|
-
// issueDate, status, totalCents,
|
|
90
|
+
// jobDate, totalCents, status, sourceSystem, sourceJobRef, possible };
|
|
91
|
+
// invoices: { id, number, clientName, issueDate, status, totalCents,
|
|
92
|
+
// possible }. `possible` counts the other
|
|
92
93
|
// side with the same client and total.
|
|
93
94
|
export async function listReconcile(db, organisationId) {
|
|
94
95
|
const jobs = await db.select({
|
|
@@ -97,6 +98,9 @@ export async function listReconcile(db, organisationId) {
|
|
|
97
98
|
practitioner: billingObligations.practitioner,
|
|
98
99
|
jobDate: billingObligations.jobDate,
|
|
99
100
|
totalCents: billingObligations.totalCents,
|
|
101
|
+
status: billingObligations.sourceStatus,
|
|
102
|
+
sourceSystem: billingObligations.sourceSystem,
|
|
103
|
+
sourceJobRef: billingObligations.sourceJobRef,
|
|
100
104
|
// An unclear accounting match: "Make invoice" settles it first.
|
|
101
105
|
unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
|
|
102
106
|
possible: sql`(select count(*) from invoices i
|
package/server/core/sending.js
CHANGED
|
@@ -38,8 +38,9 @@ const LEASE_MS = 15 * 60 * 1000;
|
|
|
38
38
|
// Outcomes that mean the accounting system holds the invoice.
|
|
39
39
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
40
40
|
|
|
41
|
-
// `short` is the few words the Invoices list shows; `message` says how to fix
|
|
42
|
-
|
|
41
|
+
// `short` is the few words the Invoices list shows; `message` says how to fix
|
|
42
|
+
// it. `extra` adds `fields` (the invoice fields to fix) and `line`.
|
|
43
|
+
const problem = (code, message, short = null, extra = {}) => ({ code, message, ...(short ? { short } : {}), ...extra });
|
|
43
44
|
|
|
44
45
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
45
46
|
export function draftProblems(invoice, lines) {
|
|
@@ -47,7 +48,7 @@ export function draftProblems(invoice, lines) {
|
|
|
47
48
|
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
|
|
48
49
|
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
|
|
49
50
|
for (const line of lines) {
|
|
50
|
-
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item
|
|
51
|
+
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { fields: ['line_item'], line: line.position }));
|
|
51
52
|
}
|
|
52
53
|
return problems;
|
|
53
54
|
}
|
|
@@ -77,7 +77,9 @@ async function get(url, { fetchImpl, retryDelayMs, as = "json" }) {
|
|
|
77
77
|
throw new ReleaseUnavailable(lastError);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// The release GitHub names for the channel, alone. A publish guard asks
|
|
81
|
+
// GitHub and npm separately, so the two must agree.
|
|
82
|
+
export async function fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs }) {
|
|
81
83
|
if (channel === "stable") {
|
|
82
84
|
const latest = await get(`${githubApi}/repos/${slug}/releases/latest`, { fetchImpl, retryDelayMs });
|
|
83
85
|
const release = parseReleaseTag(latest?.tag_name);
|
|
@@ -95,7 +97,8 @@ async function fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs })
|
|
|
95
97
|
return tags.at(-1);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
// The release npm's dist-tag names for the channel, alone.
|
|
101
|
+
export async function fromNpm(channel, { npmRegistry, packageName, fetchImpl, retryDelayMs }) {
|
|
99
102
|
const metadata = await get(`${npmRegistry}/${packageName.replaceAll("/", "%2f")}`, { fetchImpl, retryDelayMs });
|
|
100
103
|
const distTag = channel === "stable" ? "latest" : "beta";
|
|
101
104
|
const version = metadata?.["dist-tags"]?.[distTag];
|