@arcforgelabs/flow 2026.10.38 → 2026.10.40
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 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +74 -6
- package/server/core/ledger.js +29 -7
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +205 -15
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/integrations/xero/settings.js +2 -1
- package/server/ui/app.js +42 -6
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/monitoring.js +13 -12
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, eq } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { FlowError } from '../../core/errors.js';
|
|
4
|
+
import { createActionGuard } from '../call-guard.js';
|
|
4
5
|
import { billingObligations, organisations } from '../../db/schema.js';
|
|
5
6
|
import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
|
|
6
7
|
import { createXeroClient } from './client.js';
|
|
@@ -43,10 +44,25 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
43
44
|
// POST /webhooks Xero: change events, signed with the
|
|
44
45
|
// webhook key (XERO_WEBHOOK_KEY); no session
|
|
45
46
|
//
|
|
47
|
+
// Each POST that calls Xero waits between presses (XERO_ACTIONS, the action
|
|
48
|
+
// guard): a press too soon answers 429 with the wait.
|
|
49
|
+
//
|
|
46
50
|
// Only an owner, with a verified owner sign-in, can connect or disconnect.
|
|
47
51
|
// The callback carries no Flow session: the one-use state from /connect,
|
|
48
52
|
// issued to that owner for that organisation, is what admits it.
|
|
49
53
|
|
|
54
|
+
// The buttons that call Xero, and how long each waits before it runs again
|
|
55
|
+
// (the action guard, call-guard.js). A send is one invoice at a time, so it
|
|
56
|
+
// only runs once at a time and backs off after a failure.
|
|
57
|
+
export const XERO_ACTIONS = Object.freeze({
|
|
58
|
+
sync: { cooldownMs: 5 * 60_000 },
|
|
59
|
+
recheck: { cooldownMs: 30_000 },
|
|
60
|
+
push: { cooldownMs: 0, budget: false },
|
|
61
|
+
obligationCheck: { cooldownMs: 30_000 },
|
|
62
|
+
paymentsSync: { cooldownMs: 5 * 60_000 },
|
|
63
|
+
mirror: { cooldownMs: 2 * 60_000 },
|
|
64
|
+
});
|
|
65
|
+
|
|
50
66
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
51
67
|
|
|
52
68
|
|
|
@@ -93,10 +109,11 @@ export function callsState(status) {
|
|
|
93
109
|
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
112
|
+
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 }) }) {
|
|
97
113
|
const page = (title, text, tone) => messagePage({ title, text, tone });
|
|
98
114
|
const { settings } = runtime;
|
|
99
115
|
const router = express.Router();
|
|
116
|
+
const act = (name, key) => actions.route(name, { key });
|
|
100
117
|
|
|
101
118
|
// Xero's webhooks (webhooks.js), before the JSON parser: the signature
|
|
102
119
|
// covers the raw body. Xero wants 200 with no body within 5 seconds, and
|
|
@@ -220,7 +237,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
220
237
|
return res.json({ ok: true, disconnected: true, revoked });
|
|
221
238
|
}));
|
|
222
239
|
|
|
223
|
-
router.post('/sync', ...manager, handle(log, async (req, res) => {
|
|
240
|
+
router.post('/sync', ...manager, act('sync'), handle(log, async (req, res) => {
|
|
224
241
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
225
242
|
const run = await syncCatalogue(db, runtime, organisationId, { startedBy: req.person.id });
|
|
226
243
|
res.json({ ok: true, sync: run });
|
|
@@ -252,7 +269,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
252
269
|
// Mark resolved: read Xero's changes (a few calls, through the call guard),
|
|
253
270
|
// then check this one invoice again. The item closes only when the
|
|
254
271
|
// difference is gone; otherwise it stays open with the new evidence.
|
|
255
|
-
router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
|
|
272
|
+
router.post('/invoices/:invoiceId/recheck', ...manager, act('recheck', (req) => req.params.invoiceId), handle(log, async (req, res) => {
|
|
256
273
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
257
274
|
const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
|
|
258
275
|
await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
|
|
@@ -261,7 +278,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
261
278
|
res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
|
|
262
279
|
}));
|
|
263
280
|
|
|
264
|
-
router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
|
|
281
|
+
router.post('/invoices/push', ...manager, act('push'), handle(log, async (req, res) => {
|
|
265
282
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
266
283
|
const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
|
|
267
284
|
const allowExistingReference = req.body?.allowExistingReference === undefined ? [] : requireInvoiceIds(req.body.allowExistingReference, 'allowExistingReference');
|
|
@@ -276,7 +293,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
276
293
|
res.json({ ok: true, pushes: (await listPushes(db, organisationId, invoiceId)).map(describePush) });
|
|
277
294
|
}));
|
|
278
295
|
|
|
279
|
-
router.post('/obligations/:obligationId/check', ...manager, handle(log, async (req, res) => {
|
|
296
|
+
router.post('/obligations/:obligationId/check', ...manager, act('obligationCheck', (req) => req.params.obligationId), handle(log, async (req, res) => {
|
|
280
297
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
281
298
|
const [obligationId] = requireInvoiceIds([req.params.obligationId], 'obligationId');
|
|
282
299
|
const [obligation] = await db.select().from(billingObligations)
|
|
@@ -286,13 +303,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
286
303
|
res.json({ ok: true, obligation: row });
|
|
287
304
|
}));
|
|
288
305
|
|
|
289
|
-
router.post('/payments/sync', ...manager, handle(log, async (req, res) => {
|
|
306
|
+
router.post('/payments/sync', ...manager, act('paymentsSync'), handle(log, async (req, res) => {
|
|
290
307
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
291
308
|
const result = await recordXeroPayments(db, runtime, organisationId, { actor: `person:${req.person.id}` });
|
|
292
309
|
res.json({ ok: true, ...result });
|
|
293
310
|
}));
|
|
294
311
|
|
|
295
|
-
router.post('/mirror', ...manager, handle(log, async (req, res) => {
|
|
312
|
+
router.post('/mirror', ...manager, act('mirror'), handle(log, async (req, res) => {
|
|
296
313
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
297
314
|
const resources = req.body?.resources ?? MIRROR_RESOURCES;
|
|
298
315
|
if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
|
|
@@ -27,7 +27,8 @@ export const XERO_ENDPOINTS = Object.freeze({
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
|
|
30
|
-
|
|
30
|
+
// accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
|
|
31
|
+
export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
|
|
31
32
|
export const DEFAULT_XERO_DAY_LIMIT = 1000;
|
|
32
33
|
export const DEFAULT_DUE_DAYS = 30;
|
|
33
34
|
export const DEFAULT_MIRROR_PAGE_SIZE = 200;
|
package/server/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
|
+
import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
|
|
16
17
|
import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
17
18
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
18
19
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
@@ -126,10 +127,41 @@ async function flow(path, options = {}) {
|
|
|
126
127
|
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
127
128
|
});
|
|
128
129
|
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
129
|
-
|
|
130
|
+
// A button that calls Xero or Seazona waits between presses (the action
|
|
131
|
+
// guard): a press too soon says how long (retryAfterMs), and a good run
|
|
132
|
+
// says its own wait (Flow-Cooldown-Ms). holdButton shows the wait.
|
|
133
|
+
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details, retryAfterMs: body.retryAfterMs });
|
|
134
|
+
const cooldownMs = Number(res.headers.get('Flow-Cooldown-Ms'));
|
|
135
|
+
if (cooldownMs > 0 && body && typeof body === 'object') body.cooldownMs = cooldownMs;
|
|
130
136
|
return body;
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
// Keeps a button off for `ms`, with the time left on it, then puts it back.
|
|
140
|
+
function holdButton(button, ms) {
|
|
141
|
+
if (!button || !(ms > 0)) return;
|
|
142
|
+
clearInterval(button.fxHold);
|
|
143
|
+
const label = button.dataset.label ?? button.textContent;
|
|
144
|
+
button.dataset.label = label;
|
|
145
|
+
const until = Date.now() + ms;
|
|
146
|
+
const draw = () => {
|
|
147
|
+
const left = Math.ceil((until - Date.now()) / 1000);
|
|
148
|
+
if (left <= 0) {
|
|
149
|
+
clearInterval(button.fxHold);
|
|
150
|
+
button.textContent = label;
|
|
151
|
+
delete button.dataset.label;
|
|
152
|
+
button.removeAttribute('title');
|
|
153
|
+
button.disabled = false;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
157
|
+
button.disabled = true;
|
|
158
|
+
button.textContent = `${label} · ${time}`;
|
|
159
|
+
button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
|
|
160
|
+
};
|
|
161
|
+
draw();
|
|
162
|
+
button.fxHold = setInterval(draw, 1000);
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
134
166
|
|
|
135
167
|
function pageHeader(title, { subtitle, actions = [] } = {}) {
|
|
@@ -204,7 +236,7 @@ const VIEWS = {
|
|
|
204
236
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
205
237
|
reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
206
238
|
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
207
|
-
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
239
|
+
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
208
240
|
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
209
241
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
210
242
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
@@ -371,7 +403,7 @@ function navigate(event) {
|
|
|
371
403
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
372
404
|
// takes a path, or a link's click event.
|
|
373
405
|
const ui = () => ({
|
|
374
|
-
h, flow, toast, notice, pageHeader, dialog, me,
|
|
406
|
+
h, flow, toast, notice, pageHeader, dialog, me, holdButton,
|
|
375
407
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
376
408
|
});
|
|
377
409
|
|
|
@@ -969,16 +1001,20 @@ function xeroConnection(organisations, canChange) {
|
|
|
969
1001
|
let hint;
|
|
970
1002
|
let control = null;
|
|
971
1003
|
let stopped = false;
|
|
1004
|
+
let refused = false;
|
|
972
1005
|
try {
|
|
973
1006
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
974
1007
|
if (status.connected && status.calls?.stopped) {
|
|
975
1008
|
// A refused connection stopped calls (the call guard): say so, and
|
|
976
1009
|
// make Connect again the main button, which starts them again.
|
|
977
1010
|
const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
|
|
1011
|
+
// One refused resource stops only that resource, and Flow tries it
|
|
1012
|
+
// again by itself: a calm note, not a stop.
|
|
978
1013
|
hint = status.calls.all
|
|
979
1014
|
? `Xero refused Flow${at}. Flow stopped all calls.`
|
|
980
|
-
: `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow
|
|
981
|
-
stopped =
|
|
1015
|
+
: `Connected. Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Other calls still work. Flow tries again by itself.`;
|
|
1016
|
+
stopped = status.calls.all;
|
|
1017
|
+
refused = !status.calls.all;
|
|
982
1018
|
} else if (status.connected) {
|
|
983
1019
|
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
984
1020
|
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
|
|
@@ -986,7 +1022,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
986
1022
|
hint = 'Not connected.';
|
|
987
1023
|
}
|
|
988
1024
|
if (canChange) {
|
|
989
|
-
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
1025
|
+
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
990
1026
|
try {
|
|
991
1027
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
992
1028
|
location.assign(authorizeUrl);
|