@arcforgelabs/flow 2026.10.22 → 2026.10.23

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.
@@ -0,0 +1,234 @@
1
+ import { eq, sql } from 'drizzle-orm';
2
+ import express from 'express';
3
+ import { FlowError } from '../core/errors.js';
4
+ import { deriveKey, openSecret, sealSecret } from '../core/secrets.js';
5
+ import { integrationSettingEvents, integrationSettings, organisations } from '../db/schema.js';
6
+
7
+ // Integration setup in Settings (docs/INTEGRATIONS.md, "Set up in Flow"). An
8
+ // owner turns an integration on and enters its settings in the app; no one
9
+ // edits the host's secrets. Plain settings are stored by their environment
10
+ // name; credentials are sealed with Flow's secrets key. Each adapter reads
11
+ // the result as its environment: the host's own environment wins, then the
12
+ // stored settings, then each derived key. A change applies at once
13
+ // (registry.reconfigure), without a restart.
14
+
15
+ const MAX_VALUE = 2000;
16
+ const ONOFF = ['on', 'off'];
17
+ const context = (adapterId, name) => `integration:${adapterId}:${name}`;
18
+
19
+ const hostValue = (env, name) => String(env[name] ?? '').trim();
20
+
21
+ export async function readStoredSettings(db) {
22
+ const rows = await db.select().from(integrationSettings);
23
+ return new Map(rows.map((row) => [row.adapterId, row]));
24
+ }
25
+
26
+ // The ids the host turns on (FLOW_INTEGRATIONS) and the ids an owner turned
27
+ // on in Settings.
28
+ export function hostEnabled(env) {
29
+ return new Set((env.FLOW_INTEGRATIONS ?? '').split(',').map((id) => id.trim().toLowerCase()).filter(Boolean));
30
+ }
31
+
32
+ // Builds the environment the adapters read: the stored settings, then the
33
+ // host's environment over them. A credential that cannot be opened (the key
34
+ // changed) is left out and reported. Never logs a value.
35
+ export function settingsEnvironment({ env, stored, adapters, secrets }) {
36
+ const merged = {};
37
+ const unreadable = [];
38
+ for (const adapter of adapters) {
39
+ const row = stored.get(adapter.id);
40
+ if (!adapter.setup) continue;
41
+ for (const field of adapter.setup.fields) {
42
+ if (field.secret) {
43
+ const sealed = row?.secrets?.[field.name];
44
+ if (!sealed) continue;
45
+ try {
46
+ merged[field.name] = openSecret(secrets, sealed, context(adapter.id, field.name));
47
+ } catch {
48
+ unreadable.push(`${adapter.id}:${field.name}`);
49
+ }
50
+ } else if (row?.config?.[field.name] != null) {
51
+ merged[field.name] = String(row.config[field.name]);
52
+ }
53
+ }
54
+ if (secrets) {
55
+ for (const name of adapter.setup.derivedKeys) merged[name] = deriveKey(secrets, name).toString('hex');
56
+ }
57
+ }
58
+ for (const [name, value] of Object.entries(env)) {
59
+ if (value != null && String(value).trim() !== '') merged[name] = value;
60
+ }
61
+ return { env: merged, unreadable };
62
+ }
63
+
64
+ // The adapters an owner turned on in Settings and the host does not.
65
+ export function storedEnabled(stored, env) {
66
+ const host = hostEnabled(env);
67
+ return [...stored.values()].filter((row) => row.enabled && !host.has(row.adapterId)).map((row) => row.adapterId);
68
+ }
69
+
70
+ // What Settings shows for one adapter. A credential shows only whether it
71
+ // is set and where from; never its value.
72
+ export function describeSetup({ adapter, row, env, secrets, entry, unreadable = [] }) {
73
+ const host = hostEnabled(env);
74
+ const fields = adapter.setup.fields.map((field) => {
75
+ const fromHost = hostValue(env, field.name) !== '';
76
+ const base = {
77
+ name: field.name,
78
+ label: field.label,
79
+ kind: field.kind ?? 'text',
80
+ secret: Boolean(field.secret),
81
+ help: field.help ?? null,
82
+ choices: field.choices ?? null,
83
+ suggest: field.suggest ?? null,
84
+ source: fromHost ? 'host' : null,
85
+ };
86
+ if (field.secret) {
87
+ const storedHere = Boolean(row?.secrets?.[field.name]);
88
+ return {
89
+ ...base,
90
+ set: fromHost || (storedHere && !unreadable.includes(`${adapter.id}:${field.name}`)),
91
+ source: fromHost ? 'host' : storedHere ? 'flow' : null,
92
+ unreadable: !fromHost && unreadable.includes(`${adapter.id}:${field.name}`),
93
+ };
94
+ }
95
+ const value = fromHost ? hostValue(env, field.name) : (row?.config?.[field.name] ?? null);
96
+ return { ...base, value, source: fromHost ? 'host' : value != null ? 'flow' : null };
97
+ });
98
+ return {
99
+ id: adapter.id,
100
+ name: adapter.name,
101
+ enabled: entry?.enabled ?? false,
102
+ enabledBy: host.has(adapter.id) ? 'host' : row?.enabled ? 'flow' : null,
103
+ state: entry?.state ?? 'disabled',
104
+ error: entry?.error ?? null,
105
+ guide: adapter.setup.guide,
106
+ fields,
107
+ updatedAt: row?.updatedAt ?? null,
108
+ };
109
+ }
110
+
111
+ function checkValue(field, value) {
112
+ if (value === null) return null;
113
+ if (typeof value !== 'string') throw new FlowError('invalid', `${field.label} must be text`);
114
+ const trimmed = value.trim();
115
+ if (!trimmed) return null;
116
+ if (trimmed.length > MAX_VALUE) throw new FlowError('invalid', `${field.label} is too long`);
117
+ if (field.kind === 'choice' && !field.choices.some((c) => c.value === trimmed)) {
118
+ throw new FlowError('invalid', `${field.label} must be one of ${field.choices.map((c) => c.value).join(', ')}`);
119
+ }
120
+ if (field.kind === 'onoff' && !ONOFF.includes(trimmed)) throw new FlowError('invalid', `${field.label} must be on or off`);
121
+ if (field.kind === 'url') {
122
+ let url;
123
+ try {
124
+ url = new URL(trimmed);
125
+ } catch {
126
+ throw new FlowError('invalid', `${field.label} must be an address that starts with https://`);
127
+ }
128
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost') throw new FlowError('invalid', `${field.label} must start with https://`);
129
+ }
130
+ if (field.secret && /\s/.test(trimmed)) throw new FlowError('invalid', `${field.label} must be one value with no spaces`);
131
+ return trimmed;
132
+ }
133
+
134
+ // Stores an owner's change: `enabled` (optional) and `values` by setting
135
+ // name (null removes one). Answers the event kinds written.
136
+ export async function saveSettings(db, { adapter, secrets, actor, enabled, values = {} }) {
137
+ if (!adapter.setup) throw new FlowError('invalid', `${adapter.name} has no settings here`);
138
+ const byName = new Map(adapter.setup.fields.map((f) => [f.name, f]));
139
+ const unknown = Object.keys(values).filter((name) => !byName.has(name));
140
+ if (unknown.length) throw new FlowError('invalid', `unknown setting: ${unknown.join(', ')}`);
141
+ const checked = Object.entries(values).map(([name, value]) => [byName.get(name), checkValue(byName.get(name), value)]);
142
+ if (checked.some(([field, value]) => field.secret && value !== null) && !secrets) {
143
+ throw new FlowError('secrets_key_missing', 'Flow has no secrets key. Set FLOW_SECRETS_KEY (or SESSION_SECRET) on the host, then restart.');
144
+ }
145
+ return db.transaction(async (tx) => {
146
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`integration_settings:${adapter.id}`}, 0))`);
147
+ const [row] = await tx.select().from(integrationSettings).where(eq(integrationSettings.adapterId, adapter.id));
148
+ const config = { ...(row?.config ?? {}) };
149
+ const sealed = { ...(row?.secrets ?? {}) };
150
+ const events = [];
151
+ const changed = [];
152
+ for (const [field, value] of checked) {
153
+ if (field.secret) {
154
+ if (value === null) {
155
+ if (sealed[field.name]) { delete sealed[field.name]; events.push({ kind: 'secret_removed', settings: [field.name] }); }
156
+ } else {
157
+ sealed[field.name] = sealSecret(secrets, value, context(adapter.id, field.name));
158
+ events.push({ kind: 'secret_set', settings: [field.name] });
159
+ }
160
+ } else if ((config[field.name] ?? null) !== value) {
161
+ if (value === null) delete config[field.name];
162
+ else config[field.name] = value;
163
+ changed.push(field.name);
164
+ }
165
+ }
166
+ if (changed.length) events.push({ kind: 'setting_changed', settings: changed });
167
+ const on = enabled ?? row?.enabled ?? false;
168
+ if (enabled !== undefined && enabled !== (row?.enabled ?? false)) events.push({ kind: enabled ? 'turned_on' : 'turned_off', settings: [] });
169
+ await tx.insert(integrationSettings)
170
+ .values({ adapterId: adapter.id, enabled: on, config, secrets: sealed, updatedBy: actor })
171
+ .onConflictDoUpdate({
172
+ target: integrationSettings.adapterId,
173
+ set: { enabled: on, config, secrets: sealed, updatedBy: actor, updatedAt: sql`now()` },
174
+ });
175
+ for (const event of events) {
176
+ await tx.insert(integrationSettingEvents).values({ adapterId: adapter.id, actor, ...event });
177
+ }
178
+ return events.map((e) => e.kind);
179
+ });
180
+ }
181
+
182
+ // GET /api/integrations (owner): every adapter with settings, its state and
183
+ // what is set. PUT /api/integrations/:id (owner): save and apply.
184
+ export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console }) {
185
+ const router = express.Router();
186
+ const owner = [personAuth.requireSession, personAuth.requireRole('owner')];
187
+ // A change needs a verified owner sign-in, as connecting Xero does.
188
+ const verifiedOwner = [personAuth.requireSession, personAuth.requireOwner];
189
+
190
+ const describeAll = async () => {
191
+ const stored = await readStoredSettings(db);
192
+ const adapters = registry.list().filter((entry) => registry.get(entry.id).setup);
193
+ const { unreadable } = settingsEnvironment({ env: {}, stored, adapters: adapters.map((e) => registry.get(e.id)), secrets });
194
+ return adapters.map((entry) => describeSetup({
195
+ adapter: registry.get(entry.id), row: stored.get(entry.id), env: baseEnv, secrets, entry, unreadable,
196
+ }));
197
+ };
198
+
199
+ router.get('/', ...owner, async (_req, res, next) => {
200
+ try {
201
+ const labs = await db.select({ id: organisations.id, name: organisations.name }).from(organisations);
202
+ res.json({ ok: true, secretsKey: secrets ? { source: secrets.source, id: secrets.id } : null, organisations: labs, integrations: await describeAll() });
203
+ } catch (error) { next(error); }
204
+ });
205
+
206
+ router.put('/:id', ...verifiedOwner, express.json({ limit: '32kb' }), async (req, res) => {
207
+ const adapter = registry.get(req.params.id);
208
+ if (!adapter?.setup) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
209
+ try {
210
+ const { enabled, values } = req.body ?? {};
211
+ if (enabled !== undefined && typeof enabled !== 'boolean') throw new FlowError('invalid', 'enabled must be true or false');
212
+ if (enabled === false && hostEnabled(baseEnv).has(adapter.id)) {
213
+ throw new FlowError('invalid', `${adapter.name} is turned on by the host (FLOW_INTEGRATIONS). Turn it off there.`);
214
+ }
215
+ const events = await saveSettings(db, { adapter, secrets, actor: req.person.id, enabled, values: values ?? {} });
216
+ const stored = await readStoredSettings(db);
217
+ const { env } = settingsEnvironment({ env: baseEnv, stored, adapters: [...registry.list().map((e) => registry.get(e.id))], secrets });
218
+ const on = hostEnabled(baseEnv).has(adapter.id) || Boolean(stored.get(adapter.id)?.enabled);
219
+ const state = await registry.reconfigure(adapter.id, { enabled: on, env });
220
+ log.info?.(`[integrations] ${adapter.id} settings changed by person ${req.person.id} (${events.join(', ') || 'no change'}); now ${state}`);
221
+ const entry = registry.list().find((e) => e.id === adapter.id);
222
+ const { unreadable } = settingsEnvironment({ env: {}, stored, adapters: [adapter], secrets });
223
+ return res.json({ ok: true, integration: describeSetup({ adapter, row: stored.get(adapter.id), env: baseEnv, secrets, entry, unreadable }) });
224
+ } catch (error) {
225
+ if (error instanceof FlowError) {
226
+ return res.status(error.code === 'secrets_key_missing' ? 409 : 400).json({ ok: false, code: error.code, error: error.message });
227
+ }
228
+ log.error?.(`[integrations] saving ${adapter.id} settings failed: ${error.message}`);
229
+ return res.status(500).json({ ok: false, code: 'error', error: 'Saving the settings failed.' });
230
+ }
231
+ });
232
+
233
+ return router;
234
+ }
@@ -9,6 +9,7 @@ import { XERO_ENDPOINTS } from './settings.js';
9
9
  export const DEFAULT_TIMEOUT_MS = 15_000;
10
10
  export const DEFAULT_ATTEMPTS = 2;
11
11
  export const XERO_PAGE_SIZE = 100;
12
+ const MIRROR_PATHS = Object.freeze({ invoices: 'Invoices', credit_notes: 'CreditNotes', payments: 'Payments' });
12
13
 
13
14
  export function xeroError(code, message, { status = 502, ...extra } = {}) {
14
15
  const error = new FlowError(code, message, extra.details);
@@ -230,6 +231,18 @@ export function createXeroClient({
230
231
  headers: { 'If-Modified-Since': since.toISOString().replace(/\.\d{3}Z$/, '') },
231
232
  })).Payments, { maxPages });
232
233
  },
234
+ // One page of invoices, credit notes or payments changed since
235
+ // `modifiedSince` (a Date, or null for all), oldest change first. The
236
+ // mirror (mirror.js) pages by that time, so it never skips a record.
237
+ async listChangedPage(resource, { page = 1, pageSize = XERO_PAGE_SIZE, modifiedSince = null } = {}) {
238
+ const path = MIRROR_PATHS[resource];
239
+ if (!path) throw new TypeError(`listChangedPage: unknown resource ${resource}`);
240
+ const payload = await request('GET', path, {
241
+ query: { page, pageSize, order: 'UpdatedDateUTC ASC' },
242
+ headers: modifiedSince ? { 'If-Modified-Since': modifiedSince.toISOString().replace(/\.\d{3}Z$/, '') } : {},
243
+ });
244
+ return payload[path] ?? [];
245
+ },
233
246
  // One invoice. `idempotencyKey` makes a retry within Xero's window
234
247
  // return the first result instead of a second invoice.
235
248
  async createInvoice(invoice, { idempotencyKey }) {
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { enqueueJob, runNextJob } from '../../core/queue.js';
4
+ import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
5
+
6
+ // The history import. When an owner connects Xero, Flow copies the
7
+ // organisation's invoices, credit notes and payments into the mirror
8
+ // (mirror.js) as a background job, so nobody runs a script by hand. The job
9
+ // reads in slices; a slice that stops at the day's budget or its page limit
10
+ // queues the next slice. It only reads from Xero.
11
+
12
+ export const IMPORT_KIND = 'xero.import';
13
+ const IMPORT_KEY = 'history';
14
+ // Pages per resource in one slice, so one job stays well inside its lease.
15
+ const SLICE_PAGES = 20;
16
+ const LEASE_MS = 30 * 60 * 1000;
17
+ // Xero's day limit resets on a rolling day; an hour is a cheap re-check.
18
+ const BUDGET_WAIT_MS = 60 * 60 * 1000;
19
+ const IDLE_MS = 30 * 1000;
20
+
21
+ export async function queueHistoryImport(db, organisationId, { reason = 'connected', runAfter = null } = {}) {
22
+ return enqueueJob(db, organisationId, { kind: IMPORT_KIND, dedupeKey: IMPORT_KEY, payload: { reason }, runAfter });
23
+ }
24
+
25
+ // Forgets where the copy stands, so the next slice reads from the start.
26
+ // Rows already copied stay; a re-read updates them.
27
+ export async function resetImport(db, organisationId) {
28
+ await db.execute(sql`delete from xero_mirror_cursors where organisation_id = ${organisationId}`);
29
+ }
30
+
31
+ // Organisations connected in this mode whose history is not yet complete: a
32
+ // resource with no finished pass. Start-up queues these, so an installation
33
+ // connected before this release imports too.
34
+ export async function organisationsToImport(db, mode) {
35
+ const { rows } = await db.execute(sql`
36
+ select c.organisation_id from xero_connections c
37
+ where c.mode = ${mode}
38
+ and (select count(*) from xero_mirror_cursors m
39
+ where m.organisation_id = c.organisation_id and m.passes > 0) < ${MIRROR_RESOURCES.length}`);
40
+ return rows.map((r) => r.organisation_id);
41
+ }
42
+
43
+ // One slice of the import for one job. Answers what it read and whether more
44
+ // remains (`more`: 'budget' or 'max_pages').
45
+ export async function runImportSlice(db, runtime, job, { maxPages = SLICE_PAGES } = {}) {
46
+ const results = await mirrorXero(db, runtime, job.organisationId, { maxPages });
47
+ const stopped = results.find((r) => r.stopped !== 'done')?.stopped ?? null;
48
+ return { results, more: stopped };
49
+ }
50
+
51
+ // Claims and runs one due import job, then queues the next slice when one is
52
+ // needed. Answers the queue's outcome, or null when nothing was due.
53
+ export async function runImportOnce(db, runtime, { workerId = `xero-import-${randomUUID()}`, maxPages, now = Date.now } = {}) {
54
+ const ran = await runNextJob(db, {
55
+ workerId,
56
+ leaseMs: LEASE_MS,
57
+ handlers: { [IMPORT_KIND]: (job) => runImportSlice(db, runtime, job, { maxPages }) },
58
+ });
59
+ if (ran?.outcome === 'succeeded' && ran.job.result?.more) {
60
+ const wait = ran.job.result.more === 'budget' ? BUDGET_WAIT_MS : 0;
61
+ await queueHistoryImport(db, ran.job.organisationId, { reason: ran.job.result.more, runAfter: new Date(Number(now()) + wait) });
62
+ }
63
+ return ran;
64
+ }
65
+
66
+ // The background worker: runs due slices one at a time, then waits. Started
67
+ // by the adapter outside replay mode; stop() waits for a slice in flight.
68
+ export function createImportWorker({ db, runtime, log = console, timers = { setTimeout, clearTimeout }, firstDelayMs = 10_000 }) {
69
+ const workerId = `xero-import-${randomUUID()}`;
70
+ let timer = null;
71
+ let running = null;
72
+ let stopped = true;
73
+
74
+ function schedule(delayMs) {
75
+ if (stopped) return;
76
+ timer = timers.setTimeout(tick, delayMs);
77
+ timer?.unref?.();
78
+ }
79
+
80
+ async function tick() {
81
+ timer = null;
82
+ if (stopped) return;
83
+ let busy = false;
84
+ running = (async () => {
85
+ try {
86
+ const ran = await runImportOnce(db, runtime, { workerId });
87
+ if (ran) {
88
+ busy = true;
89
+ const read = (ran.job.result?.results ?? []).map((r) => `${r.resource} ${r.read}`).join(', ');
90
+ log.info?.(`[xero] import ${ran.outcome} for organisation ${ran.job.organisationId}${read ? `: ${read}` : ''}${ran.job.result?.more ? `; more (${ran.job.result.more})` : ''}`);
91
+ }
92
+ } catch (error) {
93
+ log.error?.(`[xero] import worker: ${error?.code ?? 'error'}: ${error?.message ?? error}`);
94
+ }
95
+ })();
96
+ await running;
97
+ running = null;
98
+ schedule(busy ? 0 : IDLE_MS);
99
+ }
100
+
101
+ return {
102
+ async start() {
103
+ stopped = false;
104
+ for (const organisationId of await organisationsToImport(db, runtime.settings.mode)) {
105
+ await queueHistoryImport(db, organisationId, { reason: 'start' });
106
+ }
107
+ schedule(firstDelayMs);
108
+ },
109
+ async stop() {
110
+ stopped = true;
111
+ if (timer) timers.clearTimeout(timer);
112
+ timer = null;
113
+ await running;
114
+ },
115
+ };
116
+ }
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { pushInvoice } from './drafts.js';
4
+ import { createImportWorker } from './import.js';
4
5
  import { readPayments } from './ledger.js';
5
6
  import { createXeroRouter } from './routes.js';
6
7
  import { createXeroRuntime, requireXeroSettings } from './runtime.js';
@@ -10,14 +11,40 @@ import { writesAllowed } from './settings.js';
10
11
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
11
12
  // is on (docs/INTEGRATIONS.md).
12
13
 
13
- export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors']);
14
+ export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors']);
14
15
 
15
- // `runtime` is for tests. Otherwise start() builds it from the environment,
16
- // so a turned-off adapter never reads its settings, and a malformed setting
17
- // stops boot when Xero is on.
16
+ // What an owner enters in Settings, Integrations (server/integrations/setup.js).
17
+ // The refresh token's key comes from Flow's secrets key.
18
+ export const XERO_SETUP = {
19
+ guide: {
20
+ title: 'Create your own Xero app',
21
+ link: 'https://developer.xero.com/app/manage',
22
+ steps: [
23
+ 'Sign in at developer.xero.com with a Xero login that can manage the organisation, and open My Apps.',
24
+ 'Press New app. Name it, for example "Flow". Choose Web app.',
25
+ 'Company or application URL: this installation\'s address.',
26
+ 'Redirect URI: copy it from below, exactly.',
27
+ 'Open the app\'s Configuration. Copy the client id. Generate a secret and copy it: Xero shows it once.',
28
+ 'Paste both below, choose the mode, name your Xero organisation, and turn Xero on.',
29
+ ],
30
+ },
31
+ fields: [
32
+ { name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
33
+ { name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'This installation\'s address plus /api/xero/oauth/callback. Register the same address on the Xero app.' },
34
+ { name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
35
+ { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
36
+ { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
37
+ { name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
38
+ ],
39
+ derivedKeys: ['XERO_TOKEN_KEY'],
40
+ };
41
+
42
+ // `runtime` is for tests. Otherwise start() builds it from its settings, so a
43
+ // turned-off adapter never reads them, and a malformed setting stops it.
18
44
  export function createXeroAdapter({ runtime: given = null } = {}) {
19
45
  let runtime = given;
20
46
  let database = null;
47
+ let importer = null;
21
48
  const current = () => {
22
49
  runtime ??= createXeroRuntime({ settings: requireXeroSettings(process.env) });
23
50
  return runtime;
@@ -31,10 +58,23 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
31
58
  migrationsFolder: fileURLToPath(new URL('./migrations', import.meta.url)),
32
59
  tables: [...XERO_TABLES],
33
60
  },
34
- async start({ db, log }) {
61
+ setup: XERO_SETUP,
62
+ async start({ db, log, env }) {
35
63
  database = db;
64
+ if (!given) runtime = createXeroRuntime({ settings: requireXeroSettings(env ?? process.env) });
36
65
  const { settings } = current();
37
66
  log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
67
+ // The history import (import.js) runs in the background outside replay
68
+ // mode. Replay tests run its slices directly.
69
+ if (settings.mode !== 'replay') {
70
+ importer = createImportWorker({ db, runtime: current(), log });
71
+ await importer.start();
72
+ }
73
+ },
74
+ async stop() {
75
+ await importer?.stop();
76
+ importer = null;
77
+ runtime = given;
38
78
  },
39
79
  capabilities: {
40
80
  async health() {
@@ -0,0 +1,96 @@
1
+ -- Flow's copy of the connected organisation's Xero invoices, credit notes and
2
+ -- payments (docs/DATA_SPINE.md, issue 54). Xero owns these records; Flow keeps
3
+ -- them so it can report on years of history and link them to its own records
4
+ -- without spending Xero calls. A row is upserted by its Xero id, and changes
5
+ -- only when its content hash changes. Free text (references, line
6
+ -- descriptions) is kept only with XERO_KEEP_REFERENCE=on.
7
+ CREATE TABLE "xero_invoices" (
8
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
9
+ "xero_invoice_id" text NOT NULL,
10
+ "type" text NOT NULL,
11
+ "invoice_number" text,
12
+ "reference" text,
13
+ "xero_contact_id" text,
14
+ "status" text NOT NULL,
15
+ "issued_on" date,
16
+ "due_on" date,
17
+ "fully_paid_on" date,
18
+ "currency" text,
19
+ "subtotal_cents" bigint,
20
+ "tax_cents" bigint,
21
+ "total_cents" bigint,
22
+ "amount_due_cents" bigint,
23
+ "amount_paid_cents" bigint,
24
+ "amount_credited_cents" bigint,
25
+ "lines" jsonb DEFAULT '[]'::jsonb NOT NULL,
26
+ "xero_updated_at" timestamp with time zone,
27
+ "record_hash" text NOT NULL,
28
+ "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
29
+ "last_changed_at" timestamp with time zone DEFAULT now() NOT NULL,
30
+ CONSTRAINT "xero_invoices_pk" PRIMARY KEY("organisation_id", "xero_invoice_id")
31
+ );
32
+ --> statement-breakpoint
33
+ CREATE INDEX "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
34
+ --> statement-breakpoint
35
+ CREATE INDEX "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
36
+ --> statement-breakpoint
37
+ CREATE TABLE "xero_credit_notes" (
38
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
39
+ "xero_credit_note_id" text NOT NULL,
40
+ "type" text NOT NULL,
41
+ "credit_note_number" text,
42
+ "reference" text,
43
+ "xero_contact_id" text,
44
+ "status" text NOT NULL,
45
+ "issued_on" date,
46
+ "currency" text,
47
+ "subtotal_cents" bigint,
48
+ "tax_cents" bigint,
49
+ "total_cents" bigint,
50
+ "remaining_credit_cents" bigint,
51
+ "allocations" jsonb DEFAULT '[]'::jsonb NOT NULL,
52
+ "lines" jsonb DEFAULT '[]'::jsonb NOT NULL,
53
+ "xero_updated_at" timestamp with time zone,
54
+ "record_hash" text NOT NULL,
55
+ "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
56
+ "last_changed_at" timestamp with time zone DEFAULT now() NOT NULL,
57
+ CONSTRAINT "xero_credit_notes_pk" PRIMARY KEY("organisation_id", "xero_credit_note_id")
58
+ );
59
+ --> statement-breakpoint
60
+ CREATE TABLE "xero_payments" (
61
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
62
+ "xero_payment_id" text NOT NULL,
63
+ "payment_type" text,
64
+ "status" text NOT NULL,
65
+ "xero_invoice_id" text,
66
+ "xero_credit_note_id" text,
67
+ "paid_on" date,
68
+ "amount_cents" bigint,
69
+ "reference" text,
70
+ "is_reconciled" boolean,
71
+ "xero_updated_at" timestamp with time zone,
72
+ "record_hash" text NOT NULL,
73
+ "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
74
+ "last_changed_at" timestamp with time zone DEFAULT now() NOT NULL,
75
+ CONSTRAINT "xero_payments_pk" PRIMARY KEY("organisation_id", "xero_payment_id")
76
+ );
77
+ --> statement-breakpoint
78
+ CREATE INDEX "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
79
+ --> statement-breakpoint
80
+ -- Where the next read of each resource starts. `modified_since` is the
81
+ -- keyset: the next read asks Xero for records changed since then, oldest
82
+ -- change first. `page` moves on only when one second holds a full page.
83
+ CREATE TABLE "xero_mirror_cursors" (
84
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
85
+ "resource" text NOT NULL,
86
+ "modified_since" timestamp with time zone,
87
+ "page" integer DEFAULT 1 NOT NULL,
88
+ "passes" integer DEFAULT 0 NOT NULL,
89
+ "rows_read" bigint DEFAULT 0 NOT NULL,
90
+ "last_read_at" timestamp with time zone,
91
+ "last_pass_finished_at" timestamp with time zone,
92
+ "last_error" text,
93
+ CONSTRAINT "xero_mirror_cursors_pk" PRIMARY KEY("organisation_id", "resource"),
94
+ CONSTRAINT "xero_mirror_cursors_resource_valid" CHECK ("resource" in ('invoices', 'credit_notes', 'payments')),
95
+ CONSTRAINT "xero_mirror_cursors_page_positive" CHECK ("page" > 0)
96
+ );
@@ -29,6 +29,13 @@
29
29
  "when": 1792368180000,
30
30
  "tag": "0003_xero_payment_cursors",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1792388000000,
37
+ "tag": "0004_xero_mirror",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }