@arcforgelabs/flow 2026.10.30 → 2026.10.32

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 (79) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
@@ -99,6 +99,27 @@
99
99
  "when": 1792414148360,
100
100
  "tag": "0013_line_mappings",
101
101
  "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "7",
106
+ "when": 1792448941014,
107
+ "tag": "0014_price_list",
108
+ "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "7",
113
+ "when": 1792452497948,
114
+ "tag": "0015_alert_dismissals",
115
+ "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1792456270558,
121
+ "tag": "0016_invoice_reference",
122
+ "breakpoints": true
102
123
  }
103
124
  ]
104
125
  }
@@ -64,8 +64,12 @@ export const priceItems = pgTable('price_items', {
64
64
  description: text('description'),
65
65
  unitAmountCents: integer('unit_amount_cents').notNull(),
66
66
  taxCode: text('tax_code').notNull().default('GST'),
67
+ // The sales account a line with this code books to, when the lab sets one.
68
+ accountCode: text('account_code'),
67
69
  archivedAt: timestamp('archived_at', { withTimezone: true }),
70
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
68
71
  createdAt: createdAt(),
72
+ updatedAt: updatedAt(),
69
73
  }, (t) => [
70
74
  unique('price_items_org_id').on(t.organisationId, t.id),
71
75
  unique('price_items_org_code').on(t.organisationId, t.code),
@@ -494,6 +498,20 @@ export const reviewItems = pgTable('review_items', {
494
498
  check('review_items_closed_has_resolution', sql`${t.state} = 'open' or (${t.resolution} is not null and ${t.resolvedBy} is not null and ${t.resolvedAt} is not null)`),
495
499
  ]);
496
500
 
501
+ // Quiet alerts (issue 117, docs/ALERTS.md). An alert is worked out each time
502
+ // from Flow's own records (server/core/alerts.js); only a person's dismissal
503
+ // is stored. `members` are the things the alert was about when it was
504
+ // dismissed: the alert stays hidden for that person until something new joins.
505
+ export const alertDismissals = pgTable('alert_dismissals', {
506
+ organisationId: organisationId().references(() => organisations.id),
507
+ personId: uuid('person_id').notNull().references(() => people.id, { onDelete: 'cascade' }),
508
+ alertKey: text('alert_key').notNull(),
509
+ members: text('members').array().notNull().default(sql`'{}'::text[]`),
510
+ dismissedAt: timestamp('dismissed_at', { withTimezone: true }).notNull().defaultNow(),
511
+ }, (t) => [
512
+ primaryKey({ columns: [t.organisationId, t.personId, t.alertKey] }),
513
+ ]);
514
+
497
515
  // Integration settings an owner enters in Settings (server/integrations/setup.js).
498
516
  // One row per adapter for the installation. `config` holds plain settings by
499
517
  // their environment name; `secrets` holds credentials sealed with Flow's
@@ -606,6 +624,8 @@ export const invoicingSettings = pgTable('invoicing_settings', {
606
624
  // The price item code of a rush charge, and its share of the other lines.
607
625
  rushItemCode: text('rush_item_code'),
608
626
  rushPercent: integer('rush_percent').notNull().default(20),
627
+ // The invoice Reference, from tokens (server/core/references.js).
628
+ referenceTemplate: text('reference_template').notNull().default('{practitioner} - {patient}'),
609
629
  // The fixed checks above became invoice rules (invoice_rules). True once
610
630
  // this organisation's starter rules were made from them.
611
631
  rulesSeeded: boolean('rules_seeded').notNull().default(false),
@@ -717,3 +737,29 @@ export const lineMappings = pgTable('line_mappings', {
717
737
  check('line_mappings_price', sql`${t.kind} not in ('price_alias', 'saved') or ${t.unitAmountCents} is not null`),
718
738
  check('line_mappings_label_valid', sql`length(${t.label}) between 1 and 200`),
719
739
  ]);
740
+
741
+ // The price list's links (server/core/price-list.js, docs/PRICE_LIST.md): one
742
+ // row joins one record in a connected system's price list (a Xero item, a
743
+ // Seazona product) to one Flow price item. Flow's price list stays the source
744
+ // of truth; a link only says which record to compare and map. Many Seazona
745
+ // products can share one price item; a price item has one accounting item at
746
+ // most (the code checks it: the adapter says which kind it is).
747
+ // matched_by code the same code on both sides, linked by Flow
748
+ // person a person chose it
749
+ export const PRICE_LINK_MATCHES = ['code', 'person'];
750
+ export const priceItemLinks = pgTable('price_item_links', {
751
+ id: id(),
752
+ organisationId: organisationId().references(() => organisations.id),
753
+ priceItemId: uuid('price_item_id').notNull(),
754
+ system: text('system').notNull(),
755
+ externalId: text('external_id').notNull(),
756
+ externalCode: text('external_code'),
757
+ matchedBy: text('matched_by').notNull(),
758
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
759
+ createdAt: createdAt(),
760
+ }, (t) => [
761
+ foreignKey({ name: 'price_item_links_item_same_org', columns: [t.organisationId, t.priceItemId], foreignColumns: [priceItems.organisationId, priceItems.id] }).onDelete('cascade'),
762
+ uniqueIndex('price_item_links_external').on(t.organisationId, t.system, t.externalId),
763
+ check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
764
+ check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
765
+ ]);
@@ -4,6 +4,16 @@
4
4
  // 2026.10.18-beta.2), so GET /health shows it as build.release. A checkout
5
5
  // starts server/index.js (`npm start`) instead: its package.json holds the
6
6
  // upcoming version, which is not a release yet.
7
+ //
8
+ // flow-server start Flow
9
+ // flow-server migrate bring DATABASE_URL up to this build
10
+ // (Flow's tables and every shipped
11
+ // adapter's), then stop
12
+ // flow-server doctor [--live <origin>] [--json]
13
+ // check DATABASE_URL against this build,
14
+ // and with --live, against the live
15
+ // app's GET /health (server/db/doctor.js);
16
+ // exit 1 when it is not at this build
7
17
  import { readFileSync } from 'node:fs';
8
18
  import { parseReleaseTag } from './release-channel.js';
9
19
 
@@ -12,4 +22,13 @@ if (!process.env.FLOW_RELEASE) {
12
22
  const release = parseReleaseTag(version);
13
23
  if (release) process.env.FLOW_RELEASE = release.tag;
14
24
  }
15
- await import('./index.js');
25
+ const [command, ...args] = process.argv.slice(2);
26
+ if (command === 'migrate' || command === 'doctor') {
27
+ const { runCommand } = await import('./db/commands.js');
28
+ process.exitCode = await runCommand(command, args);
29
+ } else if (command) {
30
+ console.error(`flow-server: unknown command '${command}'. Use: flow-server, flow-server migrate, flow-server doctor`);
31
+ process.exitCode = 2;
32
+ } else {
33
+ await import('./index.js');
34
+ }
package/server/index.js CHANGED
@@ -4,6 +4,7 @@ import { createClerkProxy, ensureClerkAccount, loadClerkAdapter, resolveClerkPro
4
4
  import { seedFirstOwner } from './auth/people.js';
5
5
  import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
+ import { expectedSchema } from './db/doctor.js';
7
8
  import { migrateDatabase, openDatabase } from './db/index.js';
8
9
  import {
9
10
  importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
@@ -141,7 +142,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
141
142
 
142
143
  const updates = createUpdateChecker();
143
144
 
144
- createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
145
+ createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
145
146
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
146
147
  });
147
148
 
@@ -0,0 +1,362 @@
1
+ import { sql } from 'drizzle-orm';
2
+
3
+ // The call guard: one pattern every adapter that calls an outside system
4
+ // follows (docs/INTEGRATIONS.md, "Outbound calls"). A provider can cut off an
5
+ // installation's API access after many failed calls, even inside its rate
6
+ // limit (issues #101 and #111). So, before each call, the guard says whether
7
+ // a call may go, and after it, the guard records how it ended:
8
+ //
9
+ // - a refused credential halts every call until a person acts (saves the
10
+ // settings or connects again, which calls resume());
11
+ // - a 401 or 403 on one resource, when the adapter's auth failures stop a
12
+ // resource only, stops that resource until a person acts;
13
+ // - `circuitFailures` failed calls inside `circuitWindowMs` open the
14
+ // circuit: no call for `circuitPauseMs`, then a probe; a failed probe opens
15
+ // it again for twice as long, to at most `maxCircuitPauseMs`, and a
16
+ // success closes it;
17
+ // - `dailyFailureBudget` failed calls in one UTC day stop calls until the
18
+ // next day.
19
+ //
20
+ // A stopped call fails at once with CallsStopped; it never waits. Each stop
21
+ // is reported once with log.error, so the connected error service gets one
22
+ // report per problem; a repeat is a warning.
23
+ //
24
+ // State lives in PostgreSQL, in two tables the adapter owns and creates in its
25
+ // own migrations (CALL_GUARD_COLUMNS), so every process on one database
26
+ // shares it and a restart does not reset it. Nothing here names a vendor.
27
+
28
+ export const CALL_GUARD_POLICY = Object.freeze({
29
+ circuitFailures: 5,
30
+ circuitWindowMs: 10 * 60_000,
31
+ circuitPauseMs: 5 * 60_000,
32
+ maxCircuitPauseMs: 6 * 60 * 60_000,
33
+ dailyFailureBudget: 50,
34
+ // 'all': a 401 or 403 halts every call. 'resource': it stops only the
35
+ // resource it came from; halt() still stops everything.
36
+ authStops: 'all',
37
+ telemetryRetentionMs: 35 * 24 * 60 * 60_000,
38
+ });
39
+
40
+ // The columns each guard table must have. `key` and `at` may have other names
41
+ // (the `columns` option), so an adapter that kept telemetry before the guard
42
+ // existed can keep its tables.
43
+ export const CALL_GUARD_COLUMNS = Object.freeze({
44
+ state: ['key', 'circuit_open_until', 'circuit_opens', 'halted_reason', 'halted_at', 'stopped_resources', 'updated_at'],
45
+ calls: ['key', 'at', 'resource', 'request_path', 'status_code', 'failure_kind', 'duration_ms'],
46
+ });
47
+
48
+ const DAY_MS = 24 * 60 * 60_000;
49
+ const EPOCH = new Date(0);
50
+
51
+ // A call that failed in a way a provider sees as a fault: no answer, a 5xx, a
52
+ // 429, or a 4xx other than 404 (a 404 on one record means it was deleted).
53
+ export function countsAsFailure({ statusCode = null, failureKind = null } = {}) {
54
+ if (failureKind) return true;
55
+ if (!Number.isInteger(statusCode)) return false;
56
+ return statusCode >= 500 || (statusCode >= 400 && statusCode !== 404);
57
+ }
58
+
59
+ export const isAuthRefusal = (statusCode) => statusCode === 401 || statusCode === 403;
60
+
61
+ export function startOfUtcDay(ms) {
62
+ const date = new Date(ms);
63
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
64
+ }
65
+
66
+ // The pause after the circuit has opened `opens` times before.
67
+ export function circuitPauseMs(opens, policy = CALL_GUARD_POLICY) {
68
+ return Math.min(policy.maxCircuitPauseMs, policy.circuitPauseMs * 2 ** Math.min(Math.max(0, opens), 10));
69
+ }
70
+
71
+ // Why no call may go now, or null. `state` is the guard row
72
+ // ({ haltedReason, circuitOpenUntil, stoppedResources }); `failedToday`
73
+ // counts failed calls since the start of the UTC day.
74
+ export function stoppedReason(state, { nowMs, failedToday, resource = null }, policy = CALL_GUARD_POLICY) {
75
+ if (state?.haltedReason) return { reason: 'halted', retryAfterMs: null };
76
+ if (resource && state?.stoppedResources?.[resource]) return { reason: 'resource_stopped', retryAfterMs: null, resource };
77
+ const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
78
+ if (openUntil > nowMs) return { reason: 'circuit_open', retryAfterMs: openUntil - nowMs };
79
+ const dayStart = startOfUtcDay(nowMs);
80
+ if (failedToday >= policy.dailyFailureBudget) return { reason: 'budget_spent', retryAfterMs: dayStart + DAY_MS - nowMs };
81
+ return null;
82
+ }
83
+
84
+ // Whether a failed call opens the circuit. `recentFailures` counts failed
85
+ // calls since max(now - circuitWindowMs, the end of the last pause), this one
86
+ // included. Answers { pauseMs } or null.
87
+ export function circuitAfterFailure(state, { nowMs, recentFailures }, policy = CALL_GUARD_POLICY) {
88
+ const opens = state?.circuitOpens ?? 0;
89
+ const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
90
+ // After a pause the next call is a probe: if it fails, open again at once.
91
+ const probeFailed = opens > 0 && openUntil <= nowMs;
92
+ if (!probeFailed && (recentFailures < policy.circuitFailures || openUntil > nowMs)) return null;
93
+ return { pauseMs: circuitPauseMs(opens, policy) };
94
+ }
95
+
96
+ const STOP_TEXT = {
97
+ halted: (name) => `${name} calls are stopped until a person checks the connection (it was refused)`,
98
+ resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped until a person checks the connection (they were refused)`,
99
+ circuit_open: (name) => `${name} calls are paused after repeated failures`,
100
+ budget_spent: (name) => `${name} calls are stopped for today: the day's failed calls are spent`,
101
+ };
102
+ const STOP_CODE = {
103
+ halted: 'api_halted',
104
+ resource_stopped: 'api_resource_stopped',
105
+ circuit_open: 'api_circuit_open',
106
+ budget_spent: 'api_failure_budget_spent',
107
+ };
108
+
109
+ // Calls are stopped. `retryAfterMs` is null for a stop that only a person
110
+ // clears. `code` is `<integration>_<what>`, for example api_circuit_open.
111
+ export class CallsStopped extends Error {
112
+ constructor({ integration, name = integration, reason, retryAfterMs = null, resource = null }) {
113
+ super(STOP_TEXT[reason](name, resource));
114
+ this.name = 'CallsStopped';
115
+ this.code = `${integration}_${STOP_CODE[reason]}`;
116
+ this.kind = 'calls_stopped';
117
+ this.integration = integration;
118
+ this.reason = reason;
119
+ this.resource = resource;
120
+ this.retryAfterMs = retryAfterMs;
121
+ this.status = 503;
122
+ }
123
+ }
124
+
125
+ export const isCallsStopped = (error) => error?.kind === 'calls_stopped';
126
+
127
+ // The quiet alerts a guard's status() makes (server/core/alerts.js): only the
128
+ // stops a person must clear. A paused circuit or a spent day ends by itself,
129
+ // so it is no alert. `name` is what a person reads; `fix` says how a person
130
+ // clears the stop.
131
+ export function guardAlerts(name, status, { fix = 'Check the connection, then connect again.' } = {}) {
132
+ const alerts = [];
133
+ if (status?.haltedReason) {
134
+ alerts.push({
135
+ id: 'calls-stopped',
136
+ tone: 'danger',
137
+ title: `${name} refused Flow. Flow stopped all ${name} calls. ${fix}`,
138
+ members: [`halted:${status.haltedAt ?? ''}`],
139
+ });
140
+ }
141
+ const resources = status?.stoppedResources ?? [];
142
+ if (resources.length) {
143
+ alerts.push({
144
+ id: 'resources-stopped',
145
+ tone: 'danger',
146
+ title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow stopped those calls. ${fix}`,
147
+ members: resources.map((r) => `${r.resource}:${r.at ?? ''}`),
148
+ });
149
+ }
150
+ return alerts;
151
+ }
152
+
153
+ // Path only: drop any host and query, and keep a safe character set.
154
+ export function normaliseRequestPath(value) {
155
+ const path = String(value || '').split(/[?#]/)[0].replace(/^[a-z]+:\/\/[^/]+/i, '');
156
+ return path.replace(/[^A-Za-z0-9/_.:-]+/g, '').slice(0, 160) || null;
157
+ }
158
+
159
+ const cleanKey = (value) => String(value ?? '').trim().slice(0, 120);
160
+ const cleanResource = (value) => (value ? String(value).replace(/[^A-Za-z0-9/_.:-]+/g, '').slice(0, 80) || null : null);
161
+ const iso = (value) => (value ? new Date(value).toISOString() : null);
162
+
163
+ // `integration` is the adapter id (codes, the advisory lock), `name` what a
164
+ // person reads. `tables` names the adapter's two tables; `columns` renames
165
+ // the `key` and `at` columns when an adapter's tables use other names, and
166
+ // `callDefaults` fills other required columns of a new call row. An adapter
167
+ // that admits its calls in its own transaction passes `lockName`, so the
168
+ // guard takes the same lock. `report: false` leaves the reports to the
169
+ // adapter, for one that already reports each stop once at a higher level.
170
+ export function createCallGuard({
171
+ db,
172
+ integration,
173
+ name = integration,
174
+ tables,
175
+ columns = {},
176
+ callDefaults = {},
177
+ lockName = `${integration}-call-guard`,
178
+ report: reports = true,
179
+ policy: overrides = {},
180
+ now = Date.now,
181
+ log = console,
182
+ } = {}) {
183
+ if (!db) throw new TypeError('createCallGuard: db is required');
184
+ if (!/^[a-z][a-z0-9-]{1,31}$/.test(integration ?? '')) throw new TypeError('createCallGuard: integration must be an adapter id');
185
+ if (!tables?.state || !tables?.calls) throw new TypeError('createCallGuard: tables.state and tables.calls are required');
186
+ const policy = { ...CALL_GUARD_POLICY, ...overrides };
187
+ if (!['all', 'resource'].includes(policy.authStops)) throw new TypeError('createCallGuard: authStops must be "all" or "resource"');
188
+ const T = { state: sql.identifier(tables.state), calls: sql.identifier(tables.calls) };
189
+ const C = { key: sql.identifier(columns.key ?? 'key'), at: sql.identifier(columns.at ?? 'at') };
190
+ const tag = `[${integration}]`;
191
+ const failed = sql`(failure_kind is not null or status_code >= 500 or (status_code >= 400 and status_code <> 404))`;
192
+
193
+ const lock = (tx, key) => tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${lockName}:${key}`}))`);
194
+ const extraColumns = Object.keys(callDefaults).map((column) => sql`, ${sql.identifier(column)}`);
195
+ const extraValues = Object.values(callDefaults).map((value) => sql`, ${value}`);
196
+
197
+ async function readState(runner, key) {
198
+ const { rows } = await runner.execute(sql`select circuit_open_until, circuit_opens, halted_reason, halted_at, stopped_resources
199
+ from ${T.state} where ${C.key} = ${key}`);
200
+ const row = rows[0];
201
+ if (!row) return { circuitOpenUntil: EPOCH, circuitOpens: 0, haltedReason: null, haltedAt: null, stoppedResources: {} };
202
+ return {
203
+ circuitOpenUntil: new Date(row.circuit_open_until),
204
+ circuitOpens: Number(row.circuit_opens),
205
+ haltedReason: row.halted_reason,
206
+ haltedAt: row.halted_at ? new Date(row.halted_at) : null,
207
+ stoppedResources: row.stopped_resources ?? {},
208
+ };
209
+ }
210
+
211
+ async function failuresSince(runner, key, fromMs) {
212
+ const { rows } = await runner.execute(sql`select count(*)::int as n from ${T.calls}
213
+ where ${C.key} = ${key} and ${C.at} >= ${new Date(fromMs)} and ${failed}`);
214
+ return Number(rows[0].n);
215
+ }
216
+
217
+ const ensureRow = (runner, key) => runner.execute(sql`insert into ${T.state} (${C.key}) values (${key}) on conflict do nothing`);
218
+
219
+ // Why no call may go to `resource` now, or null. `runner` is the database
220
+ // or a transaction the adapter holds (with the guard's lock).
221
+ async function stoppedNow(runner, rawKey, { resource = null } = {}) {
222
+ const key = cleanKey(rawKey);
223
+ const nowMs = Number(now());
224
+ const state = await readState(runner, key);
225
+ const failedToday = await failuresSince(runner, key, startOfUtcDay(nowMs));
226
+ return stoppedReason(state, { nowMs, failedToday, resource: cleanResource(resource) }, policy);
227
+ }
228
+
229
+ // Throws CallsStopped when no call may go to `resource` now.
230
+ async function check(rawKey, options = {}) {
231
+ const stopped = await stoppedNow(db, rawKey, options);
232
+ if (stopped) throw new CallsStopped({ integration, name, ...stopped });
233
+ }
234
+
235
+ // Records how one call ended, and what that does to the guard. Answers
236
+ // what this call stopped ({ halted, resourceStopped, opened, budgetSpent }),
237
+ // each of which was just reported once. `callId` names a call row the
238
+ // adapter wrote when it admitted the call: that row is updated, not added.
239
+ async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
240
+ const key = cleanKey(rawKey);
241
+ const where = cleanResource(resource);
242
+ const nowMs = Number(now());
243
+ const nowAt = new Date(nowMs);
244
+ const status = Number.isInteger(statusCode) ? statusCode : null;
245
+ const outcome = await db.transaction(async (tx) => {
246
+ await lock(tx, key);
247
+ await ensureRow(tx, key);
248
+ const kind = failureKind ? String(failureKind).slice(0, 40) : null;
249
+ const took = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null;
250
+ if (callId !== null) {
251
+ await tx.execute(sql`update ${T.calls} set resource = ${where}, request_path = ${normaliseRequestPath(path)}, status_code = ${status},
252
+ failure_kind = ${kind}, duration_ms = ${took} where id = ${callId} and ${C.key} = ${key}`);
253
+ } else {
254
+ await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${sql.join(extraColumns, sql``)})
255
+ values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${sql.join(extraValues, sql``)})`);
256
+ }
257
+ const state = await readState(tx, key);
258
+ if (!countsAsFailure({ statusCode: status, failureKind })) {
259
+ if (state.circuitOpens > 0) {
260
+ await tx.execute(sql`update ${T.state} set circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
261
+ return { closed: true };
262
+ }
263
+ return {};
264
+ }
265
+ const result = {};
266
+ const dayStart = startOfUtcDay(nowMs);
267
+ if (await failuresSince(tx, key, dayStart) === policy.dailyFailureBudget) result.budgetSpent = { until: new Date(dayStart + DAY_MS).toISOString() };
268
+ if (isAuthRefusal(status)) {
269
+ if (policy.authStops === 'resource' && where) {
270
+ if (!state.stoppedResources[where]) {
271
+ await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: { statusCode: status, at: nowAt.toISOString() } })}::jsonb,
272
+ updated_at = ${nowAt} where ${C.key} = ${key}`);
273
+ result.resourceStopped = where;
274
+ }
275
+ return result;
276
+ }
277
+ if (!state.haltedReason) {
278
+ await tx.execute(sql`update ${T.state} set halted_reason = 'auth', halted_at = ${nowAt}, updated_at = ${nowAt} where ${C.key} = ${key}`);
279
+ result.halted = 'auth';
280
+ }
281
+ return result;
282
+ }
283
+ const recentFailures = await failuresSince(tx, key, Math.max(nowMs - policy.circuitWindowMs, state.circuitOpenUntil.getTime()));
284
+ const open = circuitAfterFailure(state, { nowMs, recentFailures }, policy);
285
+ if (open) {
286
+ const until = new Date(nowMs + open.pauseMs);
287
+ await tx.execute(sql`update ${T.state} set circuit_open_until = ${until}, circuit_opens = circuit_opens + 1, updated_at = ${nowAt} where ${C.key} = ${key}`);
288
+ result.opened = { until: until.toISOString(), pauseMs: open.pauseMs, opens: state.circuitOpens + 1 };
289
+ }
290
+ return result;
291
+ });
292
+ report(outcome, { status, failureKind, where });
293
+ await db.execute(sql`delete from ${T.calls} where ${C.key} = ${key} and ${C.at} < ${new Date(nowMs - policy.telemetryRetentionMs)}`);
294
+ return outcome;
295
+ }
296
+
297
+ // One log.error per stop, so the error service gets one report each. A
298
+ // circuit that opens again after a failed probe is a warning: the first
299
+ // opening was reported.
300
+ function report(outcome, { status, failureKind, where }) {
301
+ if (!reports) return;
302
+ const cause = status ? `HTTP ${status}` : failureKind;
303
+ if (outcome.halted) log.error?.(`${tag} ${name} refused the connection (${cause}). Calls are stopped until a person checks the ${name} settings or connects again.`);
304
+ if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped until a person checks the connection.`);
305
+ if (outcome.opened) {
306
+ const line = `${tag} ${name} calls failed repeatedly (last: ${cause}${where ? ` on ${where}` : ''}). Calls pause until ${outcome.opened.until}.`;
307
+ if (outcome.opened.opens === 1) log.error?.(line);
308
+ else log.warn?.(line);
309
+ }
310
+ if (outcome.budgetSpent) log.error?.(`${tag} ${policy.dailyFailureBudget} ${name} calls failed today (last: ${cause}). Calls stop until ${outcome.budgetSpent.until}.`);
311
+ }
312
+
313
+ // Stops every call until resume(): for a refusal found outside a call, for
314
+ // example a credential the provider no longer accepts. Reported once.
315
+ async function halt(rawKey, reason = 'auth', { detail = null } = {}) {
316
+ const key = cleanKey(rawKey);
317
+ const nowAt = new Date(Number(now()));
318
+ const changed = await db.transaction(async (tx) => {
319
+ await lock(tx, key);
320
+ await ensureRow(tx, key);
321
+ const { rows } = await tx.execute(sql`update ${T.state} set halted_reason = ${String(reason).slice(0, 40)}, halted_at = ${nowAt}, updated_at = ${nowAt}
322
+ where ${C.key} = ${key} and halted_reason is null returning 1`);
323
+ return rows.length > 0;
324
+ });
325
+ if (changed && reports) log.error?.(`${tag} ${name} calls are stopped until a person checks the ${name} settings or connects again${detail ? ` (${detail})` : ''}.`);
326
+ return changed;
327
+ }
328
+
329
+ // A person acted: clear a halt and stopped resources, and close the
330
+ // circuit. The day's failure budget stays. No key: every key.
331
+ async function resume(rawKey = null) {
332
+ const nowAt = new Date(Number(now()));
333
+ if (rawKey === null) {
334
+ await db.execute(sql`update ${T.state} set halted_reason = null, halted_at = null, stopped_resources = '{}'::jsonb,
335
+ circuit_open_until = ${EPOCH}, circuit_opens = 0, updated_at = ${nowAt}`);
336
+ return;
337
+ }
338
+ const key = cleanKey(rawKey);
339
+ await ensureRow(db, key);
340
+ await db.execute(sql`update ${T.state} set halted_reason = null, halted_at = null, stopped_resources = '{}'::jsonb,
341
+ circuit_open_until = ${EPOCH}, circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
342
+ }
343
+
344
+ // What Settings and /health show, the same for every adapter.
345
+ async function status(rawKey) {
346
+ const key = cleanKey(rawKey);
347
+ const nowMs = Number(now());
348
+ const state = await readState(db, key);
349
+ const openUntil = state.circuitOpenUntil.getTime();
350
+ return {
351
+ circuit: state.haltedReason ? 'halted' : openUntil > nowMs ? 'open' : state.circuitOpens > 0 ? 'half_open' : 'closed',
352
+ circuitOpenUntil: openUntil > nowMs ? iso(openUntil) : null,
353
+ haltedReason: state.haltedReason,
354
+ haltedAt: iso(state.haltedAt),
355
+ stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry })),
356
+ failedToday: await failuresSince(db, key, startOfUtcDay(nowMs)),
357
+ dailyFailureBudget: policy.dailyFailureBudget,
358
+ };
359
+ }
360
+
361
+ return { integration, policy, check, stoppedNow, record, halt, resume, status };
362
+ }
@@ -11,17 +11,23 @@ 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
+ 'pullHistoryJobs', // past work from a history import, as ledger jobs marked historical (Seazona: its API backfill)
14
15
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
15
16
  'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
