@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
- import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
3
+ import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
- import { listRules, rulePending } from './invoice-rules.js';
5
+ import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
7
7
  import { issueInvoice } from './invoices.js';
8
8
  import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
@@ -17,7 +17,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
17
17
  // charge, an account) on every line. When an accounting
18
18
  // system is connected, its own checks are added (for Xero: the customer
19
19
  // matches one Xero contact, every item is in Xero), and the lab's pending
20
- // rules (invoice-rules.js) add their notes. A draft with a problem is
20
+ // rules (invoice-rules.js) add their notes. An enabled skip rule does too:
21
+ // a draft whose job it matches stays Pending, even when the draft was made
22
+ // by hand or before the rule existed. A draft with a problem is
21
23
  // Pending (the "problem" view). A draft without one is Ready.
22
24
  //
23
25
  // Send all ready issues every ready draft (each gets its number) and, when an
@@ -81,19 +83,21 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
81
83
  const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
82
84
  if (found.length) problems.set(draft.id, found);
83
85
  }
84
- const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference }).from(billingObligations)
86
+ const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference, customerName: contacts.name }).from(billingObligations)
85
87
  .innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
88
+ .leftJoin(contacts, and(eq(contacts.id, billingObligations.contactId), eq(contacts.organisationId, billingObligations.organisationId)))
86
89
  .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
87
- // The lab's pending rules (invoice-rules.js): a draft from a job that
88
- // matches one waits on Pending with the rule's note, such as a Reference
89
- // with no real practitioner.
90
- const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.action.kind === 'pending');
90
+ // The lab's rules (invoice-rules.js). A pending rule puts its note on the
91
+ // draft. A skip rule holds a draft that already exists: void it, or change
92
+ // the rule. Matching stays in invoice-rules.js.
93
+ const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && (r.action.kind === 'pending' || r.action.kind === 'skip'));
91
94
  if (rules.length && jobs.length) {
92
95
  const settings = await readInvoicingSettings(db, organisationId);
93
96
  const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
94
- for (const { obligation, reference } of jobs) {
95
- const customer = effectiveSettings(settings, customers.get(obligation.contactId));
96
- const found = rulePending(rules, { ...obligation, reference }, { customer });
97
+ for (const { obligation, reference, customerName } of jobs) {
98
+ const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
99
+ const job = { ...obligation, reference };
100
+ const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
97
101
  if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
98
102
  }
99
103
  }
@@ -196,23 +200,61 @@ async function assertCanSend(accounting, organisationId) {
196
200
  // Read before act (issue #141): the jobs behind these drafts whose work
197
201
  // system report is old are read again first. Answers the drafts that may go
198
202
  // now; a draft whose job went to review, or could not be read, waits.
203
+ // `stops` names each held draft: `unread` (read failed or past the cap) or
204
+ // `review` (the job changed).
199
205
  async function readJobsFirst(db, organisationId, invoiceIds, { work, now, personId }) {
200
- if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') return { go: invoiceIds, waiting: [] };
206
+ if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') {
207
+ return { go: invoiceIds, waiting: [], stops: [] };
208
+ }
201
209
  const jobs = await db.select().from(billingObligations)
202
210
  .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
203
211
  const old = jobs.filter((job) => needsRead(job, { work, now }));
204
212
  const outcomes = await readBeforeAct(db, organisationId, old.slice(0, READS_PER_PASS), { work, now, actor: `person:${personId}` });
205
- const held = new Set(old.slice(READS_PER_PASS).map((job) => job.invoiceId));
213
+ const held = new Set();
214
+ const stops = [];
215
+ for (const job of old.slice(READS_PER_PASS)) {
216
+ held.add(job.invoiceId);
217
+ stops.push({ invoiceId: job.invoiceId, kind: 'deferred' });
218
+ }
206
219
  for (const { outcome, obligation } of outcomes.values()) {
207
- if (outcome === 'waiting' || outcome === 'review') held.add(obligation.invoiceId);
220
+ if (outcome === 'waiting') {
221
+ held.add(obligation.invoiceId);
222
+ stops.push({ invoiceId: obligation.invoiceId, kind: 'unread' });
223
+ } else if (outcome === 'review') {
224
+ held.add(obligation.invoiceId);
225
+ stops.push({ invoiceId: obligation.invoiceId, kind: 'review' });
226
+ }
227
+ }
228
+ return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)), stops };
229
+ }
230
+
231
+ // The error when a ticked press sends nothing: name the cause (and the count
232
+ // for jobs), never one shared sentence.
233
+ function stoppedSendMessage({ accountingWaiting, accountingReason, stops, waiting }) {
234
+ if (accountingWaiting) {
235
+ if (accountingReason && /more changes than one read takes/i.test(accountingReason)) {
236
+ return 'The accounting copy has more changes than one read takes. Nothing was sent.';
237
+ }
238
+ if (accountingReason) return `${accountingReason.replace(/\.?$/, '')}. Nothing was sent.`;
239
+ return 'The accounting copy could not be read. Nothing was sent.';
240
+ }
241
+ const unread = stops.filter((stop) => stop.kind === 'unread').length;
242
+ const reviews = stops.filter((stop) => stop.kind === 'review').length;
243
+ if (unread) {
244
+ return `${unread} ${unread === 1 ? 'job' : 'jobs'} could not be read from the work system. Nothing was sent.`;
245
+ }
246
+ if (reviews) {
247
+ return `${reviews} ${reviews === 1 ? 'job' : 'jobs'} changed and ${reviews === 1 ? 'needs' : 'need'} a look. Nothing was sent.`;
208
248
  }
209
- return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)) };
249
+ const n = waiting.length || 1;
250
+ return `${n} ${n === 1 ? 'job' : 'jobs'} could not be confirmed with the work system. Nothing was sent.`;
210
251
  }
