@arcforgelabs/flow 2026.10.30 → 2026.10.32-beta.1

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 (67) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/history.js +65 -0
  9. package/server/core/invoice-pdf.js +266 -0
  10. package/server/core/invoicing-rules.js +12 -0
  11. package/server/core/job-board.js +25 -3
  12. package/server/core/line-mapping.js +36 -10
  13. package/server/core/pipeline.js +75 -3
  14. package/server/core/price-list.js +348 -0
  15. package/server/db/commands.js +42 -0
  16. package/server/db/doctor.js +168 -0
  17. package/server/db/migrations/0014_price_list.sql +34 -0
  18. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  19. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  20. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +44 -0
  23. package/server/flow-server.js +20 -1
  24. package/server/index.js +2 -1
  25. package/server/integrations/call-guard.js +362 -0
  26. package/server/integrations/port.js +20 -1
  27. package/server/integrations/seazona/feed.js +23 -1
  28. package/server/integrations/seazona/governor.js +50 -122
  29. package/server/integrations/seazona/history/backfill.js +208 -0
  30. package/server/integrations/seazona/history/csv.js +65 -0
  31. package/server/integrations/seazona/history/index.js +584 -0
  32. package/server/integrations/seazona/history/read.js +196 -0
  33. package/server/integrations/seazona/index.js +37 -1
  34. package/server/integrations/seazona/lanes.js +186 -50
  35. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  36. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  37. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  38. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  40. package/server/integrations/seazona/routes.js +145 -0
  41. package/server/integrations/seazona/schema.js +94 -0
  42. package/server/integrations/seazona/settings.js +14 -3
  43. package/server/integrations/seazona/sync-once.js +1 -1
  44. package/server/integrations/seazona/tables.js +4 -0
  45. package/server/integrations/xero/client.js +27 -7
  46. package/server/integrations/xero/drafts.js +46 -8
  47. package/server/integrations/xero/import.js +125 -17
  48. package/server/integrations/xero/index.js +36 -7
  49. package/server/integrations/xero/lookup.js +26 -0
  50. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  51. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  52. package/server/integrations/xero/mirror.js +11 -2
  53. package/server/integrations/xero/oauth.js +4 -1
  54. package/server/integrations/xero/reconcile.js +173 -0
  55. package/server/integrations/xero/routes.js +35 -0
  56. package/server/integrations/xero/runtime.js +45 -4
  57. package/server/release-channel.js +3 -3
  58. package/server/ui/alerts.js +77 -0
  59. package/server/ui/app-page.js +1 -1
  60. package/server/ui/app.js +48 -14
  61. package/server/ui/clerk-appearance.js +42 -9
  62. package/server/ui/flow.css +31 -2
  63. package/server/ui/invoicing-settings.js +1 -1
  64. package/server/ui/invoicing.js +121 -8
  65. package/server/ui/line-mappings.js +3 -2
  66. package/server/ui/price-list.js +247 -0
  67. package/server/ui/routes.js +1 -1
@@ -8,6 +8,7 @@ import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from '
8
8
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
9
9
  import { queueHistoryImport, resetImport } from './import.js';
10
10
  import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
11
+ import { accountingStatus, checkSentInvoices } from './reconcile.js';
11
12
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
12
13
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
13
14
  import { writesAllowed } from './settings.js';
@@ -27,6 +28,10 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
27
28
  // would make, and what stops them (no Xero calls)
28
29
  // POST /invoices/push owner or admin: issued invoices to Xero drafts
29
30
  // GET /invoices/:invoiceId/pushes?organisationId= any signed-in person
31
+ // POST /invoices/status any signed-in person: each invoice's Xero
32
+ // status and any difference, from the copy
33
+ // POST /invoices/:invoiceId/recheck owner or admin: reads Xero's changes,
34
+ // then checks that one invoice again (Mark resolved)
30
35
  // POST /obligations/:obligationId/check owner or admin: Xero's invoice for
31
36
  // a billable job, recorded on the ledger
32
37
  // POST /payments/sync owner or admin: Xero payments to the ledger