16
17
  'checkDrafts', // the accounting system's problems with Flow drafts, from its copy; null while not connected
18
+ 'documentFacts', // what the customer's copy of a sent invoice says (number, reference, dates), from the system's copy in Flow; never a live call
17
19
  'sendStatus', // { connected, writesAllowed }: may Flow send invoices now?
18
20
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
19
21
  'pullPayments', // payments since a cursor, matched to invoices and bills
20
22
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
23
+ 'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
21
24
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
25
+ 'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
22
26
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
23
27
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
28
+ 'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
24
29
  'health', // connection state for Settings
30
+ 'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
25
31
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
26
32
  'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
27
33
  ]);
@@ -71,7 +77,7 @@ export function defineAdapter(adapter) {
71
77
  if (!adapter || typeof adapter !== 'object') {
72
78
  throw new TypeError('An adapter must be an object');
73
79
  }
74
- const { id, name, bundled = false, capabilities, schema, start, stop, routes, setup } = adapter;
80
+ const { id, name, bundled = false, capabilities, schema, start, stop, routes, setup, callGuard } = adapter;
75
81
  if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
76
82
  throw new TypeError(`Adapter id must match ${ID_PATTERN}`);
77
83
  }
@@ -108,6 +114,16 @@ export function defineAdapter(adapter) {
108
114
  throw new TypeError(`Adapter ${id} setup field ${field?.name} needs an upper-case setting name and a label`);
109
115
  }
