@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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +205 -15
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/integrations/xero/settings.js +2 -1
  33. package/server/ui/app.js +42 -6
  34. package/server/ui/custom-fields.js +252 -94
  35. package/server/ui/flow.css +185 -29
  36. package/server/ui/invoice-notes.js +2 -2
  37. package/server/ui/invoicing.js +92 -137
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +23 -12
  40. package/server/ui/job-view.js +168 -33
  41. package/server/ui/jobs.js +316 -65
  42. package/server/ui/monitoring.js +13 -12
  43. package/server/ui/price-list.js +43 -86
  44. package/server/ui/price-match.js +93 -0
  45. package/server/ui/price-reconcile.js +357 -0
  46. package/server/ui/reconcile.js +1 -1
  47. 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
- export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.settings.read';
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
- if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details });
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 stopped those calls.`;
981
- stopped = true;
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);