@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1

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 (76) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. package/server/ui/routes.js +1 -1
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import { isCallsStopped } from '../call-guard.js';
3
4
  import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
4
5
  import { sendModeFor } from '../../core/invoicing-rules.js';
5
6
  import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
@@ -206,6 +207,38 @@ export async function listPushes(db, organisationId, invoiceId) {
206
207
  return rows.map(mapPush);
207
208
  }
208
209
 
210
+ // What the customer's Xero copy of one Flow invoice version says, from Flow's
211
+ // copy of Xero (no calls): the stored push, and the mirror row when the
212
+ // mirror has it. Before the version is in Xero, only who numbers it and the
213
+ // default due days are known.
214
+ export async function documentFacts(db, runtime, { organisationId, invoiceId, version = null }) {
215
+ const { settings } = runtime;
216
+ const facts = { system: 'Xero', sent: false, numberedBy: settings.invoiceNumber, defaultDueDays: settings.defaultDueDays };
217
+ if (!version) return facts;
218
+ const { rows: [push] } = await db.execute(sql`select xero_invoice_id, xero_invoice_number, xero_status, request_body
219
+ from xero_invoice_pushes
220
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and version = ${version} and xero_invoice_id is not null`);
221
+ if (!push) return facts;
222
+ const { rows: [copy] } = await db.execute(sql`select invoice_number, reference, status,
223
+ to_char(issued_on, 'YYYY-MM-DD') as issued_on, to_char(due_on, 'YYYY-MM-DD') as due_on
224
+ from xero_invoices where organisation_id = ${organisationId} and xero_invoice_id = ${push.xero_invoice_id}`);
225
+ let sent = {};
226
+ try {
227
+ sent = JSON.parse(push.request_body) ?? {};
228
+ } catch {
229
+ // An unreadable body leaves only the mirror and the push's own columns.
230
+ }
231
+ return {
232
+ ...facts,
233
+ sent: true,
234
+ number: copy?.invoice_number || push.xero_invoice_number || sent.InvoiceNumber || null,
235
+ reference: copy?.reference || sent.Reference || null,
236
+ issueDate: copy?.issued_on || sent.Date || null,
237
+ dueDate: copy?.due_on || sent.DueDate || null,
238
+ status: copy?.status || push.xero_status || null,
239
+ };
240
+ }
241
+
209
242
  async function settle(db, id, fields) {
210
243
  const { rows } = await db.execute(sql`update xero_invoice_pushes set
211
244
  state = ${fields.state},
@@ -313,7 +346,7 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
313
346
  try {
314
347
  results.push(await pushOne(db, runtime, client, organisationId, invoiceId, { pushedBy, allowExisting: allowed.has(invoiceId) }));
315
348
  } catch (error) {
316
- if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') {
349
+ if (stopsTheBatch(error)) {
317
350
  // Every later invoice would fail the same way: stop and say so.
318
351
  results.push(failed(invoiceId, error));
319
352
  for (const rest of invoiceIds.slice(invoiceIds.indexOf(invoiceId) + 1)) results.push(failed(rest, error, 'not_tried'));
@@ -392,9 +425,13 @@ async function assertTracking(client, tracking) {
392
425
  }
393
426
  }
394
427
 
395
- // Xero errors that prove no invoice was made: Xero refused the request.
428
+ // Errors after which every later call would fail the same way: stop the batch.
429
+ const stopsTheBatch = (error) => error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed' || isCallsStopped(error);
430
+
431
+ // Xero errors that prove no invoice was made: Xero refused the request, or
432
+ // the call guard stopped it before it went.
396
433
  function provesNothingCreated(error) {
397
- return error.code === 'xero_validation_failed' || error.code === 'xero_rate_limited'
434
+ return isCallsStopped(error) || error.code === 'xero_validation_failed' || error.code === 'xero_rate_limited'
398
435
  || error.code === 'xero_unauthorized' || error.code === 'xero_reconnect_needed'
399
436
  || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
400
437
  }
@@ -509,7 +546,7 @@ async function dispatch(db, client, push, document, { fresh = false } = {}) {
509
546
  throw error;
510
547
  }
511
548
  await settle(db, push.id, { state: 'ambiguous', errorCode: error.code ?? 'xero_uncertain', errorMessage: error.message });
512
- if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
549
+ if (stopsTheBatch(error)) throw error;
513
550
  return {
514
551
  invoiceId: push.invoiceId,
515
552
  version: push.version,
@@ -555,8 +592,9 @@ async function verify(db, client, push, draft, document, created, outcome) {
555
592
  // ---------------------------------------------------------------------------
556
593
  // Email
557
594
 
558
- // Xero errors that prove it sent no email: it refused the request.
559
- const emailRefused = (error) => error.code === 'xero_validation_failed'
595
+ // Xero errors that prove it sent no email: it refused the request, or the
596
+ // call guard stopped it before it went.
597
+ const emailRefused = (error) => isCallsStopped(error) || error.code === 'xero_validation_failed'
560
598
  || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
561
599
 
562
600
  // Asks Xero to email an approved invoice that Flow sent and checked, once.
@@ -576,7 +614,7 @@ async function emailIfDue(db, client, push) {
576
614
  ? `Xero did not email the invoice: ${error.message}`
577
615
  : 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
578
616
  await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
579
- if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
617
+ if (stopsTheBatch(error)) throw error;
580
618
  return { state, error: message, at: null };
581
619
  }
582
620
  const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
@@ -613,7 +651,7 @@ async function resume(db, runtime, client, push, document) {
613
651
  .filter((inv) => !GONE.has(inv.Status) && !push.preExistingIds.includes(inv.InvoiceID)
614
652
  && differences(inv, draft, document).filter((d) => d !== 'date').length === 0);
615
653
  } catch (error) {
616
- if (error.code === 'xero_rate_limited') throw error;
654
+ if (error.code === 'xero_rate_limited' || isCallsStopped(error)) throw error;
617
655
  return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: 'Flow could not look in Xero to settle an earlier attempt. Send again later.' } };
618
656
  }
619
657
  if (candidates.length === 1) return verify(db, client, push, draft, document, candidates[0], 'reconciled');
@@ -1,14 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { sql } from 'drizzle-orm';
3
- import { enqueueJob, runNextJob } from '../../core/queue.js';
3
+ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from '../../core/queue.js';
4
+ import { isCallsStopped } from '../call-guard.js';
4
5
  import { lastSync, syncCatalogue } from './catalogue.js';
6
+ import { mirrorState } from './lookup.js';
5
7
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
8
+ import { checkSentInvoices } from './reconcile.js';
6
9
 
7
- // The history import. When an owner connects Xero, Flow copies the
8
- // organisation's invoices, credit notes and payments into the mirror
9
- // (mirror.js) as a background job, so nobody runs a script by hand. The job
10
- // reads in slices; a slice that stops at the day's budget or its page limit
11
- // queues the next slice. It only reads from Xero.
10
+ // The history import, then the reads of changes. When an owner connects Xero,
11
+ // Flow copies the organisation's invoices, credit notes and payments into the
12
+ // mirror (mirror.js) as a background job, so nobody runs a script by hand. The
13
+ // job reads in slices; a slice that stops at the day's budget or its page
14
+ // limit queues the next slice. When every resource is read to its end, the
15
+ // same job is queued again for REFRESH_MS later and reads only what changed
16
+ // since its cursor (issue #131), so the copy stays fresh (lookup.js). It only
17
+ // reads from Xero.
12
18
 
13
19
  export const IMPORT_KIND = 'xero.import';
14
20
  const IMPORT_KEY = 'history';
@@ -18,6 +24,10 @@ const LEASE_MS = 30 * 60 * 1000;
18
24
  // Xero's day limit resets on a rolling day; an hour is a cheap re-check.
19
25
  const BUDGET_WAIT_MS = 60 * 60 * 1000;
20
26
  const IDLE_MS = 30 * 1000;
27
+ // How often a finished copy reads Xero's changes again. Each read costs about
28
+ // one call per resource when little changed: 96 a day for each of invoices and
29
+ // credit notes, inside the reserve the mirror leaves (XERO_MIRROR_RESERVE_FRACTION).
30
+ export const REFRESH_MS = 15 * 60 * 1000;
21
31
  // Contacts and items are read again once a day, so the matcher can link each
22
32
  // customer (server/core/pipeline.js) without a person pressing Sync.
23
33
  const CATALOGUE_EVERY_MS = 24 * 60 * 60 * 1000;
@@ -44,12 +54,33 @@ export async function organisationsToImport(db, mode) {
44
54
  return rows.map((r) => r.organisation_id);
45
55
  }
46
56
 
57
+ // The next read for one organisation: queued now unless one is open already.
58
+ export async function queueChangesRead(db, organisationId, { runAfter = null } = {}) {
59
+ return queueHistoryImport(db, organisationId, { reason: 'changes', runAfter });
60
+ }
61
+
47
62
  // One slice of the import for one job. Answers what it read and whether more
48
63
  // remains (`more`: 'budget' or 'max_pages').
64
+ // While the call guard stops Xero calls the slice does not fail, so it does
65
+ // not spend the job's attempts: it answers `more: 'stopped'` with the wait,
66
+ // or no wait for a stop only a person clears (connecting queues the import).
49
67
  export async function runImportSlice(db, runtime, job, { maxPages = SLICE_PAGES } = {}) {
50
- const results = await mirrorXero(db, runtime, job.organisationId, { maxPages });
51
- const stopped = results.find((r) => r.stopped !== 'done')?.stopped ?? null;
52
- return { results, more: stopped };
68
+ let results;
69
+ try {
70
+ results = await mirrorXero(db, runtime, job.organisationId, { maxPages });
71
+ } catch (error) {
72
+ if (!isCallsStopped(error)) throw error;
73
+ return { results: [], more: error.retryAfterMs === null ? null : 'stopped', retryAfterMs: error.retryAfterMs, stoppedBy: error.code };
74
+ }
75
+ // A refused resource waits for a person; it never queues another slice.
76
+ const stopped = results.find((r) => !['done', 'refused'].includes(r.stopped))?.stopped ?? null;
77
+ // Each invoice Flow sent, against what the copy now holds (issue #132):
78
+ // an edit, void or delete in Xero opens a review item. No Xero calls.
79
+ const invoices = results.find((r) => r.resource === 'invoices');
80
+ const sent = invoices && (invoices.inserted || invoices.changed || job.payload?.reason !== 'changes')
81
+ ? await checkSentInvoices(db, runtime, job.organisationId)
82
+ : null;
83
+ return { results, more: stopped, ...(sent ? { sent } : {}) };
53
84
  }
54
85
 
55
86
  // Claims and runs one due import job, then queues the next slice when one is
@@ -61,33 +92,106 @@ export async function runImportOnce(db, runtime, { workerId = `xero-import-${ran
61
92
  handlers: { [IMPORT_KIND]: (job) => runImportSlice(db, runtime, job, { maxPages }) },
62
93
  });
63
94
  if (ran?.outcome === 'succeeded' && ran.job.result?.more) {
64
- const wait = ran.job.result.more === 'budget' ? BUDGET_WAIT_MS : 0;
95
+ const { more, retryAfterMs } = ran.job.result;
96
+ const wait = more === 'budget' ? BUDGET_WAIT_MS : more === 'stopped' ? Math.max(IDLE_MS, Number(retryAfterMs) || BUDGET_WAIT_MS) : 0;
65
97
  await queueHistoryImport(db, ran.job.organisationId, { reason: ran.job.result.more, runAfter: new Date(Number(now()) + wait) });
98
+ } else if (ran?.outcome === 'succeeded' && !ran.job.result?.stoppedBy) {
99
+ // Read to the end: read the changes again later. A stop only a person
100
+ // clears queues nothing; connecting or saving the settings queues it.
101
+ await queueChangesRead(db, ran.job.organisationId, { runAfter: new Date(Number(now()) + REFRESH_MS) });
66
102
  }
67
103
  return ran;
68
104
  }
