@arcforgelabs/flow 2026.10.29 → 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 (76) 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/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. package/server/ui/routes.js +1 -1
@@ -0,0 +1,173 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { openReview, resolveReviewFor } from '../../core/review.js';
3
+
4
+ // Each invoice Flow sent, against Flow's copy of Xero (issue #132). People
5
+ // change invoices in both places after Flow sends them: a Xero draft is edited
6
+ // before approval, an invoice is voided in Xero, or voided in Flow. The issued
7
+ // version Flow sent is the merge base: the document both sides agreed on.
8
+ // This module compares the Xero copy with it and with the Flow invoice now.
9
+ // It never calls Xero and never writes to Xero or to the Flow invoice: a
10
+ // difference opens one review item (alert only), and a person fixes it where
11
+ // they choose. The item closes when a later check finds no difference.
12
+
13
+ export const OUT_OF_SYNC = 'invoice.accounting_differs';
14
+ export const ACTOR = 'system:xero-reconcile';
15
+
16
+ // Xero's status in the words the Flow screens show.
17
+ const LABELS = {
18
+ not_sent: 'Not in Xero',
19
+ sending: 'Sending to Xero',
20
+ not_copied: 'Sent, not read back yet',
21
+ draft: 'Draft in Xero',
22
+ approved: 'Approved in Xero',
23
+ part_paid: 'Part paid in Xero',
24
+ paid: 'Paid in Xero',
25
+ voided: 'Voided in Xero',
26
+ deleted: 'Deleted in Xero',
27
+ };
28
+
29
+ const GONE = new Set(['VOIDED', 'DELETED']);
30
+
31
+ function xeroState(row) {
32
+ switch (row.status) {
33
+ case 'DRAFT':
34
+ case 'SUBMITTED':
35
+ return 'draft';
36
+ case 'AUTHORISED':
37
+ return Number(row.amount_paid_cents ?? 0) > 0 ? 'part_paid' : 'approved';
38
+ case 'PAID':
39
+ return 'paid';
40
+ case 'VOIDED':
41
+ return 'voided';
42
+ case 'DELETED':
43
+ return 'deleted';
44
+ default:
45
+ return 'draft';
46
+ }
47
+ }
48
+
49
+ const num = (value) => (value === null || value === undefined ? null : Number(value));
50
+ const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : null);
51
+ const codes = (lines) => [...(lines ?? []).map((l) => l.itemCode ?? '')].sort().join(',');
52
+
53
+ // What differs between the Xero copy and the version Flow sent. Free text
54
+ // (reference, descriptions) is not compared: the copy keeps it only when the
55
+ // lab turns that on (XERO_KEEP_REFERENCE).
56
+ export function sentDifferences(sent, document, copy) {
57
+ const found = [];
58
+ const add = (field, flow, xero) => found.push({ field, flow, xero });
59
+ if (sent?.Contact?.ContactID && copy.xero_contact_id !== sent.Contact.ContactID) add('contact', sent.Contact.ContactID, copy.xero_contact_id);
60
+ if (num(copy.subtotal_cents) !== document.subtotalCents) add('subtotal', document.subtotalCents, num(copy.subtotal_cents));
61
+ if (num(copy.tax_cents) !== document.taxCents) add('tax', document.taxCents, num(copy.tax_cents));
62
+ if (num(copy.total_cents) !== document.totalCents) add('total', document.totalCents, num(copy.total_cents));
63
+ const lines = copy.lines ?? [];
64
+ if (lines.length !== document.lines.length) add('line count', document.lines.length, lines.length);
65
+ else if (codes(lines) !== codes(document.lines)) add('items', codes(document.lines), codes(lines));
66
+ if (sent?.Date && day(copy.issued_on) !== sent.Date) add('date', sent.Date, day(copy.issued_on));
67
+ return found;
68
+ }
69
+
70
+ // The finding for one invoice, or null when Flow and Xero agree.
71
+ // differs Xero's copy no longer matches the version Flow sent
72
+ // voided_in_xero Xero voided or deleted it; the Flow invoice is still issued
73
+ // voided_in_flow Flow voided it; Xero's invoice is still live
74
+ function findingFor(status) {
75
+ if (!status.xero) return null;
76
+ const live = !GONE.has(status.xero.status);
77
+ if (status.flowStatus === 'void' && live) return 'voided_in_flow';
78
+ if (status.flowStatus === 'issued' && status.current && !live) return 'voided_in_xero';
79
+ if (live && status.differences.length) return 'differs';
80
+ return null;
81
+ }
82
+
83
+ const MESSAGES = {
84
+ differs: 'Xero no longer matches the invoice Flow sent.',
85
+ voided_in_xero: 'Xero voided or deleted this invoice. It is still issued in Flow.',
86
+ voided_in_flow: 'This invoice is void in Flow. Xero still holds it as a live invoice.',
87
+ };
88
+
89
+ // Each invoice's Xero status, from Flow's copy (no Xero calls). Answers a map
90
+ // from Flow invoice id to { state, label, xero, differences, finding, message }.
91
+ export async function accountingStatus(db, runtime, organisationId, invoiceIds = null) {
92
+ const only = invoiceIds ? sql`and i.id = any(${sql.param([...new Set(invoiceIds.map(String))])}::uuid[])` : sql``;
93
+ // Without ids: only invoices Flow sent, for the check after each read.
94
+ const sentOnly = invoiceIds ? sql`` : sql`and p.id is not null`;
95
+ const { rows } = await db.execute(sql`
96
+ select i.id, i.status, i.issued_version, i.total_cents,
97
+ p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
98
+ v.document,
99
+ x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
100
+ x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
101
+ from invoices i
102
+ left join lateral (
103
+ select * from xero_invoice_pushes
104
+ where organisation_id = i.organisation_id and invoice_id = i.id
105
+ order by version desc limit 1
106
+ ) p on true
107
+ left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
108
+ left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = p.xero_invoice_id
109
+ where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
110
+ const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
111
+ const out = new Map();
112
+ for (const r of rows) {
113
+ let state;
114
+ if (!r.push_state) state = 'not_sent';
115
+ else if (['dispatching', 'ambiguous'].includes(r.push_state)) state = 'sending';
116
+ else if (!r.x_status) state = 'not_copied';
117
+ else state = xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents });
118
+ let sent = null;
119
+ try {
120
+ sent = r.request_body ? JSON.parse(r.request_body) : null;
121
+ } catch {
122
+ sent = null;
123
+ }
124
+ const xero = r.x_status ? {
125
+ invoiceId: r.xero_invoice_id,
126
+ number: r.x_number ?? r.xero_invoice_number ?? null,
127
+ status: r.x_status,
128
+ totalCents: num(r.x_total_cents),
129
+ amountDueCents: num(r.amount_due_cents),
130
+ url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(r.xero_invoice_id)}`,
131
+ } : null;
132
+ const status = {
133
+ invoiceId: r.id,
134
+ flowStatus: r.status,
135
+ sentVersion: r.sent_version ?? null,
136
+ current: r.sent_version !== null && r.sent_version === r.issued_version,
137
+ state,
138
+ label: LABELS[state],
139
+ xero,
140
+ differences: xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
141
+ flowTotalCents: num(r.total_cents),
142
+ };
143
+ status.finding = findingFor(status);
144
+ status.message = status.finding ? MESSAGES[status.finding] : null;
145
+ out.set(r.id, status);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // Opens or refreshes one review item for each finding, and closes the item of
151
+ // an invoice whose finding went away. Answers counts.
152
+ export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {
153
+ const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
154
+ const result = { checked: 0, open: 0, closed: 0 };
155
+ for (const status of statuses.values()) {
156
+ if (status.state === 'not_sent') continue;
157
+ result.checked += 1;
158
+ if (status.finding) {
159
+ await openReview(db, organisationId, {
160
+ kind: OUT_OF_SYNC,
161
+ entity: 'invoice',
162
+ flowId: status.invoiceId,
163
+ systems: ['flow', 'xero'],
164
+ evidence: { finding: status.finding, message: status.message, flowStatus: status.flowStatus, sentVersion: status.sentVersion, xero: status.xero, differences: status.differences },
165
+ moneyAtRiskCents: Math.abs(status.xero?.totalCents ?? status.flowTotalCents ?? 0),
166
+ });
167
+ result.open += 1;
168
+ } else if (await resolveReviewFor(db, organisationId, { kind: OUT_OF_SYNC, entity: 'invoice', flowId: status.invoiceId, resolution: 'no_difference', actor: ACTOR })) {
169
+ result.closed += 1;
170
+ }
171
+ }
172
+ return result;
173
+ }
@@ -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.' } },