@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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +16 -5
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +14 -1
- package/server/core/ledger.js +3 -2
- package/server/core/pipeline.js +1 -1
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +2 -0
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +8 -1
- package/server/integrations/seazona/index.js +9 -0
- package/server/integrations/seazona/lanes.js +1 -1
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +3 -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 +311 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +47 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +136 -57
- package/server/ui/routes.js +1 -1
package/server/db/schema.js
CHANGED
|
@@ -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).
|
|
17
|
-
//
|
|
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
|
-
|
|
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
|
+
);
|
|
@@ -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
|
}
|