@arcforgelabs/flow 2026.10.37 → 2026.10.39

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 (57) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +27 -9
  5. package/server/api/invoicing.js +198 -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 +89 -3
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +109 -8
  13. package/server/core/price-list.js +27 -2
  14. package/server/core/read-before-act.js +3 -2
  15. package/server/core/sending.js +36 -18
  16. package/server/db/doctor.js +18 -19
  17. package/server/db/index.js +74 -3
  18. package/server/db/migrate-all.js +3 -4
  19. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  20. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +14 -0
  23. package/server/integrations/call-guard.js +148 -0
  24. package/server/integrations/port.js +3 -2
  25. package/server/integrations/schema.js +9 -5
  26. package/server/integrations/seazona/feed.js +4 -1
  27. package/server/integrations/seazona/settings.js +8 -4
  28. package/server/integrations/xero/drafts.js +4 -4
  29. package/server/integrations/xero/fit.js +108 -0
  30. package/server/integrations/xero/import.js +6 -1
  31. package/server/integrations/xero/index.js +6 -2
  32. package/server/integrations/xero/ledger.js +3 -0
  33. package/server/integrations/xero/links.js +293 -0
  34. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  35. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/mirror.js +4 -3
  38. package/server/integrations/xero/reconcile.js +42 -12
  39. package/server/integrations/xero/routes.js +66 -7
  40. package/server/integrations/xero/settings.js +6 -5
  41. package/server/ui/app-page.js +1 -1
  42. package/server/ui/app.js +89 -3
  43. package/server/ui/bulk-bar.js +4 -3
  44. package/server/ui/custom-fields.js +252 -94
  45. package/server/ui/flow.css +326 -29
  46. package/server/ui/invoice-notes.js +2 -2
  47. package/server/ui/invoicing.js +92 -137
  48. package/server/ui/job-board.js +9 -7
  49. package/server/ui/job-change.js +23 -12
  50. package/server/ui/job-view.js +189 -31
  51. package/server/ui/jobs.js +738 -70
  52. package/server/ui/price-list.js +43 -86
  53. package/server/ui/price-match.js +93 -0
  54. package/server/ui/price-reconcile.js +357 -0
  55. package/server/ui/reconcile.js +275 -0
  56. package/server/ui/routes.js +1 -1
  57. package/server/ui/xero-import.js +329 -0
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 |
@@ -55,6 +55,7 @@ run a lab.
55
55
  | `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
56
56
  | `docs/INTEGRATIONS.md` | How an integration adapter is built, turned on and off, and owns its tables |
57
57
  | `docs/XERO.md` | The Xero adapter: modes, settings, rate limits, connecting Xero and draft invoices |
58
+ | `docs/XERO_IMPORT.md` | Xero › Import: each Xero invoice in Flow's copy linked to its Flow invoice, or made into one |
58
59
  | `docs/SEAZONA.md` | The Seazona adapter: modes, settings, the rate governor, replay files and its tables |
59
60
  | `docs/SENTRY.md` | The Sentry adapter: errors and problem reports to the installation's own Sentry project |
60
61
  | `server/integrations/port.js` | The integration port every adapter implements |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.37",
3
+ "version": "2026.10.39",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.37",
9
+ "version": "2026.10.39",
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.37",
3
+ "version": "2026.10.39",
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, 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}`);
@@ -71,8 +75,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
71
75
  router.get('/', ...signedIn, handle(async (req, res) => {
72
76
  const organisationId = await organisationOf(req);
73
77
  const archived = req.query.archived === '1' || req.query.archived === 'true';
74
- const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
75
- res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
78
+ await ensureAssignee(db, organisationId);
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 });
76
83
  }));
77
84
 
78
85
  router.post('/', ...manager, handle(async (req, res) => {
@@ -85,6 +92,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
85
92
  res.json({ ok: true, fields });
86
93
  }));
