@arcforgelabs/flow 2026.10.38 → 2026.10.40

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 (47) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +205 -15
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/integrations/xero/settings.js +2 -1
  33. package/server/ui/app.js +42 -6
  34. package/server/ui/custom-fields.js +252 -94
  35. package/server/ui/flow.css +185 -29
  36. package/server/ui/invoice-notes.js +2 -2
  37. package/server/ui/invoicing.js +92 -137
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +23 -12
  40. package/server/ui/job-view.js +168 -33
  41. package/server/ui/jobs.js +316 -65
  42. package/server/ui/monitoring.js +13 -12
  43. package/server/ui/price-list.js +43 -86
  44. package/server/ui/price-match.js +93 -0
  45. package/server/ui/price-reconcile.js +357 -0
  46. package/server/ui/reconcile.js +1 -1
  47. package/server/ui/routes.js +1 -1
package/README.md CHANGED
@@ -40,7 +40,7 @@ run a lab.
40
40
  | `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
41
41
  | `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
42
42
  | `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
43
- | `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Finalise, a changed job, and Send all ready to the accounting system |
43
+ | `docs/INVOICE_SENDING.md` | The invoice tabs (Pending, Ready, Draft, Approved, Sent, Void), a changed job, and Send all ready to the accounting system |
44
44
  | `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
45
45
  | `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
