@arcforgelabs/flow 2026.10.44 → 2026.10.46
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 +1 -0
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +53 -3
- package/server/app.js +4 -2
- package/server/auth/people.js +4 -3
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/app.js +47 -27
- package/server/ui/columns.js +82 -15
- package/server/ui/flow.css +148 -37
- package/server/ui/invoicing.js +87 -38
- package/server/ui/job-view.js +33 -4
- package/server/ui/jobs.js +6 -37
- package/server/ui/kit.js +18 -11
- package/server/ui/list-views.js +44 -2
- package/server/ui/monitoring.js +51 -8
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +67 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +4 -4
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ run a lab.
|
|
|
36
36
|
| `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
|
|
37
37
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
38
38
|
| `docs/DESIGN_SYSTEM.md` | The Flow Design System artifact: how `npm run design-system` builds it from the code, the card per shared component, publishing and review |
|
|
39
|
+
| `docs/MOBILE_TESTS.md` | The phone suite: every screen at phone, tablet and desk sizes in WebKit and Chromium, its checks and budget, the three widths, saved images |
|
|
39
40
|
| `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
|
|
40
41
|
| `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
|
|
41
42
|
| `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.46",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.46",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"flow-server": "server/flow-server.js"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
+
"@axe-core/playwright": "4.13.0",
|
|
37
|
+
"@playwright/test": "1.56.1",
|
|
36
38
|
"drizzle-kit": "0.31.11",
|
|
37
39
|
"esbuild": "0.25.12"
|
|
38
40
|
},
|
|
@@ -40,6 +42,19 @@
|
|
|
40
42
|
"node": ">=24"
|
|
41
43
|
}
|
|
42
44
|
},
|
|
45
|
+
"node_modules/@axe-core/playwright": {
|
|
46
|
+
"version": "4.13.0",
|
|
47
|
+
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
|
|
48
|
+
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
|
|
49
|
+
"dev": true,
|
|
50
|
+
"license": "MPL-2.0",
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"axe-core": "~4.13.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"playwright-core": ">= 1.0.0"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
43
58
|
"node_modules/@clerk/backend": {
|
|
44
59
|
"version": "3.25.0",
|
|
45
60
|
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.25.0.tgz",
|
|
@@ -1029,6 +1044,22 @@
|
|
|
1029
1044
|
"url": "https://github.com/sponsors/oxc-project"
|
|
1030
1045
|
}
|
|
1031
1046
|
},
|
|
1047
|
+
"node_modules/@playwright/test": {
|
|
1048
|
+
"version": "1.56.1",
|
|
1049
|
+
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
|
1050
|
+
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
|
1051
|
+
"dev": true,
|
|
1052
|
+
"license": "Apache-2.0",
|
|
1053
|
+
"dependencies": {
|
|
1054
|
+
"playwright": "1.56.1"
|
|
1055
|
+
},
|
|
1056
|
+
"bin": {
|
|
1057
|
+
"playwright": "cli.js"
|
|
1058
|
+
},
|
|
1059
|
+
"engines": {
|
|
1060
|
+
"node": ">=18"
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1032
1063
|
"node_modules/@sentry/bundler-plugins": {
|
|
1033
1064
|
"version": "11.8.0",
|
|
1034
1065
|
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.8.0.tgz",
|
|
@@ -1183,6 +1214,16 @@
|
|
|
1183
1214
|
"node": ">= 0.6"
|
|
1184
1215
|
}
|
|
1185
1216
|
},
|
|
1217
|
+
"node_modules/axe-core": {
|
|
1218
|
+
"version": "4.13.0",
|
|
1219
|
+
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
|
|
1220
|
+
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
|
|
1221
|
+
"dev": true,
|
|
1222
|
+
"license": "MPL-2.0",
|
|
1223
|
+
"engines": {
|
|
1224
|
+
"node": ">=4"
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1186
1227
|
"node_modules/balanced-match": {
|
|
1187
1228
|
"version": "4.0.4",
|
|
1188
1229
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
@@ -2380,6 +2421,67 @@
|
|
|
2380
2421
|
"split2": "^4.1.0"
|
|
2381
2422
|
}
|
|
2382
2423
|
},
|
|
2424
|
+
"node_modules/playwright": {
|
|
2425
|
+
"version": "1.56.1",
|
|
2426
|
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
|
2427
|
+
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
|
2428
|
+
"dev": true,
|
|
2429
|
+
"license": "Apache-2.0",
|
|
2430
|
+
"dependencies": {
|
|
2431
|
+
"playwright-core": "1.56.1"
|
|
2432
|
+
},
|
|
2433
|
+
"bin": {
|
|
2434
|
+
"playwright": "cli.js"
|
|
2435
|
+
},
|
|
2436
|
+
"engines": {
|
|
2437
|
+
"node": ">=18"
|
|
2438
|
+
},
|
|
2439
|
+
"optionalDependencies": {
|
|
2440
|
+
"fsevents": "2.3.2"
|
|
2441
|
+
}
|
|
2442
|
+
},
|
|
2443
|
+
"node_modules/playwright-core": {
|
|
2444
|
+
"version": "1.64.1",
|
|
2445
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
|
2446
|
+
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
|
2447
|
+
"dev": true,
|
|
2448
|
+
"license": "Apache-2.0",
|
|
2449
|
+
"peer": true,
|
|
2450
|
+
"bin": {
|
|
2451
|
+
"playwright-core": "cli.js"
|
|
2452
|
+
},
|
|
2453
|
+
"engines": {
|
|
2454
|
+
"node": ">=20"
|
|
2455
|
+
}
|
|
2456
|
+
},
|
|
2457
|
+
"node_modules/playwright/node_modules/fsevents": {
|
|
2458
|
+
"version": "2.3.2",
|
|
2459
|
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
2460
|
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
2461
|
+
"dev": true,
|
|
2462
|
+
"hasInstallScript": true,
|
|
2463
|
+
"license": "MIT",
|
|
2464
|
+
"optional": true,
|
|
2465
|
+
"os": [
|
|
2466
|
+
"darwin"
|
|
2467
|
+
],
|
|
2468
|
+
"engines": {
|
|
2469
|
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
2470
|
+
}
|
|
2471
|
+
},
|
|
2472
|
+
"node_modules/playwright/node_modules/playwright-core": {
|
|
2473
|
+
"version": "1.56.1",
|
|
2474
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
|
2475
|
+
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
|
2476
|
+
"dev": true,
|
|
2477
|
+
"license": "Apache-2.0",
|
|
2478
|
+
"bin": {
|
|
2479
|
+
"playwright-core": "cli.js"
|
|
2480
|
+
},
|
|
2481
|
+
"engines": {
|
|
2482
|
+
"node": ">=18"
|
|
2483
|
+
}
|
|
2484
|
+
},
|
|
2383
2485
|
"node_modules/png-js": {
|
|
2384
2486
|
"version": "2.0.0",
|
|
2385
2487
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.46",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
|
|
38
38
|
"db:follow-production": "node scripts/replit-workspace-db.js sync",
|
|
39
39
|
"db:follow-production:status": "node scripts/replit-workspace-db.js status",
|
|
40
|
-
"design-system": "node scripts/design-system/build.mjs"
|
|
40
|
+
"design-system": "node scripts/design-system/build.mjs",
|
|
41
|
+
"test:mobile": "playwright test -c test/mobile/playwright.config.js",
|
|
42
|
+
"test:mobile:budget": "node test/mobile/budget.mjs"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
43
45
|
"@clerk/backend": "3.25.0",
|
|
@@ -61,6 +63,8 @@
|
|
|
61
63
|
"pg": "8.23.1"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
66
|
+
"@axe-core/playwright": "4.13.0",
|
|
67
|
+
"@playwright/test": "1.56.1",
|
|
64
68
|
"drizzle-kit": "0.31.11",
|
|
65
69
|
"esbuild": "0.25.12"
|
|
66
70
|
},
|
package/server/api/invoicing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import { invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
|
|
3
4
|
import { FlowError } from '../core/errors.js';
|
|
4
5
|
import { createActionGuard } from '../integrations/call-guard.js';
|
|
5
6
|
import {
|
|
@@ -165,6 +166,21 @@ function limitOf(raw, fallback, max) {
|
|
|
165
166
|
return Math.min(n, max);
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
function offsetOf(raw) {
|
|
170
|
+
if (raw === undefined) return 0;
|
|
171
|
+
const n = Number(text(raw));
|
|
172
|
+
if (!Number.isInteger(n) || n < 0) throw invalid('offset must be a whole number from 0');
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The Filter chip's view, sent as JSON (list-views.js keeps only what it knows).
|
|
177
|
+
function filterViewOf(raw) {
|
|
178
|
+
if (raw === undefined || text(raw) === '') return normaliseInvoicesView({});
|
|
179
|
+
let value;
|
|
180
|
+
try { value = JSON.parse(String(raw)); } catch { throw invalid('filter must be JSON'); }
|
|
181
|
+
return normaliseInvoicesView(value);
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
// A LIKE pattern that matches the text anywhere, with its wildcards escaped.
|
|
169
185
|
const containing = (q) => `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
170
186
|
|
|
@@ -404,7 +420,20 @@ export function createInvoicingRouter({
|
|
|
404
420
|
throw invalid(`status must be one of ${TABS.join(', ')}`);
|
|
405
421
|
}
|
|
406
422
|
const q = text(req.query.q);
|
|
423
|
+
// The list pages on the server, so it works with any number of invoices:
|
|
424
|
+
// the tab and the search pick rows, the Filter chip's view and the sort
|
|
425
|
+
// (job-view.js, the page's own rules) run over all of them, and one page
|
|
426
|
+
// goes back with the total.
|
|
407
427
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
428
|
+
const offset = offsetOf(req.query.offset);
|
|
429
|
+
const view = filterViewOf(req.query.filter);
|
|
430
|
+
const sort = text(req.query.sort) || null;
|
|
431
|
+
if (sort && !INVOICE_SORTS.includes(sort)) throw invalid(`sort must be one of ${INVOICE_SORTS.join(', ')}`);
|
|
432
|
+
const dir = text(req.query.dir) || 'asc';
|
|
433
|
+
if (!['asc', 'desc'].includes(dir)) throw invalid('dir must be asc or desc');
|
|
434
|
+
// The person's own day, for date filters such as This week.
|
|
435
|
+
const day = text(req.query.today) || new Date().toISOString().slice(0, 10);
|
|
436
|
+
if (!isRealDate(day)) throw invalid('today must be a date (yyyy-mm-dd)');
|
|
408
437
|
// Each tab switch runs this, so the independent reads run side by side.
|
|
409
438
|
const system = accounting();
|
|
410
439
|
const sendMode = system
|
|
@@ -420,11 +449,25 @@ export function createInvoicingRouter({
|
|
|
420
449
|
const pattern = containing(q);
|
|
421
450
|
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
422
451
|
}
|
|
423
|
-
|
|
452
|
+
// First the few columns the filter and sort read, for every row; then
|
|
453
|
+
// the whole rows of the one page.
|
|
454
|
+
const light = (await db.select({
|
|
455
|
+
id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
|
|
456
|
+
createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
|
|
457
|
+
}).from(invoices)
|
|
424
458
|
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
425
459
|
.where(and(...where))
|
|
426
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
427
|
-
.
|
|
460
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id)))
|
|
461
|
+
.map((row) => ({ ...row, problems: tabs.problems.get(row.id) ?? [] }));
|
|
462
|
+
const page = invoicePage(light, { view, sort, dir, offset, limit, today: day });
|
|
463
|
+
const pageIds = page.rows.map((r) => r.id);
|
|
464
|
+
const full = pageIds.length
|
|
465
|
+
? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
466
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
467
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, pageIds)))
|
|
468
|
+
: [];
|
|
469
|
+
const byId = new Map(full.map((r) => [r.invoice.id, r]));
|
|
470
|
+
const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
|
|
428
471
|
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
429
472
|
const systems = work();
|
|
430
473
|
res.json({
|
|
@@ -438,6 +481,13 @@ export function createInvoicingRouter({
|
|
|
438
481
|
problems: tabs.problems.get(invoice.id) ?? [],
|
|
439
482
|
})),
|
|
440
483
|
counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
|
|
484
|
+
// The page: where it starts, its size, and every row the filter keeps.
|
|
485
|
+
offset,
|
|
486
|
+
limit,
|
|
487
|
+
total: page.total,
|
|
488
|
+
totalCents: page.totalCents,
|
|
489
|
+
// The Filter chip's Client choices: every client of the tab.
|
|
490
|
+
clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
|
|
441
491
|
sendsTo: system?.name ?? null,
|
|
442
492
|
sendMode: mode,
|
|
443
493
|
// The Linked column: one icon for each connected system.
|
package/server/app.js
CHANGED
|
@@ -26,7 +26,8 @@ import { createUiAssetsRouter } from './ui/routes.js';
|
|
|
26
26
|
// per client, sized for a lab of a few people: `rateLimitPerMinute` for /api,
|
|
27
27
|
// `signInRateLimitPerMinute` for the sign-in page and its config, and
|
|
28
28
|
// `clerkProxyRateLimitPerMinute` for Clerk's Frontend API, which a signed-in
|
|
29
|
-
// browser calls often
|
|
29
|
+
// browser calls often, and `assetRateLimitPerMinute` for the scripts, styles
|
|
30
|
+
// and faces every page loads. `build` is what GET /health reports
|
|
30
31
|
// (server/deployed-build.js); `updates` is a server/update-check.js checker.
|
|
31
32
|
// `integrations` is the started registry (server/integrations/index.js); each
|
|
32
33
|
// adapter's own routes are mounted under /api/<id>, and GET /health reports
|
|
@@ -51,6 +52,7 @@ export function createApp({
|
|
|
51
52
|
rateLimitPerMinute = 120,
|
|
52
53
|
signInRateLimitPerMinute = 30,
|
|
53
54
|
clerkProxyRateLimitPerMinute = 300,
|
|
55
|
+
assetRateLimitPerMinute = 600,
|
|
54
56
|
defaultFamily = DEFAULT_FAMILY,
|
|
55
57
|
// Buttons that call Xero or Seazona wait between presses (the action
|
|
56
58
|
// guard, server/integrations/call-guard.js). Off only in tests.
|
|
@@ -95,7 +97,7 @@ export function createApp({
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
// The design systems, Flow's components and the faces every page loads.
|
|
98
|
-
app.use(createUiAssetsRouter());
|
|
100
|
+
app.use(createUiAssetsRouter({ rateLimitPerMinute: assetRateLimitPerMinute }));
|
|
99
101
|
|
|
100
102
|
app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
|
|
101
103
|
|
package/server/auth/people.js
CHANGED
|
@@ -2,7 +2,7 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
|
|
|
2
2
|
import { people, PERSON_ROLES } from '../db/schema.js';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
4
|
import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
|
|
5
|
-
import {
|
|
5
|
+
import { invoiceColumnsView, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
|
|
6
6
|
import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
|
|
7
7
|
import { isPlainEmail } from './settings.js';
|
|
8
8
|
|
|
@@ -58,12 +58,13 @@ export async function setPersonAppearance(db, { id, appearance }) {
|
|
|
58
58
|
function viewCheck(page) {
|
|
59
59
|
switch (page) {
|
|
60
60
|
case 'jobs': return normaliseJobsView;
|
|
61
|
-
case 'invoices': return
|
|
61
|
+
case 'invoices': return invoiceColumnsView;
|
|
62
62
|
case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
|
|
63
|
+
case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
|
|
63
64
|
default: return null;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
|
-
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile'];
|
|
67
|
+
const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list'];
|
|
67
68
|
|
|
68
69
|
// A person's saved views as Flow sends them: only the known pages, each
|
|
69
70
|
// checked, so a view saved by an older Flow still loads.
|
|
@@ -203,6 +203,10 @@ export function createCallGuard({
|
|
|
203
203
|
lockName = `${integration}-call-guard`,
|
|
204
204
|
report: reports = true,
|
|
205
205
|
policy: overrides = {},
|
|
206
|
+
// Optional columns on this adapter's call table, filled from record().
|
|
207
|
+
// `lane` is the allow-list (anything else is stored null). `rateLimitProblem`
|
|
208
|
+
// keeps a short token such as Xero's X-Rate-Limit-Problem.
|
|
209
|
+
telemetry = {},
|
|
206
210
|
now = Date.now,
|
|
207
211
|
log = console,
|
|
208
212
|
} = {}) {
|
|
@@ -215,6 +219,14 @@ export function createCallGuard({
|
|
|
215
219
|
const C = { key: sql.identifier(columns.key ?? 'key'), at: sql.identifier(columns.at ?? 'at') };
|
|
216
220
|
const tag = `[${integration}]`;
|
|
217
221
|
const failed = sql`(failure_kind is not null or status_code >= 500 or (status_code >= 400 and status_code <> 404))`;
|
|
222
|
+
const laneAllowed = Array.isArray(telemetry.lane) ? telemetry.lane : null;
|
|
223
|
+
const keepProblem = telemetry.rateLimitProblem === true;
|
|
224
|
+
const cleanLane = (value) => (laneAllowed?.includes(value) ? value : null);
|
|
225
|
+
const cleanProblem = (value) => {
|
|
226
|
+
if (!keepProblem || typeof value !== 'string') return null;
|
|
227
|
+
const text = value.trim();
|
|
228
|
+
return /^[A-Za-z][A-Za-z0-9_-]{0,39}$/.test(text) ? text : null;
|
|
229
|
+
};
|
|
218
230
|
|
|
219
231
|
const lock = (tx, key) => tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${lockName}:${key}`}))`);
|
|
220
232
|
const extraColumns = Object.keys(callDefaults).map((column) => sql`, ${sql.identifier(column)}`);
|
|
@@ -263,12 +275,17 @@ export function createCallGuard({
|
|
|
263
275
|
// resourceResumed, opened, closed, budgetSpent }),
|
|
264
276
|
// each of which was just reported once. `callId` names a call row the
|
|
265
277
|
// adapter wrote when it admitted the call: that row is updated, not added.
|
|
266
|
-
async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
|
|
278
|
+
async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null, lane: laneRaw = null, rateLimitProblem = null } = {}) {
|
|
267
279
|
const key = cleanKey(rawKey);
|
|
268
280
|
const where = cleanResource(resource);
|
|
269
281
|
const nowMs = Number(now());
|
|
270
282
|
const nowAt = new Date(nowMs);
|
|
271
283
|
const status = Number.isInteger(statusCode) ? statusCode : null;
|
|
284
|
+
const lane = cleanLane(laneRaw);
|
|
285
|
+
const problem = cleanProblem(rateLimitProblem);
|
|
286
|
+
const extraCols = sql`${laneAllowed ? sql`, lane` : sql``}${keepProblem ? sql`, rate_limit_problem` : sql``}`;
|
|
287
|
+
const extraVals = sql`${laneAllowed ? sql`, ${lane}` : sql``}${keepProblem ? sql`, ${problem}` : sql``}`;
|
|
288
|
+
const extraSet = sql`${laneAllowed ? sql`, lane = ${lane}` : sql``}${keepProblem ? sql`, rate_limit_problem = ${problem}` : sql``}`;
|
|
272
289
|
const outcome = await db.transaction(async (tx) => {
|
|
273
290
|
await lock(tx, key);
|
|
274
291
|
await ensureRow(tx, key);
|
|
@@ -276,10 +293,10 @@ export function createCallGuard({
|
|
|
276
293
|
const took = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null;
|
|
277
294
|
if (callId !== null) {
|
|
278
295
|
await tx.execute(sql`update ${T.calls} set resource = ${where}, request_path = ${normaliseRequestPath(path)}, status_code = ${status},
|
|
279
|
-
failure_kind = ${kind}, duration_ms = ${took} where id = ${callId} and ${C.key} = ${key}`);
|
|
296
|
+
failure_kind = ${kind}, duration_ms = ${took}${extraSet} where id = ${callId} and ${C.key} = ${key}`);
|
|
280
297
|
} else {
|
|
281
|
-
await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${sql.join(extraColumns, sql``)})
|
|
282
|
-
values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${sql.join(extraValues, sql``)})`);
|
|
298
|
+
await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${extraCols}${sql.join(extraColumns, sql``)})
|
|
299
|
+
values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${extraVals}${sql.join(extraValues, sql``)})`);
|
|
283
300
|
}
|
|
284
301
|
const state = await readState(tx, key);
|
|
285
302
|
if (!countsAsFailure({ statusCode: status, failureKind })) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FlowError } from '../../core/errors.js';
|
|
2
|
+
import { currentXeroLane } from './governor.js';
|
|
2
3
|
import { XERO_ENDPOINTS } from './settings.js';
|
|
3
4
|
|
|
4
5
|
// The Xero Accounting API client. Every call goes through the rate-limit
|
|
@@ -130,7 +131,7 @@ export function createXeroClient({
|
|
|
130
131
|
} catch (error) {
|
|
131
132
|
if (error?.code === 'xero_replay_no_match') throw error;
|
|
132
133
|
const timedOut = error?.name === 'AbortError';
|
|
133
|
-
await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt });
|
|
134
|
+
await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt, lane: currentXeroLane() });
|
|
134
135
|
throw xeroError(timedOut ? 'xero_timeout' : 'xero_unreachable',
|
|
135
136
|
timedOut ? `Xero did not answer within ${timeoutMs} ms.` : 'Flow could not reach Xero.',
|
|
136
137
|
{ status: timedOut ? 504 : 503 });
|
|
@@ -169,7 +170,16 @@ export function createXeroClient({
|
|
|
169
170
|
// A first 401 usually means the access token expired: it is not counted,
|
|
170
171
|
// and the call is tried once more with a fresh token.
|
|
171
172
|
const expiredToken = response.status === 401 && !refreshed;
|
|
172
|
-
|
|
173
|
+
const problem = response.status === 429 ? response.headers?.get?.('x-rate-limit-problem')?.trim() || null : null;
|
|
174
|
+
if (!expiredToken) {
|
|
175
|
+
await guard?.record(tenantId, {
|
|
176
|
+
...call,
|
|
177
|
+
statusCode: response.status,
|
|
178
|
+
durationMs,
|
|
179
|
+
lane: currentXeroLane(),
|
|
180
|
+
rateLimitProblem: problem,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
173
183
|
if (response.ok) return payload ?? {};
|
|
174
184
|
|
|
175
185
|
const error = apiError(response, payload);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- The lane a call counted against, and a 429's X-Rate-Limit-Problem, so the
|
|
2
|
+
-- Xero card can show the day's calls without asking Xero. A row from before
|
|
3
|
+
-- this column has no lane: the card does not guess one.
|
|
4
|
+
ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "lane" text;
|
|
5
|
+
--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "rate_limit_problem" text;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
DO $flow_guard$ BEGIN
|
|
9
|
+
ALTER TABLE "xero_calls" ADD CONSTRAINT "xero_calls_lane_valid" CHECK ("lane" is null or "lane" in ('writes', 'sync', 'reads', 'other'));
|
|
10
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
11
|
+
END $flow_guard$;
|
|
@@ -14,6 +14,7 @@ import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
|
14
14
|
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
15
15
|
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
16
16
|
import { writesAllowed } from './settings.js';
|
|
17
|
+
import { readXeroUsage } from './usage.js';
|
|
17
18
|
import { saveEvents, signatureValid } from './webhooks.js';
|
|
18
19
|
import { DEFAULT_FAMILY } from '../../theme/themes.js';
|
|
19
20
|
import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
@@ -109,8 +110,12 @@ export function callsState(status) {
|
|
|
109
110
|
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
const XERO_INTEGRATION = Object.freeze({ name: 'Xero', logo: 'xero.svg' });
|
|
114
|
+
|
|
112
115
|
export function createXeroRouter({ db, personAuth, runtime, log = console, buttonCooldowns = true, actions = createActionGuard({ integration: 'xero', actions: XERO_ACTIONS, off: !buttonCooldowns }), messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
113
|
-
|
|
116
|
+
// Shared callback card (server/ui/page.js): integration marks, status line,
|
|
117
|
+
// and a short auto-return on success only (issue #211).
|
|
118
|
+
const page = (title, text, tone, extra = {}) => messagePage({ title, text, tone, integration: XERO_INTEGRATION, ...extra });
|
|
114
119
|
const { settings } = runtime;
|
|
115
120
|
const router = express.Router();
|
|
116
121
|
const act = (name, key) => actions.route(name, { key });
|
|
@@ -144,16 +149,29 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
144
149
|
router.get('/status', ...signedIn, handle(log, async (req, res) => {
|
|
145
150
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
146
151
|
const connection = await readConnection(db, organisationId);
|
|
152
|
+
const connected = Boolean(connection && connection.mode === settings.mode);
|
|
153
|
+
const picture = connected
|
|
154
|
+
? await readXeroUsage(db, {
|
|
155
|
+
organisationId,
|
|
156
|
+
tenantId: connection.tenantId,
|
|
157
|
+
governor: runtime.governor.status(connection.tenantId),
|
|
158
|
+
warningFraction: settings.usageWarningFraction,
|
|
159
|
+
refreshedAt: connection.refreshedAt,
|
|
160
|
+
now: runtime.now(),
|
|
161
|
+
})
|
|
162
|
+
: { usage: null, freshness: null };
|
|
147
163
|
res.json({
|
|
148
164
|
ok: true,
|
|
149
165
|
mode: settings.mode,
|
|
150
166
|
writesAllowed: writesAllowed(settings),
|
|
151
167
|
redirectUri: settings.redirectUri,
|
|
152
|
-
connected
|
|
168
|
+
connected,
|
|
153
169
|
connection: describeConnection(connection),
|
|
154
|
-
rateLimit:
|
|
155
|
-
calls:
|
|
170
|
+
rateLimit: connected ? runtime.governor.status(connection.tenantId) : null,
|
|
171
|
+
calls: connected ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
|
|
156
172
|
lastSync: await lastSync(db, organisationId),
|
|
173
|
+
usage: picture.usage,
|
|
174
|
+
freshness: picture.freshness,
|
|
157
175
|
});
|
|
158
176
|
}));
|
|
159
177
|
|
|
@@ -213,7 +231,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
213
231
|
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
214
232
|
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
215
233
|
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
216
|
-
|
|
234
|
+
const organisation = connection.tenantName || 'your Xero organisation';
|
|
235
|
+
return res.type('html').send(page(
|
|
236
|
+
'Xero is connected',
|
|
237
|
+
'Flow is now copying its invoice and payment history in the background.',
|
|
238
|
+
'success',
|
|
239
|
+
{ organisation, mode: settings.mode, autoReturnSeconds: 5 },
|
|
240
|
+
));
|
|
217
241
|
} catch (error) {
|
|
218
242
|
log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
|
|
219
243
|
const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
|
|
@@ -21,6 +21,11 @@ export const XERO_CALL_GUARD = Object.freeze({
|
|
|
21
21
|
name: 'Xero',
|
|
22
22
|
tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
|
|
23
23
|
policy: Object.freeze({ authStops: 'resource' }),
|
|
24
|
+
// Kept on the call row so the Xero card can show lanes and 429s after a restart.
|
|
25
|
+
telemetry: Object.freeze({
|
|
26
|
+
lane: Object.freeze(['writes', 'sync', 'reads', 'other']),
|
|
27
|
+
rateLimitProblem: true,
|
|
28
|
+
}),
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
export function requireXeroSettings(env = process.env) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { assembleUsage, startOfUtcDay } from '../../ui/xero-usage-model.js';
|
|
3
|
+
|
|
4
|
+
// What the Xero card draws. Read from counters Flow already keeps: the
|
|
5
|
+
// governor's budget, the call log, and the copy's own timestamps. No Xero call.
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const iso = (value) => (value ? new Date(value).toISOString() : null);
|
|
9
|
+
|
|
10
|
+
// Calls that count toward the day's budget. A token refresh is recorded, and
|
|
11
|
+
// it is not an Accounting API call, so it stays out of the lanes.
|
|
12
|
+
const BUDGET_CALL = sql`lane is not null and coalesce(resource, '') <> 'token'`;
|
|
13
|
+
|
|
14
|
+
export async function readXeroUsage(db, { organisationId, tenantId, governor, warningFraction, refreshedAt, now = Date.now() }) {
|
|
15
|
+
const at = Number(now);
|
|
16
|
+
const dayStart = startOfUtcDay(at);
|
|
17
|
+
const weekStart = new Date(dayStart - 6 * DAY_MS);
|
|
18
|
+
const dayEnd = new Date(dayStart + DAY_MS);
|
|
19
|
+
const today = new Date(dayStart);
|
|
20
|
+
|
|
21
|
+
const [hours, days, tasks, limited, freshness] = await Promise.all([
|
|
22
|
+
db.execute(sql`
|
|
23
|
+
select to_char(date_trunc('hour', "at" at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as hour,
|
|
24
|
+
lane, count(*)::int as n
|
|
25
|
+
from xero_calls
|
|
26
|
+
where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and ${BUDGET_CALL}
|
|
27
|
+
group by 1, 2`),
|
|
28
|
+
db.execute(sql`
|
|
29
|
+
select to_char("at" at time zone 'UTC', 'YYYY-MM-DD') as day, count(*)::int as n
|
|
30
|
+
from xero_calls
|
|
31
|
+
where key = ${tenantId} and "at" >= ${weekStart} and "at" < ${dayEnd} and ${BUDGET_CALL}
|
|
32
|
+
group by 1`),
|
|
33
|
+
db.execute(sql`
|
|
34
|
+
select coalesce(nullif(resource, ''), 'call') as task, count(*)::int as n
|
|
35
|
+
from xero_calls
|
|
36
|
+
where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and coalesce(resource, '') <> 'token'
|
|
37
|
+
group by 1
|
|
38
|
+
order by n desc, task
|
|
39
|
+
limit 8`),
|
|
40
|
+
db.execute(sql`
|
|
41
|
+
select "at", rate_limit_problem as problem, lane, resource as task
|
|
42
|
+
from xero_calls
|
|
43
|
+
where key = ${tenantId} and status_code = 429 and "at" >= ${weekStart}
|
|
44
|
+
order by "at" desc
|
|
45
|
+
limit 12`),
|
|
46
|
+
db.execute(sql`
|
|
47
|
+
select
|
|
48
|
+
(select max(received_at) from xero_webhook_events where organisation_id = ${organisationId} and source = 'webhook') as webhook_at,
|
|
49
|
+
(select max(last_read_at) from xero_mirror_cursors where organisation_id = ${organisationId}) as poll_at,
|
|
50
|
+
(select last_finished_at from xero_daily_checks where organisation_id = ${organisationId}) as nightly_at,
|
|
51
|
+
(select max(read_at) from xero_webhook_events where organisation_id = ${organisationId}) as read_at`),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const fresh = freshness.rows[0] ?? {};
|
|
55
|
+
const copyCandidates = [fresh.poll_at, fresh.nightly_at, fresh.read_at].filter(Boolean).map((value) => new Date(value).getTime());
|
|
56
|
+
return {
|
|
57
|
+
usage: assembleUsage({
|
|
58
|
+
hourRows: hours.rows,
|
|
59
|
+
dayRows: days.rows,
|
|
60
|
+
taskRows: tasks.rows,
|
|
61
|
+
limitRows: limited.rows,
|
|
62
|
+
governor,
|
|
63
|
+
warningFraction,
|
|
64
|
+
now: at,
|
|
65
|
+
}),
|
|
66
|
+
freshness: {
|
|
67
|
+
webhookAt: iso(fresh.webhook_at),
|
|
68
|
+
pollAt: iso(fresh.poll_at),
|
|
69
|
+
nightlyAt: iso(fresh.nightly_at),
|
|
70
|
+
copyAt: copyCandidates.length ? new Date(Math.max(...copyCandidates)).toISOString() : null,
|
|
71
|
+
tokenRefreshedAt: iso(refreshedAt),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|