@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
@@ -113,6 +113,20 @@
113
113
  "when": 1792452497948,
114
114
  "tag": "0015_alert_dismissals",
115
115
  "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1792456270558,
121
+ "tag": "0016_invoice_reference",
122
+ "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1792458167234,
128
+ "tag": "0017_sync_basis",
129
+ "breakpoints": true
116
130
  }
117
131
  ]
118
132
  }
@@ -250,6 +250,9 @@ export const billingObligations = pgTable('billing_obligations', {
250
250
  holdReason: text('hold_reason'),
251
251
  // The Flow invoice made for this job.
252
252
  invoiceId: uuid('invoice_id'),
253
+ // The job facts the invoice was built from: the merge base for the
254
+ // three-way compare when the job changes later (server/core/ledger.js).
255
+ draftBasis: jsonb('draft_basis'),
253
256
  // What the accounting system reported, how it matched, and when Flow checked.
254
257
  accountingState: text('accounting_state').notNull().default('unknown'),
255
258
  accountingRef: text('accounting_ref'),
@@ -624,6 +627,8 @@ export const invoicingSettings = pgTable('invoicing_settings', {
624
627
  // The price item code of a rush charge, and its share of the other lines.
625
628
  rushItemCode: text('rush_item_code'),
626
629
  rushPercent: integer('rush_percent').notNull().default(20),
630
+ // The invoice Reference, from tokens (server/core/references.js).
631
+ referenceTemplate: text('reference_template').notNull().default('{practitioner} - {patient}'),
627
632
  // The fixed checks above became invoice rules (invoice_rules). True once
628
633
  // this organisation's starter rules were made from them.
629
634
  rulesSeeded: boolean('rules_seeded').notNull().default(false),
package/server/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from './import/at-start.js';
12
12
  import { createMonitoredLog } from './core/monitoring.js';
13
13
  import { createPipelineWorker } from './core/pipeline.js';
14
+ import { createReconcileWorker } from './core/reconcile.js';
14
15
  import { createSendWorker } from './core/sending.js';
15
16
  import { resolveSecretsKey } from './core/secrets.js';
16
17
  import { adapters as shippedAdapters } from './integrations/adapters.js';
@@ -97,6 +98,10 @@ console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets
97
98
  if (process.env.FLOW_PIPELINE !== 'off') {
98
99
  createPipelineWorker({ db: database.db, integrations, log }).start();
99
100
  }
101
+ // The daily reconcile (server/core/reconcile.js) compares Flow's own copies
102
+ // and opens a review item for a day that does not agree. It never calls or
103
+ // writes to another system.
104
+ createReconcileWorker({ db: database.db, integrations, log }).start();
100
105
  // The send worker (server/core/sending.js) sends only what a person queued
101
106
  // with Send all ready, to the accounting integration that is on.
102
107
  createSendWorker({ db: database.db, integrations, log }).start();
@@ -11,6 +11,7 @@ import { isMissingTable, migrateAdapterSchema } from './schema.js';
11
11
 
12
12
  export const CAPABILITIES = Object.freeze([
13
13
  'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
14
+ 'readWorkItem', // { state: read | withdrawn | unavailable }: one job read from the work system now, as pullWorkItems gives it (read before act, server/core/read-before-act.js)
14
15
  'pullHistoryJobs', // past work from a history import, as ledger jobs marked historical (Seazona: its API backfill)
15
16
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
16
17
  'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
@@ -20,10 +21,13 @@ export const CAPABILITIES = Object.freeze([
20
21
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
21
22
  'pullPayments', // payments since a cursor, matched to invoices and bills
22
23
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
24
+ 'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
25
+ 'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
23
26
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
24
27
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
25
28
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
26
29
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
30
+ 'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
27
31
  'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
28
32
  'health', // connection state for Settings
29
33
  'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
@@ -0,0 +1,48 @@
1
+ import { readBillableOrder } from './feed.js';
2
+ import { readOrder } from './lanes.js';
3
+
4
+ // Read before act (issue #141). Just before Flow drafts or sends from a job
5
+ // whose report is old, it reads that one order from Seazona: one
6
+ // GET orders/{id}, through the call guard, stored through the mirror like
7
+ // any lane read. The answer is the order as the feed gives it:
8
+ //
9
+ // { state: 'read', item } billable; `item` is a feed item
10
+ // { state: 'withdrawn', ledgerWithdrawn, reason }
11
+ // deleted in Seazona, or no longer
12
+ // billable
13
+ // { state: 'unavailable', reason } the read failed or calls are stopped
14
+ //
15
+ // The day's counts are kept in memory for /health; a restart starts them again.
16
+ export function createActReader({ now }) {
17
+ let tally = null;
18
+ function count(key) {
19
+ const day = new Date(Number(now())).toISOString().slice(0, 10);
20
+ if (tally?.day !== day) tally = { day, reads: 0, changed: 0, withdrawn: 0, unavailable: 0 };
21
+ tally[key] += 1;
22
+ }
23
+ const unavailable = (reason) => {
24
+ count('unavailable');
25
+ return { state: 'unavailable', reason };
26
+ };
27
+
28
+ async function read({ db, client, settings }, orderId) {
29
+ count('reads');
30
+ let result;
31
+ try {
32
+ result = await readOrder({ db, client, sync: settings.sync, at: new Date(Number(now())) }, orderId, { known: true });
33
+ } catch (error) {
34
+ return unavailable(error?.message || 'the read failed');
35
+ }
36
+ if (result.outcome === 'failed') return unavailable(result.failure?.error || 'the read failed');
37
+ if (result.summary?.changed || result.summary?.created) count('changed');
38
+ const entry = await readBillableOrder(db, orderId);
39
+ if (!entry) return unavailable('Flow has no copy of the order');
40
+ if (entry.withdrawn) {
41
+ count('withdrawn');
42
+ return { state: 'withdrawn', ledgerWithdrawn: entry.withdrawn.ledgerWithdrawn, reason: entry.withdrawn.reason };
43
+ }
44
+ return { state: 'read', item: entry.item };
45
+ }
46
+
47
+ return { read, status: () => (tally ? { ...tally } : null) };
48
+ }
@@ -1,5 +1,5 @@
1
1
  import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
2
- import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
2
+ import { ledgerLinesFor, toLedgerJob, toLedgerWithdrawn } from './ledger.js';
3
3
  import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
4
4
  import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from './schema.js';
5
5
 
@@ -11,9 +11,16 @@ import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from
11
11
  //
12
12
  // The cursor is opaque; pass back `cursor` from the last page. A change to
13
13
  // the order or to its invoice moves the order to the end of the feed.
14
+ //
15
+ // A read that found no change moves the order to the end of the feed too, at
16
+ // most once an hour (`SEEN_STEP`). Without it, an order that never changes
17
+ // is reported once, its ledger report gets older than a day, and the ledger
18
+ // refuses its draft as "out of date" for ever, though the lanes read the
19
+ // order every few minutes.
14
20
 
15
21
  const BILLABLE = /\b(complete|completed|shipped|delivered)\b/i;
16
22
  const SHIPPED = /\b(shipped|delivered)\b/i;
23
+ const SEEN_STEP = 'hour';
17
24
 
18
25
  function decodeCursor(cursor) {
19
26
  if (!cursor) return null;
@@ -29,11 +36,9 @@ function encodeCursor(at, id) {
29
36
  return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
30
37
  }
31
38
 
32
- export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
33
- const after = decodeCursor(cursor);
34
- const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}))`;
35
- const pageSize = Math.max(1, Math.min(1000, limit));
36
- const rows = await db.select({
39
+ // One order, its invoice and its client, as the feed reads them.
40
+ function feedQuery(db, changedAt) {
41
+ return db.select({
37
42
  order: seazonaOrders,
38
43
  invoice: seazonaInvoices,
39
44
  client: { accountNumber: seazonaClients.accountNumber, company: seazonaClients.company, email: seazonaClients.email },
@@ -43,56 +48,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
43
48
  })
44
49
  .from(seazonaOrders)
45
50
  .leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
46
- .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
51
+ .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId));
52
+ }
53
+
54
+ // A feed row as an item, or as a withdrawn entry when it is not billable.
55
+ function toFeedEntry({ order, invoice, client, invoiceOrderCount }) {
56
+ const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
57
+ if (!billable) {
58
+ const entry = {
59
+ sourceId: order.id,
60
+ status: orderStatusLabel(order),
61
+ reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
62
+ };
63
+ const syncedAt = order.lastSyncedAt?.toISOString() ?? null;
64
+ return { withdrawn: { ...entry, ledgerWithdrawn: toLedgerWithdrawn({ ...entry, syncedAt }) } };
65
+ }
66
+ const item = {
67
+ source: 'seazona',
68
+ sourceId: order.id,
69
+ invoiceNumber: order.invoiceNumber,
70
+ status: order.status,
71
+ shipped: SHIPPED.test(order.status ?? ''),
72
+ clientId: order.clientId,
73
+ company: order.company,
74
+ doctorName: order.doctorName,
75
+ orderedOn: parseSeazonaDate(order.orderedText),
76
+ dueOn: parseSeazonaDate(order.dueText),
77
+ lines: order.lines,
78
+ subtotalCents: order.subtotalCents,
79
+ patientReference: order.patientReference,
80
+ syncedAt: order.lastSyncedAt.toISOString(),
81
+ invoice: invoice ? {
82
+ invoiceNumber: invoice.invoiceNumber,
83
+ status: invoice.status,
84
+ accountNumber: invoice.accountNumber,
85
+ invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
86
+ dueDate: parseSeazonaDate(invoice.dueText),
87
+ lines: invoice.lines,
88
+ subtotalCents: invoice.subtotalCents,
89
+ taxCents: invoice.taxCents,
90
+ totalCents: invoice.totalCents,
91
+ discountCents: invoice.discountCents,
92
+ patientReference: invoice.patientReference,
93
+ } : null,
94
+ client: client?.accountNumber || client?.company || client?.email ? client : null,
95
+ invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
96
+ };
97
+ return { item: { ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) } };
98
+ }
99
+
100
+ export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
101
+ const after = decodeCursor(cursor);
102
+ const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
103
+ const pageSize = Math.max(1, Math.min(1000, limit));
104
+ const rows = await feedQuery(db, changedAt)
47
105
  .where(after ? sql`(${changedAt}, ${seazonaOrders.id}) > (${after.at}::timestamptz, ${after.id})` : undefined)
48
106
  .orderBy(changedAt, seazonaOrders.id)
49
107
  .limit(pageSize);
50
108
 
51
109
  const items = [];
52
110
  const withdrawn = [];
53
- for (const { order, invoice, client, invoiceOrderCount } of rows) {
54
- const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
55
- if (!billable) {
56
- const entry = {
57
- sourceId: order.id,
58
- status: orderStatusLabel(order),
59
- reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
60
- };
61
- withdrawn.push({ ...entry, ledgerHold: toLedgerHold(entry) });
62
- continue;
63
- }
64
- const item = {
65
- source: 'seazona',
66
- sourceId: order.id,
67
- invoiceNumber: order.invoiceNumber,
68
- status: order.status,
69
- shipped: SHIPPED.test(order.status ?? ''),
70
- clientId: order.clientId,
71
- company: order.company,
72
- doctorName: order.doctorName,
73
- orderedOn: parseSeazonaDate(order.orderedText),
74
- dueOn: parseSeazonaDate(order.dueText),
75
- lines: order.lines,
76
- subtotalCents: order.subtotalCents,
77
- patientReference: order.patientReference,
78
- syncedAt: order.lastSyncedAt.toISOString(),
79
- invoice: invoice ? {
80
- invoiceNumber: invoice.invoiceNumber,
81
- status: invoice.status,
82
- accountNumber: invoice.accountNumber,
83
- invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
84
- dueDate: parseSeazonaDate(invoice.dueText),
85
- lines: invoice.lines,
86
- subtotalCents: invoice.subtotalCents,
87
- taxCents: invoice.taxCents,
88
- totalCents: invoice.totalCents,
89
- discountCents: invoice.discountCents,
90
- patientReference: invoice.patientReference,
91
- } : null,
92
- client: client?.accountNumber || client?.company || client?.email ? client : null,
93
- invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
94
- };
95
- items.push({ ...item, linesFrom: ledgerLinesFor(item).linesFrom, ledgerJob: toLedgerJob(item) });
111
+ for (const row of rows) {
112
+ const entry = toFeedEntry(row);
113
+ if (entry.item) items.push(entry.item);
114
+ else withdrawn.push(entry.withdrawn);
96
115
  }
97
116
  const lastRow = rows.at(-1);
98
117
  return {
@@ -103,6 +122,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
103
122
  };
104
123
  }
105
124
 
125
+ // One order as the feed gives it (issue #141, read before act): `{ item }`
126
+ // when it is billable, `{ withdrawn }` when it is not, or null when Flow's
127
+ // copy does not have it.
128
+ export async function readBillableOrder(db, orderId) {
129
+ const [row] = await feedQuery(db, seazonaOrders.lastChangedAt).where(eq(seazonaOrders.id, orderId));
130
+ return row ? toFeedEntry(row) : null;
131
+ }
132
+
106
133
  // Seazona clients, by id and the company name an invoice bills. A client
107
134
  // with no company name is listed by its full name. A client the client list
108
135
  // never gave (the list can be closed to an API client) is listed by the
@@ -68,6 +68,30 @@ export class SeazonaCallsStopped extends CallsStopped {
68
68
  }
69
69
  }
70
70
 
71
+ // A request path with its record ids taken out, so /health can say which
72
+ // calls failed and never names a record: /v1/orders/5591234 is
73
+ // /v1/orders/:id. A name is lowercase words joined by hyphens, or a version.
74
+ export function pathShape(path) {
75
+ const name = /^(v\d+|[a-z]+(-[a-z]+)*)$/;
76
+ return `/${String(path || '').split('/').filter(Boolean).map((part) => (name.test(part) ? part : ':id')).join('/')}`;
77
+ }
78
+
79
+ // Issue #144: the last day's failed calls split into faults (what the call
80
+ // guard counts, and a vendor may count against the API client) and 404s
81
+ // (a record that is gone), and the top groups by path shape, status and
82
+ // kind.
83
+ function failureKinds(day, top = 10) {
84
+ const groups = new Map();
85
+ for (const row of day.failures) {
86
+ const group = { path: pathShape(row.path), statusCode: row.statusCode ?? null, failureKind: row.failureKind ?? null };
87
+ const key = JSON.stringify(group);
88
+ groups.set(key, { ...group, requests: (groups.get(key)?.requests ?? 0) + row.requests });
89
+ }
90
+ const sorted = [...groups.values()].sort((a, b) => b.requests - a.requests).slice(0, top);
91
+ // Every failed call is a fault but a 404 (countsAsFailure, call-guard.js).
92
+ return { faults: day.failed - day.notFound, notFound: day.notFound, failures: sorted };
93
+ }
94
+
71
95
  export function createSeazonaGovernor({
72
96
  db,
73
97
  clientKey,
@@ -171,7 +195,7 @@ export function createSeazonaGovernor({
171
195
  const calls = await guard.status(key);
172
196
  const [{ lastMinute }] = await db.select({ lastMinute: count() }).from(seazonaApiRequests)
173
197
  .where(and(eq(seazonaApiRequests.clientKey, key), gte(seazonaApiRequests.admittedAt, new Date(nowMs - WINDOW_MS))));
174
- const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs) });
198
+ const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs), topFailures: 500 });
175
199
  const cooldownUntil = state && state.cooldownUntil.getTime() > nowMs ? state.cooldownUntil.toISOString() : null;
176
200
  return {
177
201
  limitPerMinute: limit,
@@ -184,7 +208,7 @@ export function createSeazonaGovernor({
184
208
  haltedAt: calls.haltedAt,
185
209
  failedToday: calls.failedToday,
186
210
  dailyFailureBudget: calls.dailyFailureBudget,
187
- lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries },
211
+ lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries, ...failureKinds(day) },
188
212
  };
189
213
  }
190
214
 
@@ -201,6 +225,7 @@ export function createSeazonaGovernor({
201
225
  failed: sql`count(*) filter (where ${seazonaApiRequests.statusCode} is null or ${seazonaApiRequests.statusCode} >= 400)`,
202
226
  rateLimited: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 429)`,
203
227
  retries: sql`count(*) filter (where ${seazonaApiRequests.isRetry})`,
228
+ notFound: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 404)`,
204
229
  }).from(seazonaApiRequests).where(range);
205
230
  const failures = await db.select({
206
231
  path: seazonaApiRequests.requestPath,
@@ -219,6 +244,7 @@ export function createSeazonaGovernor({
219
244
  failed: Number(totals.failed),
220
245
  rateLimited: Number(totals.rateLimited),
221
246
  retries: Number(totals.retries),
247
+ notFound: Number(totals.notFound),
222
248
  failures: failures.map((row) => ({ ...row, requests: Number(row.requests) })),
223
249
  };
224
250
  }
@@ -1,11 +1,15 @@
1
1
  import { guardAlerts } from '../call-guard.js';
2
+ import { createActReader } from './act.js';
2
3
  import { createSeazonaClient } from './client.js';
3
4
  import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
4
5
  import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
5
6
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
7
+ import { readBillableJobs } from './reconcile.js';
6
8
  import { SEAZONA_CALL_GUARD } from './governor.js';
7
9
  import { createSeazonaRouter } from './routes.js';
10
+ import { orderPatient } from './patients.js';
8
11
  import { createSeazonaScheduler } from './scheduler.js';
12
+ import { readSweepHealth } from './sweep.js';
9
13
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
10
14
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
11
15
  import { seazonaSchema } from './tables.js';
@@ -44,11 +48,25 @@ export const SEAZONA_SETUP = {
44
48
  { name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
45
49
  { name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
46
50
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
51
+ { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
47
52
  ],
48
53
  };
49
54
 
55
+ // What a person reads for each lane: the Seazona records it copies.
56
+ const READ_NAMES = {
57
+ invoices: 'invoices',
58
+ orders_recent: 'new orders',
59
+ orders_active: 'open orders',
60
+ clients: 'practices',
61
+ products: 'products',
62
+ users: 'lab users',
63
+ history: 'past orders',
64
+ };
65
+ const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.lane).join(', ');
66
+
50
67
  export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
51
68
  let runtime = null; // { settings, db, client, scheduler }
69
+ const actReader = createActReader({ now });
52
70
 
53
71
  function current() {
54
72
  if (!runtime) throw new Error('The Seazona adapter has not started');
@@ -105,13 +123,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
105
123
  if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
106
124
  const { settings, db, client, scheduler } = runtime;
107
125
  const sync = await readSeazonaSyncHealth(db, settings, { now });
126
+ const schedulerStatus = scheduler ? scheduler.status() : null;
108
127
  return {
109
- ok: sync.ok,
128
+ ok: sync.ok && !schedulerStatus?.stuckSince,
110
129
  ...describeSeazonaSettings(settings),
111
- scheduler: scheduler ? scheduler.status() : null,
130
+ scheduler: schedulerStatus,
112
131
  governor: await client.governor.status(),
113
132
  ...sync,
133
+ // Issue #141: today's reads before a draft or a send.
134
+ readsBeforeAct: actReader.status(),
114
135
  history: await historySummary(db),
136
+ sweep: await readSweepHealth(db, settings, { now }),
115
137
  };
116
138
  },
117
139
 
@@ -120,7 +142,31 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
120
142
  // a person saves the settings. Reads Flow's database only.
121
143
  async alerts() {
122
144
  if (!runtime?.client.governor) return [];
123
- return guardAlerts('Seazona', await runtime.client.governor.status(), { fix: 'Check the API client in the Seazona settings, then save them.' });
145
+ const status = await runtime.client.governor.status();
146
+ const alerts = guardAlerts('Seazona', status, { fix: 'Check the API client in the Seazona settings, then save them.' });
147
+ // Issue #143: what the lanes cannot fix by themselves. A refused API
148
+ // client already has its own alert above, so it stops no lane here.
149
+ if (!runtime.settings.scheduled) return alerts;
150
+ const { lanes } = await readSeazonaSyncHealth(runtime.db, runtime.settings, { now });
151
+ const stopped = status.haltedReason ? [] : lanes.filter((lane) => lane.status === 'stopped');
152
+ if (stopped.length) {
153
+ alerts.push({
154
+ id: 'reads-stopped',
155
+ tone: 'danger',
156
+ title: `Seazona refused Flow's request for ${readNames(stopped)}. Flow stopped reading them. Check the Seazona settings, then save them.`,
157
+ members: stopped.map((lane) => `${lane.lane}:${lane.lastAttemptAt ?? ''}`),
158
+ });
159
+ }
160
+ const stale = lanes.filter((lane) => lane.stale && lane.status !== 'stopped');
161
+ if (stale.length) {
162
+ alerts.push({
163
+ id: 'reads-late',
164
+ tone: 'warn',
165
+ title: `Flow has not read ${readNames(stale)} from Seazona on time. Jobs and invoices may show old Seazona data.`,
166
+ members: stale.map((lane) => `${lane.lane}:${lane.lastSuccessAt ?? ''}`),
167
+ });
168
+ }
169
+ return alerts;
124
170
  },
125
171
 
126
172
  // Billable work since a cursor: complete and shipped orders that
@@ -131,12 +177,25 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
131
177
  return readBillableWork(current().db, { cursor, ...options });
132
178
  },
