@arcforgelabs/flow 2026.10.30 → 2026.10.32

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 (79) 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 +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -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/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
@@ -0,0 +1,173 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { openReview, resolveReviewFor } from '../../core/review.js';
3
+
4
+ // Each invoice Flow sent, against Flow's copy of Xero (issue #132). People
5
+ // change invoices in both places after Flow sends them: a Xero draft is edited
6
+ // before approval, an invoice is voided in Xero, or voided in Flow. The issued
7
+ // version Flow sent is the merge base: the document both sides agreed on.
8
+ // This module compares the Xero copy with it and with the Flow invoice now.
9
+ // It never calls Xero and never writes to Xero or to the Flow invoice: a
10
+ // difference opens one review item (alert only), and a person fixes it where
11
+ // they choose. The item closes when a later check finds no difference.
12
+
13
+ export const OUT_OF_SYNC = 'invoice.accounting_differs';
14
+ export const ACTOR = 'system:xero-reconcile';
15
+
16
+ // Xero's status in the words the Flow screens show.
17
+ const LABELS = {
18
+ not_sent: 'Not in Xero',
19
+ sending: 'Sending to Xero',
20
+ not_copied: 'Sent, not read back yet',
21
+ draft: 'Draft in Xero',
22
+ approved: 'Approved in Xero',
23
+ part_paid: 'Part paid in Xero',
24
+ paid: 'Paid in Xero',
25
+ voided: 'Voided in Xero',
26
+ deleted: 'Deleted in Xero',
27
+ };
28
+
29
+ const GONE = new Set(['VOIDED', 'DELETED']);
30
+
31
+ function xeroState(row) {
32
+ switch (row.status) {
33
+ case 'DRAFT':
34
+ case 'SUBMITTED':
35
+ return 'draft';
36
+ case 'AUTHORISED':
37
+ return Number(row.amount_paid_cents ?? 0) > 0 ? 'part_paid' : 'approved';
38
+ case 'PAID':
39
+ return 'paid';
40
+ case 'VOIDED':
41
+ return 'voided';
42
+ case 'DELETED':
43
+ return 'deleted';
44
+ default:
45
+ return 'draft';
46
+ }
47
+ }
48
+
49
+ const num = (value) => (value === null || value === undefined ? null : Number(value));
50
+ const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : null);
51
+ const codes = (lines) => [...(lines ?? []).map((l) => l.itemCode ?? '')].sort().join(',');
52
+
53
+ // What differs between the Xero copy and the version Flow sent. Free text
54
+ // (reference, descriptions) is not compared: the copy keeps it only when the
55
+ // lab turns that on (XERO_KEEP_REFERENCE).
56
+ export function sentDifferences(sent, document, copy) {
57
+ const found = [];
58
+ const add = (field, flow, xero) => found.push({ field, flow, xero });
59
+ if (sent?.Contact?.ContactID && copy.xero_contact_id !== sent.Contact.ContactID) add('contact', sent.Contact.ContactID, copy.xero_contact_id);
60
+ if (num(copy.subtotal_cents) !== document.subtotalCents) add('subtotal', document.subtotalCents, num(copy.subtotal_cents));
61
+ if (num(copy.tax_cents) !== document.taxCents) add('tax', document.taxCents, num(copy.tax_cents));
62
+ if (num(copy.total_cents) !== document.totalCents) add('total', document.totalCents, num(copy.total_cents));
63
+ const lines = copy.lines ?? [];
64
+ if (lines.length !== document.lines.length) add('line count', document.lines.length, lines.length);
65
+ else if (codes(lines) !== codes(document.lines)) add('items', codes(document.lines), codes(lines));
66
+ if (sent?.Date && day(copy.issued_on) !== sent.Date) add('date', sent.Date, day(copy.issued_on));
67
+ return found;
68
+ }
69
+
70
+ // The finding for one invoice, or null when Flow and Xero agree.
71
+ // differs Xero's copy no longer matches the version Flow sent
72
+ // voided_in_xero Xero voided or deleted it; the Flow invoice is still issued
73
+ // voided_in_flow Flow voided it; Xero's invoice is still live
74
+ function findingFor(status) {
75
+ if (!status.xero) return null;
76
+ const live = !GONE.has(status.xero.status);
77
+ if (status.flowStatus === 'void' && live) return 'voided_in_flow';
78
+ if (status.flowStatus === 'issued' && status.current && !live) return 'voided_in_xero';
79
+ if (live && status.differences.length) return 'differs';
80
+ return null;
81
+ }
82
+
83
+ const MESSAGES = {
84
+ differs: 'Xero no longer matches the invoice Flow sent.',
85
+ voided_in_xero: 'Xero voided or deleted this invoice. It is still issued in Flow.',
86
+ voided_in_flow: 'This invoice is void in Flow. Xero still holds it as a live invoice.',
87
+ };
88
+
89
+ // Each invoice's Xero status, from Flow's copy (no Xero calls). Answers a map
90
+ // from Flow invoice id to { state, label, xero, differences, finding, message }.
91
+ export async function accountingStatus(db, runtime, organisationId, invoiceIds = null) {
92
+ const only = invoiceIds ? sql`and i.id = any(${sql.param([...new Set(invoiceIds.map(String))])}::uuid[])` : sql``;
93
+ // Without ids: only invoices Flow sent, for the check after each read.
94
+ const sentOnly = invoiceIds ? sql`` : sql`and p.id is not null`;
95
+ const { rows } = await db.execute(sql`
96
+ select i.id, i.status, i.issued_version, i.total_cents,
97
+ p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
98
+ v.document,
99
+ x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
100
+ x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
101
+ from invoices i
102
+ left join lateral (
103
+ select * from xero_invoice_pushes
104
+ where organisation_id = i.organisation_id and invoice_id = i.id
105
+ order by version desc limit 1
106
+ ) p on true
107
+ left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
108
+ left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = p.xero_invoice_id
109
+ where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
110
+ const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
111
+ const out = new Map();
112
+ for (const r of rows) {
113
+ let state;
114
+ if (!r.push_state) state = 'not_sent';
115
+ else if (['dispatching', 'ambiguous'].includes(r.push_state)) state = 'sending';
116
+ else if (!r.x_status) state = 'not_copied';
117
+ else state = xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents });
118
+ let sent = null;
119
+ try {
120
+ sent = r.request_body ? JSON.parse(r.request_body) : null;
121
+ } catch {
122
+ sent = null;
123
+ }
124
+ const xero = r.x_status ? {
125
+ invoiceId: r.xero_invoice_id,
126
+ number: r.x_number ?? r.xero_invoice_number ?? null,
127
+ status: r.x_status,
128
+ totalCents: num(r.x_total_cents),
129
+ amountDueCents: num(r.amount_due_cents),
130
+ url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(r.xero_invoice_id)}`,
131
+ } : null;
132
+ const status = {
133
+ invoiceId: r.id,
134
+ flowStatus: r.status,
135
+ sentVersion: r.sent_version ?? null,
136
+ current: r.sent_version !== null && r.sent_version === r.issued_version,
137
+ state,
138
+ label: LABELS[state],
139
+ xero,
140
+ differences: xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
141
+ flowTotalCents: num(r.total_cents),
142
+ };
143
+ status.finding = findingFor(status);
144
+ status.message = status.finding ? MESSAGES[status.finding] : null;
145
+ out.set(r.id, status);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // Opens or refreshes one review item for each finding, and closes the item of
151
+ // an invoice whose finding went away. Answers counts.
152
+ export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {
153
+ const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
154
+ const result = { checked: 0, open: 0, closed: 0 };
155
+ for (const status of statuses.values()) {
156
+ if (status.state === 'not_sent') continue;
157
+ result.checked += 1;
158
+ if (status.finding) {
159
+ await openReview(db, organisationId, {
160
+ kind: OUT_OF_SYNC,
161
+ entity: 'invoice',
162
+ flowId: status.invoiceId,
163
+ systems: ['flow', 'xero'],
164
+ evidence: { finding: status.finding, message: status.message, flowStatus: status.flowStatus, sentVersion: status.sentVersion, xero: status.xero, differences: status.differences },
165
+ moneyAtRiskCents: Math.abs(status.xero?.totalCents ?? status.flowTotalCents ?? 0),
166
+ });
167
+ result.open += 1;
168
+ } else if (await resolveReviewFor(db, organisationId, { kind: OUT_OF_SYNC, entity: 'invoice', flowId: status.invoiceId, resolution: 'no_difference', actor: ACTOR })) {
169
+ result.closed += 1;
170
+ }
171
+ }
172
+ return result;
173
+ }
@@ -8,9 +8,11 @@ import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from '
8
8
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
9
9
  import { queueHistoryImport, resetImport } from './import.js';
10
10
  import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
11
+ import { accountingStatus, checkSentInvoices } from './reconcile.js';
11
12
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
12
13
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
13
14
  import { writesAllowed } from './settings.js';
15
+ import { saveEvents, signatureValid } from './webhooks.js';
14
16
  import { DEFAULT_FAMILY } from '../../theme/themes.js';
15
17
  import { messagePage as themedMessagePage } from '../../ui/page.js';
16
18
 
@@ -27,12 +29,18 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
27
29
  // would make, and what stops them (no Xero calls)
28
30
  // POST /invoices/push owner or admin: issued invoices to Xero drafts
29
31
  // GET /invoices/:invoiceId/pushes?organisationId= any signed-in person
32
+ // POST /invoices/status any signed-in person: each invoice's Xero
33
+ // status and any difference, from the copy
34
+ // POST /invoices/:invoiceId/recheck owner or admin: reads Xero's changes,
35
+ // then checks that one invoice again (Mark resolved)
30
36
  // POST /obligations/:obligationId/check owner or admin: Xero's invoice for
31
37
  // a billable job, recorded on the ledger
32
38
  // POST /payments/sync owner or admin: Xero payments to the ledger
33
39
  // POST /mirror owner or admin: copies Xero invoices, credit
34
40
  // notes and payments changed since the last copy
35
41
  // GET /mirror?organisationId= any signed-in person: what the copy holds
42
+ // POST /webhooks Xero: change events, signed with the
43
+ // webhook key (XERO_WEBHOOK_KEY); no session
36
44
  //
37
45
  // Only an owner, with a verified owner sign-in, can connect or disconnect.
38
46
  // The callback carries no Flow session: the one-use state from /connect,
@@ -76,10 +84,35 @@ export const handle = (log, fn) => async (req, res) => {
76
84
 
77
85
  // `messagePage` draws the callback's answer in the installation's design
78
86
  // system (server/ui/page.js); the server's app passes it.
87
+ // What the Xero page says about calls for one connection: stopped until a
88
+ // person connects again (a refused connection or resource), and since when.
89
+ export function callsState(status) {
90
+ const resources = status.stoppedResources.map((r) => r.resource);
91
+ const since = status.haltedAt ?? status.stoppedResources.map((r) => r.at).filter(Boolean).sort()[0] ?? null;
92
+ return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
93
+ }
94
+
79
95
  export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
80
96
  const page = (title, text, tone) => messagePage({ title, text, tone });
81
97
  const { settings } = runtime;
82
98
  const router = express.Router();
99
+
100
+ // Xero's webhooks (webhooks.js), before the JSON parser: the signature
101
+ // covers the raw body. Xero wants 200 with no body within 5 seconds, and
102
+ // 401 for a bad signature (its "intent to receive" test sends both).
103
+ router.post('/webhooks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
104
+ if (!settings.webhookKey) return res.status(404).end();
105
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
106
+ if (!signatureValid(settings.webhookKey, raw, req.get('x-xero-signature'))) return res.status(401).end();
107
+ try {
108
+ await saveEvents(db, runtime, JSON.parse(raw.toString('utf8') || '{}'));
109
+ } catch (error) {
110
+ log.error?.(`[xero] saving webhook events failed: ${error?.code ?? error?.name ?? 'error'}`);
111
+ return res.status(500).end();
112
+ }
113
+ return res.status(200).end();
114
+ });
115
+
83
116
  router.use(express.json({ limit: '256kb' }));
84
117
 
85
118
  // Sign-in off: nothing but the callback answers, and it fails for want of
@@ -101,6 +134,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
101
134
  connected: Boolean(connection && connection.mode === settings.mode),
102
135
  connection: describeConnection(connection),
103
136
  rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
137
+ calls: connection ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
104
138
  lastSync: await lastSync(db, organisationId),
105
139
  });
