@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,32 @@
1
+ -- One row for each issued Flow invoice version sent to Xero as a draft. It
2
+ -- holds the exact request and its Idempotency-Key from before the request
3
+ -- leaves, so an uncertain outcome is resolved without a second invoice.
4
+ CREATE TABLE "xero_invoice_pushes" (
5
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6
+ "organisation_id" uuid NOT NULL,
7
+ "invoice_id" uuid NOT NULL,
8
+ "version" integer NOT NULL,
9
+ "state" text NOT NULL,
10
+ "idempotency_key" text NOT NULL,
11
+ "request_body" text NOT NULL,
12
+ "body_hash" text NOT NULL,
13
+ "pre_existing_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
14
+ "xero_invoice_id" text,
15
+ "xero_invoice_number" text,
16
+ "xero_status" text,
17
+ "verified" boolean DEFAULT false NOT NULL,
18
+ "error_code" text,
19
+ "error_message" text,
20
+ "attempts" integer DEFAULT 1 NOT NULL,
21
+ "pushed_by" uuid REFERENCES "people"("id") ON DELETE SET NULL,
22
+ "first_dispatched_at" timestamp with time zone DEFAULT now() NOT NULL,
23
+ "last_dispatched_at" timestamp with time zone DEFAULT now() NOT NULL,
24
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
25
+ CONSTRAINT "xero_invoice_pushes_invoice_version" UNIQUE("invoice_id", "version"),
26
+ CONSTRAINT "xero_invoice_pushes_invoice_same_org" FOREIGN KEY ("organisation_id", "invoice_id") REFERENCES "invoices"("organisation_id", "id"),
27
+ CONSTRAINT "xero_invoice_pushes_version_issued" FOREIGN KEY ("invoice_id", "version") REFERENCES "invoice_versions"("invoice_id", "version"),
28
+ CONSTRAINT "xero_invoice_pushes_state_valid" CHECK ("state" in ('dispatching', 'ambiguous', 'created', 'existing', 'mismatch')),
29
+ CONSTRAINT "xero_invoice_pushes_settled_has_id" CHECK ("state" in ('dispatching', 'ambiguous') or "xero_invoice_id" is not null)
30
+ );
31
+ --> statement-breakpoint
32
+ CREATE INDEX "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
@@ -0,0 +1,7 @@
1
+ -- Where the next read of Xero payments starts, for each Flow organisation.
2
+ -- (This is the Xero adapter's own migration journal, not the core's.)
3
+ CREATE TABLE "xero_payment_cursors" (
4
+ "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
+ "payments_since" timestamp with time zone NOT NULL,
6
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
7
+ );
@@ -0,0 +1,34 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1792368000000,
9
+ "tag": "0000_xero_connections",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1792368060000,
16
+ "tag": "0001_xero_catalogue",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1792368120000,
23
+ "tag": "0002_xero_invoice_pushes",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1792368180000,
30
+ "tag": "0003_xero_payment_cursors",
31
+ "breakpoints": true
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1,143 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { DEFAULT_TIMEOUT_MS, xeroError } from './client.js';
3
+
4
+ // Xero OAuth 2.0 (authorisation code with offline_access). Bodies that carry
5
+ // tokens never reach a log or an error message.
6
+
7
+ export const STATE_TTL_MS = 10 * 60 * 1000;
8
+
9
+ export function authorizeUrl(settings, state) {
10
+ const url = new URL(settings.endpoints.authorize);
11
+ url.searchParams.set('response_type', 'code');
12
+ url.searchParams.set('client_id', settings.clientId);
13
+ url.searchParams.set('redirect_uri', settings.redirectUri);
14
+ url.searchParams.set('scope', settings.scope);
15
+ url.searchParams.set('state', state);
16
+ return url.toString();
17
+ }
18
+
19
+ async function call(fetchImpl, url, init, what) {
20
+ const controller = new AbortController();
21
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
22
+ try {
23
+ let response;
24
+ try {
25
+ response = await fetchImpl(url, { ...init, signal: controller.signal });
26
+ } catch (error) {
27
+ if (error?.code === 'xero_replay_no_match') throw error;
28
+ throw xeroError('xero_unreachable', `Flow could not reach Xero for ${what}.`, { status: 503 });
29
+ }
30
+ const text = await response.text().catch(() => '');
31
+ let body = null;
32
+ try {
33
+ body = text ? JSON.parse(text) : null;
34
+ } catch {
35
+ body = null;
36
+ }
37
+ return { response, body };
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+
43
+ async function tokenRequest(settings, fetchImpl, form, what) {
44
+ const basic = Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString('base64');
45
+ const { response, body } = await call(fetchImpl, settings.endpoints.token, {
46
+ method: 'POST',
47
+ headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
48
+ body: new URLSearchParams(form).toString(),
49
+ }, what);
50
+ if (!response.ok) {
51
+ // invalid_grant: the refresh token was revoked, expired (60 days unused)
52
+ // or already used. Only a new connection fixes that.
53
+ if (body?.error === 'invalid_grant') {
54
+ throw xeroError('xero_reconnect_needed', 'Xero no longer accepts this connection. An owner must connect Xero again.', { status: 409 });
55
+ }
56
+ throw xeroError('xero_token_failed', `Xero refused ${what} (${response.status}${body?.error ? `, ${body.error}` : ''}).`, { status: 502 });
57
+ }
58
+ if (!body?.access_token || !body?.refresh_token) {
59
+ throw xeroError('xero_token_failed', `Xero's answer to ${what} had no tokens.`, { status: 502 });
60
+ }
61
+ return body;
62
+ }
63
+
64
+ export function exchangeCode(settings, fetchImpl, code) {
65
+ if (!code) throw xeroError('xero_oauth_failed', 'Xero sent no authorisation code.', { status: 400 });
66
+ return tokenRequest(settings, fetchImpl, { grant_type: 'authorization_code', code, redirect_uri: settings.redirectUri }, 'the authorisation code');
67
+ }
68
+
69
+ export function refreshTokens(settings, fetchImpl, refreshToken) {
70
+ return tokenRequest(settings, fetchImpl, { grant_type: 'refresh_token', refresh_token: refreshToken }, 'the token refresh');
71
+ }
72
+
73
+ // Best effort: a disconnect deletes Flow's copy even when Xero is down.
74
+ export async function revokeToken(settings, fetchImpl, refreshToken) {
75
+ const basic = Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString('base64');
76
+ const { response } = await call(fetchImpl, settings.endpoints.revoke, {
77
+ method: 'POST',
78
+ headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
79
+ body: new URLSearchParams({ token: refreshToken }).toString(),
80
+ }, 'revoking the token');
81
+ return response.ok;
82
+ }
83
+
84
+ export async function fetchConnections(settings, fetchImpl, accessToken) {
85
+ const { response, body } = await call(fetchImpl, settings.endpoints.connections, {
86
+ method: 'GET',
87
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
88
+ }, 'the organisation list');
89
+ if (!response.ok || !Array.isArray(body)) {
90
+ throw xeroError('xero_oauth_failed', `Xero did not list the authorised organisations (${response.status}).`, { status: 502 });
91
+ }
92
+ return body;
93
+ }
94
+
95
+ const sameName = (a, b) => String(a ?? '').trim().toLowerCase() === String(b ?? '').trim().toLowerCase();
96
+
97
+ // The one Xero organisation this connection is for. XERO_TENANT_ID and
98
+ // XERO_TENANT_NAME narrow the choice; more than one left is refused.
99
+ export function selectConnection(connections, settings) {
100
+ let matches = connections.filter((c) => (c.tenantType ?? 'ORGANISATION') === 'ORGANISATION');
101
+ if (settings.tenantId) matches = matches.filter((c) => c.tenantId === settings.tenantId);
102
+ if (settings.tenantName) matches = matches.filter((c) => sameName(c.tenantName, settings.tenantName));
103
+ const names = connections.map((c) => c.tenantName).filter(Boolean);
104
+ if (!matches.length) {
105
+ const wanted = settings.tenantId || settings.tenantName;
106
+ throw xeroError('xero_wrong_organisation',
107
+ wanted
108
+ ? `The Xero organisation this installation is set to (XERO_TENANT_ID or XERO_TENANT_NAME) was not among the ones approved: ${names.join(', ') || 'none'}.`
109
+ : 'No Xero organisation was approved.',
110
+ { status: 409 });
111
+ }
112
+ if (matches.length > 1) {
113
+ throw xeroError('xero_ambiguous_organisation',
114
+ `More than one Xero organisation was approved (${names.join(', ')}). Set XERO_TENANT_ID or XERO_TENANT_NAME, or approve only one.`,
115
+ { status: 409 });
116
+ }
117
+ return matches[0];
118
+ }
119
+
120
+ // One-use OAuth states, kept in memory for ten minutes. One Flow process
121
+ // serves one lab, and an owner finishes the Xero approval in the same
122
+ // browser within minutes.
123
+ export function createStateStore({ now = Date.now, ttlMs = STATE_TTL_MS } = {}) {
124
+ const states = new Map();
125
+ const purge = () => {
126
+ for (const [state, entry] of states) if (now() - entry.createdAt > ttlMs) states.delete(state);
127
+ };
128
+ return {
129
+ issue(entry) {
130
+ purge();
131
+ const state = randomBytes(24).toString('base64url');
132
+ states.set(state, { ...entry, createdAt: now() });
133
+ return state;
134
+ },
135
+ consume(state) {
136
+ purge();
137
+ if (typeof state !== 'string' || !states.has(state)) return null;
138
+ const entry = states.get(state);
139
+ states.delete(state);
140
+ return entry;
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ // Replay mode (XERO_MODE=replay): a stand-in for fetch that answers from
5
+ // recorded, synthetic Xero responses and never opens a connection. Tests use
6
+ // it, and so can a development workspace. A request no interaction matches
7
+ // fails, so replay can never fall through to the real Xero.
8
+ //
9
+ // A fixture file is { "interactions": [ { "request": { "method", "url",
10
+ // "query"? }, "response": { "status"?, "headers"?, "body"? }, "repeat"? } ] }.
11
+ // A request matches on method, URL without query, and every query value the
12
+ // interaction lists. Interactions are used once, in file order, unless
13
+ // "repeat" is true. Fixtures hold synthetic data only (CONTRIBUTING.md).
14
+
15
+ export const DEFAULT_REPLAY_FIXTURES = fileURLToPath(new URL('./fixtures/replay.json', import.meta.url));
16
+
17
+ export function loadReplayFixtures(path = DEFAULT_REPLAY_FIXTURES) {
18
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
19
+ if (!Array.isArray(parsed?.interactions)) throw new Error('A Xero replay fixture needs an "interactions" list');
20
+ return parsed.interactions;
21
+ }
22
+
23
+ function noMatch(method, url) {
24
+ const error = new Error(`Xero replay has no recorded answer for ${method} ${url.origin}${url.pathname}`);
25
+ error.code = 'xero_replay_no_match';
26
+ return error;
27
+ }
28
+
29
+ function matches(interaction, method, url) {
30
+ const expected = interaction.request ?? {};
31
+ if ((expected.method ?? 'GET').toUpperCase() !== method) return false;
32
+ const want = new URL(expected.url);
33
+ if (want.origin !== url.origin || want.pathname !== url.pathname) return false;
34
+ for (const [key, value] of Object.entries(expected.query ?? {})) {
35
+ if (url.searchParams.get(key) !== String(value)) return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ function readBody(body) {
41
+ if (body === undefined || body === null) return undefined;
42
+ if (typeof body === 'string') {
43
+ try {
44
+ return JSON.parse(body);
45
+ } catch {
46
+ return body;
47
+ }
48
+ }
49
+ return String(body);
50
+ }
51
+
52
+ // Returns a fetch function. `calls` records each request without its
53
+ // Authorization header, for tests.
54
+ export function createReplayFetch(interactions = loadReplayFixtures()) {
55
+ const pending = interactions.map((interaction) => ({ ...interaction, used: false }));
56
+ const calls = [];
57
+
58
+ async function replayFetch(input, init = {}) {
59
+ const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
60
+ const method = String(init.method ?? 'GET').toUpperCase();
61
+ const headers = Object.fromEntries(Object.entries(init.headers ?? {})
62
+ .filter(([name]) => name.toLowerCase() !== 'authorization'));
63
+ calls.push({ method, url: url.toString(), path: url.pathname, query: Object.fromEntries(url.searchParams), headers, body: readBody(init.body) });
64
+
65
+ const interaction = pending.find((entry) => (entry.repeat || !entry.used) && matches(entry, method, url));
66
+ if (!interaction) throw noMatch(method, url);
67
+ interaction.used = true;
68
+
69
+ const { status = 200, headers: responseHeaders = {}, body } = interaction.response ?? {};
70
+ if (interaction.response?.networkError) {
71
+ throw Object.assign(new Error('replayed network failure'), { name: 'TypeError' });
72
+ }
73
+ const text = body === undefined ? '' : typeof body === 'string' ? body : JSON.stringify(body);
74
+ return new Response(status === 204 || status === 304 ? null : text, {
75
+ status,
76
+ headers: { 'content-type': 'application/json', ...responseHeaders },
77
+ });
78
+ }
79
+
80
+ replayFetch.calls = calls;
81
+ replayFetch.unused = () => pending.filter((entry) => !entry.used && !entry.repeat);
82
+ return replayFetch;
83
+ }
@@ -0,0 +1,234 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import express from 'express';
3
+ import { FlowError } from '../../core/errors.js';
4
+ import { billingObligations, organisations } from '../../db/schema.js';
5
+ import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
6
+ import { createXeroClient } from './client.js';
7
+ import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
8
+ import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
9
+ import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
10
+ import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
11
+ import { writesAllowed } from './settings.js';
12
+
13
+ // The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
14
+ //
15
+ // GET /status?organisationId= any signed-in person: mode and connection
16
+ // POST /connect owner: returns Xero's approval address
17
+ // GET /oauth/callback Xero sends the owner back here
18
+ // POST /disconnect owner: revokes and forgets the connection
19
+ // POST /sync owner or admin: copies Xero contacts and items
20
+ // GET /contacts?organisationId=&q= any signed-in person: the copy
21
+ // GET /items?organisationId= any signed-in person: the copy
22
+ // POST /invoices/preview any signed-in person: the Xero drafts Flow
23
+ // would make, and what stops them (no Xero calls)
24
+ // POST /invoices/push owner or admin: issued invoices to Xero drafts
25
+ // GET /invoices/:invoiceId/pushes?organisationId= any signed-in person
26
+ // POST /obligations/:obligationId/check owner or admin: Xero's invoice for
27
+ // a billable job, recorded on the ledger
28
+ // POST /payments/sync owner or admin: Xero payments to the ledger
29
+ //
30
+ // Only an owner, with a verified owner sign-in, can connect or disconnect.
31
+ // The callback carries no Flow session: the one-use state from /connect,
32
+ // issued to that owner for that organisation, is what admits it.
33
+
34
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
+
36
+ function escapeHtml(value) {
37
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
38
+ }
39
+
40
+ function page(title, text) {
41
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title></head>`
42
+ + `<body style="font-family: system-ui, sans-serif; max-width: 36rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p></body></html>`;
43
+ }
44
+
45
+ export function sendError(res, error, log) {
46
+ const status = error instanceof FlowError ? (error.status ?? 400) : 500;
47
+ if (status >= 500) log.error?.(`[xero] ${error.code ?? 'error'}: ${error.message}`);
48
+ const body = { ok: false, code: error.code ?? 'xero_failed', error: status === 500 && !(error instanceof FlowError) ? 'Something went wrong talking to Xero.' : error.message };
49
+ if (error.details !== undefined) body.details = error.details;
50
+ if (error.retryAfterMs) res.set('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
51
+ return res.status(status).json(body);
52
+ }
53
+
54
+ function requireInvoiceIds(raw, name = 'invoiceIds') {
55
+ if (!Array.isArray(raw) || !raw.length || raw.length > MAX_BATCH || !raw.every((id) => typeof id === 'string' && UUID.test(id))) {
56
+ throw new FlowError('invalid', `${name} must list 1 to ${MAX_BATCH} Flow invoice ids`);
57
+ }
58
+ return [...new Set(raw)];
59
+ }
60
+
61
+ export async function requireOrganisation(db, raw) {
62
+ const organisationId = typeof raw === 'string' ? raw.trim() : '';
63
+ if (!UUID.test(organisationId)) throw new FlowError('invalid', 'organisationId must be a Flow organisation id');
64
+ const [row] = await db.select({ id: organisations.id }).from(organisations).where(eq(organisations.id, organisationId));
65
+ if (!row) throw Object.assign(new FlowError('not_found', 'organisation not found'), { status: 404 });
66
+ return organisationId;
67
+ }
68
+
69
+ // Wraps an async handler so a thrown error becomes a JSON answer.
70
+ export const handle = (log, fn) => async (req, res) => {
71
+ try {
72
+ await fn(req, res);
73
+ } catch (error) {
74
+ sendError(res, error, log);
75
+ }
76
+ };
77
+
78
+ export function createXeroRouter({ db, personAuth, runtime, log = console }) {
79
+ const { settings } = runtime;
80
+ const router = express.Router();
81
+ router.use(express.json({ limit: '256kb' }));
82
+
83
+ // Sign-in off: nothing but the callback answers, and it fails for want of
84
+ // a state.
85
+ const signedIn = personAuth
86
+ ? [personAuth.requireSession]
87
+ : [(_req, res) => res.status(503).json({ ok: false, code: 'sign_in_not_configured', error: 'Sign-in is not configured on this installation (FLOW_AUTH is unset).' })];
88
+ const owner = personAuth ? [...signedIn, personAuth.requireOwner] : signedIn;
89
+ const manager = personAuth ? [...signedIn, personAuth.requireRole('owner', 'admin')] : signedIn;
90
+
91
+ router.get('/status', ...signedIn, handle(log, async (req, res) => {
92
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
93
+ const connection = await readConnection(db, organisationId);
94
+ res.json({
95
+ ok: true,
96
+ mode: settings.mode,
97
+ writesAllowed: writesAllowed(settings),
98
+ redirectUri: settings.redirectUri,
99
+ connected: Boolean(connection && connection.mode === settings.mode),
100
+ connection: describeConnection(connection),
101
+ rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
102
+ lastSync: await lastSync(db, organisationId),
103
+ });
104
+ }));
105
+
106
+ router.post('/connect', ...owner, handle(log, async (req, res) => {
107
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
108
+ if (!settings.redirectUri) {
109
+ throw Object.assign(new FlowError('xero_redirect_uri_missing', 'Set FLOW_PUBLIC_ORIGIN (or XERO_REDIRECT_URI) to this installation\'s address, and register its /api/xero/oauth/callback on the Xero app.'), { status: 503 });
110
+ }
111
+ const state = runtime.states.issue({ organisationId, personId: req.person.id });
112
+ res.json({ ok: true, authorizeUrl: authorizeUrl(settings, state), redirectUri: settings.redirectUri, mode: settings.mode });
113
+ }));
114
+
115
+ router.get('/oauth/callback', async (req, res) => {
116
+ res.set('Cache-Control', 'no-store');
117
+ const entry = runtime.states.consume(req.query.state);
118
+ if (!entry) {
119
+ return res.status(400).type('html').send(page('Xero was not connected', 'This approval link has expired or was already used. Start again from Flow.'));
120
+ }
121
+ if (req.query.error) {
122
+ return res.status(400).type('html').send(page('Xero was not connected', 'The approval was cancelled in Xero. Start again from Flow if you meant to connect.'));
123
+ }
124
+ try {
125
+ const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
126
+ const connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
127
+ let chosen;
128
+ try {
129
+ chosen = selectConnection(connections, settings);
130
+ const probe = createXeroClient({
131
+ tenantId: chosen.tenantId,
132
+ accessToken: async () => tokens.access_token,
133
+ fetch: runtime.transport,
134
+ governor: runtime.governor,
135
+ apiBase: settings.endpoints.api,
136
+ log,
137
+ });
138
+ await runtime.assertEnvironment(probe);
139
+ } catch (error) {
140
+ // Refused: Xero keeps no grant for an organisation Flow will not use.
141
+ await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
142
+ throw error;
143
+ }
144
+ const connection = await saveConnection(db, {
145
+ organisationId: entry.organisationId,
146
+ mode: settings.mode,
147
+ tenantId: chosen.tenantId,
148
+ tenantName: chosen.tenantName ?? null,
149
+ scope: tokens.scope ?? settings.scope,
150
+ refreshToken: tokens.refresh_token,
151
+ connectedBy: entry.personId,
152
+ key: settings.tokenKey,
153
+ });
154
+ runtime.remember(entry.organisationId, tokens);
155
+ log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
156
+ return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab.`));
157
+ } catch (error) {
158
+ log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
159
+ const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
160
+ return res.status(error.status && error.status < 500 ? error.status : 502).type('html').send(page('Xero was not connected', message));
161
+ }
162
+ });
163
+
164
+ router.post('/disconnect', ...owner, handle(log, async (req, res) => {
165
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
166
+ const connection = await readConnection(db, organisationId);
167
+ if (!connection) return res.json({ ok: true, disconnected: false });
168
+ let revoked = false;
169
+ try {
170
+ revoked = await revokeToken(settings, runtime.transport, decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId));
171
+ } catch (error) {
172
+ log.warn?.(`[xero] could not revoke the Xero token for organisation ${organisationId}: ${error.code ?? 'error'}`);
173
+ }
174
+ await deleteConnection(db, organisationId);
175
+ runtime.forget(organisationId);
176
+ log.info?.(`[xero] organisation ${organisationId} disconnected from Xero by person ${req.person.id}`);
177
+ return res.json({ ok: true, disconnected: true, revoked });
178
+ }));
179
+
180
+ router.post('/sync', ...manager, handle(log, async (req, res) => {
181
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
182
+ const run = await syncCatalogue(db, runtime, organisationId, { startedBy: req.person.id });
183
+ res.json({ ok: true, sync: run });
184
+ }));
185
+
186
+ router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
187
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
188
+ res.json({ ok: true, contacts: await listContacts(db, organisationId, { q: typeof req.query.q === 'string' ? req.query.q : '' }) });
189
+ }));
190
+
191
+ router.get('/items', ...signedIn, handle(log, async (req, res) => {
192
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
193
+ res.json({ ok: true, items: await listItems(db, organisationId) });
194
+ }));
195
+
196
+ router.post('/invoices/preview', ...signedIn, handle(log, async (req, res) => {
197
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
198
+ const preview = await previewDrafts(db, runtime, organisationId, requireInvoiceIds(req.body?.invoiceIds));
199
+ res.json({ ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), ...preview });
200
+ }));
201
+
202
+ router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
203
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
204
+ const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
205
+ const allowExistingReference = req.body?.allowExistingReference === undefined ? [] : requireInvoiceIds(req.body.allowExistingReference, 'allowExistingReference');
206
+ const result = await pushDrafts(db, runtime, organisationId, invoiceIds, { pushedBy: req.person.id, allowExistingReference });
207
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} sent ${invoiceIds.length} invoice(s) to Xero: ${JSON.stringify(result.summary)}`);
208
+ res.json({ ok: true, ...result });
209
+ }));
210
+
211
+ router.get('/invoices/:invoiceId/pushes', ...signedIn, handle(log, async (req, res) => {
212
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
213
+ const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
214
+ res.json({ ok: true, pushes: (await listPushes(db, organisationId, invoiceId)).map(describePush) });
215
+ }));
216
+
217
+ router.post('/obligations/:obligationId/check', ...manager, handle(log, async (req, res) => {
218
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
219
+ const [obligationId] = requireInvoiceIds([req.params.obligationId], 'obligationId');
220
+ const [obligation] = await db.select().from(billingObligations)
221
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
222
+ if (!obligation) throw Object.assign(new FlowError('not_found', 'obligation not found'), { status: 404 });
223
+ const row = await recordXeroEvidence(db, runtime, organisationId, obligation, { actor: `person:${req.person.id}` });
224
+ res.json({ ok: true, obligation: row });
225
+ }));
226
+
227
+ router.post('/payments/sync', ...manager, handle(log, async (req, res) => {
228
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
229
+ const result = await recordXeroPayments(db, runtime, organisationId, { actor: `person:${req.person.id}` });
230
+ res.json({ ok: true, ...result });
231
+ }));
232
+
233
+ return router;
234
+ }