@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.
- package/README.md +3 -0
- package/SECURITY.md +4 -1
- package/npm-shrinkwrap.json +32 -3
- package/package.json +3 -2
- package/server/app.js +26 -3
- package/server/auth/routes.js +27 -12
- package/server/auth/session.js +6 -3
- package/server/auth/settings.js +26 -7
- package/server/auth/sign-in.html +17 -6
- package/server/core/invoices.js +6 -3
- package/server/core/ledger.js +420 -0
- package/server/db/migrations/0003_billing_ledger.sql +84 -0
- package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
- package/server/db/migrations/meta/0003_snapshot.json +1327 -0
- package/server/db/migrations/meta/0004_snapshot.json +1327 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +137 -0
- package/server/import/earlier-flow.js +230 -0
- package/server/import-earlier-flow.js +66 -0
- package/server/index.js +16 -4
- package/server/integrations/adapters.js +6 -0
- package/server/integrations/health.js +21 -0
- package/server/integrations/index.js +26 -0
- package/server/integrations/port.js +154 -6
- package/server/integrations/remove-tables.js +31 -0
- package/server/integrations/schema.js +62 -0
- package/server/integrations/seazona/client.js +196 -0
- package/server/integrations/seazona/drizzle.config.js +9 -0
- package/server/integrations/seazona/feed.js +104 -0
- package/server/integrations/seazona/governor.js +174 -0
- package/server/integrations/seazona/index.js +84 -0
- package/server/integrations/seazona/lanes.js +431 -0
- package/server/integrations/seazona/ledger.js +66 -0
- package/server/integrations/seazona/map.js +229 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
- package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
- package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
- package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
- package/server/integrations/seazona/replay.js +79 -0
- package/server/integrations/seazona/scheduler.js +78 -0
- package/server/integrations/seazona/schema.js +157 -0
- package/server/integrations/seazona/settings.js +161 -0
- package/server/integrations/seazona/store.js +219 -0
- package/server/integrations/seazona/sync-once.js +44 -0
- package/server/integrations/seazona/tables.js +17 -0
- package/server/integrations/xero/catalogue.js +184 -0
- package/server/integrations/xero/client.js +253 -0
- package/server/integrations/xero/connections.js +104 -0
- package/server/integrations/xero/drafts.js +525 -0
- package/server/integrations/xero/fixtures/replay.json +45 -0
- package/server/integrations/xero/governor.js +275 -0
- package/server/integrations/xero/index.js +56 -0
- package/server/integrations/xero/ledger.js +269 -0
- package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
- package/server/integrations/xero/migrations/meta/_journal.json +34 -0
- package/server/integrations/xero/oauth.js +143 -0
- package/server/integrations/xero/replay.js +83 -0
- package/server/integrations/xero/routes.js +234 -0
- package/server/integrations/xero/runtime.js +135 -0
- package/server/integrations/xero/settings.js +170 -0
- 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
|
+
}
|