106
140
  }));
@@ -155,6 +189,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
155
189
  key: settings.tokenKey,
156
190
  });
157
191
  runtime.remember(entry.organisationId, tokens);
192
+ // A new connection is a person acting: calls stopped before resume.
193
+ await runtime.guardFor(db).resume();
158
194
  // Another Xero organisation starts its history from the beginning.
159
195
  if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
160
196
  await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
@@ -205,6 +241,25 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
205
241
  res.json({ ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), ...preview });
206
242
  }));
207
243
 
244
+ // Xero's status of each invoice, from Flow's copy: no Xero calls.
245
+ router.post('/invoices/status', ...signedIn, handle(log, async (req, res) => {
246
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
247
+ const statuses = await accountingStatus(db, runtime, organisationId, requireInvoiceIds(req.body?.invoiceIds));
248
+ res.json({ ok: true, invoices: [...statuses.values()] });
249
+ }));
250
+
251
+ // Mark resolved: read Xero's changes (a few calls, through the call guard),
252
+ // then check this one invoice again. The item closes only when the
253
+ // difference is gone; otherwise it stays open with the new evidence.
254
+ router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
255
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
256
+ const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
257
+ await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
258
+ const result = await checkSentInvoices(db, runtime, organisationId, { invoiceIds: [invoiceId] });
259
+ const status = (await accountingStatus(db, runtime, organisationId, [invoiceId])).get(invoiceId) ?? null;
260
+ res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
261
+ }));
262
+
208
263
  router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
