@arcforgelabs/flow 2026.10.32-beta.1 → 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 (34) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +16 -5
  4. package/server/core/automations.js +17 -3
  5. package/server/core/invoices.js +2 -2
  6. package/server/core/invoicing-rules.js +14 -1
  7. package/server/core/ledger.js +3 -2
  8. package/server/core/pipeline.js +1 -1
  9. package/server/core/references.js +99 -0
  10. package/server/core/sending.js +11 -5
  11. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  12. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  13. package/server/db/migrations/meta/_journal.json +7 -0
  14. package/server/db/schema.js +2 -0
  15. package/server/integrations/port.js +1 -0
  16. package/server/integrations/seazona/feed.js +8 -1
  17. package/server/integrations/seazona/index.js +9 -0
  18. package/server/integrations/seazona/lanes.js +1 -1
  19. package/server/integrations/seazona/patients.js +30 -0
  20. package/server/integrations/xero/client.js +12 -0
  21. package/server/integrations/xero/import.js +9 -3
  22. package/server/integrations/xero/index.js +3 -1
  23. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/mirror.js +16 -8
  26. package/server/integrations/xero/routes.js +20 -0
  27. package/server/integrations/xero/settings.js +3 -0
  28. package/server/integrations/xero/webhooks.js +311 -0
  29. package/server/ui/app.js +15 -7
  30. package/server/ui/flow.css +47 -7
  31. package/server/ui/invoice-notes.js +26 -0
  32. package/server/ui/invoicing-settings.js +3 -1
  33. package/server/ui/invoicing.js +136 -57
  34. package/server/ui/routes.js +1 -1
@@ -113,6 +113,13 @@
113
113
  "when": 1792452497948,
114
114
  "tag": "0015_alert_dismissals",
115
115
  "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1792456270558,
121
+ "tag": "0016_invoice_reference",
122
+ "breakpoints": true
116
123
  }
117
124
  ]
118
125
  }