110
116
  }
117
+ if (callGuard !== undefined && callGuard !== null) {
118
+ const { integration, tables } = callGuard;
119
+ if (integration !== id || !tables?.state || !tables?.calls) {
120
+ throw new TypeError(`Adapter ${id} callGuard must name integration ${id} and its tables.state and tables.calls`);
121
+ }
122
+ const owned = new Set(schema?.tables ?? []);
123
+ if (!owned.has(tables.state) || !owned.has(tables.calls)) {
124
+ throw new TypeError(`Adapter ${id} callGuard tables must be among its own schema tables`);
125
+ }
126
+ }
111
127
  return Object.freeze({
112
128
  id,
113
129
  name,
@@ -120,6 +136,10 @@ export function defineAdapter(adapter) {
120
136
  // stop() ends that work (tests, shutdown).
121
137
  start: start ?? null,
122
138
  stop: stop ?? null,
139
+ // The call guard (./call-guard.js) every outbound call goes through: the
140
+ // integration id, its two tables, and its policy. An adapter that calls
141
+ // an outside system declares it (docs/INTEGRATIONS.md, "Outbound calls").
142
+ callGuard: callGuard ? Object.freeze({ ...callGuard }) : null,
123
143
  // Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
124
144
  // mounted at /api/<id> (registry.routes).
125
145
  routes: routes ?? null,
@@ -1,7 +1,7 @@
1
1
  import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
2
2
  import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
3
3
  import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
4
- import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
4
+ import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from './schema.js';
5
5
 
6
6
  // The billing feed behind the port's pullWorkItems: every order that changed
7
7
  // since a cursor, oldest change first. A complete or shipped order is an
@@ -11,9 +11,16 @@ import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
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;
@@ -31,7 +38,7 @@ function encodeCursor(at, id) {
31
38
 
32
39
  export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
33
40
  const after = decodeCursor(cursor);
34
- const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}))`;
41
+ const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
35
42
  const pageSize = Math.max(1, Math.min(1000, limit));
36
43
  const rows = await db.select({
37
44
  order: seazonaOrders,
@@ -125,6 +132,28 @@ export async function listClients(db) {
125
132
  ];
126
133
  }
127
134
 
135
+ // Seazona's products, from Flow's copy (the products lane reads them daily),
136
+ // for the price list's links and price check (server/core/price-list.js).
137
+ // Seazona says only whether a product is taxable: taxable reads as GST.
138
+ export async function listProducts(db) {
139
+ const rows = await db.select({
140
+ id: seazonaReference.id, code: seazonaReference.code, name: seazonaReference.name,
141
+ priceCents: seazonaReference.priceCents, taxable: seazonaReference.taxable, syncedAt: seazonaReference.lastSyncedAt,
142
+ }).from(seazonaReference).where(eq(seazonaReference.resource, 'products'));
143
+ return {
144
+ kind: 'work',
145
+ syncedAt: rows.reduce((at, r) => (!at || r.syncedAt > at ? r.syncedAt : at), null),
146
+ items: rows.map((r) => ({
147
+ id: r.id,
148
+ code: r.code,
149
+ name: r.name,
150
+ unitAmountCents: r.priceCents === null ? null : Number(r.priceCents),
151
+ taxCode: r.taxable === null ? null : (r.taxable ? 'GST' : 'GST_FREE'),
152
+ sold: true,
153
+ })),
154
+ };
155
+ }
156
+
128
157
  // A final order (shipped, delivered, cancelled; map.js isFinalStatus) stays on
129
158
  // the Jobs board this long after it last changed.
130
159
  export const WORK_FINISHED_DAYS = 30;