211
252
 
212
253
  // Send all ready: issues each ready draft, then queues it for the accounting
213
- // system. With `invoiceIds`, only those drafts, and each must be ready (the
214
- // Send button on one invoice). `work` is the work integration, for the read
215
- // before act. Answers { issued, queued, remaining, waiting }.
254
+ // system. With `invoiceIds`, only those drafts. One id (the invoice page) is
255
+ // all or nothing. More than one id (the bulk bar) sends the ones Flow could
256
+ // confirm and leaves the rest. `work` is the work integration, for the read
257
+ // before act. Answers { issued, queued, remaining, waiting, held }.
216
258
  export async function sendAllReady(db, organisationId, { accounting = null, work = null, personId, issueDate, invoiceIds = null, now = new Date() }) {
217
259
  await assertCanSend(accounting, organisationId);
218
260
  // Issue #45: the accounting copy reads its latest changes first, so the
@@ -236,11 +278,23 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
236
278
  : 'Only a ready invoice can be sent. Mark it ready first.');
237
279
  }
238
280
  }
239
- const { go, waiting } = accountingRead.outcome === 'waiting'
240
- ? { go: [], waiting: ready.slice(0, SEND_BATCH) }
281
+ const accountingWaiting = accountingRead.outcome === 'waiting';
282
+ const { go, waiting, stops } = accountingWaiting
283
+ ? { go: [], waiting: ready.slice(0, SEND_BATCH), stops: [] }
241
284
  : await readJobsFirst(db, organisationId, ready.slice(0, SEND_BATCH), { work, now, personId });
