@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1

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 (76) 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 +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -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/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. package/server/ui/routes.js +1 -1
@@ -99,6 +99,20 @@
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
102
116
  }
103
117
  ]
104
118
  }
@@ -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
@@ -717,3 +735,29 @@ export const lineMappings = pgTable('line_mappings', {
717
735
  check('line_mappings_price', sql`${t.kind} not in ('price_alias', 'saved') or ${t.unitAmountCents} is not null`),
718
736
  check('line_mappings_label_valid', sql`length(${t.label}) between 1 and 200`),
719
737
  ]);
738
+
739
+ // The price list's links (server/core/price-list.js, docs/PRICE_LIST.md): one
740
+ // row joins one record in a connected system's price list (a Xero item, a
741
+ // Seazona product) to one Flow price item. Flow's price list stays the source
742
+ // of truth; a link only says which record to compare and map. Many Seazona
743
+ // products can share one price item; a price item has one accounting item at
744
+ // most (the code checks it: the adapter says which kind it is).
745
+ // matched_by code the same code on both sides, linked by Flow
746
+ // person a person chose it
747
+ export const PRICE_LINK_MATCHES = ['code', 'person'];
748
+ export const priceItemLinks = pgTable('price_item_links', {
749
+ id: id(),
750
+ organisationId: organisationId().references(() => organisations.id),
751
+ priceItemId: uuid('price_item_id').notNull(),
752
+ system: text('system').notNull(),
753
+ externalId: text('external_id').notNull(),
754
+ externalCode: text('external_code'),
755
+ matchedBy: text('matched_by').notNull(),
756
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
757
+ createdAt: createdAt(),
758
+ }, (t) => [
759
+ foreignKey({ name: 'price_item_links_item_same_org', columns: [t.organisationId, t.priceItemId], foreignColumns: [priceItems.organisationId, priceItems.id] }).onDelete('cascade'),
760
+ uniqueIndex('price_item_links_external').on(t.organisationId, t.system, t.externalId),
761
+ check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
762
+ check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
763
+ ]);
@@ -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,22 @@ 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)
21
23
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
24
+ 'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
22
25
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
23
26
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
27
+ '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
28
  'health', // connection state for Settings
29
+ 'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
25
30
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
26
31
  'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
27
32
  ]);
@@ -71,7 +76,7 @@ export function defineAdapter(adapter) {
71
76
  if (!adapter || typeof adapter !== 'object') {
72
77
  throw new TypeError('An adapter must be an object');
73
78
  }
74
- const { id, name, bundled = false, capabilities, schema, start, stop, routes, setup } = adapter;
79
+ const { id, name, bundled = false, capabilities, schema, start, stop, routes, setup, callGuard } = adapter;
75
80
  if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
76
81
  throw new TypeError(`Adapter id must match ${ID_PATTERN}`);
77
82
  }
@@ -108,6 +113,16 @@ export function defineAdapter(adapter) {
108
113
  throw new TypeError(`Adapter ${id} setup field ${field?.name} needs an upper-case setting name and a label`);
109
114
  }
110
115
  }
116
+ if (callGuard !== undefined && callGuard !== null) {
117
+ const { integration, tables } = callGuard;
118
+ if (integration !== id || !tables?.state || !tables?.calls) {
119
+ throw new TypeError(`Adapter ${id} callGuard must name integration ${id} and its tables.state and tables.calls`);
120
+ }
121
+ const owned = new Set(schema?.tables ?? []);
122
+ if (!owned.has(tables.state) || !owned.has(tables.calls)) {
123
+ throw new TypeError(`Adapter ${id} callGuard tables must be among its own schema tables`);
124
+ }
125
+ }
111
126
  return Object.freeze({
112
127
  id,
113
128
  name,
@@ -120,6 +135,10 @@ export function defineAdapter(adapter) {
120
135
  // stop() ends that work (tests, shutdown).
121
136
  start: start ?? null,
122
137
  stop: stop ?? null,
138
+ // The call guard (./call-guard.js) every outbound call goes through: the
139
+ // integration id, its two tables, and its policy. An adapter that calls
140
+ // an outside system declares it (docs/INTEGRATIONS.md, "Outbound calls").
141
+ callGuard: callGuard ? Object.freeze({ ...callGuard }) : null,
123
142
  // Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
124
143
  // mounted at /api/<id> (registry.routes).
125
144
  routes: routes ?? null,
@@ -234,7 +253,9 @@ export function createRegistry({ log = console, env = process.env } = {}) {
234
253
  tablesReady.add(id);
235
254
  }
236
255
  if (adapter.start) {
237
- await adapter.start({ db: database, log, env: settingsEnv });
256
+ // A person changed the settings: an adapter may clear a stop that
257
+ // waits for a person (issue #101).
258
+ await adapter.start({ db: database, log, env: settingsEnv, reconfigured: true });
238
259
  running.push(adapter);
239
260
  }
240
261
  } catch (error) {
@@ -17,6 +17,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, m
17
17
  // A failed Seazona request. The message names the method, the path and the
18
18
  // status or failure kind; never the host, the query or a credential.
19
19
  // kind: http, timeout, network, invalid_json, replay_miss, governor_timeout.
20
+ // The governor's own SeazonaCallsStopped (kind calls_stopped) passes through
21
+ // unchanged: no call was made.
20
22
  export class SeazonaApiError extends Error {
21
23
  constructor(message, { statusCode = null, kind = 'http', path = null, retryAfterMs = null } = {}) {
22
24
  super(message);
@@ -93,6 +95,10 @@ export function createSeazonaClient({
93
95
  requestsPerMinute: rate.requestsPerMinute,
94
96
  maxWaitMs: rate.maxWaitMs,
95
97
  telemetryRetentionMs: rate.telemetryRetentionMs,
98
+ circuitFailures: rate.circuitFailures,
99
+ circuitWindowMs: rate.circuitWindowMs,
100
+ circuitPauseMs: rate.circuitPauseMs,
101
+ dailyFailureBudget: rate.dailyFailureBudget,
96
102
  now,
97
103
  sleepFn,
98
104
  });
@@ -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
@@ -125,6 +125,28 @@ export async function listClients(db) {
125
125
  ];
126
126
  }
127
127
 
128
+ // Seazona's products, from Flow's copy (the products lane reads them daily),
129
+ // for the price list's links and price check (server/core/price-list.js).
130
+ // Seazona says only whether a product is taxable: taxable reads as GST.
131
+ export async function listProducts(db) {
132
+ const rows = await db.select({
133
+ id: seazonaReference.id, code: seazonaReference.code, name: seazonaReference.name,
134
+ priceCents: seazonaReference.priceCents, taxable: seazonaReference.taxable, syncedAt: seazonaReference.lastSyncedAt,
135
+ }).from(seazonaReference).where(eq(seazonaReference.resource, 'products'));
136
+ return {
137
+ kind: 'work',
138
+ syncedAt: rows.reduce((at, r) => (!at || r.syncedAt > at ? r.syncedAt : at), null),
139
+ items: rows.map((r) => ({
140
+ id: r.id,
141
+ code: r.code,
142
+ name: r.name,
143
+ unitAmountCents: r.priceCents === null ? null : Number(r.priceCents),
144
+ taxCode: r.taxable === null ? null : (r.taxable ? 'GST' : 'GST_FREE'),
145
+ sold: true,
146
+ })),
147
+ };
148
+ }
149
+
128
150
  // A final order (shipped, delivered, cancelled; map.js isFinalStatus) stays on
129
151
  // the Jobs board this long after it last changed.
130
152
  export const WORK_FINISHED_DAYS = 30;