69
105
 
106
+ // Failed catalogue reads in a row since the last good one, and the newest
107
+ // failure's code.
108
+ async function failuresInARow(db, organisationId) {
109
+ const { rows } = await db.execute(sql`
110
+ select count(*)::int as failures,
111
+ (array_agg(error_code order by started_at desc))[1] as last_code
112
+ from xero_sync_runs
113
+ where organisation_id = ${organisationId} and status = 'failed'
114
+ and started_at > coalesce((select max(started_at) from xero_sync_runs
115
+ where organisation_id = ${organisationId} and status = 'succeeded'), 'epoch')`);
116
+ return { failures: rows[0]?.failures ?? 0, lastCode: rows[0]?.last_code ?? null };
117
+ }
118
+
119
+ // The wait after `failures` failed reads in a row: an hour, doubling, at most
120
+ // a day (issue #111: never every hour for ever).
121
+ export function catalogueRetryMs(failures) {
122
+ return Math.min(CATALOGUE_EVERY_MS, BUDGET_WAIT_MS * 2 ** Math.max(0, failures - 1));
123
+ }
124
+
70
125
  // Reads the contacts and items of each connected organisation whose last good
71
- // read is older than a day. A failure is logged and tried again next hour.
126
+ // read is older than a day. A failed read waits longer each time (above). A
127
+ // failure is reported once with log.error; the same failure again is a
128
+ // warning. While the call guard stops Xero calls, no read is tried.
72
129
  export async function refreshStaleCatalogues(db, runtime, { now = Date.now, log = console } = {}) {
73
- const { rows } = await db.execute(sql`select organisation_id from xero_connections where mode = ${runtime.settings.mode}`);
74
- for (const { organisation_id: organisationId } of rows) {
130
+ const { rows } = await db.execute(sql`select organisation_id, tenant_id from xero_connections where mode = ${runtime.settings.mode}`);
131
+ for (const { organisation_id: organisationId, tenant_id: tenantId } of rows) {
75
132
  const last = await lastSync(db, organisationId);
76
133
  const at = last?.finishedAt ?? last?.startedAt;
77
- if (last && at && Number(now()) - new Date(at) < (last.status === 'succeeded' ? CATALOGUE_EVERY_MS : BUDGET_WAIT_MS)) continue;
134
+ const { failures, lastCode } = last?.status === 'failed' ? await failuresInARow(db, organisationId) : { failures: 0, lastCode: null };
135
+ if (last && at && Number(now()) - new Date(at) < (last.status === 'succeeded' ? CATALOGUE_EVERY_MS : catalogueRetryMs(failures))) continue;
136
+ try {
137
+ await runtime.guardFor(db).check(tenantId);
138
+ } catch (error) {
139
+ if (isCallsStopped(error)) continue;
140
+ throw error;
141
+ }
78
142
  try {
79
143
  const run = await syncCatalogue(db, runtime, organisationId);
80
144
  log.info?.(`[xero] contacts and items read for organisation ${organisationId}: ${run.contactCount} contacts, ${run.itemCount} items`);
81
145
  } catch (error) {
82
- log.error?.(`[xero] reading contacts and items for organisation ${organisationId} failed: ${error?.code ?? 'error'}`);
146
+ const code = error?.code ?? 'error';
147
+ const line = `[xero] reading contacts and items for organisation ${organisationId} failed: ${code}; next try in ${Math.round(catalogueRetryMs(failures + 1) / 60_000)} minutes`;
148
+ // The guard reported a stop already; a repeat of the same failure is a warning.
149
+ if (isCallsStopped(error) || (failures > 0 && code === lastCode)) log.warn?.(line);
150
+ else log.error?.(line);
151
+ }
152
+ }
153
+ }
154
+
155
+ // Keeps a read queued for each connected organisation. A read that gave up
156
+ // (dead) waits for a person, a restart or the next day (issue #101), and
157
+ // while the call guard stops Xero calls no read is queued.
158
+ export async function keepReadsQueued(db, runtime, { startedAtMs = Date.now(), now = Date.now } = {}) {
159
+ const { rows } = await db.execute(sql`select organisation_id, tenant_id from xero_connections where mode = ${runtime.settings.mode}`);
160
+ for (const { organisation_id: organisationId, tenant_id: tenantId } of rows) {
161
+ if (await deadSince(db, organisationId, { kind: IMPORT_KIND, dedupeKey: IMPORT_KEY, since: deadHoldSince(startedAtMs, Number(now())) })) continue;
162
+ try {
163
+ await runtime.guardFor(db).check(tenantId);
164
+ } catch (error) {
165
+ if (isCallsStopped(error)) continue;
166
+ throw error;
167
+ }
168
+ await queueChangesRead(db, organisationId);
169
+ }
170
+ }
171
+
172
+ // Reports a copy that went stale once with log.error, and its recovery with
173
+ // log.info. `stale` is the set of organisations already reported.
174
+ export async function reportStaleCopies(db, runtime, { stale = new Set(), now = Date.now, log = console } = {}) {
175
+ const { rows } = await db.execute(sql`select organisation_id from xero_connections where mode = ${runtime.settings.mode}`);
176
+ for (const { organisation_id: organisationId } of rows) {
177
+ const state = await mirrorState(db, organisationId, { now: new Date(Number(now())) });
178
+ if (!state.complete) continue;
179
+ if (!state.fresh && !stale.has(organisationId)) {
180
+ stale.add(organisationId);
181
+ log.error?.(`[xero] the copy of Xero for organisation ${organisationId} is out of date (last full read ${state.lastPassAt?.toISOString() ?? 'never'}); drafts by rule wait until it is read again`);
182
+ } else if (state.fresh && stale.delete(organisationId)) {
183
+ log.info?.(`[xero] the copy of Xero for organisation ${organisationId} is up to date again`);
83
184
  }
84
185
  }
186
+ return stale;
85
187
  }
86
188
 
87
189
  // The background worker: runs due slices one at a time, then waits. Started
88
190
  // by the adapter outside replay mode; stop() waits for a slice in flight.
89
191
  export function createImportWorker({ db, runtime, log = console, timers = { setTimeout, clearTimeout }, firstDelayMs = 10_000 }) {
90
192
  const workerId = `xero-import-${randomUUID()}`;
193
+ const startedAtMs = Date.now();
194
+ const stale = new Set();
91
195
  let timer = null;
92
196
  let running = null;
93
197
  let stopped = true;
@@ -105,11 +209,14 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
105
209
  running = (async () => {
106
210
  try {
107
211
  await refreshStaleCatalogues(db, runtime, { log });
212
+ await keepReadsQueued(db, runtime, { startedAtMs });
213
+ await reportStaleCopies(db, runtime, { stale, log });
108
214
  const ran = await runImportOnce(db, runtime, { workerId });
109
215
  if (ran) {
110
216
  busy = true;
111
217
  const read = (ran.job.result?.results ?? []).map((r) => `${r.resource} ${r.read}`).join(', ');
112
- log.info?.(`[xero] import ${ran.outcome} for organisation ${ran.job.organisationId}${read ? `: ${read}` : ''}${ran.job.result?.more ? `; more (${ran.job.result.more})` : ''}`);
218
+ if (ran.outcome === 'dead') log.error?.('[xero] reading Xero gave up after its last attempt');
219
+ log.info?.(`[xero] read ${ran.outcome} for organisation ${ran.job.organisationId}${read ? `: ${read}` : ''}${ran.job.result?.more ? `; more (${ran.job.result.more})` : ''}`);
113
220
  }
114
221
  } catch (error) {
115
222
  log.error?.(`[xero] import worker: ${error?.code ?? 'error'}: ${error?.message ?? error}`);
@@ -126,6 +233,8 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
126
233
  for (const organisationId of await organisationsToImport(db, runtime.settings.mode)) {
127
234
  await queueHistoryImport(db, organisationId, { reason: 'start' });
128
235
  }
236
+ // A finished copy reads its changes again from the first tick on
237
+ // (keepReadsQueued), so an upgrade or a restart needs no step.
129
238
  schedule(firstDelayMs);
130
239
  },
131
240
  async stop() {
@@ -1,19 +1,20 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
- import { checkDrafts, pushInvoice, pushInvoices } from './drafts.js';
3
+ import { guardAlerts } from '../call-guard.js';
4
+ import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
4
5
  import { readConnection } from './connections.js';
5
6
  import { createImportWorker } from './import.js';
6
7
  import { readPayments, xeroLiveCheck } from './ledger.js';
7
- import { findInvoices, listCustomers } from './lookup.js';
8
+ import { findInvoices, listCustomers, listPriceItems, mirrorState } from './lookup.js';
8
9
  import { createXeroRouter } from './routes.js';
9
- import { createXeroRuntime, requireXeroSettings } from './runtime.js';
10
+ import { createXeroRuntime, requireXeroSettings, XERO_CALL_GUARD } from './runtime.js';
10
11
  import { writesAllowed } from './settings.js';
11
12
 
12
13
  // The Xero adapter (docs/XERO.md). FLOW_INTEGRATIONS=xero turns it on and
13
14
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
14
15
  // is on (docs/INTEGRATIONS.md).
15
16
 
16
- 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']);
17
+ 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', 'xero_call_guard', 'xero_calls']);
17
18
 
18
19
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
19
20
  // The refresh token's key comes from Flow's secrets key.
@@ -61,10 +62,16 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
61
62
  tables: [...XERO_TABLES],
62
63
  },
63
64
  setup: XERO_SETUP,
64
- async start({ db, log, env }) {
65
+ // Every Xero call goes through this call guard (docs/INTEGRATIONS.md,
66
+ // "Outbound calls").
67
+ callGuard: XERO_CALL_GUARD,
68
+ // `reconfigured` is true when a person saved the settings or turned Xero
69
+ // on: that clears a halt, stopped resources and an open circuit.
70
+ async start({ db, log, env, reconfigured = false }) {
65
71
  database = db;
66
72
  if (!given) runtime = createXeroRuntime({ settings: requireXeroSettings(env ?? process.env) });
67
73
  const { settings } = current();
74
+ if (reconfigured) await current().guardFor(db).resume();
68
75
  log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
69
76
  // The history import (import.js) runs in the background outside replay
70
77
  // mode. Replay tests run its slices directly.
@@ -81,8 +88,25 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
81
88
  capabilities: {
82
89
  async health() {
83
90
  const { settings } = current();
84
- const { rows } = await database.execute(sql`select count(*)::int as connected from xero_connections where mode = ${settings.mode}`);
85
- return { ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), connected: rows[0].connected };
91
+ const { rows } = await database.execute(sql`select organisation_id, tenant_id from xero_connections where mode = ${settings.mode}`);
92
+ const guard = current().guardFor(database);
93
+ const calls = await Promise.all(rows.map(async (r) => ({ tenantId: r.tenant_id, ...await guard.status(r.tenant_id) })));
94
+ const stopped = calls.some((c) => c.circuit !== 'closed' || c.stoppedResources.length || c.failedToday >= c.dailyFailureBudget);
95
+ // How fresh each copy of Xero is (lookup.js): a complete copy that is
96
+ // not fresh stops drafts by rule (issue #131).
97
+ const copies = await Promise.all(rows.map(async (r) => {
98
+ const { complete, fresh, lastPassAt } = await mirrorState(database, r.organisation_id);
99
+ return { tenantId: r.tenant_id, complete, fresh, lastPassAt };
100
+ }));
101
+ const stale = copies.some((c) => c.complete && !c.fresh);
102
+ return { ok: !stopped && !stale, mode: settings.mode, writesAllowed: writesAllowed(settings), connected: rows.length, calls, copies };
103
+ },
104
+ // What only a person can clear, for the Integrations page
105
+ // (server/core/alerts.js): a refused connection stops calls.
106
+ async alerts({ organisationId }) {
107
+ const connection = await readConnection(database, organisationId);
108
+ if (!connection || connection.mode !== current().settings.mode) return [];
109
+ return guardAlerts('Xero', await current().guardFor(database).status(connection.tenantId));
86
110
  },
87
111
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
88
112
  // and version are the idempotency key.
@@ -96,6 +120,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
96
120
  const connection = await readConnection(database, organisationId);
97
121
  return { connected: Boolean(connection && connection.mode === settings.mode), writesAllowed: writesAllowed(settings) };
98
122
  },
123
+ // What the customer's Xero copy of a Flow invoice version says, from
124
+ // Flow's copy of Xero (drafts.js; no calls). The invoice PDF uses it.
125
+ documentFacts: ({ organisationId, invoiceId, version = null }) => documentFacts(database, current(), { organisationId, invoiceId, version }),
99
126
  // Sales invoice payments changed since `since` (ledger.js). The answer's
100
127
  // cursor is the next `since`.
101
128
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
@@ -106,6 +133,8 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
106
133
  listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
107
134
  // Sales invoices from the mirror, with the mirror's state (lookup.js).
108
135
  findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
136
+ // Xero items, from Flow's copy, for the price list (lookup.js).
137
+ listPriceItems: ({ organisationId }) => listPriceItems(database, organisationId, { taxTypes: current().settings.taxTypes }),
109
138
  },
110
139
  routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
111
140
  };
@@ -22,6 +22,32 @@ export async function listCustomers(db, organisationId) {
22
22
  return rows.map((r) => ({ id: r.xero_contact_id, name: r.name }));
23
23
  }
24
24
 
25
+ // Xero's items, from Flow's copy (catalogue.js, read on each sync), for the
26
+ // price list's links and price check (server/core/price-list.js). A tax type
27
+ // that charges GST reads as GST; any other set type reads as GST free.
28
+ const GST_TAX_TYPES = new Set(['OUTPUT', 'OUTPUT2']);
29
+ export async function listPriceItems(db, organisationId, { taxTypes = {} } = {}) {
30
+ const { rows } = await db.execute(sql`
31
+ select xero_item_id, code, name, description, is_sold, sales_unit_amount_cents, sales_tax_type, synced_at
32
+ from xero_items where organisation_id = ${organisationId} order by code`);
33
+ const taxOf = (type) => {
34
+ if (!type) return null;
35
+ return type === taxTypes.GST || GST_TAX_TYPES.has(type) ? 'GST' : 'GST_FREE';
36
+ };
37
+ return {
38
+ kind: 'accounting',
39
+ syncedAt: rows.reduce((at, r) => (!at || r.synced_at > at ? r.synced_at : at), null),
40
+ items: rows.map((r) => ({
41
+ id: r.xero_item_id,
42
+ code: r.code,
43
+ name: r.name || r.description || r.code,
44
+ unitAmountCents: r.sales_unit_amount_cents,
45
+ taxCode: taxOf(r.sales_tax_type),
46
+ sold: r.is_sold,
47
+ })),
48
+ };
49
+ }
50
+
25
51
  // Whether the mirror holds the whole history and was read lately. Until then a
26
52
  // search that finds nothing proves nothing.
27
53
  export async function mirrorState(db, organisationId, { now = new Date() } = {}) {
@@ -0,0 +1,26 @@
1
+ -- The call guard's state and telemetry (server/integrations/call-guard.js,
2
+ -- issue 111): one row per Xero organisation (tenant) with the circuit, a halt
3
+ -- and the resources Xero refused, and one row per call sent. A call row keeps
4
+ -- the resource and path, never the host, the query, a token or a response.
5
+ CREATE TABLE IF NOT EXISTS "xero_call_guard" (
6
+ "key" text PRIMARY KEY NOT NULL,
7
+ "circuit_open_until" timestamp with time zone DEFAULT 'epoch' NOT NULL,
8
+ "circuit_opens" integer DEFAULT 0 NOT NULL,
9
+ "halted_reason" text,
10
+ "halted_at" timestamp with time zone,
11
+ "stopped_resources" jsonb DEFAULT '{}'::jsonb NOT NULL,
12
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
13
+ );
14
+ --> statement-breakpoint
15
+ CREATE TABLE IF NOT EXISTS "xero_calls" (
16
+ "id" bigserial PRIMARY KEY NOT NULL,
17
+ "key" text NOT NULL,
18
+ "at" timestamp with time zone NOT NULL,
19
+ "resource" text,
20
+ "request_path" text,
21
+ "status_code" integer,
22
+ "failure_kind" text,
23
+ "duration_ms" integer
24
+ );
25
+ --> statement-breakpoint
26
+ CREATE INDEX IF NOT EXISTS "xero_calls_window" ON "xero_calls" USING btree ("key","at");
@@ -43,6 +43,13 @@
43
43
  "when": 1792406000000,
44
44
  "tag": "0005_xero_send_modes",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1792490000000,
51
+ "tag": "0006_xero_call_guard",
52
+ "breakpoints": true
46
53
  }
47
54
  ]
48
55
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { sql } from 'drizzle-orm';
3
+ import { isCallsStopped } from '../call-guard.js';
3
4
  import { parseXeroDate } from './catalogue.js';
4
5
  import { withXeroLane } from './governor.js';
5
6
 
@@ -257,12 +258,20 @@ export async function mirrorResource(db, runtime, organisationId, resource, { ma
257
258
  }
258
259
 
259
260
  // Reads each resource in turn, one organisation at a time with any other Xero
260
- // work for it (runtime.serial).
261
+ // work for it (runtime.serial). A resource Xero refused (the call guard
262
+ // stopped it: the connection lacks its scope) is skipped as `refused`, so
263
+ // the others still read.
261
264
  export async function mirrorXero(db, runtime, organisationId, { resources = MIRROR_RESOURCES, maxPages } = {}) {
262
265
  return runtime.serial(organisationId, async () => {
263
266
  const results = [];
264
267
  for (const resource of resources) {
265
- const result = await mirrorResource(db, runtime, organisationId, resource, { maxPages });
268
+ let result;
269
+ try {
270
+ result = await mirrorResource(db, runtime, organisationId, resource, { maxPages });
271
+ } catch (error) {
272
+ if (!isCallsStopped(error) || error.reason !== 'resource_stopped') throw error;
273
+ result = { resource, pages: 0, read: 0, inserted: 0, changed: 0, unchanged: 0, stopped: 'refused' };
274
+ }
266
275
  results.push(result);
267
276
  if (result.stopped === 'budget') break;
268
277
  }
@@ -53,7 +53,10 @@ async function tokenRequest(settings, fetchImpl, form, what) {
53
53
  if (body?.error === 'invalid_grant') {
54
54
  throw xeroError('xero_reconnect_needed', 'Xero no longer accepts this connection. An owner must connect Xero again.', { status: 409 });
55
55
  }
56
- throw xeroError('xero_token_failed', `Xero refused ${what} (${response.status}${body?.error ? `, ${body.error}` : ''}).`, { status: 502 });
56
+ throw xeroError('xero_token_failed', `Xero refused ${what} (${response.status}${body?.error ? `, ${body.error}` : ''}).`, {
57
+ status: 502,
58
+ oauthError: typeof body?.error === 'string' ? body.error.slice(0, 40) : null,
59
+ });
57
60
  }
58
61
  if (!body?.access_token || !body?.refresh_token) {
59
62
  throw xeroError('xero_token_failed', `Xero's answer to ${what} had no tokens.`, { status: 502 });