@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.
- package/README.md +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +69 -26
- package/server/app.js +4 -0
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +64 -5
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +245 -8
- package/server/core/pipeline.js +30 -22
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +52 -8
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +5 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +4 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +77 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +69 -3
- package/server/integrations/seazona/lanes.js +82 -9
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +7 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +346 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +56 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +231 -68
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
- 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
|
+
);
|
|
@@ -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 =
|
|
249
|
+
const rows = mapRecords(resource, raw, settings);
|
|
237
250
|
const next = nextPosition(position, rows, pageSize);
|
|
238
251
|
await db.transaction(async (tx) => {
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// the
|
|
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
|
|
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
|
-
|
|
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
|