@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.33
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 +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +69 -26
- package/server/app.js +4 -0
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +64 -5
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +245 -8
- package/server/core/pipeline.js +30 -22
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +52 -8
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +5 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +4 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +77 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +69 -3
- package/server/integrations/seazona/lanes.js +82 -9
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +7 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +346 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +56 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +231 -68
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
- package/server/ui/routes.js +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ run a lab.
|
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| `VISION.md` | What Flow is for, the core and its integrations, and what it will not do |
|
|
29
29
|
| `AGENTS.md` | Agent entry point (`CLAUDE.md` links to it) |
|
|
30
|
-
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, merging |
|
|
30
|
+
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, the changelog, merging |
|
|
31
|
+
| `CHANGELOG.md` | What changed in each release: an index of one file per release in `CHANGELOG/` |
|
|
31
32
|
| `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
|
|
32
33
|
| `LICENSE` | MIT licence |
|
|
33
34
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
@@ -37,6 +38,7 @@ run a lab.
|
|
|
37
38
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
38
39
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
39
40
|
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
|
|
41
|
+
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
40
42
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
41
43
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
|
42
44
|
| `docs/LINE_MAPPING.md` | Line mapping: how each work system line finds its price item, and Price codes in Settings |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.33",
|
|
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.33",
|
|
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.33",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "node server/index.js",
|
|
33
33
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
34
|
+
"changelog:check": "node scripts/release/changelog.mjs check",
|
|
34
35
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
35
36
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
36
37
|
},
|
package/server/api/invoicing.js
CHANGED
|
@@ -13,13 +13,15 @@ import {
|
|
|
13
13
|
} from '../core/line-mapping.js';
|
|
14
14
|
import {
|
|
15
15
|
effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
|
|
16
|
-
saveCustomerBilling, saveInvoicingSettings, sendModeSummary,
|
|
16
|
+
saveCustomerBilling, saveInvoicingSettings, sendModeFor, sendModeSummary,
|
|
17
17
|
} from '../core/invoicing-rules.js';
|
|
18
18
|
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
|
+
import { readBeforeAct } from '../core/read-before-act.js';
|
|
20
|
+
import { jobReference } from '../core/references.js';
|
|
19
21
|
import {
|
|
20
22
|
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
21
23
|
} from '../core/price-list.js';
|
|
22
|
-
import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
|
|
24
|
+
import { draftInvoiceForObligation, explainObligation, listObligations, setReady, useSourceLines } from '../core/ledger.js';
|
|
23
25
|
import { isHistoryHeld } from '../core/history.js';
|
|
24
26
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
25
27
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
@@ -36,9 +38,9 @@ import {
|
|
|
36
38
|
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
37
39
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
38
40
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
39
|
-
// accounting system { issueDate }
|
|
41
|
+
// accounting system { issueDate, invoiceIds? (only these, each ready) }
|
|
40
42
|
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
41
|
-
// GET /invoices/:id one invoice: lines, contact, job, issued versions
|
|
43
|
+
// GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
|
|
42
44
|
// GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
|
|
43
45
|
// with the accounting system's number and dates once it holds it;
|
|
44
46
|
// no accounting system calls
|
|
@@ -250,6 +252,11 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
|
|
|
250
252
|
return {
|
|
251
253
|
...invoice,
|
|
252
254
|
...(await invoiceView(db, organisationId, invoice, { accounting })),
|
|
255
|
+
// Where a draft goes when it is sent, and how: this customer's send mode.
|
|
256
|
+
sendsTo: accounting?.name ?? null,
|
|
257
|
+
sendMode: accounting && invoice.status === 'draft'
|
|
258
|
+
? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
|
|
259
|
+
: null,
|
|
253
260
|
contact: contact ?? null,
|
|
254
261
|
job: jobs.get(invoiceId) ?? null,
|
|
255
262
|
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
@@ -281,25 +288,36 @@ export function createInvoicingRouter({
|
|
|
281
288
|
}
|
|
282
289
|
const q = text(req.query.q);
|
|
283
290
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
290
|
-
else if (status) where.push(eq(invoices.status, status));
|
|
291
|
-
if (q) {
|
|
292
|
-
const pattern = containing(q);
|
|
293
|
-
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
294
|
-
}
|
|
295
|
-
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
296
|
-
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
297
|
-
.where(and(...where))
|
|
298
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
299
|
-
.limit(limit);
|
|
300
|
-
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
301
|
-
const counted = await db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
291
|
+
// Each tab switch runs this, so the independent reads run side by side;
|
|
292
|
+
// only the to-check, ready and problem lists wait on the draft checks.
|
|
293
|
+
const system = accounting();
|
|
294
|
+
const checking = invoiceViews(db, organisationId, { accounting: system });
|
|
295
|
+
const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
302
296
|
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
297
|
+
const sendMode = system
|
|
298
|
+
? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
|
|
299
|
+
.then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
|
|
300
|
+
: null;
|
|
301
|
+
const listing = (async () => {
|
|
302
|
+
const views = VIEWS_OF_INVOICES.includes(status) ? await checking : null;
|
|
303
|
+
const where = [eq(invoices.organisationId, organisationId)];
|
|
304
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
305
|
+
if (status === 'to_check') where.push(only(views.toCheck));
|
|
306
|
+
else if (status === 'ready') where.push(only(views.ready));
|
|
307
|
+
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
308
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
309
|
+
if (q) {
|
|
310
|
+
const pattern = containing(q);
|
|
311
|
+
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
312
|
+
}
|
|
313
|
+
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
314
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
315
|
+
.where(and(...where))
|
|
316
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
317
|
+
.limit(limit);
|
|
318
|
+
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
319
|
+
})();
|
|
320
|
+
const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
|
|
303
321
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
304
322
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
305
323
|
counts.to_check = views.toCheck.length;
|
|
@@ -318,8 +336,8 @@ export function createInvoicingRouter({
|
|
|
318
336
|
problems: views.problems.get(invoice.id) ?? [],
|
|
319
337
|
})),
|
|
320
338
|
counts,
|
|
321
|
-
sendsTo:
|
|
322
|
-
sendMode:
|
|
339
|
+
sendsTo: system?.name ?? null,
|
|
340
|
+
sendMode: mode,
|
|
323
341
|
});
|
|
324
342
|
}));
|
|
325
343
|
|
|
@@ -348,7 +366,10 @@ export function createInvoicingRouter({
|
|
|
348
366
|
router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
|
|
349
367
|
const organisationId = await organisationOf(req);
|
|
350
368
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
351
|
-
const
|
|
369
|
+
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
370
|
+
// Read before act (issue #141) reads each old job from the work system that can.
|
|
371
|
+
const reader = work().find((a) => typeof a.capabilities.readWorkItem === 'function') ?? null;
|
|
372
|
+
const result = await sendAllReady(db, organisationId, { accounting: accounting(), work: reader, personId: req.person.id, issueDate, invoiceIds: ids });
|
|
352
373
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
|
|
353
374
|
res.json({ ok: true, ...result });
|
|
354
375
|
}));
|
|
@@ -488,11 +509,33 @@ export function createInvoicingRouter({
|
|
|
488
509
|
const organisationId = await organisationOf(req);
|
|
489
510
|
// With no accounting system connected, Flow's own records are the check.
|
|
490
511
|
const liveCheck = liveCheckFor(organisationId) ?? null;
|
|
491
|
-
|
|
512
|
+
let [obligation] = await db.select().from(billingObligations)
|
|
513
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
514
|
+
const source = obligation ? work().find((a) => a.id === obligation.sourceSystem) ?? null : null;
|
|
515
|
+
if (obligation) {
|
|
516
|
+
// Read before act (issue #141): an old job report is read again first.
|
|
517
|
+
const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor: `person:${req.person.id}` })).get(obligation.id);
|
|
518
|
+
if (read.outcome === 'waiting' || read.outcome === 'review') {
|
|
519
|
+
throw new FlowError('not_ready', 'the draft invoice was refused', { reasons: [read.reason ?? read.obligation.holdReason ?? 'the job needs a look'] });
|
|
520
|
+
}
|
|
521
|
+
obligation = read.obligation;
|
|
522
|
+
}
|
|
523
|
+
const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
|
|
524
|
+
const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: source }) : null;
|
|
525
|
+
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
|
|
492
526
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
493
527
|
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
494
528
|
}));
|
|
495
529
|
|
|
530
|
+
// "Use new lines": the job changed in the work system after its draft was
|
|
531
|
+
// made, and only there. Flow changes its own draft; no other system.
|
|
532
|
+
router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
|
|
533
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
534
|
+
const organisationId = await organisationOf(req);
|
|
535
|
+
const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
|
|
536
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
537
|
+
}));
|
|
538
|
+
|
|
496
539
|
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
497
540
|
const obligationId = pathId(req.params.id, 'job');
|
|
498
541
|
const organisationId = await organisationOf(req);
|
package/server/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createConnectorApi } from './api/connector.js';
|
|
|
4
4
|
import { createInvoicingRouter } from './api/invoicing.js';
|
|
5
5
|
import { createSignInRouter } from './auth/routes.js';
|
|
6
6
|
import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
|
|
7
|
+
import { reconcileHealth } from './core/reconcile.js';
|
|
7
8
|
import { appliedMigrationCounts } from './db/doctor.js';
|
|
8
9
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
9
10
|
import { integrationsHealth } from './integrations/health.js';
|
|
@@ -72,6 +73,9 @@ export function createApp({
|
|
|
72
73
|
.then((migrations) => ({ migrations }), () => null);
|
|
73
74
|
}
|
|
74
75
|
if (integrations) body.integrations = await integrationsHealth(integrations);
|
|
76
|
+
// The daily reconcile's last run (server/core/reconcile.js): counts only.
|
|
77
|
+
// A difference does not fail the health check.
|
|
78
|
+
if (databaseOk && database.db) body.reconcile = await reconcileHealth(database.db).catch(() => null);
|
|
75
79
|
res.status(ok ? 200 : 503).json(body);
|
|
76
80
|
});
|
|
77
81
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { and, eq, sql } from 'drizzle-orm';
|
|
2
2
|
import { automations, billingObligations, invoices } from '../db/schema.js';
|
|
3
3
|
import { FlowError, notFound } from './errors.js';
|
|
4
|
-
import { autoDraftJobs } from './invoicing-rules.js';
|
|
4
|
+
import { autoDraftJobs, readInvoicingSettings } from './invoicing-rules.js';
|
|
5
5
|
import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
|
|
6
6
|
import { setReady } from './ledger.js';
|
|
7
|
+
import { refreshReferences } from './references.js';
|
|
7
8
|
|
|
8
9
|
// Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
|
|
9
10
|
// own records and moves them on, and each is on or off with its own settings,
|
|
@@ -19,6 +20,8 @@ import { setReady } from './ledger.js';
|
|
|
19
20
|
// rules (invoicing-rules.js) gets a draft invoice in Flow.
|
|
20
21
|
// ready_when_drafted: a draft the draft routine made goes straight to Ready
|
|
21
22
|
// on the Invoices page, until a person changes it or moves it back.
|
|
23
|
+
// reference_from_rule: a draft made from a job keeps the Reference the
|
|
24
|
+
// lab's Reference rule gives (references.js), as the job's fields arrive.
|
|
22
25
|
|
|
23
26
|
const BATCH = 500;
|
|
24
27
|
|
|
@@ -92,6 +95,14 @@ export const AUTOMATIONS = Object.freeze([
|
|
|
92
95
|
defaults: { enabled: true, config: {} },
|
|
93
96
|
readConfig: () => ({}),
|
|
94
97
|
},
|
|
98
|
+
{
|
|
99
|
+
key: 'reference_from_rule',
|
|
100
|
+
place: 'invoicing',
|
|
101
|
+
name: 'Reference from the rule',
|
|
102
|
+
hint: 'A draft made from a job gets its Reference from the Reference rule. A Reference a person typed stays.',
|
|
103
|
+
defaults: { enabled: true, config: {} },
|
|
104
|
+
readConfig: () => ({}),
|
|
105
|
+
},
|
|
95
106
|
]);
|
|
96
107
|
|
|
97
108
|
const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
|
|
@@ -169,16 +180,19 @@ async function readyWhenDrafted(db, organisationId) {
|
|
|
169
180
|
|
|
170
181
|
// Runs every routine that is on. `accounting` is the integration whose copy
|
|
171
182
|
// the draft routine checks; without one, Flow's own records are the check.
|
|
172
|
-
export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
|
|
183
|
+
export async function runAutomations(db, organisationId, { accounting = null, work = null, now = new Date() } = {}) {
|
|
173
184
|
const summary = {};
|
|
174
185
|
for (const automation of await listAutomations(db, organisationId)) {
|
|
175
186
|
if (!automation.enabled) continue;
|
|
176
187
|
if (automation.key === 'mark_ready_from_work_status') {
|
|
177
188
|
summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
|
|
178
189
|
} else if (automation.key === 'draft_when_ready') {
|
|
179
|
-
summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
|
|
190
|
+
summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, work, now, actor: actorOf(automation.key) });
|
|
180
191
|
} else if (automation.key === 'ready_when_drafted') {
|
|
181
192
|
summary[automation.key] = await readyWhenDrafted(db, organisationId);
|
|
193
|
+
} else if (automation.key === 'reference_from_rule') {
|
|
194
|
+
const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
|
|
195
|
+
summary[automation.key] = await refreshReferences(db, organisationId, { template, work });
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
return summary;
|
package/server/core/invoices.js
CHANGED
|
@@ -98,8 +98,8 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
|
|
|
98
98
|
}
|
|
99
99
|
if ('reference' in changes) fields.reference = changes.reference;
|
|
100
100
|
if ('dueDate' in changes) fields.dueDate = changes.dueDate;
|
|
101
|
-
// A
|
|
102
|
-
|
|
101
|
+
// A saved change keeps the draft where it is. Flow's checks still run on
|
|
102
|
+
// it, so a change that breaks one puts the draft in Problem.
|
|
103
103
|
let row = invoice;
|
|
104
104
|
if (Object.keys(fields).length) {
|
|
105
105
|
[row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
import { FlowError, notFound } from './errors.js';
|
|
6
6
|
import { listRules, ruleReasons } from './invoice-rules.js';
|
|
7
7
|
import { draftInvoiceForObligation } from './ledger.js';
|
|
8
|
+
import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
|
|
9
|
+
import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
|
|
8
10
|
|
|
9
11
|
// Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
|
|
10
12
|
// is ready to invoice passes before Flow drafts its invoice, and how a checked
|
|
@@ -30,6 +32,7 @@ export const INVOICING_DEFAULTS = Object.freeze({
|
|
|
30
32
|
requirePractitioner: false,
|
|
31
33
|
rushItemCode: null,
|
|
32
34
|
rushPercent: 20,
|
|
35
|
+
referenceTemplate: DEFAULT_REFERENCE,
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
const CUSTOMER_DEFAULTS = Object.freeze({
|
|
@@ -58,6 +61,15 @@ function shortText(value, name) {
|
|
|
58
61
|
return value.trim() || null;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// An empty rule gives no Reference: the accounting system shows the number.
|
|
65
|
+
function referenceTemplate(value) {
|
|
66
|
+
if (value === null) return '';
|
|
67
|
+
if (typeof value !== 'string' || value.length > 200) throw invalid('referenceTemplate must be text of 200 characters at most');
|
|
68
|
+
const problem = checkReferenceTemplate(value);
|
|
69
|
+
if (problem) throw invalid(`referenceTemplate: ${problem}`);
|
|
70
|
+
return value.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
61
73
|
function sendMode(value, name) {
|
|
62
74
|
if (!SEND_MODES.includes(value)) throw invalid(`${name} must be one of ${SEND_MODES.join(', ')}`);
|
|
63
75
|
return value;
|
|
@@ -79,6 +91,7 @@ function readSettingChanges(changes) {
|
|
|
79
91
|
else if (key === 'paymentTermsDays') out[key] = wholeNumber(value, key, 0, 365);
|
|
80
92
|
else if (key === 'rushPercent') out[key] = wholeNumber(value, key, 0, 100);
|
|
81
93
|
else if (key === 'rushItemCode') out[key] = shortText(value, key);
|
|
94
|
+
else if (key === 'referenceTemplate') out[key] = referenceTemplate(value);
|
|
82
95
|
else throw invalid(`unknown setting: ${key}`);
|
|
83
96
|
}
|
|
84
97
|
return out;
|
|
@@ -209,14 +222,20 @@ export function copyCheck(accounting, organisationId) {
|
|
|
209
222
|
};
|
|
210
223
|
}
|
|
211
224
|
|
|
225
|
+
async function reloadObligation(db, organisationId, id) {
|
|
226
|
+
const [row] = await db.select().from(billingObligations)
|
|
227
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, id)));
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
// Drafts the invoice of each job that is ready to invoice and passes the
|
|
213
232
|
// ledger's checks and every rule, except for customers whose drafting is off.
|
|
214
233
|
// It writes only Flow's own records: the drafts wait in Flow for a person.
|
|
215
234
|
// The draft automation (automations.js) runs it. Answers counts.
|
|
216
235
|
// With `accounting` null no accounting system is connected: Flow's own records
|
|
217
236
|
// are the only check, as for a person's draft (ledger.js, ledgerView).
|
|
218
|
-
export async function autoDraftJobs(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
219
|
-
const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
|
|
237
|
+
export async function autoDraftJobs(db, organisationId, { accounting = null, work = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
238
|
+
const result = { drafted: 0, waiting: 0, off: 0, refused: 0, readFirst: 0 };
|
|
220
239
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
221
240
|
const rules = await listRules(db, organisationId);
|
|
222
241
|
if (accounting) {
|
|
@@ -239,22 +258,62 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, now
|
|
|
239
258
|
const customers = await customerBillingByContact(db, organisationId, candidates.map((o) => o.contactId));
|
|
240
259
|
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
241
260
|
const invoiceDate = isoDay(now);
|
|
261
|
+
let accountingRead = null;
|
|
242
262
|
|
|
243
|
-
for (const
|
|
244
|
-
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
let effective = effectiveSettings(settings, customers.get(candidate.contactId));
|
|
245
265
|
if (!effective.autoDraft) {
|
|
246
266
|
result.off += 1;
|
|
247
267
|
continue;
|
|
248
268
|
}
|
|
249
|
-
if (ruleBlockers(
|
|
269
|
+
if (ruleBlockers(candidate, effective, { now, rules }).length) {
|
|
270
|
+
result.waiting += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Issue #141: an old report is read again first. The rules then check
|
|
274
|
+
// what the work system says now; the ledger checks the rest.
|
|
275
|
+
if (result.readFirst >= READS_PER_PASS && needsRead(candidate, { work, now })) {
|
|
276
|
+
result.waiting += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
let { outcome, obligation } = (await readBeforeAct(db, organisationId, [candidate], { work, now, actor })).get(candidate.id);
|
|
280
|
+
if (outcome !== 'fresh') result.readFirst += 1;
|
|
281
|
+
if (outcome === 'waiting' || outcome === 'review') {
|
|
250
282
|
result.waiting += 1;
|
|
251
283
|
continue;
|
|
252
284
|
}
|
|
285
|
+
// Issue #45: the accounting copy reads its latest changes once, before
|
|
286
|
+
// the first draft. When it changed, the matcher ran: this job may now
|
|
287
|
+
// have its invoice.
|
|
288
|
+
if (accounting) {
|
|
289
|
+
if (!accountingRead) {
|
|
290
|
+
accountingRead = await readAccountingFirst(db, organisationId, { accounting, now });
|
|
291
|
+
// The summary says when the copy read or could not read.
|
|
292
|
+
if (accountingRead.outcome !== 'fresh') result.accountingRead = accountingRead.outcome;
|
|
293
|
+
}
|
|
294
|
+
if (accountingRead.outcome === 'waiting') {
|
|
295
|
+
result.waiting += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (accountingRead.outcome === 'read') obligation = await reloadObligation(db, organisationId, obligation.id);
|
|
299
|
+
if (obligation.accountingState !== 'not_found') {
|
|
300
|
+
result.waiting += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (obligation !== candidate) {
|
|
305
|
+
effective = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
306
|
+
if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
|
|
307
|
+
result.waiting += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
253
311
|
try {
|
|
254
312
|
await draftInvoiceForObligation(db, organisationId, obligation.id, {
|
|
255
313
|
liveCheck,
|
|
256
314
|
actor,
|
|
257
315
|
now,
|
|
316
|
+
reference: await jobReference(db, organisationId, obligation, { template: settings.referenceTemplate, work }),
|
|
258
317
|
dueDate: addDays(invoiceDate, effective.paymentTermsDays),
|
|
259
318
|
});
|
|
260
319
|
result.drafted += 1;
|
package/server/core/job-board.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
|
-
import { contacts, invoices, priceItems } from '../db/schema.js';
|
|
2
|
+
import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
|
|
3
3
|
import { listRules } from './invoice-rules.js';
|
|
4
4
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
|
-
import { listObligations } from './ledger.js';
|
|
5
|
+
import { REVIEW_CHANGED, listObligations } from './ledger.js';
|
|
6
6
|
import { isHistoryHeld } from './history.js';
|
|
7
7
|
import { invoiceViews } from './sending.js';
|
|
8
8
|
|
|
@@ -21,7 +21,8 @@ import { invoiceViews } from './sending.js';
|
|
|
21
21
|
export async function describeObligations(db, organisationId, listed, { accounting = null } = {}) {
|
|
22
22
|
const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
|
|
23
23
|
const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
|
|
24
|
-
const
|
|
24
|
+
const changedIds = listed.filter((e) => e.obligation.holdState === 'review' && e.obligation.invoiceId).map((e) => e.obligation.id);
|
|
25
|
+
const [names, numbers, views, changed] = await Promise.all([
|
|
25
26
|
contactIds.length
|
|
26
27
|
? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
27
28
|
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
|
|
@@ -31,7 +32,16 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
31
32
|
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
32
33
|
: [],
|
|
33
34
|
invoiceIds.length ? invoiceViews(db, organisationId, { accounting }) : null,
|
|
35
|
+
// The open "job changed" alerts (ledger.js): only the work system changed
|
|
36
|
+
// and the draft is not sent, so a person can use the new lines.
|
|
37
|
+
changedIds.length
|
|
38
|
+
? db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence }).from(reviewItems).where(and(
|
|
39
|
+
eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_CHANGED),
|
|
40
|
+
eq(reviewItems.state, 'open'), inArray(reviewItems.flowId, changedIds),
|
|
41
|
+
))
|
|
42
|
+
: [],
|
|
34
43
|
]);
|
|
44
|
+
const offers = new Set(changed.filter((r) => r.evidence?.canUseNewLines).map((r) => r.flowId));
|
|
35
45
|
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
36
46
|
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
37
47
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
@@ -46,8 +56,9 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
46
56
|
// draft can still come from.
|
|
47
57
|
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
48
58
|
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
49
|
-
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view, reasons }) => ({
|
|
59
|
+
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
|
|
50
60
|
...o,
|
|
61
|
+
canUseNewLines: offers.has(o.id),
|
|
51
62
|
contactName: nameOf.get(o.contactId) ?? null,
|
|
52
63
|
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
53
64
|
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|