@arcforgelabs/flow 2026.10.32 → 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 (42) 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 +56 -24
  5. package/server/app.js +4 -0
  6. package/server/core/invoicing-rules.js +50 -4
  7. package/server/core/job-board.js +15 -4
  8. package/server/core/ledger.js +242 -6
  9. package/server/core/pipeline.js +29 -21
  10. package/server/core/read-before-act.js +96 -0
  11. package/server/core/reconcile.js +350 -0
  12. package/server/core/sending.js +44 -6
  13. package/server/db/migrations/0017_sync_basis.sql +1 -0
  14. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  15. package/server/db/migrations/meta/_journal.json +7 -0
  16. package/server/db/schema.js +3 -0
  17. package/server/index.js +5 -0
  18. package/server/integrations/port.js +3 -0
  19. package/server/integrations/seazona/act.js +48 -0
  20. package/server/integrations/seazona/feed.js +70 -50
  21. package/server/integrations/seazona/governor.js +28 -2
  22. package/server/integrations/seazona/index.js +60 -3
  23. package/server/integrations/seazona/lanes.js +81 -8
  24. package/server/integrations/seazona/ledger.js +14 -9
  25. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  26. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  27. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  28. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  29. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  30. package/server/integrations/seazona/reconcile.js +40 -0
  31. package/server/integrations/seazona/scheduler.js +32 -4
  32. package/server/integrations/seazona/schema.js +21 -0
  33. package/server/integrations/seazona/settings.js +12 -0
  34. package/server/integrations/seazona/store.js +24 -2
  35. package/server/integrations/seazona/sweep.js +574 -0
  36. package/server/integrations/seazona/tables.js +1 -0
  37. package/server/integrations/xero/index.js +4 -0
  38. package/server/integrations/xero/webhooks.js +35 -0
  39. package/server/ui/flow.css +9 -0
  40. package/server/ui/invoicing.js +101 -17
  41. package/server/ui/job-board.js +2 -1
  42. package/server/ui/jobs.js +19 -1
@@ -120,6 +120,13 @@
120
120
  "when": 1792456270558,
121
121
  "tag": "0016_invoice_reference",
122
122
  "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1792458167234,
128
+ "tag": "0017_sync_basis",
129
+ "breakpoints": true
123
130
  }
124
131
  ]
125
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'),
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,11 +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
23
25
  'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
24
26
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
25
27
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
26
28
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
27
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)
28
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
29
32
  'health', // connection state for Settings
30
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
 
@@ -36,11 +36,9 @@ function encodeCursor(at, id) {
36
36
  return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
37
37
  }
38
38
 
39
- export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
40
- const after = decodeCursor(cursor);
41
- const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
42
- const pageSize = Math.max(1, Math.min(1000, limit));
43
- 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({
44
42
  order: seazonaOrders,
45
43
  invoice: seazonaInvoices,
46
44
  client: { accountNumber: seazonaClients.accountNumber, company: seazonaClients.company, email: seazonaClients.email },
@@ -50,56 +48,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
50
48
  })
51
49
  .from(seazonaOrders)
52
50
  .leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
53
- .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)
54
105
  .where(after ? sql`(${changedAt}, ${seazonaOrders.id}) > (${after.at}::timestamptz, ${after.id})` : undefined)
55
106
  .orderBy(changedAt, seazonaOrders.id)
56
107
  .limit(pageSize);
57
108
 
58
109
  const items = [];
59
110
  const withdrawn = [];