@@ -76,6 +81,14 @@ export const handle = (log, fn) => async (req, res) => {
76
81
 
77
82
  // `messagePage` draws the callback's answer in the installation's design
78
83
  // system (server/ui/page.js); the server's app passes it.
84
+ // What the Xero page says about calls for one connection: stopped until a
85
+ // person connects again (a refused connection or resource), and since when.
86
+ export function callsState(status) {
87
+ const resources = status.stoppedResources.map((r) => r.resource);
88
+ const since = status.haltedAt ?? status.stoppedResources.map((r) => r.at).filter(Boolean).sort()[0] ?? null;
89
+ return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
90
+ }
91
+
79
92
  export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
80
93
  const page = (title, text, tone) => messagePage({ title, text, tone });
81
94
  const { settings } = runtime;
@@ -101,6 +114,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
101
114
  connected: Boolean(connection && connection.mode === settings.mode),
102
115
  connection: describeConnection(connection),
103
116
  rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
117
+ calls: connection ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
104
118
  lastSync: await lastSync(db, organisationId),
105
119
  });
106
120
  }));
@@ -155,6 +169,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
155
169
  key: settings.tokenKey,
156
170
  });
157
171
  runtime.remember(entry.organisationId, tokens);
172
+ // A new connection is a person acting: calls stopped before resume.
173
+ await runtime.guardFor(db).resume();
158
174
  // Another Xero organisation starts its history from the beginning.
159
175
  if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
160
176
  await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
@@ -205,6 +221,25 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
205
221
  res.json({ ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), ...preview });
206
222
  }));
207
223
 
224
+ // Xero's status of each invoice, from Flow's copy: no Xero calls.
225
+ router.post('/invoices/status', ...signedIn, handle(log, async (req, res) => {
226
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
227
+ const statuses = await accountingStatus(db, runtime, organisationId, requireInvoiceIds(req.body?.invoiceIds));
228
+ res.json({ ok: true, invoices: [...statuses.values()] });
229
+ }));
230
+
231
+ // Mark resolved: read Xero's changes (a few calls, through the call guard),
232
+ // then check this one invoice again. The item closes only when the
233
+ // difference is gone; otherwise it stays open with the new evidence.
234
+ router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
235
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
236
+ const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
237
+ await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
238
+ const result = await checkSentInvoices(db, runtime, organisationId, { invoiceIds: [invoiceId] });
239
+ const status = (await accountingStatus(db, runtime, organisationId, [invoiceId])).get(invoiceId) ?? null;
240
+ res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
241
+ }));
242
+
208
243
  router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
209
244
  const organisationId = await requireOrganisation(db, req.body?.organisationId);
210
245
  const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
@@ -1,3 +1,4 @@
1
+ import { createCallGuard } from '../call-guard.js';
1
2
  import { createXeroClient, xeroError } from './client.js';
2
3
  import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
3
4
  import { createGovernor } from './governor.js';
@@ -6,9 +7,21 @@ import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from '
6
7
  import { resolveXeroSettings, writesAllowed } from './settings.js';
7
8
 
8
9
  // The Xero adapter's state for one Flow process: settings, the rate-limit
9
- // governor, the transport (real fetch, or replay), OAuth states, and the
10
- // access tokens in memory. Access tokens live 30 minutes and are never
11
- // stored; the refresh token is stored sealed (connections.js).
10
+ // governor, the call guard (its state is in PostgreSQL), the transport (real
11
+ // fetch, or replay), OAuth states, and the access tokens in memory. Access
12
+ // tokens live 30 minutes and are never stored; the refresh token is stored
13
+ // sealed (connections.js).
14
+
15
+ // The call guard's tables and policy (../call-guard.js, docs/XERO.md). A 401
16
+ // or 403 that a fresh token does not clear stops only the resource it came
17
+ // from: a connection can lack one scope (payments) and still serve the rest.
18
+ // A refused token refresh halts every call.
19
+ export const XERO_CALL_GUARD = Object.freeze({
20
+ integration: 'xero',
21
+ name: 'Xero',
22
+ tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
23
+ policy: Object.freeze({ authStops: 'resource' }),
24
+ });
12
25
 