209
264
  const organisationId = await requireOrganisation(db, req.body?.organisationId);
210
265
  const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
@@ -1,3 +1,4 @@
1
+ import { createCallGuard } from '../call-guard.js';
1
2
  import { createXeroClient, xeroError } from './client.js';
2
3
  import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
3
4
  import { createGovernor } from './governor.js';
@@ -6,9 +7,21 @@ import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from '
6
7
  import { resolveXeroSettings, writesAllowed } from './settings.js';
7
8
 
8
9
  // The Xero adapter's state for one Flow process: settings, the rate-limit
9
- // governor, the transport (real fetch, or replay), OAuth states, and the
10
- // access tokens in memory. Access tokens live 30 minutes and are never
11
- // stored; the refresh token is stored sealed (connections.js).
10
+ // governor, the call guard (its state is in PostgreSQL), the transport (real
11
+ // fetch, or replay), OAuth states, and the access tokens in memory. Access
12
+ // tokens live 30 minutes and are never stored; the refresh token is stored
13
+ // sealed (connections.js).
14
+
15
+ // The call guard's tables and policy (../call-guard.js, docs/XERO.md). A 401
16
+ // or 403 that a fresh token does not clear stops only the resource it came
17
+ // from: a connection can lack one scope (payments) and still serve the rest.
18
+ // A refused token refresh halts every call.
19
+ export const XERO_CALL_GUARD = Object.freeze({
20
+ integration: 'xero',
21
+ name: 'Xero',
22
+ tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
23
+ policy: Object.freeze({ authStops: 'resource' }),
24
+ });
12
25
 