87
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
+
88
100
  router.get('/values', ...signedIn, handle(async (req, res) => {
89
101
  res.json({ ok: true, values: await listJobFieldValues(db, await organisationOf(req)) });
90
102
  }));
@@ -111,5 +123,11 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
111
123
  res.json({ ok: true, field });
112
124
  }));
113
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
+
114
132
  return router;
115
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';
@@ -17,12 +18,14 @@ import {
17
18
  } from '../core/invoicing-rules.js';
18
19
  import { boardJobs, describeObligations } from '../core/job-board.js';
19
20
  import { readBeforeAct } from '../core/read-before-act.js';
21
+ import { invoiceMatchDetail, jobMatchDetail, listReconcile } from '../core/job-match.js';
20
22
  import { jobReference } from '../core/references.js';
21
23
  import {
22
- acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
24
+ acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, linkPriceItems, MAX_LINKS_AT_ONCE, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
23
25
  } from '../core/price-list.js';
24
26
  import {
25
- draftInvoiceForObligation, explainObligation, jobChangeFor, listObligations, setReady, useSourceLines,
27
+ confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
28
+ setReady, settleJobChange, useSourceLines,
26
29
  } from '../core/ledger.js';
27
30
  import { isHistoryHeld } from '../core/history.js';
28
31
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
@@ -35,8 +38,8 @@ import {
35
38
  // UI's invoices, customers, price list and jobs pages read and write here.
36
39
  //
37
40
  // GET /context the organisations, and whether this person may issue
38
- // GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by status;
39
- // 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)
40
43
  // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
41
44
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
42
45
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
@@ -79,6 +82,7 @@ import {
79
82
  // POST /price-items/:id/links owner or admin: { system, externalId } link a record of a system's list
80
83
  // POST /price-items/:id/resolve owner or admin: Mark resolved: check again, accept what still differs
81
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
82
86
  // GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
83
87
  // PUT /automations/:key owner or admin: { enabled, config }
84
88
  // GET /work-statuses the work system's job statuses, with job counts
@@ -99,7 +103,15 @@ import {
99
103
 
100
104
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
101
105
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
102
- 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' };
103
115
  const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
104
116
  const MAX_LINES = 200;
105
117
  const MAX_TEXT = 500;
@@ -233,7 +245,7 @@ export async function resolveOrganisation(db, raw) {
233
245
  }
234
246
 
235
247
  const jobOf = (o) => (o ? {
236
- 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,
237
249
  } : null);
238
250
 
239
251
  // A job with its work system's name and its link in that system's web app
@@ -279,6 +291,46 @@ async function jobChangeDetail(db, organisationId, invoiceId, work) {
279
291
  return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
280
292
  }
281
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
+
282
334
  async function invoiceDetail(db, organisationId, invoiceId, accounting = null, work = []) {
283
335
  const invoice = await getInvoice(db, organisationId, invoiceId);
284
336
  const [[contact], versions, jobs, jobChange] = await Promise.all([
@@ -288,10 +340,12 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
288
340
  jobsByInvoice(db, organisationId, [invoiceId]),
289
341
  jobChangeDetail(db, organisationId, invoiceId, work),
290
342
  ]);
343
+ const viewed = await invoiceView(db, organisationId, invoice, { accounting });
291
344
  return {
292
345
  jobChange,
293
346
  ...invoice,
294
- ...(await invoiceView(db, organisationId, invoice, { accounting })),
347
+ ...viewed,
348
+ tab: await tabOfInvoice(db, organisationId, invoice, viewed.view, accounting),
295
349
  // Where a draft goes when it is sent, and how: this customer's send mode.
296
350
  sendsTo: accounting?.name ?? null,
297
351
  sendMode: accounting && invoice.status === 'draft'
@@ -305,11 +359,29 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
305
359
  };
306
360
  }
307
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
+
308
377
  export function createInvoicingRouter({
309
378
  db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [], priceSystems = () => [],
379
+ buttonCooldowns = true, actions = createActionGuard({ integration: 'invoicing', actions: INVOICING_ACTIONS, off: !buttonCooldowns }),
310
380
  }) {
311
381
  const router = express.Router();
382
+ const act = (name, key) => actions.route(name, { key });
312
383
  router.use(express.json({ limit: '64kb' }));
384
+ const rechecked = new Map();
313
385
 
314
386
  const signedIn = [personAuth.requireSession];
315
387
  const manager = [...signedIn, personAuth.requireRole('owner', 'admin')];
@@ -325,61 +397,46 @@ export function createInvoicingRouter({
325
397
 
326
398
  router.get('/invoices', ...signedIn, handle(log, async (req, res) => {
327
399
  const organisationId = await organisationOf(req);
328
- const status = text(req.query.status);
329
- if (status && ![...INVOICE_STATUSES, ...VIEWS_OF_INVOICES].includes(status)) {
330
- 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(', ')}`);
331
404
  }
332
405
  const q = text(req.query.q);
333
406
  const limit = limitOf(req.query.limit, 200, 500);
334
- // Each tab switch runs this, so the independent reads run side by side;
335
- // 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.
336
408
  const system = accounting();
337
- const checking = invoiceViews(db, organisationId, { accounting: system });
338
- const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
339
- .where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
340
409
  const sendMode = system
341
410
  ? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
342
411
  .then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
343
412
  : null;
344
- const listing = (async () => {
345
- const views = VIEWS_OF_INVOICES.includes(status) ? await checking : null;
346
- const where = [eq(invoices.organisationId, organisationId)];
347
- const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
348
- if (status === 'to_check') where.push(only(views.toCheck));
349
- else if (status === 'ready') where.push(only(views.ready));
350
- else if (status === 'problem') where.push(only([...views.problems.keys()]));
351
- else if (status) where.push(eq(invoices.status, status));
352
- if (q) {
353
- const pattern = containing(q);
354
- where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
355
- }
356
- const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
357
- .leftJoin(contacts, eq(contacts.id, invoices.contactId))
358
- .where(and(...where))
359
- .orderBy(desc(invoices.createdAt), desc(invoices.id))
360
- .limit(limit);
361
- return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
362
- })();
363
- 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));
364
428
  const systems = work();
365
- const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
366
- for (const { status: s, n } of counted) counts[s] = n;
367
- counts.to_check = views.toCheck.length;
368
- counts.ready = views.ready.length;
369
- counts.problem = views.problems.size;
370
- const ready = new Set(views.ready);
371
- const toCheck = new Set(views.toCheck);
372
429
  res.json({
373
430
  ok: true,
374
431
  invoices: rows.map(({ invoice, contactName }) => ({
375
432
  ...invoice,
376
433
  contactName,
377
434
  job: withLink(jobs.get(invoice.id) ?? null, systems),
378
- view: views.problems.has(invoice.id) ? 'problem' : ready.has(invoice.id) ? 'ready' : toCheck.has(invoice.id) ? 'to_check'
379
- : views.sending.has(invoice.id) ? 'sending' : null,
380
- 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) ?? [],
381
438
  })),
382
- counts,
439
+ counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
383
440
  sendsTo: system?.name ?? null,
384
441
  sendMode: mode,
385
442
  // The Linked column: one icon for each connected system.
@@ -409,7 +466,7 @@ export function createInvoicingRouter({
409
466
  res.json({ ok: true, ...(await markNotReady(db, organisationId, invoiceIdsOf(req.body))) });
410
467
  }));
411
468
 
412
- 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) => {
413
470
  const organisationId = await organisationOf(req);
414
471
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
415
472
  const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
@@ -420,7 +477,7 @@ export function createInvoicingRouter({
420
477
  res.json({ ok: true, ...result });
421
478
  }));
422
479
 
423
- 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) => {
424
481
  const organisationId = await organisationOf(req);
425
482
  const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
426
483
  const result = await sendAgain(db, organisationId, { accounting: accounting(), personId: req.person.id, invoiceIds: ids });
@@ -486,7 +543,7 @@ export function createInvoicingRouter({
486
543
  // Ready and Send do.
487
544
  const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
488
545
  const held = problems.find((p) => p.code === 'job_review');
489
- 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.`);
490
547
  const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
491
548
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
492
549
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
@@ -556,7 +613,7 @@ export function createInvoicingRouter({
556
613
  res.json({ ok: true, jobs, statuses, accountingName: accounting()?.name ?? null });
557
614
  }));
558
615
 
559
- 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) => {
560
617
  const obligationId = pathId(req.params.id, 'job');
561
618
  const organisationId = await organisationOf(req);
562
619
  // With no accounting system connected, Flow's own records are the check.
@@ -584,10 +641,85 @@ export function createInvoicingRouter({
584
641
  router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
585
642
  const obligationId = pathId(req.params.id, 'job');
586
643
  const organisationId = await organisationOf(req);
587
- 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 });
588
645
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
589
646
  }));
590
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
+
676
+ // A job with no invoice whose match is unclear (ledger.js). The linking
677
+ // tool lists the matcher's suggestions, read from the accounting copy;
678
+ // "Make invoice" says there is none yet; "Link" picks one of them.
679
+ // The Reconcile page: Flow's jobs with no Flow invoice, Flow invoices with
680
+ // no job, and one of them beside its possible other side
681
+ // (core/job-match.js). Flow's own records only; no integration is called.
682
+ router.get('/reconcile', ...signedIn, handle(log, async (req, res) => {
683
+ const organisationId = await organisationOf(req);
684
+ res.json({ ok: true, ...await listReconcile(db, organisationId) });
685
+ }));
686
+
687
+ router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
688
+ const obligationId = pathId(req.params.id, 'job');
689
+ const organisationId = await organisationOf(req);
690
+ const [obligation] = await db.select().from(billingObligations)
691
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
692
+ if (!obligation) throw new FlowError('not_found', 'job not found');
693
+ // The Reference Flow would give its invoice, from Flow's own copy only.
694
+ const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
695
+ const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
696
+ const reference = await jobReference(db, organisationId, obligation, { template, work: source });
697
+ const detail = await jobMatchDetail(db, organisationId, obligationId, { reference, q: text(req.query.q) ?? '' });
698
+ res.json({ ok: true, ...detail, job: { ...detail.job, sourceName: source?.name ?? null } });
699
+ }));
700
+
701
+ router.get('/invoices/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
702
+ const invoiceId = pathId(req.params.id, 'invoice');
703
+ const organisationId = await organisationOf(req);
704
+ res.json({ ok: true, ...await invoiceMatchDetail(db, organisationId, invoiceId, { q: text(req.query.q) ?? '' }) });
705
+ }));
706
+
707
+ router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
708
+ const obligationId = pathId(req.params.id, 'job');
709
+ const organisationId = await organisationOf(req);
710
+ const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
711
+ res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
712
+ }));
713
+
714
+ // Links a job to a Flow invoice that already exists.
715
+ router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
716
+ const obligationId = pathId(req.params.id, 'job');
717
+ const organisationId = await organisationOf(req);
718
+ const invoiceId = pathId(text(req.body?.invoiceId) ?? '', 'invoice');
719
+ const job = await linkJobToInvoice(db, organisationId, obligationId, { invoiceId, actor: `person:${req.person.id}` });
720
+ res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId } });
721
+ }));
722
+
591
723
  router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
592
724
  const obligationId = pathId(req.params.id, 'job');
593
725
  const organisationId = await organisationOf(req);
@@ -789,6 +921,21 @@ export function createInvoicingRouter({
789
921
  res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
790
922
  }));
791
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
+
792
939
  router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
793
940
  const itemId = pathId(req.params.id, 'price item');
794
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
  },