46
46
  | `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.38",
3
+ "version": "2026.10.40",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.38",
9
+ "version": "2026.10.40",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.38",
3
+ "version": "2026.10.40",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,9 @@
34
34
  "changelog:check": "node scripts/release/changelog.mjs check",
35
35
  "release:stable": "node scripts/release/release-stable.mjs",
36
36
  "theme:contrast": "node scripts/theme-contrast.mjs",
37
- "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
37
+ "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
38
+ "db:follow-production": "node scripts/replit-workspace-db.js sync",
39
+ "db:follow-production:status": "node scripts/replit-workspace-db.js status"
38
40
  },
39
41
  "dependencies": {
40
42
  "@clerk/backend": "3.25.0",
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
2
  import {
3
- CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, ensureAssignee, jobFieldHistory, listFields,
4
- listJobFieldValues, orderFields, setJobFieldValue, updateField,
3
+ CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, deleteField, ensureAssignee, integrationConnectedAt,
4
+ jobFieldHistory, listFields, listJobFieldValues, orderFields, readGroupColors, setGroupColor, setJobFieldValue, updateField,
5
5
  } from '../core/custom-fields.js';
6
6
  import { FlowError } from '../core/errors.js';
7
7
  import { resolveOrganisation } from './invoicing.js';
@@ -9,11 +9,13 @@ import { resolveOrganisation } from './invoicing.js';
9
9
  // Custom fields for jobs (server/core/custom-fields.js, docs/CUSTOM_FIELDS.md).
10
10
  //
11
11
  // GET /api/custom-fields[?archived=1] the lab's fields, the work systems' own fields (read only),
12
- // and the people a person field can name
13
- // POST /api/custom-fields { name, type[, options] } owner or admin: make a field
12
+ // the people a person field can name, and each type group's colour
13
+ // POST /api/custom-fields { name, type[, options, color] } owner or admin: make a field
14
14
  // PUT /api/custom-fields/order { ids } owner or admin: the field order
15
- // PUT /api/custom-fields/:id { name?, options? } owner or admin: rename, change options
15
+ // PUT /api/custom-fields/groups/:type { color } owner or admin: a type group's colour (null: its own)
16
+ // PUT /api/custom-fields/:id { name?, options?, color? } owner or admin: rename, change options or colour
16
17
  // POST /api/custom-fields/:id/archive { archived } owner or admin: archive or bring back
18
+ // DELETE /api/custom-fields/:id owner or admin: delete a field, its values and history
17
19
  // GET /api/custom-fields/values every job's values
18
20
  // PUT /api/custom-fields/values { sourceSystem, sourceJobRef, fieldId, value[, expectedUpdatedAt] }
19
21
  // set one job's value; a stale expectedUpdatedAt gives 409 with the current value
@@ -51,15 +53,17 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
51
53
 
52
54
  // The work systems' own fields: their job statuses, as the Stage lanes on
53
55
  // the Jobs board. Read from Flow's copy; a work system that fails to answer
54
- // is left out, not an error.
56
+ // is left out, not an error. `createdAt` is when the system was connected.
55
57
  async function systemFields() {
56
58
  const fields = [];
57
59
  for (const adapter of work()) {
58
60
  try {
59
61
  const { statuses = [] } = await adapter.capabilities.listWork();
62
+ const seen = statuses.map((s) => s.firstSeenAt).filter(Boolean).sort()[0] ?? null;
60
63
  fields.push({
61
64
  id: `${adapter.id}:stage`, source: adapter.id, sourceLabel: adapter.name ?? adapter.id, name: 'Stage',
62
- type: 'dropdown', readOnly: true, options: statuses.map((s) => ({ id: s.label, name: s.label, final: Boolean(s.final) })),
65
+ type: 'dropdown', readOnly: true, createdAt: await integrationConnectedAt(db, adapter.id, seen),
66
+ options: statuses.map((s) => ({ id: s.label, name: s.label, final: Boolean(s.final), firstSeenAt: s.firstSeenAt ?? null })),
63
67
  });
64
68
  } catch (error) {
65
69
  log.warn?.(`[custom-fields] ${adapter.id} stages: ${error.message}`);
@@ -72,8 +76,10 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
72
76
  const organisationId = await organisationOf(req);
73
77
  const archived = req.query.archived === '1' || req.query.archived === 'true';
74
78
  await ensureAssignee(db, organisationId);
75
- const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
76
- res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
79
+ const [fields, system, chosen, groupColors] = await Promise.all([
80
+ listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db), readGroupColors(db, organisationId),
81
+ ]);
82
+ res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS, groupColors });
77
83
  }));
78
84
 
79
85
  router.post('/', ...manager, handle(async (req, res) => {
@@ -86,6 +92,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
86
92
  res.json({ ok: true, fields });
87
93
  }));
88
94
 
95
+ router.put('/groups/:type', ...manager, handle(async (req, res) => {
96
+ const groupColors = await setGroupColor(db, await organisationOf(req), req.params.type, req.body?.color, { personId: req.person.id });
97
+ res.json({ ok: true, groupColors });
98
+ }));
99
+
89
100
  router.get('/values', ...signedIn, handle(async (req, res) => {
90
101
  res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
91
102
  }));
@@ -112,5 +123,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
112
123
  res.json({ ok: true, field });
113
124
  }));
114
125
 
126
+ router.delete('/:id', ...manager, handle(async (req, res) => {
127
+ const deleted = await deleteField(db, await organisationOf(req), req.params.id);
128
+ log.info?.(`[custom-fields] deleted field ${deleted.id} with ${deleted.valuesDeleted} values`);
129
+ res.json({ ok: true, deleted });
130
+ }));
131
+
115
132
  return router;
116
133
  }
@@ -1,6 +1,7 @@
1
1
  import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
2
2
  import express from 'express';
3
3
  import { FlowError } from '../core/errors.js';
4
+ import { createActionGuard } from '../integrations/call-guard.js';
4
5
  import {
5
6
  createDraftInvoice, getInvoice, issueInvoice, listInvoiceVersions, reviseInvoice, updateDraftInvoice, voidInvoice,
6
7
  } from '../core/invoices.js';
@@ -20,11 +21,11 @@ import { readBeforeAct } from '../core/read-before-act.js';
20
21
  import { invoiceMatchDetail, jobMatchDetail, listReconcile } from '../core/job-match.js';
21
22
  import { jobReference } from '../core/references.js';
22
23
  import {
23
- acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
24
+ acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, linkPriceItems, MAX_LINKS_AT_ONCE, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
24
25
  } from '../core/price-list.js';
25
26
  import {
26
27
  confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
27
- setReady, useSourceLines,
28
+ setReady, settleJobChange, useSourceLines,
28
29
  } from '../core/ledger.js';
29
30
  import { isHistoryHeld } from '../core/history.js';
30
31
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
@@ -37,8 +38,8 @@ import {
37
38
  // UI's invoices, customers, price list and jobs pages read and write here.
38
39
  //
39
40
  // GET /context the organisations, and whether this person may issue
40
- // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status;
41
- // status to_check, ready or problem lists those views (server/core/sending.js)
41
+ // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by tab;
42
+ // status is a tab: pending, ready, draft, approved, sent or void (TABS)
42
43
  // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
43
44
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
44
45
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
@@ -81,6 +82,7 @@ import {
81
82
  // POST /price-items/:id/links owner or admin: { system, externalId } link a record of a system's list
82
83
  // POST /price-items/:id/resolve owner or admin: Mark resolved: check again, accept what still differs
83
84
  // DELETE /price-links/:id owner or admin: remove a link
85
+ // POST /price-links owner or admin: { links: [{ priceItemId, system, externalId }] } many links at once
84
86
  // GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
85
87
  // PUT /automations/:key owner or admin: { enabled, config }
86
88
  // GET /work-statuses the work system's job statuses, with job counts
@@ -101,7 +103,15 @@ import {
101
103
 
102
104
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
103
105
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
104
- const VIEWS_OF_INVOICES = ['to_check', 'ready', 'problem'];
106
+ // The Invoices page tabs, left to right. Pending: a person must look (a
107
+ // draft with a problem, a refused send, or out of sync with the accounting
108
+ // system). Ready: a draft whose checks pass. Draft, Approved and Sent: an
109
+ // issued invoice, by its stage in the accounting system (its invoiceStages;
110
+ // Draft while it is on its way); with no accounting system, an issued invoice
111
+ // is Approved. Void: void in Flow or in the accounting system.
112
+ const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'void'];
113
+ // Earlier names a saved link may still use.
114
+ const OLD_TABS = { problem: 'pending', to_check: 'ready', issued: 'approved' };
105
115
  const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
106
116
  const MAX_LINES = 200;
107
117
  const MAX_TEXT = 500;
@@ -235,7 +245,7 @@ export async function resolveOrganisation(db, raw) {
235
245
  }
236
246
 
237
247
  const jobOf = (o) => (o ? {
238
- id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
248
+ id: o.id, sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, sourceInvoiceRef: o.sourceInvoiceRef, caseRef: o.caseRef, practitioner: o.practitioner, shippedOn: o.shippedOn,
239
249
  } : null);
240
250
 
241
251
  // A job with its work system's name and its link in that system's web app
@@ -281,6 +291,46 @@ async function jobChangeDetail(db, organisationId, invoiceId, work) {
281
291
  return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
282
292
  }
283
293
 
294
+ // Each invoice's tab (TABS): Map(id -> tab), the ids on each tab, the
295
+ // problems of each pending one and the ones on their way to the accounting
296
+ // system. Drafts come from the draft checks (sending.js); issued invoices
297
+ // from the accounting system's stages, read from Flow's copy (no calls).
298
+ async function invoiceTabs(db, organisationId, system) {
299
+ const [views, issued, voided, stages] = await Promise.all([
300
+ invoiceViews(db, organisationId, { accounting: system }),
301
+ db.select({ id: invoices.id }).from(invoices).where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'))),
302
+ db.select({ id: invoices.id }).from(invoices).where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'void'))),
303
+ typeof system?.capabilities?.invoiceStages === 'function' ? system.capabilities.invoiceStages({ organisationId }) : new Map(),
304
+ ]);
305
+ const tabOf = new Map();
306
+ for (const id of [...views.toCheck, ...views.ready]) tabOf.set(id, 'ready');
307
+ for (const { id } of issued) tabOf.set(id, issuedTab(id, { views, stages, system }));
308
+ for (const { id } of voided) tabOf.set(id, 'void');
309
+ for (const id of views.problems.keys()) tabOf.set(id, 'pending');
310
+ const ids = Object.fromEntries(TABS.map((t) => [t, []]));
311
+ for (const [id, tab] of tabOf) ids[tab].push(id);
312
+ return { tabOf, ids, problems: views.problems, sending: views.sending };
313
+ }
314
+
315
+ function issuedTab(id, { views, stages, system }) {
316
+ if (views.problems.has(id)) return 'pending';
317
+ if (!system) return 'approved';
318
+ if (views.sending.has(id)) return 'draft';
319
+ return stages.get(id) ?? 'draft';
320
+ }
321
+
322
+ // One invoice's tab, for its page (and its pager).
323
+ async function tabOfInvoice(db, organisationId, invoice, view, accounting) {
324
+ if (invoice.status === 'void') return 'void';
325
+ if (view === 'problem') return 'pending';
326
+ if (invoice.status === 'draft') return 'ready';
327
+ if (!accounting) return 'approved';
328
+ if (view === 'sending') return 'draft';
329
+ const stages = typeof accounting.capabilities.invoiceStages === 'function'
330
+ ? await accounting.capabilities.invoiceStages({ organisationId, invoiceIds: [invoice.id] }) : new Map();
331
+ return stages.get(invoice.id) ?? 'draft';
332
+ }
333
+
284
334
  async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
285
335
  const invoice = await getInvoice(db, organisationId, invoiceId);
286
336
  const [[contact], versions, jobs, jobChange] = await Promise.all([
@@ -290,10 +340,12 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
290
340
  jobsByInvoice(db, organisationId, [invoiceId]),
291
341
  jobChangeDetail(db, organisationId, invoiceId, work),
292
342
  ]);
343
+ const viewed = await invoiceView(db, organisationId, invoice, { accounting });
293
344
  return {
294
345
  jobChange,
295
346
  ...invoice,
296
- ...(await invoiceView(db, organisationId, invoice, { accounting })),
347
+ ...viewed,
348
+ tab: await tabOfInvoice(db, organisationId, invoice, viewed.view, accounting),
297
349
  // Where a draft goes when it is sent, and how: this customer's send mode.
298
350
  sendsTo: accounting?.name ?? null,
299
351
  sendMode: accounting && invoice.status === 'draft'
@@ -307,11 +359,29 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
307
359
  };
308
360
  }
309
361
 
362
+ // A person's Recheck reads a job from its work system at most once in this
363
+ // time; a second click inside it answers from Flow's copy.
364
+ const RECHECK_GAP_MS = 10 * 1000;
365
+
366
+ // The buttons here that may call a connected system, and how long each waits
367
+ // before it runs again (the action guard, server/integrations/call-guard.js).
368
+ // A send or a draft is one at a time, and a draft waits per job, so ticking
369
+ // many jobs still works. Check prices reads Flow's copies only, so it has no
370
+ // wait.
371
+ export const INVOICING_ACTIONS = Object.freeze({
372
+ sendReady: { cooldownMs: 0, budget: false },
373
+ sendAgain: { cooldownMs: 0, budget: false },
374
+ draftJob: { cooldownMs: 10_000, budget: false },
375
+ });
376
+
310
377
  export function createInvoicingRouter({
311
378
  db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [], priceSystems = () => [],
379
+ buttonCooldowns = true, actions = createActionGuard({ integration: 'invoicing', actions: INVOICING_ACTIONS, off: !buttonCooldowns }),
312
380
  }) {
313
381
  const router = express.Router();
382
+ const act = (name, key) => actions.route(name, { key });
314
383
  router.use(express.json({ limit: '64kb' }));
384
+ const rechecked = new Map();
315
385
 
316
386
  const signedIn = [personAuth.requireSession];
317
387
  const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
@@ -327,61 +397,46 @@ export function createInvoicingRouter({
327
397
 
328
398
  router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
329
399
  const organisationId = await organisationOf(req);
330
- const status = text(req.query.status);
331
- if (status && ![...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].includes(status)) {
332
- throw invalid(`status must be one of ${[...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].join(', ')}`);
400
+ const asked = text(req.query.status);
401
+ const status = OLD_TABS[asked] ?? asked;
402
+ if (status && !TABS.includes(status) && !INVOICE_STATUSES.includes(status)) {
403
+ throw invalid(`status must be one of ${TABS.join(', ')}`);
333
404
  }
334
405
  const q = text(req.query.q);
335
406
  const limit = limitOf(req.query.limit, 200, 500);
336
- // Each tab switch runs this, so the independent reads run side by side;
337
- // only the to-check, ready and problem lists wait on the draft checks.
407
+ // Each tab switch runs this, so the independent reads run side by side.
338
408
  const system = accounting();
339
- const checking = invoiceViews(db, organisationId, { accounting: system });
340
- const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
341
- .where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
342
409
  const sendMode = system
343
410
  ? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
344
411
  .then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
345
412
  : null;
346
- const listing = (async () => {
347
- const views = VIEWS_OF_INVOICES.includes(status) ? await checking : null;
348
- const where = [eq(invoices.organisationId, organisationId)];
349
- const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
350
- if (status === 'to_check') where.push(only(views.toCheck));
351
- else if (status === 'ready') where.push(only(views.ready));
352
- else if (status === 'problem') where.push(only([...views.problems.keys()]));
353
- else if (status) where.push(eq(invoices.status, status));
354
- if (q) {
355
- const pattern = containing(q);
356
- where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
357
- }
358
- const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
359
- .leftJoin(contacts, eq(contacts.id, invoices.contactId))
360
- .where(and(...where))
361
- .orderBy(desc(invoices.createdAt), desc(invoices.id))
362
- .limit(limit);
363
- return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
364
- })();
365
- const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
413
+ const [tabs, mode] = await Promise.all([invoiceTabs(db, organisationId, system), sendMode]);
414
+ const where = [eq(invoices.organisationId, organisationId)];
415
+ const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
416
+ if (TABS.includes(status)) where.push(only(tabs.ids[status]));
417
+ else if (status) where.push(eq(invoices.status, status));
418
+ if (q) {
419
+ const pattern = containing(q);
420
+ where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
421
+ }
422
+ const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
423
+ .leftJoin(contacts, eq(contacts.id, invoices.contactId))
424
+ .where(and(...where))
425
+ .orderBy(desc(invoices.createdAt), desc(invoices.id))
426
+ .limit(limit);
427
+ const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
366
428
  const systems = work();
367
- const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
368
- for (const { status: s, n } of counted) counts[s] = n;
369
- counts.to_check = views.toCheck.length;
370
- counts.ready = views.ready.length;
371
- counts.problem = views.problems.size;
372
- const ready = new Set(views.ready);
373
- const toCheck = new Set(views.toCheck);
374
429
  res.json({
375
430
  ok: true,
376
431
  invoices: rows.map(({ invoice, contactName }) => ({
377
432
  ...invoice,
378
433
  contactName,
379
434
  job: withLink(jobs.get(invoice.id) ?? null, systems),
380
- view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
381
- : views.sending.has(invoice.id) ? 'sending' : null,
382
- problems: views.problems.get(invoice.id) ?? [],
435
+ tab: tabs.tabOf.get(invoice.id) ?? null,
436
+ sending: tabs.sending.has(invoice.id),
437
+ problems: tabs.problems.get(invoice.id) ?? [],
383
438
  })),
384
- counts,
439
+ counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
385
440
  sendsTo: system?.name ?? null,
386
441
  sendMode: mode,
387
442
  // The Linked column: one icon for each connected system.
@@ -411,7 +466,7 @@ export function createInvoicingRouter({
411
466
  res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
412
467
  }));
413
468
 
414
- router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
469
+ router.post('/invoices/send-ready', ...manager, act('sendReady'), handle(log, async (req, res) => {
415
470
  const organisationId = await organisationOf(req);
416
471
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
417
472
  const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
@@ -422,7 +477,7 @@ export function createInvoicingRouter({
422
477
  res.json({ ok: true, ...result });
423
478
  }));
424
479
 
425
- router.post('/invoices/send-again', ...manager, handle(log, async (req, res) => {
480
+ router.post('/invoices/send-again', ...manager, act('sendAgain'), handle(log, async (req, res) => {
426
481
  const organisationId = await organisationOf(req);
427
482
  const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
428
483
  const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
@@ -488,7 +543,7 @@ export function createInvoicingRouter({
488
543
  // Ready and Send do.
489
544
  const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
490
545
  const held = problems.find((p) => p.code === 'job_review');
491
- if (held) throw new FlowError('not_ready', `${held.message} Nothing was finalised.`);
546
+ if (held) throw new FlowError('not_ready', `${held.message} Nothing was approved.`);
492
547
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
493
548
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
494
549
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
@@ -558,7 +613,7 @@ export function createInvoicingRouter({
558
613
  res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
559
614
  }));
560
615
 
561
- router.post('/jobs/:id/draft', ...manager, handle(log, async (req, res) => {
616
+ router.post('/jobs/:id/draft', ...manager, act('draftJob', (req) => req.params.id), handle(log, async (req, res) => {
562
617
  const obligationId = pathId(req.params.id, 'job');
563
618
  const organisationId = await organisationOf(req);
564
619
  // With no accounting system connected, Flow's own records are the check.
@@ -586,10 +641,38 @@ export function createInvoicingRouter({
586
641
  router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
587
642
  const obligationId = pathId(req.params.id, 'job');
588
643
  const organisationId = await organisationOf(req);
589
- const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
644
+ const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}`, accept: req.body?.accept === true });
590
645
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
591
646
  }));
592
647
 
648
+ // "Recheck": a person thinks the work system's copy of this job is old or
649
+ // was wrong and has been fixed there. Flow reads this one job from the work
650
+ // system now (one call, its readWorkItem) into the ledger, as read before
651
+ // act does; a change hold whose job matches the draft again is over. Then
652
+ // the invoice page reloads, so every check runs again. Reads only.
653
+ router.post('/jobs/:id/recheck', ...signedIn, handle(log, async (req, res) => {
654
+ const obligationId = pathId(req.params.id, 'job');
655
+ const organisationId = await organisationOf(req);
656
+ const [obligation] = await db.select().from(billingObligations)
657
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
658
+ if (!obligation) throw new FlowError('not_found', 'job not found');
659
+ const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
660
+ if (typeof source?.capabilities?.readWorkItem !== 'function') throw new FlowError('not_ready', 'the work system cannot read one job');
661
+ // One read for each job in RECHECK_GAP_MS, whatever the clicks.
662
+ const last = rechecked.get(obligationId) ?? 0;
663
+ let outcome = 'fresh';
664
+ if (Date.now() - last >= RECHECK_GAP_MS) {
665
+ rechecked.set(obligationId, Date.now());
666
+ const actor = `person:${req.person.id}`;
667
+ const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor, force: true })).get(obligationId);
668
+ outcome = 'read';
669
+ if (read.outcome === 'waiting') throw new FlowError('not_ready', `${source.name} could not be read: ${read.reason ?? 'try again soon'}`);
670
+ await settleJobChange(db, organisationId, obligationId, { actor });
671
+ }
672
+ const invoice = obligation.invoiceId ? await invoiceDetail(db, organisationId, obligation.invoiceId, accounting(), work()) : null;
673
+ res.json({ ok: true, outcome, invoice });
674
+ }));
675
+
593
676
  // A job with no invoice whose match is unclear (ledger.js). The linking
