@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.33

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 (59) hide show
  1. package/README.md +3 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +69 -26
  5. package/server/app.js +4 -0
  6. package/server/core/automations.js +17 -3
  7. package/server/core/invoices.js +2 -2
  8. package/server/core/invoicing-rules.js +64 -5
  9. package/server/core/job-board.js +15 -4
  10. package/server/core/ledger.js +245 -8
  11. package/server/core/pipeline.js +30 -22
  12. package/server/core/read-before-act.js +96 -0
  13. package/server/core/reconcile.js +350 -0
  14. package/server/core/references.js +99 -0
  15. package/server/core/sending.js +52 -8
  16. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/_journal.json +14 -0
  21. package/server/db/schema.js +5 -0
  22. package/server/index.js +5 -0
  23. package/server/integrations/port.js +4 -0
  24. package/server/integrations/seazona/act.js +48 -0
  25. package/server/integrations/seazona/feed.js +77 -50
  26. package/server/integrations/seazona/governor.js +28 -2
  27. package/server/integrations/seazona/index.js +69 -3
  28. package/server/integrations/seazona/lanes.js +82 -9
  29. package/server/integrations/seazona/ledger.js +14 -9
  30. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  31. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  32. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  33. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  34. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  35. package/server/integrations/seazona/patients.js +30 -0
  36. package/server/integrations/seazona/reconcile.js +40 -0
  37. package/server/integrations/seazona/scheduler.js +32 -4
  38. package/server/integrations/seazona/schema.js +21 -0
  39. package/server/integrations/seazona/settings.js +12 -0
  40. package/server/integrations/seazona/store.js +24 -2
  41. package/server/integrations/seazona/sweep.js +574 -0
  42. package/server/integrations/seazona/tables.js +1 -0
  43. package/server/integrations/xero/client.js +12 -0
  44. package/server/integrations/xero/import.js +9 -3
  45. package/server/integrations/xero/index.js +7 -1
  46. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  47. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  48. package/server/integrations/xero/mirror.js +16 -8
  49. package/server/integrations/xero/routes.js +20 -0
  50. package/server/integrations/xero/settings.js +3 -0
  51. package/server/integrations/xero/webhooks.js +346 -0
  52. package/server/ui/app.js +15 -7
  53. package/server/ui/flow.css +56 -7
  54. package/server/ui/invoice-notes.js +26 -0
  55. package/server/ui/invoicing-settings.js +3 -1
  56. package/server/ui/invoicing.js +231 -68
  57. package/server/ui/job-board.js +2 -1
  58. package/server/ui/jobs.js +19 -1
  59. package/server/ui/routes.js +1 -1
@@ -9,12 +9,13 @@ import { findInvoices, listCustomers, listPriceItems, mirrorState } from './look
9
9
  import { createXeroRouter } from './routes.js';
10
10
  import { createXeroRuntime, requireXeroSettings, XERO_CALL_GUARD } from './runtime.js';
11
11
  import { writesAllowed } from './settings.js';
12
+ import { readChangesFirst } from './webhooks.js';
12
13
 
13
14
  // The Xero adapter (docs/XERO.md). FLOW_INTEGRATIONS=xero turns it on and
14
15
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
15
16
  // is on (docs/INTEGRATIONS.md).
16
17
 
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']);
18
+ 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
19
 
19
20
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
20
21
  // The refresh token's key comes from Flow's secrets key.
@@ -29,6 +30,7 @@ export const XERO_SETUP = {
29
30
  'Redirect URI: copy it from below, exactly.',
30
31
  'Open the app\'s Configuration. Copy the client id. Generate a secret and copy it: Xero shows it once.',
31
32
  'Paste both below, choose the mode, name your Xero organisation, and turn Xero on.',
33
+ '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
34
  ],
33
35
  },
