@arcforgelabs/flow 2026.10.49 → 2026.10.51

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.
@@ -162,6 +162,13 @@
162
162
  "when": 1792733287865,
163
163
  "tag": "0022_contacts",
164
164
  "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "7",
169
+ "when": 1793110575758,
170
+ "tag": "0023_captures",
171
+ "breakpoints": true
165
172
  }
166
173
  ]
167
- }
174
+ }
@@ -858,3 +858,50 @@ export const jobFieldEvents = pgTable('job_field_events', {
858
858
  foreignKey({ name: 'job_field_events_field_same_org', columns: [t.organisationId, t.fieldId], foreignColumns: [jobFields.organisationId, jobFields.id] }).onDelete('cascade'),
859
859
  index('job_field_events_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef, t.createdAt),
860
860
  ]);
861
+
862
+ // A photo of a handwritten lab form (server/core/captures.js). The bytes live
863
+ // in the object storage adapter; this row keeps the key, size, type and
864
+ // checksum. `read` is what the vision reader returned (server/core/reader.js):
865
+ // the fields, the model, the tokens and the cost. A new row is `reading`
866
+ // while that runs, or `problem` when the reader is off. A person may type
867
+ // the proposal either way. `done` means someone applied or discarded it.
868
+ // The photo bytes are deleted then, and a row with no decision is deleted
869
+ // after 7 days.
870
+ export const CAPTURE_STATES = ['reading', 'ready', 'done', 'problem'];
871
+ export const CAPTURE_SOURCES = ['phone', 'desk', 'api'];
872
+ export const CAPTURE_DECISIONS = ['applied', 'discarded'];
873
+ export const captures = pgTable('captures', {
874
+ id: id(),
875
+ organisationId: organisationId().references(() => organisations.id),
876
+ state: text('state').notNull(),
877
+ source: text('source').notNull(),
878
+ fileKey: text('file_key').notNull(),
879
+ fileType: text('file_type').notNull(),
880
+ fileSize: integer('file_size').notNull(),
881
+ fileSha256: text('file_sha256').notNull(),
882
+ idempotencyKey: text('idempotency_key').notNull(),
883
+ // Fields, model, token counts and cost in micros. Null until a read finishes.
884
+ read: jsonb('read'),
885
+ match: jsonb('match'),
886
+ proposal: jsonb('proposal').notNull().default(sql`'{"action":null,"jobId":null,"changes":[]}'::jsonb`),
887
+ problem: text('problem'),
888
+ decision: text('decision'),
889
+ decidedBy: uuid('decided_by').references(() => people.id, { onDelete: 'set null' }),
890
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
891
+ photoDeletedAt: timestamp('photo_deleted_at', { withTimezone: true }),
892
+ createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
893
+ createdAt: createdAt(),
894
+ updatedAt: updatedAt(),
895
+ }, (t) => [
896
+ unique('captures_org_id').on(t.organisationId, t.id),
897
+ unique('captures_org_idempotency').on(t.organisationId, t.idempotencyKey),
898
+ unique('captures_file_key').on(t.fileKey),
899
+ index('captures_photo_open').on(t.createdAt).where(sql`photo_deleted_at is null`),
900
+ check('captures_state_valid', inList('state', CAPTURE_STATES)),
901
+ check('captures_source_valid', inList('source', CAPTURE_SOURCES)),
902
+ check('captures_decision_valid', sql`${t.decision} is null or ${t.decision} in ('applied', 'discarded')`),
903
+ check('captures_done_has_decision', sql`(${t.state} = 'done') = (${t.decision} is not null)`),
904
+ check('captures_file_size_valid', sql`${t.fileSize} >= 0`),
905
+ check('captures_sha256_valid', sql`${t.fileSha256} ~ '^[0-9a-f]{64}$'`),
906
+ check('captures_problem_valid', sql`${t.problem} is null or length(${t.problem}) between 1 and 200`),
907
+ ]);
package/server/index.js CHANGED
@@ -9,7 +9,12 @@ import { migrateDatabase, openDatabase } from './db/index.js';
9
9
  import {
10
10
  importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
11
11
  } from './import/at-start.js';
12
+ import { createAiPort, resolveReader } from './core/ai.js';
13
+ import { createBedrockClient } from './core/ai-bedrock.js';
14
+ import { createCaptureRetentionWorker } from './core/captures.js';
12
15
  import { createMonitoredLog } from './core/monitoring.js';
16
+ import { createCaptureReader } from './core/reader.js';
17
+ import { resolveObjectStorage } from './core/storage.js';
13
18
  import { createTracing, traceQueries } from './core/tracing.js';
14
19
  import { createPipelineWorker } from './core/pipeline.js';
15
20
  import { createReconcileWorker } from './core/reconcile.js';
@@ -117,6 +122,43 @@ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start(
117
122
  // with Send all ready, to the accounting integration that is on.
118
123
  if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
119
124
 
125
+ // Capture photos (server/core/storage.js). A bad storage setting stops boot.
126
+ // On Replit, and where the local folder cannot be created, the default is
127
+ // off. The retention pass
128
+ // only deletes Flow's own files, so it still runs where background jobs are
129
+ // off: a second copy deleting the same key does nothing.
130
+ let storage = null;
131
+ try {
132
+ const resolved = await resolveObjectStorage({ env: process.env, secrets });
133
+ storage = resolved.storage;
134
+ if (storage) console.log(`[storage] ${resolved.mode}`);
135
+ else console.log(`[storage] captures off (${resolved.reason})`);
136
+ } catch (error) {
137
+ console.error(`[storage] ${error.message}`);
138
+ process.exit(1);
139
+ }
140
+ if (storage) createCaptureRetentionWorker({ db: database.db, storage, log }).start();
141
+
142
+ // The vision reader (server/core/reader.js). A bad region or a model id that
143
+ // is not an Australia profile stops boot. Off, or on without keys, stores
144
+ // the photo and leaves the capture for a person to type.
145
+ let reader = null;
146
+ try {
147
+ const resolved = resolveReader({ env: process.env });
148
+ if (!resolved.on) console.log(`[reader] off (${resolved.reason})`);
149
+ else if (!storage) console.log('[reader] captures off (no file store)');
150
+ else {
151
+ const port = createAiPort(createBedrockClient(resolved));
152
+ reader = createCaptureReader({ db: database.db, storage, port, settings: resolved, log, jobsOn });
153
+ if (jobsOn) reader.worker.start();
154
+ else console.log('[reader] background jobs off: each capture is read in the upload');
155
+ console.log(`[reader] on (${resolved.region})`);
156
+ }
157
+ } catch (error) {
158
+ console.error(`[reader] ${error.message}`);
159
+ process.exit(1);
160
+ }
161
+
120
162
  let auth = null;
121
163
  if (settings.mode === 'clerk') {
122
164
  const { keys } = settings;
@@ -158,7 +200,11 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
158
200
 
159
201
  const updates = createUpdateChecker();
160
202
 
161
- createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing }).listen(port, () => {
203
+ createApp({
204
+ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log, tracing, storage,
205
+ publicOrigin: process.env.FLOW_PUBLIC_ORIGIN,
206
+ reader: reader ? { on: true, schedule: reader.schedule } : null,
207
+ }).listen(port, () => {
162
208
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
163
209
  });
164
210
 
@@ -10,6 +10,8 @@ import { xeroError } from './client.js';
10
10
  // settings and are never stored. Tokens are never logged.
11
11
 
12
12
  const FORMAT = 'v1';
13
+ // What a Custom Connection's row seals in place of a refresh token.
14
+ export const CUSTOM_CONNECTION = 'custom-connection';
13
15
 
14
16
  function aad(organisationId) {
15
17
  return Buffer.from(`flow:xero:refresh-token:${organisationId}`);
@@ -40,6 +40,7 @@ export const XERO_SETUP = {
40
40
  },
41
41
  fields: [
42
42
  { name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
43
+ { name: 'XERO_CONNECTION', label: 'Xero app type', kind: 'choice', choices: [{ value: 'app', label: 'Web app' }, { value: 'custom', label: 'Custom connection (Demo Company only)' }], help: 'Custom connection: no Xero sign-in step. Sandbox mode only.' },
43
44
  { name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
44
45
  { name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
45
46
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { DEFAULT_TIMEOUT_MS, xeroError } from './client.js';
3
3
 
4
- // Xero OAuth 2.0 (authorisation code with offline_access). Bodies that carry
5
- // tokens never reach a log or an error message.
4
+ // Xero OAuth 2.0: the authorisation code with offline_access, or a Custom
5
+ // Connection's client credentials (settings.js, XERO_CONNECTION). Bodies that
6
+ // carry tokens never reach a log or an error message.
6
7
 
7
8
  export const STATE_TTL_MS = 10 * 60 * 1000;
8
9
 
@@ -58,7 +59,7 @@ async function tokenRequest(settings, fetchImpl, form, what) {
58
59
  oauthError: typeof body?.error === 'string' ? body.error.slice(0, 40) : null,
59
60
  });
60
61
  }
61
- if (!body?.access_token || !body?.refresh_token) {
62
+ if (!body?.access_token || (form.grant_type !== 'client_credentials' && !body?.refresh_token)) {
62
63
  throw xeroError('xero_token_failed', `Xero's answer to ${what} had no tokens.`, { status: 502 });
63
64
  }
64
65
  return body;
@@ -73,6 +74,12 @@ export function refreshTokens(settings, fetchImpl, refreshToken) {
73
74
  return tokenRequest(settings, fetchImpl, { grant_type: 'refresh_token', refresh_token: refreshToken }, 'the token refresh');
74
75
  }
75
76
 
77
+ // A Custom Connection's access token. There is no refresh token: Flow asks
78
+ // again when the token runs out.
79
+ export function clientCredentials(settings, fetchImpl) {
80
+ return tokenRequest(settings, fetchImpl, { grant_type: 'client_credentials', scope: settings.scope }, 'the Custom Connection token');
81
+ }
82
+
76
83
  // Best effort: a disconnect deletes Flow's copy even when Xero is down.
77
84
  export async function revokeToken(settings, fetchImpl, refreshToken) {
78
85
  const basic = Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString('base64');
@@ -12,8 +12,8 @@ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchX
12
12
  import { queueHistoryImport, resetImport } from './import.js';
13
13
  import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
14
14
  import { accountingStatus, checkSentInvoices } from './reconcile.js';
15
- import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
16
- import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
15
+ import { CUSTOM_CONNECTION, decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
16
+ import { authorizeUrl, clientCredentials, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
17
17
  import { writesAllowed } from './settings.js';
18
18
  import { readXeroUsage } from './usage.js';
19
19
  import { saveEvents, signatureValid } from './webhooks.js';
@@ -23,7 +23,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
23
23
  // The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
24
24
  //
25
25
  // GET /status?organisationId= any signed-in person: mode and connection
26
- // POST /connect owner: returns Xero's approval address
26
+ // POST /connect owner: returns Xero's approval address, or
27
+ // connects a Custom Connection at once
27
28
  // GET /oauth/callback Xero sends the owner back here
28
29
  // POST /disconnect owner: revokes and forgets the connection
29
30
  // POST /sync owner or admin: copies Xero contacts and items
@@ -182,8 +183,59 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
182
183
  });
183
184
  }));
184
185
 
186
+ // Keeps the one Xero organisation these tokens reach, after proving it is
187
+ // one Flow may use (a Demo Company in sandbox). A refused code-flow grant
188
+ // is revoked at Xero. Then the history import starts.
189
+ async function connectWith(tokens, { organisationId, personId }) {
190
+ const connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
191
+ let chosen;
192
+ try {
193
+ chosen = selectConnection(connections, settings);
194
+ const probe = createXeroClient({
195
+ tenantId: chosen.tenantId,
196
+ accessToken: async () => tokens.access_token,
197
+ fetch: runtime.transport,
198
+ governor: runtime.governor,
199
+ apiBase: settings.endpoints.api,
200
+ log,
201
+ });
202
+ await runtime.assertEnvironment(probe);
203
+ } catch (error) {
204
+ // Refused: Xero keeps no grant for an organisation Flow will not use.
205
+ if (tokens.refresh_token) await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
206
+ throw error;
207
+ }
208
+ const previous = await readConnection(db, organisationId);
209
+ const connection = await saveConnection(db, {
210
+ organisationId,
211
+ mode: settings.mode,
212
+ tenantId: chosen.tenantId,
213
+ tenantName: chosen.tenantName ?? null,
214
+ scope: tokens.scope ?? settings.scope,
215
+ // A Custom Connection has no refresh token; the column holds a sealed
216
+ // marker that is never read (runtime.js asks Xero again instead).
217
+ refreshToken: tokens.refresh_token ?? CUSTOM_CONNECTION,
218
+ connectedBy: personId,
219
+ key: settings.tokenKey,
220
+ });
221
+ runtime.remember(organisationId, tokens);
222
+ // A new connection is a person acting: calls stopped before resume.
223
+ await runtime.guardFor(db).resume();
224
+ // Another Xero organisation starts its history from the beginning.
225
+ if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, organisationId);
226
+ await queueHistoryImport(db, organisationId, { reason: 'connected' });
227
+ log.info?.(`[xero] organisation ${organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}, ${settings.connection}) by person ${personId}`);
228
+ return connection;
229
+ }
230
+
231
+ // A Custom Connection connects here at once; a Xero app answers with the
232
+ // approval address the browser opens.
185
233
  router.post('/connect', ...owner, handle(log, async (req, res) => {
186
234
  const organisationId = await requireOrganisation(db, req.body?.organisationId);
235
+ if (settings.connection === 'custom') {
236
+ const connection = await connectWith(await clientCredentials(settings, runtime.transport), { organisationId, personId: req.person.id });
237
+ return res.json({ ok: true, connected: true, connection: describeConnection(connection), mode: settings.mode });
238
+ }
187
239
  if (!settings.redirectUri) {
188
240
  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 });
189
241
  }
@@ -202,42 +254,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
202
254
  }
203
255
  try {
204
256
  const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
205
- const connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
206
- let chosen;
207
- try {
208
- chosen = selectConnection(connections, settings);
209
- const probe = createXeroClient({
210
- tenantId: chosen.tenantId,
211
- accessToken: async () => tokens.access_token,
212
- fetch: runtime.transport,
213
- governor: runtime.governor,
214
- apiBase: settings.endpoints.api,
215
- log,
216
- });
217
- await runtime.assertEnvironment(probe);
218
- } catch (error) {
219
- // Refused: Xero keeps no grant for an organisation Flow will not use.
220
- await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
221
- throw error;
222
- }
223
- const previous = await readConnection(db, entry.organisationId);
224
- const connection = await saveConnection(db, {
225
- organisationId: entry.organisationId,
226
- mode: settings.mode,
227
- tenantId: chosen.tenantId,
228
- tenantName: chosen.tenantName ?? null,
229
- scope: tokens.scope ?? settings.scope,
230
- refreshToken: tokens.refresh_token,
231
- connectedBy: entry.personId,
232
- key: settings.tokenKey,
233
- });
234
- runtime.remember(entry.organisationId, tokens);
235
- // A new connection is a person acting: calls stopped before resume.
236
- await runtime.guardFor(db).resume();
237
- // Another Xero organisation starts its history from the beginning.
238
- if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
239
- await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
240
- log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
257
+ const connection = await connectWith(tokens, entry);
241
258
  const organisation = connection.tenantName || 'your Xero organisation';
242
259
  return res.type('html').send(page(
243
260
  'Xero is connected',
@@ -257,7 +274,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
257
274
  const connection = await readConnection(db, organisationId);
258
275
  if (!connection) return res.json({ ok: true, disconnected: false });
259
276
  let revoked = false;
260
- try {
277
+ // A Custom Connection has no token to revoke: it ends in Xero's My Apps.
278
+ if (settings.connection !== 'custom') try {
261
279
  revoked = await revokeToken(settings, runtime.transport, decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId));
262
280
  } catch (error) {
263
281
  log.warn?.(`[xero] could not revoke the Xero token for organisation ${organisationId}: ${error.code ?? 'error'}`);
@@ -2,7 +2,7 @@ import { createCallGuard } from '../call-guard.js';
2
2
  import { createXeroClient, xeroError } from './client.js';
3
3
  import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
4
4
  import { createGovernor } from './governor.js';
5
- import { createStateStore, refreshTokens } from './oauth.js';
5
+ import { clientCredentials, createStateStore, refreshTokens } from './oauth.js';
6
6
  import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from './replay.js';
7
7
  import { resolveXeroSettings, writesAllowed } from './settings.js';
8
8
 
@@ -101,10 +101,13 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
101
101
  return db.transaction(async (tx) => {
102
102
  const connection = usable(await readConnection(tx, organisationId, { lock: true }));
103
103
  await guard.check(connection.tenantId, { resource: 'token' });
104
- const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
104
+ // A Custom Connection has no refresh token: Flow asks with the client
105
+ // id and secret again, and nothing rotates.
106
+ const custom = settings.connection === 'custom';
107
+ const refreshToken = custom ? null : decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
105
108
  let tokens;
106
109
  try {
107
- tokens = await refreshTokens(settings, transport, refreshToken);
110
+ tokens = custom ? await clientCredentials(settings, transport) : await refreshTokens(settings, transport, refreshToken);
108
111
  } catch (error) {
109
112
  // invalid_grant: the connection is gone. invalid_client: the app's
110
113
  // client id or secret is wrong. Neither clears without a person.
@@ -116,7 +119,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
116
119
  throw error;
117
120
  }
118
121
  await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
119
- await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
122
+ if (!custom) await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
120
123
  remember(organisationId, tokens);
121
124
  return tokens.access_token;
122
125
  });
@@ -10,6 +10,14 @@
10
10
  // is not DEMO, so a sandbox can never write to a real ledger.
11
11
  // replay no network: recorded, synthetic Xero responses
12
12
  // (server/integrations/xero/fixtures). Development and CI.
13
+ //
14
+ // XERO_CONNECTION says how Flow gets its Xero access tokens.
15
+ // app (default) a Xero web app: an owner approves in the browser, and
16
+ // Flow keeps the rotating refresh token.
17
+ // custom a Xero Custom Connection (client credentials): no browser step
18
+ // and no refresh token. Sandbox only: Xero makes Custom
19
+ // Connections free on the Demo Company, so a demo or a test
20
+ // session connects with the client id and secret alone.
13
21
 
14
22
  import { backgroundJobsOn } from '../../core/host.js';
15
23
 
@@ -29,6 +37,8 @@ export const XERO_ENDPOINTS = Object.freeze({
29
37
  export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
30
38
  // accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
31
39
  export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
40
+ // A Custom Connection has no refresh token, so it asks for no offline_access.
41
+ export const DEFAULT_CUSTOM_SCOPE = DEFAULT_XERO_SCOPE.replace('offline_access ', '');
32
42
  export const DEFAULT_XERO_DAY_LIMIT = 1000;
33
43
  export const DEFAULT_DUE_DAYS = 30;
34
44
  export const DEFAULT_MIRROR_PAGE_SIZE = 200;
@@ -121,6 +131,12 @@ export function resolveXeroSettings(env = process.env) {
121
131
  if (!mode) return null;
122
132
  if (!XERO_MODES.includes(mode)) throw new Error('XERO_MODE must be "live", "sandbox", "replay" or unset');
123
133
 
134
+ const connection = (readEnv(env, 'XERO_CONNECTION') || 'app').toLowerCase();
135
+ if (!['app', 'custom'].includes(connection)) throw new Error('XERO_CONNECTION must be "app", "custom" or unset');
136
+ if (connection === 'custom' && mode !== 'sandbox') {
137
+ throw new Error('XERO_CONNECTION=custom works only with XERO_MODE=sandbox (a Xero Demo Company)');
138
+ }
139
+
124
140
  const clientId = readEnv(env, 'XERO_CLIENT_ID');
125
141
  const clientSecret = readEnv(env, 'XERO_CLIENT_SECRET');
126
142
  const tokenKey = decodeTokenKey(readEnv(env, 'XERO_TOKEN_KEY'));
@@ -150,11 +166,12 @@ export function resolveXeroSettings(env = process.env) {
150
166
 
151
167
  return {
152
168
  mode,
169
+ connection,
153
170
  clientId: clientId || (mode === 'replay' ? 'replay-client-id' : ''),
154
171
  clientSecret: clientSecret || (mode === 'replay' ? 'replay-client-secret' : ''),
155
172
  tokenKey,
156
173
  redirectUri: resolveRedirectUri(env),
157
- scope: readEnv(env, 'XERO_SCOPE') || DEFAULT_XERO_SCOPE,
174
+ scope: readEnv(env, 'XERO_SCOPE') || (connection === 'custom' ? DEFAULT_CUSTOM_SCOPE : DEFAULT_XERO_SCOPE),
158
175
  tenantId,
159
176
  tenantName,
160
177
  liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({
package/server/ui/app.js CHANGED
@@ -348,7 +348,7 @@ function renderSignedOut() {
348
348
  return;
349
349
  }
350
350
  $('sign-in-note').textContent = config.publicSignUp
351
- ? 'Sign in, or ask for access with your work email.'
351
+ ? 'New here? Ask for access with your work email.'
352
352
  : 'Sign in with the account you were invited with.';
353
353
  // With public sign-up off the widget never creates a Clerk account: no
354
354
  // sign-up link, and an OAuth sign-in for an unknown email is not turned
@@ -867,6 +867,7 @@ async function renderIntegration(main, id) {
867
867
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
868
868
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
869
869
  on && current.state === 'ready' ? personSettingsSection(current) : null,
870
+ current.id === 'seazona' && current.state === 'ready' ? seazonaHistorySection() : null,
870
871
  current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
871
872
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
872
873
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
@@ -978,6 +979,56 @@ function xeroImportSection(organisations) {
978
979
  return section;
979
980
  }
980
981
 
982
+ // Seazona's history read (docs/SEAZONA.md, "The API backfill"): past orders
983
+ // from a start day, a few calls a run inside the rate limit. Start and Stop
984
+ // call no Seazona; the background lane reads. While it runs, the row reads
985
+ // its progress again every 30 seconds, from Flow only.
986
+ function seazonaHistorySection() {
987
+ const canAct = ['owner', 'admin'].includes(me?.person?.role);
988
+ const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
989
+ const from = h('input', { class: 'fx-input', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
990
+ const status = h('span', {}, 'Checking…');
991
+ const jobs = h('span', { class: 'fx-num' }, '…');
992
+ const control = h('span', { class: 'fx-row' });
993
+ const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
994
+ const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
995
+ h('h2', { class: 'fx-set__title' }, 'History'),
996
+ settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Each job waits on hold and never drafts by itself.', control }),
997
+ settingRow({ label: 'Jobs read', control: jobs }));
998
+ const press = async (button, path, body) => {
999
+ button.disabled = true;
1000
+ try {
1001
+ await flow(path, { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) });
1002
+ toast(body ? 'History read started.' : 'History read stopped.', 'success');
1003
+ } catch (error) {
1004
+ toast(error.message, 'danger');
1005
+ if (error.retryAfterMs) holdButton(button, error.retryAfterMs);
1006
+ }
1007
+ load();
1008
+ };
1009
+ const load = async () => {
1010
+ let body;
1011
+ try {
1012
+ body = await flow('/api/seazona/history');
1013
+ } catch (error) {
1014
+ status.replaceChildren(error.message);
1015
+ return;
1016
+ }
1017
+ const run = body.backfill;
1018
+ jobs.replaceChildren(String(body.history?.jobs ?? 0));
1019
+ if (run?.running) {
1020
+ status.replaceChildren(`Reading ${day(run.nextDay ?? run.from)} · ${run.ordersRead} read`);
1021
+ control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
1022
+ setTimeout(() => { if (section.isConnected) load(); }, 30_000);
1023
+ return;
1024
+ }
1025
+ status.replaceChildren(run ? `${run.stopped ? 'Stopped' : 'Done'} · ${run.ordersRead} read from ${day(run.from)}` : 'Not started');
1026
+ control.replaceChildren(from, h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', { from: from.value }) }, 'Start'));
1027
+ };
1028
+ load();
1029
+ return section;
1030
+ }
1031
+
981
1032
  // Once Xero is on: each organisation's connection, the history copy, and
982
1033
  // Connect, which goes to Xero and comes back.
983
1034
  function xeroConnection(organisations, canChange) {
@@ -1014,8 +1065,11 @@ function xeroConnection(organisations, canChange) {
1014
1065
  if (canChange) {
1015
1066
  control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
1016
1067
  try {
1068
+ // A Custom Connection connects at once; a Xero app sends the
1069
+ // browser to Xero to approve.
1017
1070
  const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
1018
- location.assign(authorizeUrl);
1071
+ if (authorizeUrl) location.assign(authorizeUrl);
1072
+ else location.reload();
1019
1073
  } catch (error) {
1020
1074
  toast(error.message, 'danger');
1021
1075
  }
@@ -1239,7 +1293,8 @@ drawModeToggles();
1239
1293
  // the Clerk instance is called.
1240
1294
  const LOCALIZATION = {
1241
1295
  signIn: {
1242
- start: { title: 'Sign in to Flow', subtitle: 'Choose how to sign in.' },
1296
+ // titleCombined is the sign-in-or-sign-up screen (public sign-up on).
1297
+ start: { title: 'Sign in', titleCombined: 'Sign in', subtitle: '' },
1243
1298
  password: { subtitle: 'Enter the password for your account.' },
1244
1299
  },
1245
1300
  signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
@@ -440,7 +440,12 @@ a.fx-strong:hover { text-decoration: underline; }
440
440
  .fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
441
441
  .fx-tabs { display: flex; gap: 2px; }
442
442
  .fx-tabs button { flex: none; }
443
- @media (max-width: 639px) { .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; } }
443
+ /* Phones and tablets: the tabs scroll in one row and each is 44px tall, so
444
+ seven tabs never run past the page and a thumb hits each one. */
445
+ @media (max-width: 1023px) {
446
+ .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
447
+ .fx-tabs button { min-height: 44px; }
448
+ }
444
449
  .fx-tabs button {
445
450
  display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
446
451
  border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
@@ -1346,7 +1351,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1346
1351
  /* Touch: every control a thumb uses is 44px (docs/MOBILE_TESTS.md). The
1347
1352
  icons and text keep their size; the box around them grows. */
1348
1353
  #menu-toggle, #search { width: 44px; height: 44px; }
1349
- .fx-tabs button { min-height: 44px; }
1350
1354
  /* The notice's buttons grow to 44px into its padding, so the notice
1351
1355
  stays one short line. */
1352
1356
  .fx-alerts__close { width: 44px; height: 44px; margin: -12px -8px; }
@@ -14,7 +14,7 @@ const TEST_LABELS = {
14
14
  is_not_one_of: 'is not one of', contains: 'contains', does_not_contain: 'does not contain',
15
15
  starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', number_in_words: 'has a number beside words', one_word: 'is one word',
16
16
  };
17
- const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note' };
17
+ const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note', non_chargeable: 'Non-chargeable' };
18
18
  const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word'];
19
19
  const MANY = ['is_one_of', 'is_not_one_of'];
20
20
  const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];