13
26
  export function requireXeroSettings(env = process.env) {
14
27
  const settings = resolveXeroSettings(env);
@@ -31,6 +44,14 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
31
44
  const accessTokens = new Map(); // organisation id -> { token, expiresAt }
32
45
  const refreshing = new Map(); // organisation id -> promise
33
46
  const queues = new Map(); // organisation id -> tail of its work queue
47
+ const guards = new WeakMap(); // database handle -> call guard
48
+
49
+ // The call guard for one database. Keyed by the Xero tenant id: Xero's
50
+ // limits are per organisation.
51
+ function guardFor(db) {
52
+ if (!guards.has(db)) guards.set(db, createCallGuard({ db, ...XERO_CALL_GUARD, now, log }));
53
+ return guards.get(db);
54
+ }
34
55
 
35
56
  // Runs heavy Xero work for one organisation one at a time, in order: a
36
57
  // sync and a batch of drafts draw on the same call budget, and running them
@@ -67,11 +88,29 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
67
88
 
68
89
  // Refresh under a row lock, and store the rotated refresh token in the same
69
90
  // transaction, so two refreshes never race and the newest token is kept.
91
+ // A refresh Xero refuses for good halts every call until an owner connects
92
+ // again or saves the settings; another failed refresh counts as a failed
93
+ // call.
70
94
  async function refresh(db, organisationId) {
95
+ const guard = guardFor(db);
71
96
  return db.transaction(async (tx) => {
72
97
  const connection = usable(await readConnection(tx, organisationId, { lock: true }));
98
+ await guard.check(connection.tenantId, { resource: 'token' });
73
99
  const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
74
- const tokens = await refreshTokens(settings, transport, refreshToken);
100
+ let tokens;
101
+ try {
102
+ tokens = await refreshTokens(settings, transport, refreshToken);
103
+ } catch (error) {
104
+ // invalid_grant: the connection is gone. invalid_client: the app's
105
+ // client id or secret is wrong. Neither clears without a person.
106
+ if (error?.code === 'xero_reconnect_needed' || ['invalid_client', 'unauthorized_client'].includes(error?.oauthError)) {
107
+ await guard.halt(connection.tenantId, 'auth', { detail: `Xero refused the token refresh${error.oauthError ? ` (${error.oauthError})` : ''}` });
108
+ } else if (error?.code !== 'xero_replay_no_match') {
109
+ await guard.record(connection.tenantId, { resource: 'token', failureKind: error?.code === 'xero_unreachable' ? 'network' : 'token_refresh' });
110
+ }
111
+ throw error;
112
+ }
113
+ await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
75
114
  await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
76
115
  remember(organisationId, tokens);
77
116
  return tokens.access_token;
@@ -96,6 +135,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
96
135
  invalidateAccessToken: () => accessTokens.delete(organisationId),
97
136
  fetch: transport,
98
137
  governor,
138
+ guard: guardFor(db),
99
139
  apiBase: settings.endpoints.api,
100
140
  log,
101
141
  });
@@ -122,6 +162,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
122
162
  settings,
123
163
  transport,
124
164
  governor,
165
+ guardFor,
125
166
  states,
126
167
  remember,
127
168
  forget: (organisationId) => accessTokens.delete(organisationId),
@@ -2,8 +2,8 @@
2
2
  // GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
3
3
  // deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
4
4
  //
5
- // Release names follow docs/RELEASING.md: vYYYY.M.D is stable, and
6
- // vYYYY.M.D-beta.N is a beta of the upcoming YYYY.M.D.
5
+ // Release names follow docs/RELEASING.md: vYYYY.M.N is stable, and
6
+ // vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
7
7
  //
8
8
  // stable GitHub's latest release (what Flow Release Promote points at, so
9
9
  // a rollback is followed), else npm `latest`
@@ -13,7 +13,7 @@
13
13
  // Nothing here sends anything about the installation: each request is an
14
14
  // anonymous GET of public data.
15
15
 
16
- const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]|[12]\d|3[01])(?:-beta\.([1-9]\d*))?$/;
16
+ const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
17
17
  export const CHANNELS = ["stable", "beta"];
18
18
  export const DEFAULT_GITHUB_API = "https://api.github.com";
19
19
  export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