@@ -624,6 +624,8 @@ export const invoicingSettings = pgTable('invoicing_settings', {
624
624
  // The price item code of a rush charge, and its share of the other lines.
625
625
  rushItemCode: text('rush_item_code'),
626
626
  rushPercent: integer('rush_percent').notNull().default(20),
627
+ // The invoice Reference, from tokens (server/core/references.js).
628
+ referenceTemplate: text('reference_template').notNull().default('{practitioner} - {patient}'),
627
629
  // The fixed checks above became invoice rules (invoice_rules). True once
628
630
  // this organisation's starter rules were made from them.
629
631
  rulesSeeded: boolean('rules_seeded').notNull().default(false),
@@ -20,6 +20,7 @@ export const CAPABILITIES = Object.freeze([
20
20
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
21
21
  'pullPayments', // payments since a cursor, matched to invoices and bills
22
22
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
23
+ 'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
23
24
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
24
25
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
25
26
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
@@ -11,9 +11,16 @@ import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from
11
11
  //
12
12
  // The cursor is opaque; pass back `cursor` from the last page. A change to
13
13
  // the order or to its invoice moves the order to the end of the feed.
14
+ //
15
+ // A read that found no change moves the order to the end of the feed too, at
16
+ // most once an hour (`SEEN_STEP`). Without it, an order that never changes
17
+ // is reported once, its ledger report gets older than a day, and the ledger
18
+ // refuses its draft as "out of date" for ever, though the lanes read the
19
+ // order every few minutes.
14
20
 
15
21
  const BILLABLE = /\b(complete|completed|shipped|delivered)\b/i;
16
22
  const SHIPPED = /\b(shipped|delivered)\b/i;
23
+ const SEEN_STEP = 'hour';
17
24
 
18
25
  function decodeCursor(cursor) {
19
26
  if (!cursor) return null;
@@ -31,7 +38,7 @@ function encodeCursor(at, id) {
31
38
 
32
39
  export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
33
40
  const after = decodeCursor(cursor);
34
- const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}))`;
41
+ const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
35
42
  const pageSize = Math.max(1, Math.min(1000, limit));
36
43
  const rows = await db.select({
37
44
  order: seazonaOrders,
@@ -5,6 +5,7 @@ import { historyJobIssues, historySummary, readHistoryWork } from './history/ind
5
5
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
6
6
  import { SEAZONA_CALL_GUARD } from './governor.js';
7
7
  import { createSeazonaRouter } from './routes.js';
8
+ import { orderPatient } from './patients.js';
8
9
  import { createSeazonaScheduler } from './scheduler.js';
9
10
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
10
11
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
@@ -44,6 +45,7 @@ export const SEAZONA_SETUP = {
44
45
  { name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
45
46
  { name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
46
47
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
48
+ { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
47
49
  ],
48
50
  };
49
51
 
@@ -149,6 +151,13 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
149
151
  return listWork(current().db, { now: now() });
150
152
  },
151
153
 
154
+ // The fields a job gives the invoice Reference: the patient name, when
155
+ // this installation keeps patient names (patients.js).
156
+ async jobReferenceFields({ sourceJobRef, read = false }) {
157
+ const { db, client, settings } = current();
158
+ return orderPatient({ db, client, settings, now }, sourceJobRef, { read });
159
+ },
160
+
152
161
  // History jobs a person must decide, as issues on their job cards,
153
162
  // each with a link to the reconciliation. Reads Flow's copy only.
154
163
  async jobIssues({ refs = [] } = {}) {
@@ -157,7 +157,7 @@ function judgeReads(what, attempted, failures, extra = {}) {
157
157
 
158
158
  // Reads one order's detail and applies issue #12. Returns
159
159
  // { outcome: 'read' | 'deleted' | 'failed', failure? }.
160
- async function readOrder(ctx, id, { listItem = null, known = null } = {}) {
160
+ export async function readOrder(ctx, id, { listItem = null, known = null } = {}) {
161
161
  const { db, client, sync, at } = ctx;
162
162
  const mapOptions = { keepPatientReference: sync.keepPatientReference };
163
163
  try {
@@ -0,0 +1,30 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { readOrder } from './lanes.js';
3
+ import { seazonaInvoices, seazonaOrders } from './schema.js';
4
+
5
+ // The patient name of one Seazona order, for the invoice Reference
6
+ // (server/core/references.js). Flow keeps it only when the installation keeps
7
+ // patient names (SEAZONA_KEEP_PATIENT_REFERENCE); off, this answers no name
8
+ // and never calls Seazona. An order read before the setting was on has no
9
+ // name yet: with `read`, Flow reads that order once more. An order that has
10
+ // no patient in Seazona is kept as '' so it is not read again.
11
+ export async function orderPatient({ db, client, settings, now }, orderId, { read = false } = {}) {
12
+ if (!settings.sync.keepPatientReference) return { patient: null };
13
+ const stored = async () => {
14
+ const [order] = await db.select({ patient: seazonaOrders.patientReference, invoiceNumber: seazonaOrders.invoiceNumber })
15
+ .from(seazonaOrders).where(eq(seazonaOrders.id, orderId));
16
+ if (order?.patient != null || !order?.invoiceNumber) return order ?? null;
17
+ const [invoice] = await db.select({ patient: seazonaInvoices.patientReference })
18
+ .from(seazonaInvoices).where(eq(seazonaInvoices.invoiceNumber, order.invoiceNumber));
19
+ return { ...order, patient: invoice?.patient || order.patient };
20
+ };
21
+ const known = await stored();
22
+ if (!known) return { patient: null };
23
+ if (known.patient != null) return { patient: known.patient || null };
24
+ if (!read) return { patient: null };
25
+ const result = await readOrder({ db, client, sync: settings.sync, at: new Date(Number(now())) }, orderId, { known: true });
26
+ if (result.outcome !== 'read') return { patient: null, asked: true };
27
+ const after = await stored();
28
+ if (after?.patient == null) await db.update(seazonaOrders).set({ patientReference: '' }).where(eq(seazonaOrders.id, orderId));
29
+ return { patient: after?.patient || null, asked: true };
30
+ }
@@ -263,6 +263,18 @@ export function createXeroClient({
263
263
  });
264
264
  return payload[path] ?? [];
265
265
  },
266
+ // Invoices by Xero id, with their lines (a `page` brings the lines).
267
+ // Up to 50 ids a call, for the webhook inbox (webhooks.js).
268
+ async listInvoicesByIds(ids) {
269
+ if (!ids.length) return [];
270
+ const payload = await request('GET', 'Invoices', { query: { IDs: ids.join(','), page: 1, pageSize: 100 } });
271
+ return payload.Invoices ?? [];
272
+ },
273
+ // One page of every invoice, in summary (no lines), for the daily check.
274
+ async listInvoiceSummaries({ page = 1, pageSize = 1000 } = {}) {
275
+ const payload = await request('GET', 'Invoices', { query: { summaryOnly: 'true', page, pageSize, order: 'InvoiceID ASC' } });
276
+ return payload.Invoices ?? [];
277
+ },
266
278
  // Xero emails one approved invoice to its contact, with the organisation's
267
279
  // own template, and marks it sent. Not idempotent: a repeat sends again.
268
280
  async emailInvoice(invoiceId) {
@@ -6,6 +6,7 @@ import { lastSync, syncCatalogue } from './catalogue.js';
6
6
  import { mirrorState } from './lookup.js';
7
7
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
8
8
  import { checkSentInvoices } from './reconcile.js';
9
+ import { runWebhookWork, WEBHOOK_REFRESH_MS, webhooksActive } from './webhooks.js';
9
10
 
10
11
  // The history import, then the reads of changes. When an owner connects Xero,
11
12
  // Flow copies the organisation's invoices, credit notes and payments into the
@@ -13,8 +14,9 @@ import { checkSentInvoices } from './reconcile.js';
13
14
  // job reads in slices; a slice that stops at the day's budget or its page
14
15
  // limit queues the next slice. When every resource is read to its end, the
15
16
  // 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.
17
+ // since its cursor (issue #131), so the copy stays fresh (lookup.js). While
18
+ // Xero's webhooks arrive (webhooks.js), it waits WEBHOOK_REFRESH_MS instead.
19
+ // It only reads from Xero.
18
20
 
19
21
  export const IMPORT_KIND = 'xero.import';
20
22
  const IMPORT_KEY = 'history';
@@ -98,7 +100,9 @@ export async function runImportOnce(db, runtime, { workerId = `xero-import-${ran
98
100
  } else if (ran?.outcome === 'succeeded' && !ran.job.result?.stoppedBy) {
99
101
  // Read to the end: read the changes again later. A stop only a person
100
102
  // clears queues nothing; connecting or saving the settings queues it.
101
- await queueChangesRead(db, ran.job.organisationId, { runAfter: new Date(Number(now()) + REFRESH_MS) });
103
+ // While Xero's webhooks arrive, the poll is only the safety net (issue #45).
104
+ const every = await webhooksActive(db, runtime, ran.job.organisationId, { now: Number(now()) }) ? WEBHOOK_REFRESH_MS : REFRESH_MS;
105
+ await queueChangesRead(db, ran.job.organisationId, { runAfter: new Date(Number(now()) + every) });
102
106
  }
103
107
  return ran;
104
108
  }
@@ -192,6 +196,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
192
196
  const workerId = `xero-import-${randomUUID()}`;
193
197
  const startedAtMs = Date.now();
194
198
  const stale = new Set();
199
+ const holds = new Map();
195
200
  let timer = null;
196
201
  let running = null;
197
202
  let stopped = true;
@@ -211,6 +216,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
211
216
  await refreshStaleCatalogues(db, runtime, { log });
212
217
  await keepReadsQueued(db, runtime, { startedAtMs });
213
218
  await reportStaleCopies(db, runtime, { stale, log });
219
+ await runWebhookWork(db, runtime, { log, holds });
214
220
  const ran = await runImportOnce(db, runtime, { workerId });
215
221
  if (ran) {
216
222
  busy = true;
@@ -14,7 +14,7 @@ import { writesAllowed } from './settings.js';
14
14
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
15
15
  // is on (docs/INTEGRATIONS.md).
16
16
 
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
+ 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', 'xero_webhook_events', 'xero_daily_checks']);
18
18
 
19
19
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
20
20
  // The refresh token's key comes from Flow's secrets key.
@@ -29,6 +29,7 @@ export const XERO_SETUP = {
29
29
  'Redirect URI: copy it from below, exactly.',
30
30
  'Open the app\'s Configuration. Copy the client id. Generate a secret and copy it: Xero shows it once.',
31
31
  'Paste both below, choose the mode, name your Xero organisation, and turn Xero on.',
32
+ 'Optional, for changes in seconds: open the app\'s Webhooks page. Delivery URL: this installation\'s address plus /api/xero/webhooks. Tick Contacts and Invoices, and Credit notes when Xero offers it. Copy the webhook key into "Webhook key" below and save, then press Send "Intent to receive" in Xero.',
32
33
  ],
33
34
  },
34
35
  fields: [
@@ -38,6 +39,7 @@ export const XERO_SETUP = {
38
39
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
39
40
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
40
41
  { name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
42
+ { name: 'XERO_WEBHOOK_KEY', label: 'Webhook key', secret: true, help: 'From the Xero app\'s Webhooks page. Empty: Flow reads Xero\'s changes every 15 minutes instead.' },
41
43
  ],
42
44
  derivedKeys: ['XERO_TOKEN_KEY'],
43
45
  };
@@ -0,0 +1,30 @@
1
+ -- Xero's webhooks and the daily check (issue #45). Each event Xero pushes, and
2
+ -- each record the daily check finds out of step, waits here until Flow reads
3
+ -- that record by id into its copy. One row per record: a second event for the
4
+ -- same record before it is read only marks it pending again.
5
+ CREATE TABLE IF NOT EXISTS "xero_webhook_events" (
6
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
7
+ "resource" text NOT NULL,
8
+ "resource_id" text NOT NULL,
9
+ "event_type" text,
10
+ "source" text NOT NULL,
11
+ "event_at" timestamp with time zone,
12
+ "received_at" timestamp with time zone DEFAULT now() NOT NULL,
13
+ "pending" boolean DEFAULT true NOT NULL,
14
+ "read_at" timestamp with time zone,
15
+ CONSTRAINT "xero_webhook_events_pk" PRIMARY KEY("organisation_id", "resource", "resource_id")
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE INDEX IF NOT EXISTS "xero_webhook_events_pending" ON "xero_webhook_events" ("organisation_id", "resource") WHERE "pending";
19
+ --> statement-breakpoint
20
+ CREATE INDEX IF NOT EXISTS "xero_webhook_events_received" ON "xero_webhook_events" ("organisation_id", "source", "received_at");
21
+ --> statement-breakpoint
22
+ -- One full summary read of the invoices a day for each organisation.
23
+ CREATE TABLE IF NOT EXISTS "xero_daily_checks" (
24
+ "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
25
+ "last_started_at" timestamp with time zone,
26
+ "last_finished_at" timestamp with time zone,
27
+ "last_read" integer,
28
+ "last_flagged" integer,
29
+ "last_error" text
30
+ );
@@ -50,6 +50,13 @@
50
50
  "when": 1792490000000,
51
51
  "tag": "0006_xero_call_guard",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1792500000000,
58
+ "tag": "0007_xero_webhooks",
59
+ "breakpoints": true
53
60
  }
54
61
  ]
55
62
  }
@@ -182,6 +182,20 @@ const UPSERT = {
182
182
  const MAPPERS = { invoices: mapInvoice, credit_notes: mapCreditNote, payments: mapPayment };
183
183
  const ID_OF = { invoices: 'xeroInvoiceId', credit_notes: 'xeroCreditNoteId', payments: 'xeroPaymentId' };
184
184
 
185
+ // Xero records as the copy's rows.
186
+ export const mapRecords = (resource, raw, settings) => raw.map((record) => MAPPERS[resource](record, settings)).filter((r) => r[ID_OF[resource]]);
187
+
188
+ // Writes each mapped row to the copy. `tx` is a transaction or the database.
189
+ // Adds to `counts` (inserted, changed, unchanged).
190
+ export async function storeRows(tx, organisationId, resource, rows, counts) {
191
+ for (const row of rows) {
192
+ const { rows: written } = await tx.execute(UPSERT[resource](organisationId, row, hashOf(row)));
193
+ if (!written.length) counts.unchanged += 1;
194
+ else if (written[0].inserted) counts.inserted += 1;
195
+ else counts.changed += 1;
196
+ }
197
+ }
198
+
185
199
  async function readCursor(db, organisationId, resource) {
186
200
  await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource) values (${organisationId}, ${resource})
187
201
  on conflict do nothing`);
@@ -213,7 +227,6 @@ export function nextPosition({ modifiedSince, page }, rows, pageSize) {
213
227
  export async function mirrorResource(db, runtime, organisationId, resource, { maxPages = 50 } = {}) {
214
228
  if (!MIRROR_RESOURCES.includes(resource)) throw new TypeError(`unknown Xero mirror resource: ${resource}`);
215
229
  const { settings } = runtime;
216
- const map = MAPPERS[resource];
217
230
  const pageSize = settings.mirrorPageSize;
218
231
  const counts = { pages: 0, read: 0, inserted: 0, changed: 0, unchanged: 0 };
219
232
  return withXeroLane('sync', async () => {
@@ -233,15 +246,10 @@ export async function mirrorResource(db, runtime, organisationId, resource, { ma
233
246
  where organisation_id = ${organisationId} and resource = ${resource}`);
234
247
  throw error;
235
248
  }
