@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.
Files changed (59) hide show
  1. package/README.md +3 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +69 -26
  5. package/server/app.js +4 -0
  6. package/server/core/automations.js +17 -3
  7. package/server/core/invoices.js +2 -2
  8. package/server/core/invoicing-rules.js +64 -5
  9. package/server/core/job-board.js +15 -4
  10. package/server/core/ledger.js +245 -8
  11. package/server/core/pipeline.js +30 -22
  12. package/server/core/read-before-act.js +96 -0
  13. package/server/core/reconcile.js +350 -0
  14. package/server/core/references.js +99 -0
  15. package/server/core/sending.js +52 -8
  16. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/_journal.json +14 -0
  21. package/server/db/schema.js +5 -0
  22. package/server/index.js +5 -0
  23. package/server/integrations/port.js +4 -0
  24. package/server/integrations/seazona/act.js +48 -0
  25. package/server/integrations/seazona/feed.js +77 -50
  26. package/server/integrations/seazona/governor.js +28 -2
  27. package/server/integrations/seazona/index.js +69 -3
  28. package/server/integrations/seazona/lanes.js +82 -9
  29. package/server/integrations/seazona/ledger.js +14 -9
  30. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  31. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  32. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  33. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  34. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  35. package/server/integrations/seazona/patients.js +30 -0
  36. package/server/integrations/seazona/reconcile.js +40 -0
  37. package/server/integrations/seazona/scheduler.js +32 -4
  38. package/server/integrations/seazona/schema.js +21 -0
  39. package/server/integrations/seazona/settings.js +12 -0
  40. package/server/integrations/seazona/store.js +24 -2
  41. package/server/integrations/seazona/sweep.js +574 -0
  42. package/server/integrations/seazona/tables.js +1 -0
  43. package/server/integrations/xero/client.js +12 -0
  44. package/server/integrations/xero/import.js +9 -3
  45. package/server/integrations/xero/index.js +7 -1
  46. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  47. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  48. package/server/integrations/xero/mirror.js +16 -8
  49. package/server/integrations/xero/routes.js +20 -0
  50. package/server/integrations/xero/settings.js +3 -0
  51. package/server/integrations/xero/webhooks.js +346 -0
  52. package/server/ui/app.js +15 -7
  53. package/server/ui/flow.css +56 -7
  54. package/server/ui/invoice-notes.js +26 -0
  55. package/server/ui/invoicing-settings.js +3 -1
  56. package/server/ui/invoicing.js +231 -68
  57. package/server/ui/job-board.js +2 -1
  58. package/server/ui/jobs.js +19 -1
  59. 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 |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.32-beta.1",
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.32-beta.1",
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.32-beta.1",
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
  },
@@ -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
- const views = await invoiceViews(db, organisationId, { accounting: accounting() });
285
- const where = [eq(invoices.organisationId, organisationId)];
286
- const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
287
- if (status === 'to_check') where.push(only(views.toCheck));
288
- else if (status === 'ready') where.push(only(views.ready));
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: accounting()?.name ?? null,
322
- sendMode: accounting() ? { ...(await sendModeSummary(db, organisationId)), ...(await accounting().capabilities.sendStatus({ organisationId })) } : null,
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 result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
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
- const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
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;
@@ -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 changed draft needs a person to check it again.
102
- if (Object.keys(fields).length || 'lines' in changes) Object.assign(fields, { readyAt: null, readyBy: null });
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 obligation of candidates) {
244
- const effective = effectiveSettings(settings, customers.get(obligation.contactId));
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(obligation, effective, { now, rules }).length) {
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;
@@ -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 [names, numbers, views] = await Promise.all([
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,