@@ -0,0 +1,77 @@
1
+ // The quiet alert strip (issue 117, docs/ALERTS.md): one slim line under a
2
+ // page's header when something on that page needs this person. It is hidden
3
+ // when nothing does. It shows the worst alert first, with "+N more" when there
4
+ // are others; × hides that alert for this person until something new joins
5
+ // it, and the next one shows. Data comes from /api/alerts
6
+ // (server/api/alerts.js). The caller passes in app.js's shared pieces as `ui`.
7
+
8
+ const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
9
+ const CLOSE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
10
+
11
+ // `onTab(tab)` opens a tab on the page, for an alert whose action names one.
12
+ // `scope` narrows the page to one part (an integration's own page), and
13
+ // `onLoad({ alerts, danger })` hears each answer (`danger`: a danger alert is
14
+ // open, even one this person hid), so the page can show the same state
15
+ // elsewhere. An action that links to the page already open is left out.
16
+ // Answers { element, refresh }: put `element` on the page, and call
17
+ // `refresh()` after a change that can fix or make an alert.
18
+ export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad = () => {} } = {}) {
19
+ const { h, flow, go, toast } = ui;
20
+ const element = h('div', { class: 'fx-alerts', hidden: true, role: 'status', 'aria-live': 'polite' });
21
+ let alerts = [];
22
+
23
+ function draw() {
24
+ const [first] = alerts;
25
+ element.hidden = !first;
26
+ if (!first) {
27
+ element.replaceChildren();
28
+ return;
29
+ }
30
+ element.dataset.tone = first.tone;
31
+ const close = h('button', { class: 'fx-alerts__close', type: 'button', 'aria-label': 'Dismiss', title: 'Dismiss', onclick: () => dismiss(first.key) });
32
+ close.innerHTML = CLOSE;
33
+ let action = null;
34
+ if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
35
+ else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
36
+ element.replaceChildren(...[
37
+ h('p', { class: 'fx-alerts__text' },
38
+ first.title,
39
+ first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
40
+ action,
41
+ alerts.length > 1 ? h('span', { class: 'fx-alerts__more' }, `+${alerts.length - 1} more`) : null,
42
+ close,
43
+ ].filter(Boolean));
44
+ }
45
+
46
+ async function dismiss(key) {
47
+ alerts = alerts.filter((a) => a.key !== key);
48
+ draw();
49
+ try {
50
+ await flow('/api/alerts/dismiss', { method: 'POST', body: JSON.stringify({ key }) });
51
+ } catch (error) {
52
+ toast(error.message, 'danger');
53
+ }
54
+ }
55
+
56
+ // An alert that cannot load shows nothing: the page works without it.
57
+ async function refresh() {
58
+ let danger = false;
59
+ try {
60
+ const query = new URLSearchParams({ page, ...(scope ? { scope } : {}) });
61
+ ({ alerts, danger = false } = await flow(`/api/alerts?${query}`));
62
+ } catch {
63
+ alerts = [];
64
+ }
65
+ onLoad({ alerts, danger });
66
+ draw();
67
+ }
68
+
69
+ // Coming back to the tab checks again, while this page is open.
70
+ const onVisible = () => {
71
+ if (!element.isConnected) document.removeEventListener('visibilitychange', onVisible);
72
+ else if (document.visibilityState === 'visible') refresh();
73
+ };
74
+ document.addEventListener('visibilitychange', onVisible);
75
+ refresh();
76
+ return { element, refresh };
77
+ }
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({
package/server/ui/app.js CHANGED
@@ -12,9 +12,11 @@ import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/ass
12
12
  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
+ import { renderPriceList } from '/assets/ui/price-list.js';
15
16
  import { renderJobs } from '/assets/ui/jobs.js';
16
17
  import { startMonitoring } from '/assets/ui/monitoring.js';
17
18
  import { openSearch } from '/assets/ui/search.js';
19
+ import { alertStrip } from '/assets/ui/alerts.js';
18
20
 
19
21
  const $ = (id) => document.getElementById(id);
20
22
 
@@ -194,10 +196,13 @@ let me = null;
194
196
  const VIEWS = {
195
197
  jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
196
198
  invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
197
- 'invoicing-settings': { label: 'Invoicing', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
199
+ 'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
200
+ 'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
198
201
  people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
199
202
  integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
200
- account: { label: 'Account', icon: 'person', group: 'Settings', render: renderAccount },
203
+ // Not in the sidebar: "Manage account" in the account menu opens the same
204
+ // Clerk profile. The page stays for links and Search.
205
+ account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
201
206
  appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
202
207
  };
203
208
 
@@ -206,6 +211,7 @@ const ICON_PATHS = {
206
211
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
207
212
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
208
213
  people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
214
+ tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
209
215
  plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
210
216
  person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
211
217
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
@@ -368,6 +374,7 @@ function renderShell() {
368
374
  let group = null;
369
375
  for (const [name, view] of Object.entries(VIEWS)) {
370
376
  if (view.ownerOnly && me?.person.role !== 'owner') continue;
377
+ if (view.sidebar === false) continue;
371
378
  if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
372
379
  group = view.group;
373
380
  items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
@@ -539,6 +546,7 @@ async function renderIntegrations(main) {
539
546
  const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
540
547
  main.append(
541
548
  h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
549
+ alertStrip(ui(), 'integrations').element,
542
550
  h('div', { class: 'fx-int-bar' }, tabs, search),
543
551
  notices, content);
544
552
  let body;
@@ -623,8 +631,24 @@ async function renderIntegration(main, id) {
623
631
  return;
624
632
  }
625
633
  const canChange = Boolean(me.ownerActions?.allowed);
634
+ // The quiet alert line for this integration only (server/ui/alerts.js).
635
+ // While a danger alert is open, even one this person hid, the status says
636
+ // calls are stopped instead of the integration's state.
637
+ let stopped = false;
638
+ const statusPill = (current) => (stopped && current.state === 'ready'
639
+ ? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
640
+ : h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
641
+ let shown = found;
642
+ const alerts = alertStrip(ui(), 'integrations', {
643
+ scope: id,
644
+ onLoad: ({ danger }) => {
645
+ stopped = danger;
646
+ for (const pill of host.querySelectorAll('[data-int-status]')) pill.replaceWith(statusPill(shown));
647
+ },
648
+ });
626
649
 
627
650
  const draw = (current, progress = null) => {
651
+ shown = current;
628
652
  const inputs = new Map();
629
653
  const rows = [];
630
654
  for (const field of current.fields) {
@@ -732,10 +756,11 @@ async function renderIntegration(main, id) {
732
756
  h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
733
757
  h('div', { class: 'fx-int-hero__actions' },
734
758
  primary,
735
- on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state) : null,
759
+ on ? statusPill(current) : null,
736
760
  progress ? connectProgress(progress) : null))),
737
761
  h('div', { class: 'fx-int-page__grid' },
738
762
  h('div', { class: 'fx-int-page__main' }, ...[
763
+ alerts.element,
739
764
  !canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
740
765
  !body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
741
766
  current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
@@ -753,7 +778,7 @@ async function renderIntegration(main, id) {
753
778
  ].filter(Boolean)),
754
779
  h('aside', { class: 'fx-int-page__meta' },
755
780
  h('dl', { class: 'fx-meta' }, ...[
756
- ['Status', h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state)],
781
+ ['Status', statusPill(current)],
757
782
  ['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
758
783
  modeLabel ? ['Mode', modeLabel] : null,
759
784
  ['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
@@ -768,11 +793,11 @@ async function renderIntegration(main, id) {
768
793
  // One setting, laid out the same on every settings screen: a short name, one
769
794
  // line of smaller text under it, and the control on the right. Longer help
770
795
  // lives behind a link, not here. A wide control (a grid of choices) sits on
771
- // its own line under the name.
772
- function settingRow({ label, hint, control, wide = false }) {
796
+ // its own line under the name. `tone: 'danger'` marks a row that needs a person.
797
+ function settingRow({ label, hint, control, wide = false, tone = null }) {
773
798
  const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
774
799
  const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
775
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}` },
800
+ return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
776
801
  h('div', { class: 'fx-setting__text' },
777
802
  id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
778
803
  hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
@@ -800,16 +825,25 @@ function xeroConnection(organisations, canChange) {
800
825
  (async () => {
801
826
  let hint;
802
827
  let control = null;
828
+ let stopped = false;
803
829
  try {
804
830
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
805
- if (status.connected) {
831
+ if (status.connected && status.calls?.stopped) {
832
+ // A refused connection stopped calls (the call guard): say so, and
833
+ // make Connect again the main button, which starts them again.
834
+ const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
835
+ hint = status.calls.all
836
+ ? `Xero refused Flow${at}. Flow stopped all calls.`
837
+ : `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow stopped those calls.`;
838
+ stopped = true;
839
+ } else if (status.connected) {
806
840
  const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
807
841
  hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
808
842
  } else {
809
843
  hint = 'Not connected.';
810
844
  }
811
845
  if (canChange) {
812
- control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
846
+ control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
813
847
  try {
814
848
  const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
815
849
  location.assign(authorizeUrl);
@@ -821,7 +855,7 @@ function xeroConnection(organisations, canChange) {
821
855
  } catch (error) {
822
856
  hint = error.message;
823
857
  }
824
- row.replaceWith(settingRow({ label: organisation.name, hint, control }));
858
+ row.replaceWith(settingRow({ label: organisation.name, hint, control, tone: stopped ? 'danger' : null }));
825
859
  })();
826
860
  }
827
861
  return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
@@ -854,9 +888,9 @@ async function invitePerson() {
854
888
  // security), with the person's Flow role above it.
855
889
  function renderAccount(main) {
856
890
  const host = h('div', { class: 'fx-clerk-profile' });
857
- main.append(
858
- pageHeader('Account', { subtitle: `${me.person.email} · ${ROLE_LABEL[me.person.role]}. ${ROLE_HINT[me.person.role]}` }),
859
- host);
891
+ // Clerk's card has its own "Account" heading; Flow's page heading is for
892
+ // screen readers only, so the title shows once.
893
+ main.append(h('h1', { class: 'fx-sr' }, 'Account'), host);
860
894
  // Clerk keeps its own pages (profile, security) in memory, so the URL
861
895
  // stays Flow's.
862
896
  mountClerk('profile', host, { routing: 'virtual' });
@@ -1011,7 +1045,7 @@ drawModeToggles();
1011
1045
  // the Clerk instance is called.
1012
1046
  const LOCALIZATION = {
1013
1047
  signIn: {
1014
- start: { title: 'Sign in to Flow', subtitle: 'Welcome back. Choose how to sign in.' },
1048
+ start: { title: 'Sign in to Flow', subtitle: 'Choose how to sign in.' },
1015
1049
  password: { subtitle: 'Enter the password for your account.' },
1016
1050
  },
1017
1051
  signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
@@ -11,6 +11,9 @@ const gearIcon = {
11
11
  '&& > *': { visibility: 'hidden' },
12
12
  };
13
13
 
14
+ // A small down chevron after the name, as in other apps' account buttons.
15
+ const CHEVRON = `url("data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5L6 7.5L9 4.5"/></svg>')}")`;
16
+
14
17
  // Matches the narrow-screen rule in flow.css, where the sidebar becomes a bar.
15
18
  const NARROW = '@media (max-width: 820px)';
16
19
 
@@ -88,24 +91,54 @@ export function flowClerkAppearance({ embedded = false, publicSignUp = false } =
88
91
  badge: { '&&': { color: dim, background: `color-mix(in srgb, ${text} 5%, transparent)`, borderColor: line } },
89
92
  formButtonPrimary: { boxShadow: 'none', textTransform: 'none', fontWeight: 650 },
90
93
  socialButtonsBlockButton: controlEdge,
91
- // The account button in the sidebar foot is a chip: photo or initial,
92
- // then the name (Clerk shows the email when there is no name), clipped
93
- // with an ellipsis to fit. The top bar on narrow screens has no room for
94
- // the name, so there it is the photo or initial alone.
94
+ // The account button in the sidebar foot: photo or initial, then the
95
+ // name (Clerk shows the email when there is no name), clipped with an
96
+ // ellipsis to fit, then a small chevron. At rest it is plain; on hover,
97
+ // keyboard focus or while its menu is open it is an outlined pill. The
98
+ // top bar on narrow screens has no room for the name, so there it is the
99
+ // photo or initial alone.
95
100
  userButtonTrigger: {
96
101
  '&&': {
97
- width: '100%', minWidth: 0, justifyContent: 'flex-start', padding: '4px 10px 4px 4px', borderRadius: token('--t-radius-lg', '10px'), background: paper, boxShadow: `inset 0 0 0 1px ${line}`,
98
- '&:hover': { background: `color-mix(in srgb, ${text} 6%, ${paper})` },
99
- [NARROW]: { width: 'auto', padding: 0, borderRadius: '50%', background: 'transparent', boxShadow: 'none', '&:hover': { background: 'transparent' } },
102
+ width: 'auto', maxWidth: '100%', minWidth: 0, justifyContent: 'flex-start', padding: '4px 8px 4px 4px',
103
+ borderRadius: token('--t-radius-lg', '10px'), background: 'transparent', boxShadow: 'none',
104
+ '&:hover, &:focus-visible, &[aria-expanded="true"]': { background: `color-mix(in srgb, ${text} 5%, transparent)`, boxShadow: `inset 0 0 0 1px ${line}` },
105
+ '&:focus-visible': { boxShadow: `inset 0 0 0 1px ${line}, ${focus}` },
106
+ [NARROW]: { padding: 0, borderRadius: '50%', '&:hover, &[aria-expanded="true"]': { background: 'transparent', boxShadow: 'none' } },
107
+ },
108
+ },
109
+ userButtonBox: {
110
+ flexDirection: 'row-reverse', justifyContent: 'flex-end', minWidth: 0, gap: '6px',
111
+ // The box lays out right to left, so ::before lands after the name.
112
+ '&::before': {
113
+ content: '""', flex: 'none', width: '10px', height: '10px', background: dim,
114
+ mask: `${CHEVRON} center / contain no-repeat`, WebkitMask: `${CHEVRON} center / contain no-repeat`,
100
115
  },
116
+ [NARROW]: { '&::before': { display: 'none' } },
101
117
  },
102
- userButtonBox: { flexDirection: 'row-reverse', justifyContent: 'flex-end', minWidth: 0, gap: '8px' },
103
118
  userButtonOuterIdentifier: {
104
119
  minWidth: 0, paddingInlineStart: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
105
120
  color: token('--t-strong', text), fontSize: '14px', fontWeight: 600,
106
121
  [NARROW]: { display: 'none' },
107
122
  },
108
- userButtonPopoverCard: { border: `1px solid ${line}`, boxShadow: shadow },
123
+ // The account menu is as wide as its rows, between 220px and 280px, with
124
+ // compact rows. A long name or email ends with an ellipsis and does not
125
+ // make the menu wider.
126
+ userButtonPopoverCard: {
127
+ '&&': { width: 'max-content', minWidth: '220px', maxWidth: 'min(280px, calc(100vw - 16px))', border: `1px solid ${line}`, boxShadow: shadow },
128
+ },
129
+ userPreview__userButton: { '&&': { padding: '10px 12px', gap: '10px' } },
130
+ userPreviewAvatarContainer__userButton: { '&&, && .cl-userPreviewAvatarBox': { width: '28px', height: '28px' } },
131
+ userPreviewTextContainer__userButton: { '&&': { minWidth: 0, overflow: 'hidden' } },
132
+ userPreviewMainIdentifier__userButton: { '&&': { minWidth: 0, fontSize: '13px' } },
133
+ userPreviewMainIdentifierText__userButton: { '&&': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
134
+ userPreviewSecondaryIdentifier__userButton: {
135
+ '&&': { display: 'block', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '12px', color: dim },
136
+ },
137
+ userButtonPopoverActionButton: { '&&': { padding: '8px 12px', gap: '10px', fontSize: '13px', color: text } },
138
+ userButtonPopoverActionButtonIconBox: { '&&': { flex: '0 0 28px', width: '28px' } },
139
+ userButtonPopoverCustomItemButton: { '&&': { padding: '8px 12px', gap: '10px', fontSize: '13px', color: text } },
140
+ userButtonPopoverCustomItemButtonIconBox: { '&&': { flex: '0 0 28px', width: '28px' } },
141
+ userButtonPopoverFooter: { '&&': { minWidth: 0 } },
109
142
  userButtonPopoverActionButtonIcon__manageAccount: gearIcon,
110
143
  footer: embedded ? { display: 'none' } : undefined,
111
144
  },
@@ -132,7 +132,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
132
132
  .fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
133
133
  .fx-ico { display: inline-flex; flex: none; }
134
134
  .fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
135
- .fx-side__end { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 6px 0; border-top: 1px solid var(--fx-line); }
135
+ .fx-side__end { display: flex; align-items: center; justify-content: space-between; gap: 4px; padding: 12px 0 0; border-top: 1px solid var(--fx-line); }
136
136
  .fx-side__user { display: flex; min-width: 32px; min-height: 32px; align-items: center; }
137
137
  /* Clerk replaces the mount's classes with its root box's, so this uses the id. */
138
138
  #user-button { flex: 1 1 auto; min-width: 0; }
@@ -148,7 +148,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
148
148
  .fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
149
149
  .fx-shell[data-folded] #user-button { flex: none; }
150
150
  .fx-shell[data-folded] #user-button .cl-userButtonTrigger { width: auto; padding: 0; border-radius: 50%; background: transparent; box-shadow: none; }
151
- .fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier { display: none; }
151
+ .fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier, .fx-shell[data-folded] #user-button .cl-userButtonBox::before { display: none; }
152
152
  }
153
153
  .fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
154
154
  .fx-main:focus { outline: none; box-shadow: none; }
@@ -269,6 +269,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
269
269
  .fx-int-hero__actions { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 6px; }
270
270
  .fx-int-page__grid { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 32px; align-items: start; }
271
271
  .fx-int-page__main { display: grid; gap: 28px; }
272
+ .fx-int-page__main > .fx-alerts { margin: 0; }
272
273
  .fx-int-section { display: grid; gap: 14px; padding-top: 20px; border-top: 1px solid var(--fx-line); }
273
274
  .fx-int-section__title { color: var(--t-strong); font: 650 17px/1.3 var(--t-body); }
274
275
  .fx-int-section .fx-btn { justify-self: start; }
@@ -328,6 +329,25 @@ a.fx-strong:hover { text-decoration: underline; }
328
329
  /* Invoices: the Send all ready bar above the Ready list. */
329
330
  .fx-sendbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; padding: 12px 16px; border: 1px solid var(--fx-line); border-radius: 8px; background: var(--t-paper); margin-top: 12px; }
330
331
  .fx-sendbar[hidden] { display: none; }
332
+
333
+ /* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
334
+ only while something on the page needs this person. */
335
+ .fx-alerts { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; padding: 6px 6px 6px 14px; border: 1px solid var(--fx-line); border-left: 3px solid var(--t-warn); border-radius: var(--t-radius); background: var(--t-paper); font-size: 14px; }
336
+ .fx-alerts[hidden] { display: none; }
337
+ .fx-alerts[data-tone="danger"] { border-left-color: var(--t-danger); }
338
+ .fx-alerts__text { flex: 1; min-width: 0; margin: 0; color: var(--t-strong); }
339
+ .fx-alerts__money { color: var(--t-dim); }
340
+ .fx-alerts__action { flex: none; padding: 0; border: 0; background: none; font: inherit; font-weight: 600; color: var(--t-strong); text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
341
+ .fx-alerts__more { flex: none; font-size: 12.5px; color: var(--t-dim); white-space: nowrap; }
342
+ .fx-alerts__close { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--t-radius); background: none; color: var(--t-dim); cursor: pointer; }
343
+ .fx-alerts__close:hover { background: var(--fx-hover); color: var(--t-strong); }
344
+ .fx-alerts__close svg { width: 14px; height: 14px; }
345
+ .fx-alerts__action:focus-visible, .fx-alerts__close:focus-visible { outline: 2px solid var(--t-ring); outline-offset: 2px; }
346
+ @media (max-width: 560px) {
347
+ .fx-alerts { flex-wrap: wrap; }
348
+ .fx-alerts__text { flex-basis: calc(100% - 52px); }
349
+ .fx-alerts__action, .fx-alerts__more { order: 2; }
350
+ }
331
351
  .fx-sendbar p { margin: 0; }
332
352
  .fx-problem { color: var(--t-danger); }
333
353
  .fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -587,6 +607,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
587
607
  .fx-setting__text { display: grid; gap: 3px; min-width: 0; }
588
608
  .fx-setting__name { color: var(--t-strong); font: 500 14.5px/1.35 var(--t-body); }
589
609
  .fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
610
+ .fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
590
611
  .fx-setting__control { display: flex; justify-content: flex-end; min-width: 0; }
591
612
  .fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
592
613
  .fx-setting__control .fx-swatches { flex-wrap: nowrap; }
@@ -688,6 +709,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
688
709
  .fx-set__control .fx-input[type="number"] { width: 96px; }
689
710
  .fx-set__control .fx-select { min-width: 220px; }
690
711
  .fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
712
+ /* Price list (price-list.js): a code's price and its systems' chips. */
713
+ .fx-price__hint { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
714
+ .fx-price__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
715
+ .fx-price__head .fx-search { margin: 0; }
716
+ @media (max-width: 560px) { .fx-price__head .fx-search { width: 100%; } }
717
+ .fx-price__systems { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
718
+ .fx-set__control .fx-price__link { width: 260px; min-width: 0; }
719
+ @media (max-width: 560px) { .fx-set__control .fx-price__link { width: 150px; } }
691
720
  /* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
692
721
  rule dialog's condition lines. */
693
722
  .fx-rule__controls { align-items: center; gap: 10px; }
@@ -23,7 +23,7 @@ const SEND_MODES = [
23
23
  export async function renderInvoicingSettings(main, ui) {
24
24
  const { h, flow, toast, notice, pageHeader, me } = ui;
25
25
  const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
26
- main.append(pageHeader('Invoicing', { subtitle: 'How Flow drafts invoices. No integration needed.' }), host);
26
+ main.append(pageHeader('Invoice rules', { subtitle: 'The rules Flow uses to draft each invoice.' }), host);
27
27
  let body;
28
28
  try {
29
29
  body = await flow('/api/invoicing/settings');