60
- for (const { order, invoice, client, invoiceOrderCount } of rows) {
61
- const billable = !order.deletedInSourceAt && BILLABLE.test(order.status ?? '');
62
- if (!billable) {
63
- const entry = {
64
- sourceId: order.id,
65
- status: orderStatusLabel(order),
66
- reason: order.deletedInSourceAt ? 'deleted_in_seazona' : 'not_billable',
67
- };
68
- withdrawn.push({ ...entry, ledgerHold: toLedgerHold(entry) });
69
- continue;
70
- }
71
- const item = {
72
- source: 'seazona',
73
- sourceId: order.id,
74
- invoiceNumber: order.invoiceNumber,
75
- status: order.status,
76
- shipped: SHIPPED.test(order.status ?? ''),
77
- clientId: order.clientId,
78
- company: order.company,
79
- doctorName: order.doctorName,
80
- orderedOn: parseSeazonaDate(order.orderedText),
81
- dueOn: parseSeazonaDate(order.dueText),
82
- lines: order.lines,
83
- subtotalCents: order.subtotalCents,
84
- patientReference: order.patientReference,
85
- syncedAt: order.lastSyncedAt.toISOString(),
86
- invoice: invoice ? {
87
- invoiceNumber: invoice.invoiceNumber,
88
- status: invoice.status,
89
- accountNumber: invoice.accountNumber,
90
- invoiceDate: parseSeazonaDate(invoice.invoiceDateText),
91
- dueDate: parseSeazonaDate(invoice.dueText),
92
- lines: invoice.lines,
93
- subtotalCents: invoice.subtotalCents,
94
- taxCents: invoice.taxCents,
95
- totalCents: invoice.totalCents,
96
- discountCents: invoice.discountCents,
97
- patientReference: invoice.patientReference,
98
- } : null,
99
- client: client?.accountNumber || client?.company || client?.email ? client : null,
100
- invoiceOrderCount: order.invoiceNumber ? Number(invoiceOrderCount) : 0,
101
- };
102
- 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);
103
115
  }
104
116
  const lastRow = rows.at(-1);
105
117
  return {
@@ -110,6 +122,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
110
122
  };
111
123
  }
112
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
+
113
133
  // Seazona clients, by id and the company name an invoice bills. A client
114
134
  // with no company name is listed by its full name. A client the client list
115
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,12 +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';
8
10
  import { orderPatient } from './patients.js';
9
11
  import { createSeazonaScheduler } from './scheduler.js';
12
+ import { readSweepHealth } from './sweep.js';
10
13
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
11
14
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
12
15
  import { seazonaSchema } from './tables.js';
@@ -49,8 +52,21 @@ export const SEAZONA_SETUP = {
49
52
  ],
50
53
  };
51
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
+
52
67
  export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
53
68
  let runtime = null; // { settings, db, client, scheduler }
69
+ const actReader = createActReader({ now });
54
70
 
55
71
  function current() {
56
72
  if (!runtime) throw new Error('The Seazona adapter has not started');
@@ -107,13 +123,17 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
107
123
  if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
108
124
  const { settings, db, client, scheduler } = runtime;
109
125
  const sync = await readSeazonaSyncHealth(db, settings, { now });
126
+ const schedulerStatus = scheduler ? scheduler.status() : null;
110
127
  return {
111
- ok: sync.ok,
128
+ ok: sync.ok && !schedulerStatus?.stuckSince,
112
129
  ...describeSeazonaSettings(settings),
113
- scheduler: scheduler ? scheduler.status() : null,
130
+ scheduler: schedulerStatus,
114
131
  governor: await client.governor.status(),
115
132
  ...sync,
133
+ // Issue #141: today's reads before a draft or a send.
134
+ readsBeforeAct: actReader.status(),
116
135
  history: await historySummary(db),
136
+ sweep: await readSweepHealth(db, settings, { now }),
117
137
  };
118
138
  },
119
139
 
@@ -122,7 +142,31 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
122
142
  // a person saves the settings. Reads Flow's database only.
123
143
  async alerts() {
124
144
  if (!runtime?.client.governor) return [];
125
- 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;
126
170
  },
127
171
 
128
172
  // Billable work since a cursor: complete and shipped orders that
@@ -133,12 +177,25 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
133
177
  return readBillableWork(current().db, { cursor, ...options });
134
178
  },
135
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
+
136
187
  // Past work from the history import (docs/SEAZONA.md, "History
137
188
  // import"), as ledger jobs marked `historical`. Reads Flow's copy only.
138
189
  async pullHistoryJobs(cursor = null, options = {}) {
139
190
  return readHistoryWork(current().db, { cursor, ...options });
140
191
  },
141
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
+
142
199
  // Seazona clients by id and company name, for the identity map. Reads
143
200
  // Flow's copy only.