236
- const rows = raw.map((record) => map(record, settings)).filter((r) => r[ID_OF[resource]]);
249
+ const rows = mapRecords(resource, raw, settings);
237
250
  const next = nextPosition(position, rows, pageSize);
238
251
  await db.transaction(async (tx) => {
239
- for (const row of rows) {
240
- const { rows: written } = await tx.execute(UPSERT[resource](organisationId, row, hashOf(row)));
241
- if (!written.length) counts.unchanged += 1;
242
- else if (written[0].inserted) counts.inserted += 1;
243
- else counts.changed += 1;
244
- }
252
+ await storeRows(tx, organisationId, resource, rows, counts);
245
253
  await tx.execute(sql`update xero_mirror_cursors set
246
254
  modified_since = ${next.modifiedSince}, page = ${next.page}, rows_read = rows_read + ${raw.length},
247
255
  last_read_at = now(), last_error = null,
@@ -12,6 +12,7 @@ import { accountingStatus, checkSentInvoices } from './reconcile.js';
12
12
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
13
13
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
14
14
  import { writesAllowed } from './settings.js';
15
+ import { saveEvents, signatureValid } from './webhooks.js';
15
16
  import { DEFAULT_FAMILY } from '../../theme/themes.js';
16
17
  import { messagePage as themedMessagePage } from '../../ui/page.js';
17
18
 
@@ -38,6 +39,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
38
39
  // POST /mirror owner or admin: copies Xero invoices, credit
39
40
  // notes and payments changed since the last copy
40
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
41
44
  //
42
45
  // Only an owner, with a verified owner sign-in, can connect or disconnect.
43
46
  // The callback carries no Flow session: the one-use state from /connect,
@@ -93,6 +96,23 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
93
96
  const page = (title, text, tone) => messagePage({ title, text, tone });
94
97
  const { settings } = runtime;
95
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
+
96
116
  router.use(express.json({ limit: '256kb' }));
97
117
 
98
118
  // Sign-in off: nothing but the callback answers, and it fails for want of
@@ -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
  }