594
677
  // tool lists the matcher's suggestions, read from the accounting copy;
595
678
  // "Make invoice" says there is none yet; "Link" picks one of them.
@@ -838,6 +921,21 @@ export function createInvoicingRouter({
838
921
  res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
839
922
  }));
840
923
 
924
+ router.post('/price-links', ...manager, handle(log, async (req, res) => {
925
+ const organisationId = await organisationOf(req);
926
+ const raw = req.body?.links;
927
+ if (!Array.isArray(raw) || !raw.length || raw.length > MAX_LINKS_AT_ONCE) throw invalid(`links must be a list of 1 to ${MAX_LINKS_AT_ONCE} links`);
928
+ const links = raw.map((l, at) => {
929
+ const system = text(l?.system);
930
+ const externalId = text(l?.externalId);
931
+ if (!system || !externalId) throw invalid(`links[${at}]: system and externalId are required`);
932
+ return { priceItemId: pathId(l?.priceItemId, 'price item'), system, externalId };
933
+ });
934
+ const made = await linkPriceItems(db, organisationId, links, { catalogues: await catalogues(organisationId), personId: req.person.id });
935
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made ${made.length} price links`);
936
+ res.status(201).json({ ok: true, linked: made.length });
937
+ }));
938
+
841
939
  router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
842
940
  const itemId = pathId(req.params.id, 'price item');
843
941
  const organisationId = await organisationOf(req);
package/server/app.js CHANGED
@@ -52,6 +52,9 @@ export function createApp({
52
52
  signInRateLimitPerMinute = 30,
53
53
  clerkProxyRateLimitPerMinute = 300,
54
54
  defaultFamily = DEFAULT_FAMILY,
55
+ // Buttons that call Xero or Seazona wait between presses (the action
56
+ // guard, server/integrations/call-guard.js). Off only in tests.
57
+ buttonCooldowns = true,
55
58
  log = console,
56
59
  tracing = null,
57
60
  } = {}) {
@@ -141,7 +144,7 @@ export function createApp({
141
144
  // The price list: every enabled integration that keeps its own (Xero
142
145
  // items, Seazona products).
143
146
  const priceSystems = () => integrations?.with('listPriceItems') ?? [];
144
- app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems }));
147
+ app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems, buttonCooldowns }));
145
148
  // Custom fields for jobs (server/core/custom-fields.js): kept in Flow
146
149
  // only; the work systems' statuses show beside them, read only.
147
150
  app.use('/api/custom-fields', createCustomFieldsRouter({ db: database.db, personAuth: auth.personAuth, work, log }));
@@ -172,7 +175,7 @@ export function createApp({
172
175
  res.json(settings ? { ok: true, on: true, service: adapter.id, ...settings } : { ok: true, on: false });
173
176
  });
174
177
  }
175
- for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
178
+ for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page, buttonCooldowns }) ?? []) {
176
179
  app.use(path, handler);
177
180
  }
178
181
 
@@ -55,7 +55,7 @@ export function invoiceProblemsSource(db, { accounting = () => null } = {}) {
55
55
  page: 'invoices',
56
56
  tone: 'warn',
57
57
  title: `${plural(ids.length, 'invoice has', 'invoices have')} a problem.`,
58
- action: { label: 'Open Problem', tab: 'problem' },
58
+ action: { label: 'Open Pending', tab: 'pending' },
59
59
  moneyAtRiskCents: Number(total),
60
60
  members: ids,
61
61
  }];
@@ -91,7 +91,7 @@ export const AUTOMATIONS = Object.freeze([
91
91
  key: 'ready_when_drafted',
92
92
  place: 'invoicing',
93
93
  name: 'Ready when drafted',
94
- hint: 'An automatic draft goes straight to Ready. A draft a person changed stays in Drafts.',
94
+ hint: 'A draft with no problem goes straight to Ready.',
95
95
  defaults: { enabled: true, config: {} },
96
96
  readConfig: () => ({}),
97
97
  },