34
36
  fields: [
@@ -38,6 +40,7 @@ export const XERO_SETUP = {
38
40
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
39
41
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
40
42
  { name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
43
+ { 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
44
  ],
42
45
  derivedKeys: ['XERO_TOKEN_KEY'],
43
46
  };
@@ -129,6 +132,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
129
132
  // Xero's invoice for one billable job, looked up live (ledger.js): the
130
133
  // `liveCheck` for draftInvoiceForObligation.
131
134
  checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
135
+ // Xero's latest changes into the copy just before Flow drafts by rule or
136
+ // sends (webhooks.js); no call when the copy is current.
137
+ readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
132
138
  // Active Xero contacts, from Flow's copy (lookup.js).
133
139
  listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
134
140
  // Sales invoices from the mirror, with the mirror's state (lookup.js).
@@ -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
  }
@@ -0,0 +1,346 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { isCallsStopped } from '../call-guard.js';
4
+ import { lastSync, parseXeroDate, syncCatalogue } from './catalogue.js';
5
+ import { withXeroLane } from './governor.js';
6
+ import { mirrorState } from './lookup.js';
7
+ import { mapRecords, mirrorXero, storeRows, toCents } from './mirror.js';
8
+ import { checkSentInvoices } from './reconcile.js';
9
+
10
+ // Xero's webhooks, and the daily check (issue #45). The poll (import.js) reads
11
+ // Xero's changes every 15 minutes. Webhooks make a change reach Flow in
12
+ // seconds, and the daily check finds what both of them missed.
13
+ //
14
+ // 1. Xero posts an event to /api/xero/webhooks: a record id and what
15
+ // happened, never the record. Flow checks the signature, saves the id in
16
+ // the inbox (xero_webhook_events) and answers 200 at once.
17
+ // 2. The worker reads the waiting invoices by id, up to 50 a call, into the
18
+ // copy (mirror.js), then compares the sent invoices again (reconcile.js).
19
+ // Credit notes are read as changes since the cursor; contacts refresh the
20
+ // contacts and items copy.
21
+ // 3. While webhooks arrive, the poll waits an hour instead of 15 minutes. If
22
+ // they stop, it goes back to 15 minutes by itself.
23
+ // 4. Once a day, one summary read of every invoice (about 1,000 a call)
24
+ // finds each copy row whose status, amounts or change time differ from
25
+ // Xero, and each row Xero no longer lists. Those go into the inbox and are
26
+ // read by id like an event. This catches part payments that do not move
27
+ // Xero's change time, and a webhook that never came.
28
+ //
29
+ // Every read goes through the call guard and the governor's sync lane, and
30
+ // leaves the mirror's reserve of the day's calls for drafts and people. Flow
31
+ // never writes to Xero here.
32
+
33
+ export const BY_ID_BATCH = 50;
34
+ // The poll's wait while webhooks arrive, and how recent the last one must be.
35
+ export const WEBHOOK_REFRESH_MS = 60 * 60 * 1000;
36
+ export const WEBHOOK_ACTIVE_MS = 24 * 60 * 60 * 1000;
37
+ export const DAILY_CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
38
+ // A daily check that failed is tried again after this wait.
39
+ const DAILY_CHECK_RETRY_MS = 60 * 60 * 1000;
40
+ const SUMMARY_PAGE = 1000;
41
+ // At most this many records a day go back for a read by id (5 calls), so a
42
+ // check that finds the whole copy different cannot spend the day. The rest
43
+ // wait for the next day's check.
44
+ export const DAILY_FLAG_MAX = 250;
45
+ const MAX_SUMMARY_PAGES = 50;
46
+ // Contacts and items are read again at most this often for contact events.
47
+ const CONTACTS_EVERY_MS = 60 * 60 * 1000;
48
+
49
+ const CATEGORIES = { INVOICE: 'invoices', CREDITNOTE: 'credit_notes', CREDIT_NOTE: 'credit_notes', CONTACT: 'contacts' };
50
+
51
+ // Xero signs the raw body with HMAC-SHA256 and the webhook key, in base64
52
+ // (x-xero-signature). Compared in constant time.
53
+ export function signatureValid(key, rawBody, signature) {
54
+ if (!key || typeof signature !== 'string' || !signature) return false;
55
+ const expected = createHmac('sha256', key).update(rawBody).digest();
56
+ let given;
57
+ try {
58
+ given = Buffer.from(signature, 'base64');
59
+ } catch {
60
+ return false;
61
+ }
62
+ return given.length === expected.length && timingSafeEqual(given, expected);
63
+ }
64
+
65
+ // Saves the events of one verified delivery. An event for an organisation not
66
+ // connected in this mode is dropped. Answers how many were saved.
67
+ export async function saveEvents(db, runtime, payload, { now = new Date() } = {}) {
68
+ const events = Array.isArray(payload?.events) ? payload.events : [];
69
+ if (!events.length) return 0;
70
+ const { rows } = await db.execute(sql`select organisation_id, tenant_id from xero_connections where mode = ${runtime.settings.mode}`);
71
+ const byTenant = new Map(rows.map((r) => [r.tenant_id, r.organisation_id]));
72
+ let saved = 0;
73
+ for (const event of events) {
74
+ const organisationId = byTenant.get(event?.tenantId);
75
+ if (!organisationId || typeof event.resourceId !== 'string' || !event.resourceId) continue;
76
+ const resource = CATEGORIES[String(event.eventCategory ?? '').toUpperCase()] ?? 'other';
77
+ const at = Date.parse(`${String(event.eventDateUtc ?? '').replace(/Z?$/, 'Z')}`);
78
+ await flag(db, organisationId, resource, event.resourceId, {
79
+ source: 'webhook', eventType: event.eventType ?? null, eventAt: Number.isNaN(at) ? null : new Date(at), now,
80
+ });
81
+ saved += 1;
82
+ }
83
+ return saved;
84
+ }
85
+
86
+ // Marks one record as waiting to be read by id.
87
+ async function flag(db, organisationId, resource, resourceId, { source, eventType = null, eventAt = null, now = new Date() }) {
88
+ await db.execute(sql`
89
+ insert into xero_webhook_events (organisation_id, resource, resource_id, event_type, source, event_at, received_at)
90
+ values (${organisationId}, ${resource}, ${resourceId}, ${eventType}, ${source}, ${eventAt}, ${now})
91
+ on conflict (organisation_id, resource, resource_id) do update set
92
+ event_type = excluded.event_type, source = excluded.source, event_at = excluded.event_at,
93
+ received_at = excluded.received_at, pending = true`);
94
+ }
95
+
96
+ async function markRead(db, organisationId, resource, ids = null) {
97
+ const only = ids ? sql`and resource_id = any(${sql.param(ids)}::text[])` : sql``;
98
+ await db.execute(sql`update xero_webhook_events set pending = false, read_at = now()
99
+ where organisation_id = ${organisationId} and resource = ${resource} and pending ${only}`);
100
+ }
101
+
102
+ // Whether a webhook for this organisation arrived recently: the poll can wait.
103
+ export async function webhooksActive(db, runtime, organisationId, { now = Date.now() } = {}) {
104
+ if (!runtime.settings.webhookKey) return false;
105
+ const { rows: [row] } = await db.execute(sql`select max(received_at) as at from xero_webhook_events
106
+ where organisation_id = ${organisationId} and source = 'webhook'`);
107
+ return Boolean(row?.at && Number(now) - new Date(row.at).getTime() <= WEBHOOK_ACTIVE_MS);
108
+ }
109
+
110
+ // Calls left above the reserve the mirror keeps for drafts and people.
111
+ function overReserve(runtime, tenantId) {
112
+ const budget = runtime.governor.status(tenantId);
113
+ return budget.dayRemaining > Math.ceil(budget.dayLimit * runtime.settings.mirrorReserveFraction);
114
+ }
115
+
116
+ async function pendingIds(db, organisationId) {
117
+ const { rows } = await db.execute(sql`select resource, resource_id from xero_webhook_events
118
+ where organisation_id = ${organisationId} and pending order by received_at`);
119
+ const out = new Map();
120
+ for (const r of rows) out.set(r.resource, [...(out.get(r.resource) ?? []), r.resource_id]);
121
+ return out;
122
+ }
123
+
124
+ // Reads every waiting record of one organisation. Answers counts, or a
125
+ // `stopped` reason: 'calls_stopped' (the call guard) or 'budget'.
126
+ export async function readInbox(db, runtime, organisationId, { now = Date.now } = {}) {
127
+ const waiting = await pendingIds(db, organisationId);
128
+ const result = { invoices: { read: 0, inserted: 0, changed: 0, unchanged: 0 }, creditNotes: 0, contacts: false, sent: null };
129
+ if (!waiting.size) return result;
130
+ const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
131
+ where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
132
+ if (!connection) return result;
133
+ try {
134
+ await runtime.guardFor(db).check(connection.tenant_id);
135
+ } catch (error) {
136
+ if (isCallsStopped(error)) return { ...result, stopped: 'calls_stopped' };
137
+ throw error;
138
+ }
139
+
140
+ const invoiceIds = waiting.get('invoices') ?? [];
141
+ if (invoiceIds.length) {
142
+ const counts = result.invoices;
143
+ const stopped = await runtime.serial(organisationId, () => withXeroLane('sync', async () => {
144
+ const { client } = await runtime.clientFor(db, organisationId);
145
+ for (let i = 0; i < invoiceIds.length; i += BY_ID_BATCH) {
146
+ if (!overReserve(runtime, client.tenantId)) return 'budget';
147
+ const batch = invoiceIds.slice(i, i + BY_ID_BATCH);
148
+ const rows = mapRecords('invoices', await client.listInvoicesByIds(batch), runtime.settings);
149
+ await db.transaction(async (tx) => {
150
+ await storeRows(tx, organisationId, 'invoices', rows, counts);
151
+ // An id Xero did not return is read too: there is nothing more to get.
152
+ await markRead(tx, organisationId, 'invoices', batch);
153
+ });
154
+ counts.read += rows.length;
155
+ }
156
+ return null;
157
+ }));
158
+ if (stopped) return { ...result, stopped };
159
+ if (counts.inserted || counts.changed) result.sent = await checkSentInvoices(db, runtime, organisationId);
160
+ }
161
+
162
+ if (waiting.get('credit_notes')?.length) {
163
+ // Credit notes are few: one read of the changes since the cursor gets them.
164
+ const [read] = await mirrorXero(db, runtime, organisationId, { resources: ['credit_notes'] });
165
+ if (read?.stopped === 'budget') return { ...result, stopped: 'budget' };
166
+ result.creditNotes = read?.read ?? 0;
167
+ await markRead(db, organisationId, 'credit_notes');
168
+ }
169
+
170
+ if (waiting.get('contacts')?.length) {
171
+ const last = await lastSync(db, organisationId);
172
+ const at = last?.finishedAt ?? last?.startedAt;
173
+ if (!at || Number(now()) - new Date(at).getTime() >= CONTACTS_EVERY_MS) {
174
+ await syncCatalogue(db, runtime, organisationId);
175
+ await markRead(db, organisationId, 'contacts');
176
+ result.contacts = true;
177
+ }
178
+ }
179
+
180
+ // Events Flow does not copy (other categories) need no read.
181
+ if (waiting.get('other')?.length) await markRead(db, organisationId, 'other');
182
+ return result;
183
+ }
184
+
185
+ // The daily check for one organisation: one summary read of every invoice,
186
+ // compared with the copy. Each row that differs, and each live copy row Xero
187
+ // no longer lists, waits in the inbox to be read by id. Answers counts, or
188
+ // `stopped`.
189
+ export async function dailyCheck(db, runtime, organisationId, { now = () => new Date() } = {}) {
190
+ await db.execute(sql`insert into xero_daily_checks (organisation_id, last_started_at) values (${organisationId}, ${now()})
191
+ on conflict (organisation_id) do update set last_started_at = excluded.last_started_at`);
192
+ try {
193
+ const seen = [];
194
+ const stopped = await runtime.serial(organisationId, () => withXeroLane('sync', async () => {
195
+ const { client } = await runtime.clientFor(db, organisationId);
196
+ for (let page = 1; page <= MAX_SUMMARY_PAGES; page += 1) {
197
+ if (!overReserve(runtime, client.tenantId)) return 'budget';
198
+ const list = await client.listInvoiceSummaries({ page, pageSize: SUMMARY_PAGE });
199
+ seen.push(...list.filter((i) => i.InvoiceID));
200
+ if (list.length < SUMMARY_PAGE) return null;
201
+ }
202
+ return 'max_pages';
203
+ }));
204
+ if (stopped) {
205
+ await db.execute(sql`update xero_daily_checks set last_error = ${stopped} where organisation_id = ${organisationId}`);
206
+ return { read: seen.length, flagged: 0, stopped };
207
+ }
208
+ const { rows: copy } = await db.execute(sql`select xero_invoice_id, status, total_cents, amount_due_cents,
209
+ amount_paid_cents, amount_credited_cents, xero_updated_at from xero_invoices where organisation_id = ${organisationId}`);
210
+ const byId = new Map(copy.map((r) => [r.xero_invoice_id, r]));
211
+ const flagged = new Set();
212
+ for (const x of seen) {
213
+ const row = byId.get(x.InvoiceID);
214
+ byId.delete(x.InvoiceID);
215
+ if (!row || summaryDiffers(row, x)) flagged.add(x.InvoiceID);
216
+ }
217
+ // A live copy row Xero no longer lists: read it by id to learn its state.
218
+ for (const row of byId.values()) if (!['DELETED', 'VOIDED'].includes(row.status)) flagged.add(row.xero_invoice_id);
219
+ const at = now();
220
+ const list = [...flagged].slice(0, DAILY_FLAG_MAX);
221
+ for (const id of list) await flag(db, organisationId, 'invoices', id, { source: 'daily_check', now: at });
222
+ await db.execute(sql`update xero_daily_checks set last_finished_at = ${at}, last_read = ${seen.length},
223
+ last_flagged = ${flagged.size}, last_error = null where organisation_id = ${organisationId}`);
224
+ return { read: seen.length, flagged: flagged.size, queued: list.length, stopped: null };
225
+ } catch (error) {
226
+ await db.execute(sql`update xero_daily_checks set last_error = ${`${error.code ?? 'error'}: ${String(error.message).slice(0, 300)}`}
227
+ where organisation_id = ${organisationId}`);
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ const cents = (value) => (value === null || value === undefined ? null : Number(value));
233
+
234
+ // Whether a summary from Xero differs from the copy row in what the summary
235
+ // carries: status, money, and the change time.
236
+ export function summaryDiffers(row, summary) {
237
+ if (row.status !== summary.Status) return true;
238
+ for (const [column, field] of [['total_cents', 'Total'], ['amount_due_cents', 'AmountDue'], ['amount_paid_cents', 'AmountPaid'], ['amount_credited_cents', 'AmountCredited']]) {
239
+ if (summary[field] !== undefined && cents(row[column]) !== toCents(summary[field])) return true;
240
+ }
241
+ // Xero changed it after the copy's version (whole seconds, as Xero compares).
242
+ const changed = parseXeroDate(summary.UpdatedDateUTC);
243
+ const copied = row.xero_updated_at ? new Date(row.xero_updated_at) : null;
244
+ return Boolean(changed && (!copied || Math.floor(changed / 1000) > Math.floor(copied / 1000)));
245
+ }
246
+
247
+ // The organisations whose daily check is due: a complete copy, and no check
248
+ // finished in the last day (or none tried in the last hour after a failure).
249
+ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
250
+ const { rows } = await db.execute(sql`select c.organisation_id, c.tenant_id, d.last_started_at, d.last_finished_at
251
+ from xero_connections c left join xero_daily_checks d on d.organisation_id = c.organisation_id
252
+ where c.mode = ${runtime.settings.mode}`);
253
+ const due = [];
254
+ for (const r of rows) {
255
+ const finished = r.last_finished_at ? new Date(r.last_finished_at).getTime() : null;
256
+ const started = r.last_started_at ? new Date(r.last_started_at).getTime() : null;
257
+ if (finished && Number(now) - finished < DAILY_CHECK_EVERY_MS) continue;
258
+ if (started && (!finished || started > finished) && Number(now) - started < DAILY_CHECK_RETRY_MS) continue;
259
+ if (!(await mirrorState(db, r.organisation_id, { now: new Date(Number(now)) })).complete) continue;
260
+ due.push({ organisationId: r.organisation_id, tenantId: r.tenant_id });
261
+ }
262
+ return due;
263
+ }
264
+
265
+ // Read before act (issue #45, step 5): just before Flow drafts by rule or
266
+ // sends a batch, Xero's latest changes reach the copy. The copy counts as
267
+ // current when webhooks arrive and none waits, or when it read Xero's changes
268
+ // in the last READ_FIRST_MS. Otherwise this reads the waiting records and the
269
+ // invoice and credit note changes now: about two calls. Answers { state }:
270
+ // fresh no call was needed
271
+ // read the copy read Xero now; `changed` counts invoices that changed
272
+ // unavailable the read could not run (`reason`); act on nothing
273
+ export const READ_FIRST_MS = 10 * 60 * 1000;
274
+
275
+ export async function readChangesFirst(db, runtime, organisationId, { now = Date.now } = {}) {
276
+ const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
277
+ where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
278
+ if (!connection) return { state: 'unavailable', reason: 'Xero is not connected' };
279
+ const { rows: [{ waiting }] } = await db.execute(sql`select count(*)::int as waiting from xero_webhook_events
280
+ where organisation_id = ${organisationId} and pending`);
281
+ if (!waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
282
+ const { rows: [{ oldest }] } = await db.execute(sql`select min(last_read_at) as oldest from xero_mirror_cursors
283
+ where organisation_id = ${organisationId} and resource in ('invoices', 'credit_notes')`);
284
+ if (!waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
285
+ try {
286
+ const inbox = await readInbox(db, runtime, organisationId, { now });
287
+ if (inbox.stopped) return { state: 'unavailable', reason: inbox.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero calls are stopped' };
288
+ const reads = await mirrorXero(db, runtime, organisationId, { resources: ['invoices', 'credit_notes'], maxPages: 5 });
289
+ const short = reads.find((r) => !['done', 'refused'].includes(r.stopped));
290
+ if (short) return { state: 'unavailable', reason: short.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero has more changes than one read takes' };
291
+ const invoices = reads.find((r) => r.resource === 'invoices');
292
+ const changed = inbox.invoices.inserted + inbox.invoices.changed + (invoices?.inserted ?? 0) + (invoices?.changed ?? 0);
293
+ if (changed && !inbox.sent) await checkSentInvoices(db, runtime, organisationId);
294
+ return { state: 'read', changed };
295
+ } catch (error) {
296
+ return { state: 'unavailable', reason: isCallsStopped(error) ? 'Xero calls are stopped' : `reading Xero failed (${error?.code ?? 'error'})` };
297
+ }
298
+ }
299
+
300
+ // After a failed read, an organisation waits 1 minute, then 2, 4 and up to an
301
+ // hour, so a failing read is never tried every tick.
302
+ export const RETRY_FIRST_MS = 60 * 1000;
303
+ export const RETRY_MAX_MS = 60 * 60 * 1000;
304
+
305
+ // One worker step: reads each organisation's inbox, then runs any daily check
306
+ // that is due. A stop by the call guard skips that organisation quietly; the
307
+ // guard has reported it. A failure is logged once as an error; the same
308
+ // failure again is a warning. `holds` keeps each organisation's wait between
309
+ // steps.
310
+ export async function runWebhookWork(db, runtime, { log = console, now = Date.now, holds = new Map() } = {}) {
311
+ const failed = (organisationId, what, error) => {
312
+ const held = holds.get(organisationId);
313
+ const failures = (held?.failures ?? 0) + 1;
314
+ holds.set(organisationId, { failures, until: Number(now()) + Math.min(RETRY_MAX_MS, RETRY_FIRST_MS * 2 ** (failures - 1)), code: error?.code ?? 'error' });
315
+ const line = `[xero] ${what} for organisation ${organisationId} failed: ${error?.code ?? 'error'}`;
316
+ if (held?.code === (error?.code ?? 'error')) log.warn?.(line);
317
+ else log.error?.(line);
318
+ };
319
+ const waiting = (organisationId) => (holds.get(organisationId)?.until ?? 0) > Number(now());
320
+
321
+ const { rows } = await db.execute(sql`select distinct e.organisation_id from xero_webhook_events e
322
+ join xero_connections c on c.organisation_id = e.organisation_id and c.mode = ${runtime.settings.mode}
323
+ where e.pending`);
324
+ for (const { organisation_id: organisationId } of rows) {
325
+ if (waiting(organisationId)) continue;
326
+ try {
327
+ const read = await readInbox(db, runtime, organisationId, { now });
328
+ holds.delete(organisationId);
329
+ if (read.invoices.read || read.creditNotes || read.contacts) {
330
+ log.info?.(`[xero] read waiting changes for organisation ${organisationId}: ${read.invoices.read} invoices, ${read.creditNotes} credit notes${read.contacts ? ', contacts' : ''}`);
331
+ }
332
+ } catch (error) {
333
+ if (!isCallsStopped(error)) failed(organisationId, 'reading waiting changes', error);
334
+ }
335
+ }
336
+ for (const { organisationId, tenantId } of await dueDailyChecks(db, runtime, { now: now() })) {
337
+ try {
338
+ await runtime.guardFor(db).check(tenantId);
339
+ const check = await dailyCheck(db, runtime, organisationId, { now: () => new Date(Number(now())) });
340
+ log.info?.(`[xero] daily check for organisation ${organisationId}: ${check.read} invoices, ${check.flagged} to read again${check.stopped ? `; stopped (${check.stopped})` : ''}`);
341
+ } catch (error) {
342
+ // dueDailyChecks waits an hour after a failed check.
343
+ if (!isCallsStopped(error)) log.error?.(`[xero] daily check for organisation ${organisationId} failed: ${error?.code ?? 'error'}`);
344
+ }
345
+ }
346
+ }
package/server/ui/app.js CHANGED
@@ -216,7 +216,7 @@ const ICON_PATHS = {
216
216
  person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
217
217
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
218
218
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
219
- menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
219
+ sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
220
220
  palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
221
221
  };
222
222
 
@@ -226,10 +226,12 @@ function icon(name) {
226
226
  return node;
227
227
  }
228
228
 
229
- // The menu button at the top of the sidebar. On a wide screen it folds the
230
- // sidebar to its icons and back, and the choice stays in this browser. On a
231
- // narrow screen the sidebar is a bar across the top, and the button opens
232
- // the menu below it; choosing a page, Escape or a click outside closes it.
229
+ // The sidebar button at the top of the sidebar (a panel icon, not a
230
+ // hamburger; folded or narrow, the logo stands in for it). On a wide screen it folds the sidebar to its icons and back,
231
+ // and the choice stays in this browser. On a narrow screen the sidebar is a
232
+ // bar across the top, and the button slides the menu in from the left as a
233
+ // drawer over a dimmed page; choosing a page, Escape or a click on the page
234
+ // closes it.
233
235
  const NARROW = window.matchMedia('(max-width: 820px)');
234
236
  const FOLDED_KEY = 'flow_sidebar_folded';
235
237
 
@@ -243,7 +245,7 @@ function drawMenu() {
243
245
  shell.toggleAttribute('data-folded', folded);
244
246
  const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
245
247
  const toggle = $('menu-toggle');
246
- const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the menu' : 'Fold the menu');
248
+ const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
247
249
  toggle.setAttribute('aria-expanded', String(open));
248
250
  toggle.setAttribute('aria-label', label);
249
251
  toggle.title = label;
@@ -384,7 +386,13 @@ function renderShell() {
384
386
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
385
387
  icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
386
388
  nav.replaceChildren(...items, report);
387
- $('menu-toggle').replaceChildren(icon('menu'));
389
+ // Folded or narrow, the logo is the sidebar button: it shows the mark and
390
+ // turns into the sidebar icon under the pointer or keyboard focus.
391
+ const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
392
+ mark.innerHTML = document.querySelector('.fx-side__head .fx-brand svg')?.outerHTML ?? '';
393
+ const sidebar = icon('sidebar');
394
+ sidebar.classList.add('fx-toggle__icon');
395
+ $('menu-toggle').replaceChildren(mark, sidebar);
388
396
  $('menu-toggle').onclick = toggleMenu;
389
397
  drawMenu();
390
398
  // The sidebar foot shows who is signed in: photo or initial, then the name