144
201
  async listCustomers() {
@@ -11,6 +11,7 @@ import {
11
11
  } from './map.js';
12
12
  import { runHistoryBackfill } from './history/backfill.js';
13
13
  import { seazonaOrders } from './schema.js';
14
+ import { runSweep } from './sweep.js';
14
15
  import {
15
16
  claimLane,
16
17
  clearOrderFailure,
@@ -22,6 +23,8 @@ import {
22
23
  readQuarantinedOrders,
23
24
  readQuarantinedRecordCounts,
24
25
  readQuarantinedRecords,
26
+ readRecordRereadCounts,
27
+ readRecordsDueForReread,
25
28
  recordOrderFailure,
26
29
  recordRecordFailure,
27
30
  selectActiveOrders,
@@ -41,6 +44,8 @@ import {
41
44
  // users the lab user list, daily
42
45
  // history past orders, a few calls per run, only while a person
43
46
  // has started a backfill (history/backfill.js)
47
+ // sweep whole lists again, slowly, for what the others missed
48
+ // (issue #126, sweep.js)
44
49
  //
45
50
  // Issue #12 holds for every order read: a 404 marks the order deleted in
46
51
  // Seazona, an empty answer is a failure, each failure is recorded per order
@@ -58,7 +63,7 @@ import {
58
63
  // only after a person saves the settings or Flow restarts on a new release.
59
64
  // Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
60
65
 
61
- export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history']);
66
+ export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history', 'sweep']);
62
67
 
63
68
  const DAY_MS = 24 * 60 * 60_000;
64
69
  const MAX_REPORTED = 25;
@@ -237,6 +242,7 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
237
242
  at,
238
243
  quarantineFailures: sync.orderQuarantineFailures,
239
244
  quarantineMs: sync.orderQuarantineMs,
245
+ detailId: id,
240
246
  });
241
247
  if (recorded.quarantinedUntil) newlyQuarantined += 1;
242
248
  failures.push({ id: key, ...failure, ...recorded });
@@ -257,6 +263,52 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
257
263
  return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
258
264
  }
259
265
 
266
+ // Issue #142: a quarantined invoice or client is in a later list only if it
267
+ // changes again, so when its wait ends the lane reads its detail by id. A
268
+ // success stores it and clears the failure; a 404 clears the failure (the
269
+ // record is gone, issue #126); a failure quarantines it again, with no new
270
+ // report. Each read goes through the call guard like any other.
271
+ async function rereadReleased(ctx, { mirror, what, detailPath, map }) {
272
+ const { db, client, sync, at } = ctx;
273
+ const due = sync.recordRereadsPerRun ? await readRecordsDueForReread(db, mirror, { at, limit: sync.recordRereadsPerRun }) : [];
274
+ const result = { summary: { created: 0, changed: 0, unchanged: 0 }, detailReads: 0, reread: 0, gone: 0, failed: 0 };
275
+ for (const row of due) {
276
+ result.detailReads += 1;
277
+ try {
278
+ const detail = await client.get(detailPath(row.detailId));
279
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${row.recordKey}`);
280
+ addSummary(result.summary, await upsertRecords(db, mirror, [map(detail)], { at }));
281
+ await clearRecordFailures(db, mirror, [row.recordKey]);
282
+ result.reread += 1;
283
+ } catch (error) {
284
+ if (isLaneLevel(error)) throw error;
285
+ if (error?.statusCode === 404) {
286
+ await clearRecordFailures(db, mirror, [row.recordKey]);
287
+ result.gone += 1;
288
+ continue;
289
+ }
290
+ await recordRecordFailure(db, mirror, row.recordKey, describeReadFailure(error), {
291
+ at,
292
+ quarantineFailures: sync.orderQuarantineFailures,
293
+ quarantineMs: sync.orderQuarantineMs,
294
+ });
295
+ result.failed += 1;
296
+ }
297
+ }
298
+ return result;
299
+ }
300
+
301
+ // Adds the re-reads to a lane's result.
302
+ function withRereads(result, reread) {
303
+ if (!reread.detailReads) return result;
304
+ addSummary(result.summary, reread.summary);
305
+ return {
306
+ ...result,
307
+ detailReads: result.detailReads + reread.detailReads,
308
+ rereads: { attempted: reread.detailReads, read: reread.reread, gone: reread.gone, failed: reread.failed },
309
+ };
310
+ }
311
+
260
312
  // Where the next changed-list read starts. A run that stopped inside a
261
313
  // window keeps the exact start of the next part (`cursor.since`); otherwise
262
314
  // the last finished run's start, less the overlap.
@@ -351,7 +403,13 @@ const laneRuns = {
351
403
  const { client, sync, at } = ctx;
352
404
  const firstSince = cursorSince(ctx, new Date(floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS).toISOString());
353
405
  const mapOptions = { keepPatientReference: sync.keepPatientReference };
354
- return walkChangedList(ctx, {
406
+ const reread = await rereadReleased(ctx, {
407
+ mirror: 'invoices',
408
+ what: 'invoice',
409
+ detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
410
+ map: (detail) => mapInvoice({}, detail, mapOptions),
411
+ });
412
+ return withRereads(await walkChangedList(ctx, {
355
413
  what: 'invoice',
356
414
  firstSince,
357
415
  // The guide: on or after lastModified (inclusive).
@@ -367,7 +425,7 @@ const laneRuns = {
367
425
  detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
368
426
  map: (item, detail) => mapInvoice(item, detail, mapOptions),
369
427
  }),
370
- });
428
+ }), reread);
371
429
  },
372
430
 
373
431
  async orders_recent(ctx) {
@@ -485,10 +543,16 @@ const laneRuns = {
485
543
 
486
544
  async clients(ctx) {
487
545
  const { client } = ctx;
546
+ const reread = await rereadReleased(ctx, {
547
+ mirror: 'clients',
548
+ what: 'client',
549
+ detailPath: (id) => `clients/${encodeURIComponent(id)}`,
550
+ map: (detail) => mapClient({}, detail),
551
+ });
488
552
  // The first run reads the whole list; later runs read changes only.
489
553
  // Seazona answers the whole list only to an empty `?lastModified=`; a
490
554
  // bare path is a 404.
491
- return walkChangedList(ctx, {
555
+ return withRereads(await walkChangedList(ctx, {
492
556
  what: 'client',
493
557
  firstSince: cursorSince(ctx, ''),
494
558
  // The guide says "modified after": step back a second so a row in the
@@ -505,10 +569,11 @@ const laneRuns = {
505
569
  detailPath: (id) => `clients/${encodeURIComponent(id)}`,
506
570
  map: (item, detail) => mapClient(item, detail),
507
571
  }),
508
- });
572
+ }), reread);
509
573
  },
510
574
 
511
575
  history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
576
+ sweep: (ctx) => runSweep(ctx, { readOrder, isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
512
577
  products: (ctx) => syncReference(ctx, 'products'),
513
578
  users: (ctx) => syncReference(ctx, 'users'),
514
579
  };
@@ -589,20 +654,27 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
589
654
  // What /health shows for the sync: each lane's state, the failing and
590
655
  // quarantined orders by id and status, and how many orders are marked
591
656
  // deleted in Seazona. No patient data.
657
+ //
658
+ // Issue #143: a lane that ran before and has not succeeded within its
659
+ // freshness limit (or two of its own cadences, if those are longer) is
660
+ // stale, and a stale lane makes `ok` false like a failed one.
592
661
  export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {}) {
593
662
  const nowMs = Number(now());
594
663
  const states = await readLaneStates(db);
595
664
  const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
596
665
  const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
597
666
  const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
667
+ const rereadDue = await readRecordRereadCounts(db, { at: new Date(nowMs) });
598
668
  const lanes = LANES.map((lane) => {
599
669
  const state = states.get(lane);
600
- const { freshnessMs } = settings.sync.lanes[lane];
670
+ const { freshnessMs, cadenceMs } = settings.sync.lanes[lane];
671
+ const staleAfterMs = Math.max(freshnessMs, 2 * cadenceMs);
601
672
  const ageMs = state?.lastSuccessAt ? Math.max(0, nowMs - state.lastSuccessAt.getTime()) : null;
602
673
  return {
603
674
  lane,
604
675
  status: state?.status ?? 'never_run',
605
- fresh: ageMs !== null && ageMs <= freshnessMs,
676
+ fresh: ageMs !== null && ageMs <= staleAfterMs,
677
+ stale: ageMs !== null && ageMs > staleAfterMs,
606
678
  ageMs,
607
679
  lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
608
680
  lastAttemptAt: state?.lastAttemptAt?.toISOString() ?? null,
@@ -613,10 +685,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
613
685
  failureDetails: state?.failureDetails ?? null,
614
686
  ...(lane === 'orders_active' ? { quarantinedOrders } : {}),
615
687
  ...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
688
+ ...(rereadDue[lane] ? { rereadDue: rereadDue[lane] } : {}),
616
689
  };
617
690
  });
618
691
  return {
619
- ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
692
+ ok: lanes.every((lane) => !lane.stale && !['failed', 'held', 'stopped'].includes(lane.status)),
620
693
  lanes,
621
694
  deletedOrders: Number(deleted),
622
695
  };