133
179
 
180
+ // Read before act (issue #141): reads one order from Seazona now, and
181
+ // answers it as the feed would (act.js). The only capability here that
182
+ // calls Seazona on a person's or an automation's behalf.
183
+ async readWorkItem({ sourceJobRef }) {
184
+ return actReader.read(current(), sourceJobRef);
185
+ },
186
+
134
187
  // Past work from the history import (docs/SEAZONA.md, "History
135
188
  // import"), as ledger jobs marked `historical`. Reads Flow's copy only.
136
189
  async pullHistoryJobs(cursor = null, options = {}) {
137
190
  return readHistoryWork(current().db, { cursor, ...options });
138
191
  },
139
192
 
193
+ // Billable jobs by day, for the daily reconcile
194
+ // (server/core/reconcile.js). Reads Flow's copy only.
195
+ async billableJobs({ from = null, to = null } = {}) {
196
+ return readBillableJobs(current().db, { from, to });
197
+ },
198
+
140
199
  // Seazona clients by id and company name, for the identity map. Reads
141
200
  // Flow's copy only.
142
201
  async listCustomers() {
@@ -149,6 +208,13 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
149
208
  return listWork(current().db, { now: now() });
150
209
  },
151
210
 
211
+ // The fields a job gives the invoice Reference: the patient name, when
212
+ // this installation keeps patient names (patients.js).
213
+ async jobReferenceFields({ sourceJobRef, read = false }) {
214
+ const { db, client, settings } = current();
215
+ return orderPatient({ db, client, settings, now }, sourceJobRef, { read });
216
+ },
217
+
152
218
  // History jobs a person must decide, as issues on their job cards,
153
219
  // each with a link to the reconciliation. Reads Flow's copy only.
154
220
  async jobIssues({ refs = [] } = {}) {