@arcforgelabs/flow 2026.10.18 → 2026.10.19

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 (65) hide show
  1. package/README.md +3 -0
  2. package/SECURITY.md +4 -1
  3. package/npm-shrinkwrap.json +32 -3
  4. package/package.json +3 -2
  5. package/server/app.js +26 -3
  6. package/server/auth/routes.js +27 -12
  7. package/server/auth/session.js +6 -3
  8. package/server/auth/settings.js +26 -7
  9. package/server/auth/sign-in.html +17 -6
  10. package/server/core/invoices.js +6 -3
  11. package/server/core/ledger.js +420 -0
  12. package/server/db/migrations/0003_billing_ledger.sql +84 -0
  13. package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
  14. package/server/db/migrations/meta/0003_snapshot.json +1327 -0
  15. package/server/db/migrations/meta/0004_snapshot.json +1327 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +137 -0
  18. package/server/import/earlier-flow.js +230 -0
  19. package/server/import-earlier-flow.js +66 -0
  20. package/server/index.js +16 -4
  21. package/server/integrations/adapters.js +6 -0
  22. package/server/integrations/health.js +21 -0
  23. package/server/integrations/index.js +26 -0
  24. package/server/integrations/port.js +154 -6
  25. package/server/integrations/remove-tables.js +31 -0
  26. package/server/integrations/schema.js +62 -0
  27. package/server/integrations/seazona/client.js +196 -0
  28. package/server/integrations/seazona/drizzle.config.js +9 -0
  29. package/server/integrations/seazona/feed.js +104 -0
  30. package/server/integrations/seazona/governor.js +174 -0
  31. package/server/integrations/seazona/index.js +84 -0
  32. package/server/integrations/seazona/lanes.js +431 -0
  33. package/server/integrations/seazona/ledger.js +66 -0
  34. package/server/integrations/seazona/map.js +229 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
  37. package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
  38. package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
  40. package/server/integrations/seazona/replay.js +79 -0
  41. package/server/integrations/seazona/scheduler.js +78 -0
  42. package/server/integrations/seazona/schema.js +157 -0
  43. package/server/integrations/seazona/settings.js +161 -0
  44. package/server/integrations/seazona/store.js +219 -0
  45. package/server/integrations/seazona/sync-once.js +44 -0
  46. package/server/integrations/seazona/tables.js +17 -0
  47. package/server/integrations/xero/catalogue.js +184 -0
  48. package/server/integrations/xero/client.js +253 -0
  49. package/server/integrations/xero/connections.js +104 -0
  50. package/server/integrations/xero/drafts.js +525 -0
  51. package/server/integrations/xero/fixtures/replay.json +45 -0
  52. package/server/integrations/xero/governor.js +275 -0
  53. package/server/integrations/xero/index.js +56 -0
  54. package/server/integrations/xero/ledger.js +269 -0
  55. package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
  56. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
  57. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
  58. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +34 -0
  60. package/server/integrations/xero/oauth.js +143 -0
  61. package/server/integrations/xero/replay.js +83 -0
  62. package/server/integrations/xero/routes.js +234 -0
  63. package/server/integrations/xero/runtime.js +135 -0
  64. package/server/integrations/xero/settings.js +170 -0
  65. package/server/rate-limit.js +13 -19
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1792329974352,
9
+ "tag": "0000_api_governor",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1792330268730,
16
+ "tag": "0001_mirror",
17
+ "breakpoints": true
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // Replay mode: Seazona answers come from a file of recorded, synthetic
4
+ // responses, and nothing reaches a network (issue #10). Tests and development
5
+ // use it. A replay file holds synthetic data only (CONTRIBUTING.md).
6
+ //
7
+ // Format (docs/SEAZONA.md, "Replay files"):
8
+ // { "responses": [
9
+ // { "path": "invoices/", "query": { "lastModified": "*" }, "status": 200, "body": [] },
10
+ // { "path": "orders/ORD-1", "status": 404 },
11
+ // { "path": "orders/ORD-2", "sequence": [{ "status": 500 }, { "status": 200, "body": {} }] }
12
+ // ] }
13
+ // `path` is relative to the API root. `query` is optional; each listed key
14
+ // must match, and "*" matches any value. The first matching entry answers. A
15
+ // `sequence` answers in order and then repeats its last answer. A request that
16
+ // no entry matches fails closed with the kind `replay_miss`.
17
+
18
+ function normalisePath(path) {
19
+ return String(path || '').replace(/^\/+/, '').replace(/^v1\//i, '');
20
+ }
21
+
22
+ function queryMatches(expected, actual) {
23
+ if (!expected) return true;
24
+ return Object.entries(expected).every(([key, value]) => (
25
+ value === '*' ? actual.has(key) : actual.get(key) === String(value)
26
+ ));
27
+ }
28
+
29
+ export function loadReplay(source) {
30
+ const replay = typeof source === 'string' ? JSON.parse(readFileSync(source, 'utf8')) : source;
31
+ if (!replay || !Array.isArray(replay.responses)) {
32
+ throw new TypeError('A Seazona replay needs a "responses" list');
33
+ }
34
+ return replay;
35
+ }
36
+
37
+ // A fetch() that answers from the replay. `calls` lists what was asked, so a
38
+ // test can check exactly which requests the adapter made.
39
+ export function createReplayFetch(source) {
40
+ const replay = loadReplay(source);
41
+ const entries = replay.responses.map((entry) => ({ ...entry, path: normalisePath(entry.path), served: 0 }));
42
+ const calls = [];
43
+
44
+ async function replayFetch(input, init = {}) {
45
+ const url = new URL(String(input));
46
+ const method = (init.method || 'GET').toUpperCase();
47
+ const path = normalisePath(url.pathname.replace(/^\/v1\//i, ''));
48
+ calls.push({ method, path, query: Object.fromEntries(url.searchParams) });
49
+ const entry = entries.find((candidate) => (
50
+ (candidate.method || 'GET').toUpperCase() === method
51
+ && candidate.path === path
52
+ && queryMatches(candidate.query, url.searchParams)
53
+ ));
54
+ if (!entry) {
55
+ const error = new Error(`replay has no answer for ${method} ${path}`);
56
+ error.kind = 'replay_miss';
57
+ throw error;
58
+ }
59
+ const answers = Array.isArray(entry.sequence) ? entry.sequence : [entry];
60
+ const answer = answers[Math.min(entry.served, answers.length - 1)];
61
+ entry.served += 1;
62
+ if (answer.error === 'timeout') {
63
+ const error = new Error('The operation was aborted');
64
+ error.name = 'AbortError';
65
+ throw error;
66
+ }
67
+ const status = answer.status ?? 200;
68
+ let body = null;
69
+ if (typeof answer.rawBody === 'string') body = answer.rawBody;
70
+ else if (answer.body !== undefined) body = JSON.stringify(answer.body);
71
+ return new Response(status === 204 ? null : body, {
72
+ status,
73
+ headers: { 'content-type': 'application/json', ...(answer.headers || {}) },
74
+ });
75
+ }
76
+
77
+ replayFetch.calls = calls;
78
+ return replayFetch;
79
+ }
@@ -0,0 +1,78 @@
1
+ import { LANES } from './lanes.js';
2
+ import { readLaneStates } from './store.js';
3
+
4
+ // The background sync, for live mode only (issue #10: index.js never creates
5
+ // it in sandbox or replay). It runs the lanes that are due, then sleeps until
6
+ // the next lane is due, between 15 seconds and 30 minutes. One run at a time.
7
+
8
+ const MIN_DELAY_MS = 15_000;
9
+ const MAX_DELAY_MS = 30 * 60_000;
10
+
11
+ export function createSeazonaScheduler({
12
+ db,
13
+ run,
14
+ now = Date.now,
15
+ log = console,
16
+ timers = { setTimeout, clearTimeout },
17
+ firstDelayMs = 5_000,
18
+ } = {}) {
19
+ let timer = null;
20
+ let running = null;
21
+ let stopped = false;
22
+ let nextRunAt = null;
23
+ let last = null;
24
+
25
+ async function nextDelayMs() {
26
+ const states = await readLaneStates(db);
27
+ let soonest = Infinity;
28
+ for (const lane of LANES) {
29
+ const at = states.get(lane)?.nextRunAt?.getTime();
30
+ soonest = Math.min(soonest, Number.isFinite(at) ? at - Number(now()) : 0);
31
+ }
32
+ return Math.min(MAX_DELAY_MS, Math.max(MIN_DELAY_MS, soonest));
33
+ }
34
+
35
+ function schedule(delayMs) {
36
+ if (stopped) return;
37
+ nextRunAt = new Date(Number(now()) + delayMs).toISOString();
38
+ timer = timers.setTimeout(tick, delayMs);
39
+ timer?.unref?.();
40
+ }
41
+
42
+ async function tick() {
43
+ timer = null;
44
+ if (stopped) return;
45
+ running = (async () => {
46
+ const startedAt = new Date(Number(now())).toISOString();
47
+ try {
48
+ const result = await run();
49
+ const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
50
+ last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
51
+ for (const [lane, outcome] of failed) log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
52
+ } catch (error) {
53
+ last = { at: startedAt, ok: false, error: error?.message || String(error) };
54
+ log.error?.(`[seazona] sync run failed: ${last.error}`);
55
+ }
56
+ })();
57
+ await running;
58
+ running = null;
59
+ schedule(await nextDelayMs().catch(() => MIN_DELAY_MS));
60
+ }
61
+
62
+ return {
63
+ start() {
64
+ stopped = false;
65
+ schedule(firstDelayMs);
66
+ },
67
+ async stop() {
68
+ stopped = true;
69
+ if (timer) timers.clearTimeout(timer);
70
+ timer = null;
71
+ nextRunAt = null;
72
+ await running;
73
+ },
74
+ status() {
75
+ return { running: Boolean(running), nextRunAt, lastRun: last };
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,157 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { bigint, bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
3
+
4
+ // The Seazona adapter's own tables. They are not part of the core schema
5
+ // (server/db/schema.js): the adapter creates them only when it is enabled
6
+ // (issue #13). One installation talks to one Seazona account, so these tables
7
+ // are installation-wide, like `people`.
8
+
9
+ const at = (name) => timestamp(name, { withTimezone: true });
10
+
11
+ // One row per API client: when the next request may go, and any cooldown a
12
+ // 429 asked for. Every process that shares the database shares this row.
13
+ export const seazonaApiGovernor = pgTable('seazona_api_governor', {
14
+ clientKey: text('client_key').primaryKey(),
15
+ nextEligibleAt: at('next_eligible_at').notNull().default(sql`'epoch'`),
16
+ cooldownUntil: at('cooldown_until').notNull().default(sql`'epoch'`),
17
+ updatedAt: at('updated_at').notNull().defaultNow(),
18
+ });
19
+
20
+ // One row per request sent. The rate limit counts these rows, and they are
21
+ // the telemetry an operator reads to answer the vendor. A row keeps the
22
+ // request path (for example /v1/orders/<order id>, so a failing record can be
23
+ // named), never the host, the query or a credential, and never a response.
24
+ export const seazonaApiRequests = pgTable('seazona_api_requests', {
25
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
26
+ clientKey: text('client_key').notNull(),
27
+ workflow: text('workflow').notNull(),
28
+ admittedAt: at('admitted_at').notNull(),
29
+ requestPath: text('request_path'),
30
+ statusCode: integer('status_code'),
31
+ failureKind: text('failure_kind'),
32
+ durationMs: integer('duration_ms'),
33
+ isRetry: boolean('is_retry').notNull().default(false),
34
+ }, (t) => [
35
+ index('seazona_api_requests_window').on(t.clientKey, t.admittedAt),
36
+ ]);
37
+
38
+ // The mirror. Each row is what Seazona said at the last successful read,
39
+ // reduced to what billing needs: no clinical notes, no files, and no patient
40
+ // field unless SEAZONA_KEEP_PATIENT_REFERENCE is on. Money is integer cents,
41
+ // as Seazona reported it. `list_signature` is a hash of the list fields, so an
42
+ // unchanged list row costs no detail read; `record_hash` tells a change apart.
43
+ const cents = (name) => bigint(name, { mode: 'number' });
44
+ const mirrored = () => ({
45
+ listSignature: text('list_signature'),
46
+ recordHash: text('record_hash').notNull(),
47
+ firstSeenAt: at('first_seen_at').notNull(),
48
+ lastSyncedAt: at('last_synced_at').notNull(),
49
+ lastChangedAt: at('last_changed_at').notNull(),
50
+ });
51
+
52
+ export const seazonaOrders = pgTable('seazona_orders', {
53
+ id: text('id').primaryKey(),
54
+ invoiceNumber: text('invoice_number'),
55
+ clientId: text('client_id'),
56
+ company: text('company'),
57
+ doctorName: text('doctor_name'),
58
+ status: text('status'),
59
+ orderedText: text('ordered_text'),
60
+ dueText: text('due_text'),
61
+ lastModifiedText: text('last_modified_text'),
62
+ department: text('department'),
63
+ lines: jsonb('lines').notNull().default([]),
64
+ subtotalCents: cents('subtotal_cents'),
65
+ patientReference: text('patient_reference'),
66
+ // Issue #12: Seazona answered 404. The row and its history stay; the order
67
+ // reads as "Deleted in Seazona" and is out of billing. A later successful
68
+ // read clears it.
69
+ deletedInSourceAt: at('deleted_in_source_at'),
70
+ ...mirrored(),
71
+ }, (t) => [
72
+ index('seazona_orders_invoice_number').on(t.invoiceNumber),
73
+ index('seazona_orders_changed').on(t.lastChangedAt),
74
+ ]);
75
+
76
+ export const seazonaInvoices = pgTable('seazona_invoices', {
77
+ invoiceNumber: text('invoice_number').primaryKey(),
78
+ seazonaId: text('seazona_id'),
79
+ clientId: text('client_id'),
80
+ accountNumber: text('account_number'),
81
+ company: text('company'),
82
+ doctorName: text('doctor_name'),
83
+ status: text('status'),
84
+ invoiceDateText: text('invoice_date_text'),
85
+ dueText: text('due_text'),
86
+ lastModifiedText: text('last_modified_text'),
87
+ lines: jsonb('lines').notNull().default([]),
88
+ subtotalCents: cents('subtotal_cents'),
89
+ taxCents: cents('tax_cents'),
90
+ totalCents: cents('total_cents'),
91
+ discountCents: cents('discount_cents'),
92
+ patientReference: text('patient_reference'),
93
+ ...mirrored(),
94
+ }, (t) => [
95
+ index('seazona_invoices_changed').on(t.lastChangedAt),
96
+ ]);
97
+
98
+ export const seazonaClients = pgTable('seazona_clients', {
99
+ id: text('id').primaryKey(),
100
+ accountNumber: text('account_number'),
101
+ company: text('company'),
102
+ fullName: text('full_name'),
103
+ email: text('email'),
104
+ phone: text('phone'),
105
+ address: jsonb('address'),
106
+ taxRate: text('tax_rate'),
107
+ active: boolean('active'),
108
+ doctors: jsonb('doctors').notNull().default([]),
109
+ lastModifiedText: text('last_modified_text'),
110
+ ...mirrored(),
111
+ });
112
+
113
+ // Products and users: long-lived reference lists, read in full once a day.
114
+ export const seazonaReference = pgTable('seazona_reference', {
115
+ resource: text('resource').notNull(),
116
+ id: text('id').notNull(),
117
+ name: text('name'),
118
+ code: text('code'),
119
+ kind: text('kind'),
120
+ priceCents: cents('price_cents'),
121
+ taxable: boolean('taxable'),
122
+ recordHash: text('record_hash').notNull(),
123
+ lastSyncedAt: at('last_synced_at').notNull(),
124
+ }, (t) => [
125
+ primaryKey({ columns: [t.resource, t.id] }),
126
+ ]);
127
+
128
+ // One row per sync lane: its cursor, when it runs next, and its last failure.
129
+ export const seazonaLaneState = pgTable('seazona_lane_state', {
130
+ lane: text('lane').primaryKey(),
131
+ status: text('status').notNull().default('idle'),
132
+ cursorTime: at('cursor_time'),
133
+ cursor: jsonb('cursor').notNull().default({}),
134
+ lastSuccessAt: at('last_success_at'),
135
+ lastAttemptAt: at('last_attempt_at'),
136
+ nextRunAt: at('next_run_at'),
137
+ failureCount: integer('failure_count').notNull().default(0),
138
+ lastError: text('last_error'),
139
+ // The order ids and statuses behind the last failure (issue #12). Never
140
+ // patient data.
141
+ failureDetails: jsonb('failure_details'),
142
+ heldReason: text('held_reason'),
143
+ updatedAt: at('updated_at').notNull().defaultNow(),
144
+ });
145
+
146
+ // Issue #12: each failed order read, per order. A failed order moves to the
147
+ // back of the rotation; after a few failures in a row it is quarantined for a
148
+ // bounded time, then gets one more read. A successful read or a 404 clears it.
149
+ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
150
+ orderId: text('order_id').primaryKey(),
151
+ failureCount: integer('failure_count').notNull(),
152
+ lastAttemptAt: at('last_attempt_at').notNull(),
153
+ lastHttpStatus: integer('last_http_status'),
154
+ lastFailureKind: text('last_failure_kind'),
155
+ lastError: text('last_error'),
156
+ quarantinedUntil: at('quarantined_until'),
157
+ });
@@ -0,0 +1,161 @@
1
+ // Seazona adapter settings. Everything that names an installation (the API
2
+ // host, the API client) comes from configuration; the code holds no default
3
+ // host or account. docs/SEAZONA.md lists every setting.
4
+ //
5
+ // The mode says which environment the adapter acts on (issue #10):
6
+ // live the installation's real Seazona account; the scheduler runs.
7
+ // sandbox a Seazona test account; runs only when asked, never on a timer.
8
+ // replay recorded, synthetic responses from a file; no network at all.
9
+ // There is no default mode. Without one the adapter does nothing.
10
+
11
+ export const MODES = Object.freeze(['live', 'sandbox', 'replay']);
12
+
13
+ // Seazona publishes 60 requests a minute per API client. Flow stays at half of
14
+ // that, across every process that shares the database, and a setting can only
15
+ // lower it.
16
+ export const PUBLISHED_REQUESTS_PER_MINUTE = 60;
17
+ export const MAX_REQUESTS_PER_MINUTE = 30;
18
+
19
+ const DAY_MS = 24 * 60 * 60_000;
20
+
21
+ export class SeazonaSettingsError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'SeazonaSettingsError';
25
+ this.code = 'seazona_settings_invalid';
26
+ }
27
+ }
28
+
29
+ function text(value) {
30
+ return typeof value === 'string' ? value.trim() : '';
31
+ }
32
+
33
+ export function clampInt(value, fallback, min, max) {
34
+ const parsed = Number.parseInt(String(value ?? ''), 10);
35
+ if (!Number.isFinite(parsed)) return fallback;
36
+ return Math.min(max, Math.max(min, parsed));
37
+ }
38
+
39
+ // SEAZONA_API_BASE_URL is the API root, with or without /v1. It must be https:
40
+ // the API takes basic authentication.
41
+ export function normaliseBaseUrl(value) {
42
+ const raw = text(value);
43
+ if (!raw) return '';
44
+ let url;
45
+ try {
46
+ url = new URL(raw);
47
+ } catch {
48
+ throw new SeazonaSettingsError('SEAZONA_API_BASE_URL is not a URL');
49
+ }
50
+ if (url.protocol !== 'https:') throw new SeazonaSettingsError('SEAZONA_API_BASE_URL must use https');
51
+ if (url.search || url.hash || url.username || url.password) {
52
+ throw new SeazonaSettingsError('SEAZONA_API_BASE_URL must be a plain address with no query, fragment or credentials');
53
+ }
54
+ const path = url.pathname.replace(/\/+$/, '').replace(/\/v1$/i, '');
55
+ return `${url.origin}${path}/v1`;
56
+ }
57
+
58
+ // Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
59
+ // null when SEAZONA_MODE is unset: the adapter is then not configured.
60
+ export function resolveSeazonaSettings(env = process.env) {
61
+ const modeText = text(env.SEAZONA_MODE).toLowerCase();
62
+ if (modeText && !MODES.includes(modeText)) {
63
+ throw new SeazonaSettingsError(`SEAZONA_MODE must be one of ${MODES.join(', ')}`);
64
+ }
65
+ const mode = modeText || null;
66
+
67
+ const rate = {
68
+ requestsPerMinute: clampInt(env.SEAZONA_API_REQUESTS_PER_MINUTE, MAX_REQUESTS_PER_MINUTE, 1, MAX_REQUESTS_PER_MINUTE),
69
+ publishedLimitPerMinute: PUBLISHED_REQUESTS_PER_MINUTE,
70
+ // Retries cover 429 and 5xx answers only. Every attempt counts against
71
+ // the limit, so keep this small.
72
+ maxRetries: clampInt(env.SEAZONA_API_MAX_RETRIES, 2, 0, 6),
73
+ timeoutMs: clampInt(env.SEAZONA_API_TIMEOUT_MS, 30_000, 1_000, 120_000),
74
+ // How long one request may wait for a free slot before it gives up.
75
+ maxWaitMs: clampInt(env.SEAZONA_API_MAX_WAIT_MS, 120_000, 1_000, 15 * 60_000),
76
+ // Long enough to answer a vendor's question about last month.
77
+ telemetryRetentionMs: clampInt(env.SEAZONA_API_TELEMETRY_RETENTION_DAYS, 35, 1, 90) * DAY_MS,
78
+ };
79
+
80
+ const MINUTE = 60_000;
81
+ const ms = (name, fallback, min, max) => clampInt(env[name], fallback, min, max);
82
+ const sync = {
83
+ // How often each lane runs in live mode, and how old its data may get
84
+ // before /health calls it stale.
85
+ lanes: {
86
+ invoices: { cadenceMs: ms('SEAZONA_INVOICE_CADENCE_MS', 2 * MINUTE, MINUTE, DAY_MS), freshnessMs: 10 * MINUTE },
87
+ orders_recent: { cadenceMs: ms('SEAZONA_ORDER_CADENCE_MS', 5 * MINUTE, MINUTE, DAY_MS), freshnessMs: 15 * MINUTE },
88
+ orders_active: { cadenceMs: ms('SEAZONA_ACTIVE_ORDER_CADENCE_MS', 5 * MINUTE, MINUTE, DAY_MS), freshnessMs: 30 * MINUTE },
89
+ clients: { cadenceMs: ms('SEAZONA_CLIENT_CADENCE_MS', 30 * MINUTE, MINUTE, DAY_MS), freshnessMs: 60 * MINUTE },
90
+ products: { cadenceMs: ms('SEAZONA_REFERENCE_CADENCE_MS', DAY_MS, MINUTE, 30 * DAY_MS), freshnessMs: 2 * DAY_MS },
91
+ users: { cadenceMs: ms('SEAZONA_REFERENCE_CADENCE_MS', DAY_MS, MINUTE, 30 * DAY_MS), freshnessMs: 2 * DAY_MS },
92
+ },
93
+ // Re-read this much before a lastModified cursor, so a change written
94
+ // while the last run was reading is not missed.
95
+ cursorOverlapMs: 5 * MINUTE,
96
+ // The recent-order lane reads orders placed in the last N days, a few
97
+ // days per run (GET orders/?ordered= matches one UTC day).
98
+ orderLookbackDays: clampInt(env.SEAZONA_ORDER_LOOKBACK_DAYS, 30, 1, 3650),
99
+ orderDaysPerRun: clampInt(env.SEAZONA_ORDER_DAYS_PER_RUN, 6, 1, 31),
100
+ // The first invoice run reads from the start of today (UTC) minus this.
101
+ invoiceLookbackDays: clampInt(env.SEAZONA_INVOICE_LOOKBACK_DAYS, 0, 0, 3650),
102
+ // Seazona documents no paging. A list exactly this long may be cut off,
103
+ // so the lane holds its cursor instead of trusting it.
104
+ listCap: clampInt(env.SEAZONA_LIST_CAP, 50, 1, 10_000),
105
+ // The active-order lane re-reads this many open orders per run, oldest first.
106
+ activeOrdersPerRun: clampInt(env.SEAZONA_ACTIVE_ORDERS_PER_RUN, 25, 1, 500),
107
+ // Issue #12: after this many failed reads in a row an order waits this long.
108
+ orderQuarantineFailures: clampInt(env.SEAZONA_ORDER_QUARANTINE_FAILURES, 3, 1, 20),
109
+ orderQuarantineMs: ms('SEAZONA_ORDER_QUARANTINE_MS', DAY_MS, 5 * MINUTE, 30 * DAY_MS),
110
+ // A failed lane waits 1 minute, doubling to at most 6 hours.
111
+ retryBaseMs: MINUTE,
112
+ retryMaxMs: 6 * 60 * MINUTE,
113
+ heldRetryMs: 60 * MINUTE,
114
+ // A lane marked as running for longer than this is taken to have died.
115
+ stuckLaneMs: 20 * MINUTE,
116
+ // VISION.md: Flow keeps a patient reference only where an invoice must
117
+ // print one. Off by default; on, orders and invoices keep Seazona's
118
+ // patient field as the reference.
119
+ keepPatientReference: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_KEEP_PATIENT_REFERENCE).toLowerCase()),
120
+ };
121
+
122
+ const settings = {
123
+ mode,
124
+ sync,
125
+ baseUrl: '',
126
+ credentials: null,
127
+ replayFile: '',
128
+ rate,
129
+ // The scheduler starts only in live mode (issue #10).
130
+ scheduled: mode === 'live',
131
+ };
132
+
133
+ if (mode === 'live' || mode === 'sandbox') {
134
+ settings.baseUrl = normaliseBaseUrl(env.SEAZONA_API_BASE_URL);
135
+ if (!settings.baseUrl) throw new SeazonaSettingsError(`SEAZONA_API_BASE_URL is required in ${mode} mode`);
136
+ const clientId = text(env.SEAZONA_API_CLIENT_ID);
137
+ const clientSecret = text(env.SEAZONA_API_CLIENT_SECRET);
138
+ if (!clientId || !clientSecret) {
139
+ throw new SeazonaSettingsError(`SEAZONA_API_CLIENT_ID and SEAZONA_API_CLIENT_SECRET are required in ${mode} mode`);
140
+ }
141
+ settings.credentials = { clientId, clientSecret };
142
+ } else if (mode === 'replay') {
143
+ settings.replayFile = text(env.SEAZONA_REPLAY_FILE);
144
+ if (!settings.replayFile) throw new SeazonaSettingsError('SEAZONA_REPLAY_FILE is required in replay mode');
145
+ // Replay never reaches a network, so the address is only a label.
146
+ settings.baseUrl = 'https://seazona.replay.invalid/v1';
147
+ }
148
+
149
+ return settings;
150
+ }
151
+
152
+ // What /health and Settings may show. Never the credentials or the host.
153
+ export function describeSeazonaSettings(settings) {
154
+ return {
155
+ mode: settings?.mode ?? null,
156
+ configured: Boolean(settings?.mode),
157
+ scheduled: Boolean(settings?.scheduled),
158
+ requestsPerMinute: settings?.rate?.requestsPerMinute ?? null,
159
+ publishedLimitPerMinute: PUBLISHED_REQUESTS_PER_MINUTE,
160
+ };
161
+ }