13
26
  export function requireXeroSettings(env = process.env) {
14
27
  const settings = resolveXeroSettings(env);
@@ -31,6 +44,14 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
31
44
  const accessTokens = new Map(); // organisation id -> { token, expiresAt }
32
45
  const refreshing = new Map(); // organisation id -> promise
33
46
  const queues = new Map(); // organisation id -> tail of its work queue
47
+ const guards = new WeakMap(); // database handle -> call guard
48
+
49
+ // The call guard for one database. Keyed by the Xero tenant id: Xero's
50
+ // limits are per organisation.
51
+ function guardFor(db) {
52
+ if (!guards.has(db)) guards.set(db, createCallGuard({ db, ...XERO_CALL_GUARD, now, log }));
53
+ return guards.get(db);
54
+ }
34
55
 
35
56
  // Runs heavy Xero work for one organisation one at a time, in order: a
36
57
  // sync and a batch of drafts draw on the same call budget, and running them
@@ -67,11 +88,29 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
67
88
 
68
89
  // Refresh under a row lock, and store the rotated refresh token in the same
69
90
  // transaction, so two refreshes never race and the newest token is kept.
91
+ // A refresh Xero refuses for good halts every call until an owner connects
92
+ // again or saves the settings; another failed refresh counts as a failed
93
+ // call.
70
94
  async function refresh(db, organisationId) {
95
+ const guard = guardFor(db);
71
96
  return db.transaction(async (tx) => {
72
97
  const connection = usable(await readConnection(tx, organisationId, { lock: true }));
98
+ await guard.check(connection.tenantId, { resource: 'token' });
73
99
  const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
74
- const tokens = await refreshTokens(settings, transport, refreshToken);
100
+ let tokens;
101
+ try {
102
+ tokens = await refreshTokens(settings, transport, refreshToken);
103
+ } catch (error) {
104
+ // invalid_grant: the connection is gone. invalid_client: the app's
105
+ // client id or secret is wrong. Neither clears without a person.
106
+ if (error?.code === 'xero_reconnect_needed' || ['invalid_client', 'unauthorized_client'].includes(error?.oauthError)) {
107
+ await guard.halt(connection.tenantId, 'auth', { detail: `Xero refused the token refresh${error.oauthError ? ` (${error.oauthError})` : ''}` });
108
+ } else if (error?.code !== 'xero_replay_no_match') {
109
+ await guard.record(connection.tenantId, { resource: 'token', failureKind: error?.code === 'xero_unreachable' ? 'network' : 'token_refresh' });
110
+ }
111
+ throw error;
112
+ }
113
+ await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
75
114
  await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
76
115
  remember(organisationId, tokens);
77
116
  return tokens.access_token;
@@ -96,6 +135,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
96
135
  invalidateAccessToken: () => accessTokens.delete(organisationId),
97
136
  fetch: transport,
98
137
  governor,
138
+ guard: guardFor(db),
99
139
  apiBase: settings.endpoints.api,
100
140
  log,
101
141
  });
@@ -122,6 +162,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
122
162
  settings,
123
163
  transport,
124
164
  governor,
165
+ guardFor,
125
166
  states,
126
167
  remember,
127
168
  forget: (organisationId) => accessTokens.delete(organisationId),
@@ -167,6 +167,9 @@ export function resolveXeroSettings(env = process.env) {
167
167
  mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
168
168
  // The share of the day's calls the mirror leaves for drafts and people.
169
169
  mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
170
+ // The key Xero signs each webhook with (issue #45), from the Xero app's
171
+ // Webhooks page. Unset, Flow answers no webhook and reads by polling only.
172
+ webhookKey: readEnv(env, 'XERO_WEBHOOK_KEY') || null,
170
173
  endpoints: XERO_ENDPOINTS,
171
174
  };
172
175
  }