242
- if (invoiceIds && waiting.length) {
243
- throw new FlowError('not_ready', 'Flow could not confirm the job with the work system or the accounting system just now, or the job changed. Nothing was sent. Look at the invoice, then try again.');
285
+ // One invoice (the invoice page) is all or nothing: a job Flow could not
286
+ // confirm stops the send. A bulk press sends the ones it confirmed. The
287
+ // rest stay for the next press (`waiting`), or on Pending when the read
288
+ // sent the job to review. An accounting read that cannot run still stops
289
+ // every ticked invoice: that one read covers the whole press. When nothing
290
+ // can go, the error names the cause and the count.
291
+ if (invoiceIds && waiting.length && go.length === 0) {
292
+ throw new FlowError('not_ready', stoppedSendMessage({
293
+ accountingWaiting,
294
+ accountingReason: accountingRead.reason,
295
+ stops,
296
+ waiting,
297
+ }));
244
298
  }
245
299
  const batch = go;
246
300
  let issued = 0;
@@ -203,6 +203,10 @@ export function createCallGuard({
203
203
  lockName = `${integration}-call-guard`,
204
204
  report: reports = true,
205
205
  policy: overrides = {},
206
+ // Optional columns on this adapter's call table, filled from record().
207
+ // `lane` is the allow-list (anything else is stored null). `rateLimitProblem`
208
+ // keeps a short token such as Xero's X-Rate-Limit-Problem.
209
+ telemetry = {},
206
210
  now = Date.now,
207
211
  log = console,
208
212
  } = {}) {
@@ -215,6 +219,14 @@ export function createCallGuard({
215
219
  const C = { key: sql.identifier(columns.key ?? 'key'), at: sql.identifier(columns.at ?? 'at') };
216
220
  const tag = `[${integration}]`;
217
221
  const failed = sql`(failure_kind is not null or status_code >= 500 or (status_code >= 400 and status_code <> 404))`;
222
+ const laneAllowed = Array.isArray(telemetry.lane) ? telemetry.lane : null;
223
+ const keepProblem = telemetry.rateLimitProblem === true;
224
+ const cleanLane = (value) => (laneAllowed?.includes(value) ? value : null);
225
+ const cleanProblem = (value) => {
226
+ if (!keepProblem || typeof value !== 'string') return null;
227
+ const text = value.trim();
228
+ return /^[A-Za-z][A-Za-z0-9_-]{0,39}$/.test(text) ? text : null;
229
+ };
218
230
 
219
231
  const lock = (tx, key) => tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${lockName}:${key}`}))`);
220
232
  const extraColumns = Object.keys(callDefaults).map((column) => sql`, ${sql.identifier(column)}`);
@@ -263,12 +275,17 @@ export function createCallGuard({
263
275
  // resourceResumed, opened, closed, budgetSpent }),
264
276
  // each of which was just reported once. `callId` names a call row the
265
277
  // adapter wrote when it admitted the call: that row is updated, not added.
266
- async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
278
+ async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null, lane: laneRaw = null, rateLimitProblem = null } = {}) {
267
279
  const key = cleanKey(rawKey);
268
280
  const where = cleanResource(resource);
269
281
  const nowMs = Number(now());
270
282
  const nowAt = new Date(nowMs);
271
283
  const status = Number.isInteger(statusCode) ? statusCode : null;
284
+ const lane = cleanLane(laneRaw);
285
+ const problem = cleanProblem(rateLimitProblem);
286
+ const extraCols = sql`${laneAllowed ? sql`, lane` : sql``}${keepProblem ? sql`, rate_limit_problem` : sql``}`;
287
+ const extraVals = sql`${laneAllowed ? sql`, ${lane}` : sql``}${keepProblem ? sql`, ${problem}` : sql``}`;
288
+ const extraSet = sql`${laneAllowed ? sql`, lane = ${lane}` : sql``}${keepProblem ? sql`, rate_limit_problem = ${problem}` : sql``}`;
272
289
  const outcome = await db.transaction(async (tx) => {
273
290
  await lock(tx, key);
274
291
  await ensureRow(tx, key);
@@ -276,10 +293,10 @@ export function createCallGuard({
276
293
  const took = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null;
277
294
  if (callId !== null) {
278
295
  await tx.execute(sql`update ${T.calls} set resource = ${where}, request_path = ${normaliseRequestPath(path)}, status_code = ${status},
279
- failure_kind = ${kind}, duration_ms = ${took} where id = ${callId} and ${C.key} = ${key}`);
296
+ failure_kind = ${kind}, duration_ms = ${took}${extraSet} where id = ${callId} and ${C.key} = ${key}`);
280
297
  } else {
281
- await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${sql.join(extraColumns, sql``)})
282
- values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${sql.join(extraValues, sql``)})`);
298
+ await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${extraCols}${sql.join(extraColumns, sql``)})
299
+ values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${extraVals}${sql.join(extraValues, sql``)})`);
283
300
  }
284
301
  const state = await readState(tx, key);
285
302
  if (!countsAsFailure({ statusCode: status, failureKind })) {
@@ -9,13 +9,17 @@ import { scrubSpan } from '../../ui/monitoring.js';
9
9
  // the host setting SENTRY_DSN. It has no tables.
10
10
 
11
11
  // The browser SDK, loaded from Sentry's CDN only while this adapter is on
12
- // (server/ui/monitoring.js). Pinned, with its integrity hash. With traces on,
13
- // the bundle that also traces; Sentry ships it only with session replay in
14
- // it too, which Flow never turns on.
12
+ // (server/ui/monitoring.js). Pinned, with its integrity hash. With replay on,
13
+ // the bundle with session replay; with traces on, the bundle that also traces
14
+ // (Sentry ships it only with replay in it).
15
15
  export const SENTRY_BROWSER = Object.freeze({
16
16
  src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
17
17
  integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
18
18
  });
19
+ export const SENTRY_BROWSER_REPLAY = Object.freeze({
20
+ src: 'https://browser.sentry-cdn.com/11.4.0/bundle.replay.feedback.min.js',
21
+ integrity: 'sha384-9Iyc17wHvpK0bqZe+cuAedQDHoWwenMrxXR+HgLLkQ3rHM4zQD+amLzBnmrQgowj',
22
+ });
19
23
  export const SENTRY_BROWSER_TRACING = Object.freeze({
20
24
  src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
21
25
  integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
@@ -44,6 +48,7 @@ export const SENTRY_SETUP = {
44
48
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
45
49
  { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
46
50
  { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
+ { name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
47
52
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
48
53
  ],
49
54
  };
@@ -56,6 +61,7 @@ export function readSentrySettings(env) {
56
61
  // labelled apart so they never read as the live app's.
57
62
  const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
58
63
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
64
+ const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
59
65
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
60
66
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
61
67
  if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
@@ -66,7 +72,7 @@ export function readSentrySettings(env) {
66
72
  // Every event says which product and, when set, which client it is from,
67
73
  // so one support desk can serve many installations.
68
74
  const tags = client ? { product: 'flow', client } : { product: 'flow' };
69
- return { dsn, environment, feedback, tracesSampleRate, tags };
75
+ return { dsn, environment, feedback, replay, tracesSampleRate, tags };
70
76
  }
71
77
 
72
78
  // No trace for the host's health check: it is not a person waiting.
@@ -108,7 +114,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
108
114
  ),
109
115
  beforeSendSpan: scrubSpan,
110
116
  });
111
- log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
117
+ log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; replay ${settings.replay ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
112
118
  },
113
119
  async stop() {
114
120
  if (settings) await sdk.close(2000);
@@ -133,15 +139,16 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
133
139
  // meant for browsers.
134
140
  browserMonitoring() {
135
141
  if (!settings) return null;
136
- const { tracesSampleRate } = settings;
142
+ const { tracesSampleRate, replay } = settings;
137
143
  return {
138
144
  dsn: settings.dsn,
139
145
  environment: settings.environment,
140
146
  release,
141
147
  feedback: settings.feedback,
148
+ replay,
142
149
  tags: settings.tags,
143
150
  tracesSampleRate,
144
- script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
151
+ script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : replay ? SENTRY_BROWSER_REPLAY : SENTRY_BROWSER,
145
152
  };
146
153
  },
147
154
  // Runs `work` in a span of the current trace (server/core/tracing.js).
@@ -1,4 +1,5 @@
1
1
  import { FlowError } from '../../core/errors.js';
2
+ import { currentXeroLane } from './governor.js';
2
3
  import { XERO_ENDPOINTS } from './settings.js';
3
4
 
4
5
  // The Xero Accounting API client. Every call goes through the rate-limit
@@ -130,7 +131,7 @@ export function createXeroClient({
130
131
  } catch (error) {
131
132
  if (error?.code === 'xero_replay_no_match') throw error;
132
133
  const timedOut = error?.name === 'AbortError';
133
- await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt });
134
+ await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt, lane: currentXeroLane() });
134
135
  throw xeroError(timedOut ? 'xero_timeout' : 'xero_unreachable',
135
136
  timedOut ? `Xero did not answer within ${timeoutMs} ms.` : 'Flow could not reach Xero.',
136
137
  { status: timedOut ? 504 : 503 });
@@ -169,7 +170,16 @@ export function createXeroClient({
169
170
  // A first 401 usually means the access token expired: it is not counted,
170
171
  // and the call is tried once more with a fresh token.
171
172
  const expiredToken = response.status === 401 && !refreshed;
172
- if (!expiredToken) await guard?.record(tenantId, { ...call, statusCode: response.status, durationMs });
173
+ const problem = response.status === 429 ? response.headers?.get?.('x-rate-limit-problem')?.trim() || null : null;
174
+ if (!expiredToken) {
175
+ await guard?.record(tenantId, {
176
+ ...call,
177
+ statusCode: response.status,
178
+ durationMs,
179
+ lane: currentXeroLane(),
180
+ rateLimitProblem: problem,
181
+ });
182
+ }
173
183
  if (response.ok) return payload ?? {};
174
184
 
175
185
  const error = apiError(response, payload);
@@ -0,0 +1,11 @@
1
+ -- The lane a call counted against, and a 429's X-Rate-Limit-Problem, so the
2
+ -- Xero card can show the day's calls without asking Xero. A row from before
3
+ -- this column has no lane: the card does not guess one.
4
+ ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "lane" text;
5
+ --> statement-breakpoint
6
+ ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "rate_limit_problem" text;
7
+ --> statement-breakpoint
8
+ DO $flow_guard$ BEGIN
9
+ ALTER TABLE "xero_calls" ADD CONSTRAINT "xero_calls_lane_valid" CHECK ("lane" is null or "lane" in ('writes', 'sync', 'reads', 'other'));
10
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
11
+ END $flow_guard$;
@@ -71,6 +71,13 @@
71
71
  "when": 1792520000000,
72
72
  "tag": "0009_xero_sent_to_contact",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1792530000000,
79
+ "tag": "0010_xero_call_usage",
80
+ "breakpoints": true
74
81
  }
75
82
  ]
76
83
  }
@@ -14,6 +14,7 @@ import { accountingStatus, checkSentInvoices } from './reconcile.js';
14
14
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
15
15
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
16
16
  import { writesAllowed } from './settings.js';
17
+ import { readXeroUsage } from './usage.js';
17
18
  import { saveEvents, signatureValid } from './webhooks.js';
18
19
  import { DEFAULT_FAMILY } from '../../theme/themes.js';
19
20
  import { messagePage as themedMessagePage } from '../../ui/page.js';
@@ -109,8 +110,12 @@ export function callsState(status) {
109
110
  return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
110
111
  }
111
112
 
113
+ const XERO_INTEGRATION = Object.freeze({ name: 'Xero', logo: 'xero.svg' });
114
+
112
115
  export function createXeroRouter({ db, personAuth, runtime, log = console, buttonCooldowns = true, actions = createActionGuard({ integration: 'xero', actions: XERO_ACTIONS, off: !buttonCooldowns }), messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
113
- const page = (title, text, tone) => messagePage({ title, text, tone });
116
+ // Shared callback card (server/ui/page.js): integration marks, status line,
117
+ // and a short auto-return on success only (issue #211).
118
+ const page = (title, text, tone, extra = {}) => messagePage({ title, text, tone, integration: XERO_INTEGRATION, ...extra });
114
119
  const { settings } = runtime;
115
120
  const router = express.Router();
116
121
  const act = (name, key) => actions.route(name, { key });
@@ -144,16 +149,29 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
144
149
  router.get('/status', ...signedIn, handle(log, async (req, res) => {
145
150
  const organisationId = await requireOrganisation(db, req.query.organisationId);
146
151
  const connection = await readConnection(db, organisationId);
152
+ const connected = Boolean(connection && connection.mode === settings.mode);
153
+ const picture = connected
154
+ ? await readXeroUsage(db, {
155
+ organisationId,
156
+ tenantId: connection.tenantId,
157
+ governor: runtime.governor.status(connection.tenantId),
158
+ warningFraction: settings.usageWarningFraction,
159
+ refreshedAt: connection.refreshedAt,
160
+ now: runtime.now(),
161
+ })
162
+ : { usage: null, freshness: null };
147
163
  res.json({
148
164
  ok: true,
149
165
  mode: settings.mode,
150
166
  writesAllowed: writesAllowed(settings),
151
167
  redirectUri: settings.redirectUri,
152
- connected: Boolean(connection && connection.mode === settings.mode),
168
+ connected,
153
169
  connection: describeConnection(connection),
154
- rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
155
- calls: connection ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
170
+ rateLimit: connected ? runtime.governor.status(connection.tenantId) : null,
171
+ calls: connected ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
156
172
  lastSync: await lastSync(db, organisationId),
173
+ usage: picture.usage,
174
+ freshness: picture.freshness,
157
175
  });
158
176
  }));
159
177
 
@@ -213,7 +231,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
213
231
  if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
214
232
  await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
215
233
  log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
216
- return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). Flow is now copying its invoice and payment history in the background. You can close this tab.`, 'success'));
234
+ const organisation = connection.tenantName || 'your Xero organisation';
235
+ return res.type('html').send(page(
236
+ 'Xero is connected',
237
+ 'Flow is now copying its invoice and payment history in the background.',
238
+ 'success',
239
+ { organisation, mode: settings.mode, autoReturnSeconds: 5 },
240
+ ));
217
241
  } catch (error) {
218
242
  log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
219
243
  const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
@@ -21,6 +21,11 @@ export const XERO_CALL_GUARD = Object.freeze({
21
21
  name: 'Xero',
22
22
  tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
23
23
  policy: Object.freeze({ authStops: 'resource' }),
24
+ // Kept on the call row so the Xero card can show lanes and 429s after a restart.
25
+ telemetry: Object.freeze({
26
+ lane: Object.freeze(['writes', 'sync', 'reads', 'other']),
27
+ rateLimitProblem: true,
28
+ }),
24
29
  });
25
30
 
26
31
  export function requireXeroSettings(env = process.env) {
@@ -0,0 +1,74 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { assembleUsage, startOfUtcDay } from '../../ui/xero-usage-model.js';
3
+
4
+ // What the Xero card draws. Read from counters Flow already keeps: the
5
+ // governor's budget, the call log, and the copy's own timestamps. No Xero call.
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const iso = (value) => (value ? new Date(value).toISOString() : null);
9
+
10
+ // Calls that count toward the day's budget. A token refresh is recorded, and
11
+ // it is not an Accounting API call, so it stays out of the lanes.
12
+ const BUDGET_CALL = sql`lane is not null and coalesce(resource, '') <> 'token'`;
13
+
14
+ export async function readXeroUsage(db, { organisationId, tenantId, governor, warningFraction, refreshedAt, now = Date.now() }) {
15
+ const at = Number(now);
16
+ const dayStart = startOfUtcDay(at);
17
+ const weekStart = new Date(dayStart - 6 * DAY_MS);
18
+ const dayEnd = new Date(dayStart + DAY_MS);
19
+ const today = new Date(dayStart);
20
+
21
+ const [hours, days, tasks, limited, freshness] = await Promise.all([
22
+ db.execute(sql`
23
+ select to_char(date_trunc('hour', "at" at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as hour,
24
+ lane, count(*)::int as n
25
+ from xero_calls
26
+ where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and ${BUDGET_CALL}
27
+ group by 1, 2`),
28
+ db.execute(sql`
29
+ select to_char("at" at time zone 'UTC', 'YYYY-MM-DD') as day, count(*)::int as n
30
+ from xero_calls
31
+ where key = ${tenantId} and "at" >= ${weekStart} and "at" < ${dayEnd} and ${BUDGET_CALL}
32
+ group by 1`),
33
+ db.execute(sql`
34
+ select coalesce(nullif(resource, ''), 'call') as task, count(*)::int as n
35
+ from xero_calls
36
+ where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and coalesce(resource, '') <> 'token'
37
+ group by 1
38
+ order by n desc, task
39
+ limit 8`),
40
+ db.execute(sql`
41
+ select "at", rate_limit_problem as problem, lane, resource as task
42
+ from xero_calls
43
+ where key = ${tenantId} and status_code = 429 and "at" >= ${weekStart}
44
+ order by "at" desc
45
+ limit 12`),
46
+ db.execute(sql`
47
+ select
48
+ (select max(received_at) from xero_webhook_events where organisation_id = ${organisationId} and source = 'webhook') as webhook_at,
49
+ (select max(last_read_at) from xero_mirror_cursors where organisation_id = ${organisationId}) as poll_at,
50
+ (select last_finished_at from xero_daily_checks where organisation_id = ${organisationId}) as nightly_at,
51
+ (select max(read_at) from xero_webhook_events where organisation_id = ${organisationId}) as read_at`),
52
+ ]);
53
+
54
+ const fresh = freshness.rows[0] ?? {};
55
+ const copyCandidates = [fresh.poll_at, fresh.nightly_at, fresh.read_at].filter(Boolean).map((value) => new Date(value).getTime());
56
+ return {
57
+ usage: assembleUsage({
58
+ hourRows: hours.rows,
59
+ dayRows: days.rows,
60
+ taskRows: tasks.rows,
61
+ limitRows: limited.rows,
62
+ governor,
63
+ warningFraction,
64
+ now: at,
65
+ }),
66
+ freshness: {
67
+ webhookAt: iso(fresh.webhook_at),
68
+ pollAt: iso(fresh.poll_at),
69
+ nightlyAt: iso(fresh.nightly_at),
70
+ copyAt: copyCandidates.length ? new Date(Math.max(...copyCandidates)).toISOString() : null,
71
+ tokenRefreshedAt: iso(refreshedAt),
72
+ },
73
+ };
74
+ }
@@ -33,8 +33,9 @@ export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad =
33
33
  let action = null;
34
34
  if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
35
35
  else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
36
+ const line = first.moneyAtRiskCents > 0 ? `${first.title} ${money.format(first.moneyAtRiskCents / 100)} at risk.` : first.title;
36
37
  element.replaceChildren(...[
37
- h('p', { class: 'fx-alerts__text' },
38
+ h('p', { class: 'fx-alerts__text', title: line },
38
39
  first.title,
39
40
  first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
40
41
  action,