@arcforgelabs/flow 2026.10.30 → 2026.10.32

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 (79) 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 +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -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/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
@@ -0,0 +1,311 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { isCallsStopped } from '../call-guard.js';
4
+ import { lastSync, parseXeroDate, syncCatalogue } from './catalogue.js';
5
+ import { withXeroLane } from './governor.js';
6
+ import { mirrorState } from './lookup.js';
7
+ import { mapRecords, mirrorXero, storeRows, toCents } from './mirror.js';
8
+ import { checkSentInvoices } from './reconcile.js';
9
+
10
+ // Xero's webhooks, and the daily check (issue #45). The poll (import.js) reads
11
+ // Xero's changes every 15 minutes. Webhooks make a change reach Flow in
12
+ // seconds, and the daily check finds what both of them missed.
13
+ //
14
+ // 1. Xero posts an event to /api/xero/webhooks: a record id and what
15
+ // happened, never the record. Flow checks the signature, saves the id in
16
+ // the inbox (xero_webhook_events) and answers 200 at once.
17
+ // 2. The worker reads the waiting invoices by id, up to 50 a call, into the
18
+ // copy (mirror.js), then compares the sent invoices again (reconcile.js).
19
+ // Credit notes are read as changes since the cursor; contacts refresh the
20
+ // contacts and items copy.
21
+ // 3. While webhooks arrive, the poll waits an hour instead of 15 minutes. If
22
+ // they stop, it goes back to 15 minutes by itself.
23
+ // 4. Once a day, one summary read of every invoice (about 1,000 a call)
24
+ // finds each copy row whose status, amounts or change time differ from
25
+ // Xero, and each row Xero no longer lists. Those go into the inbox and are
26
+ // read by id like an event. This catches part payments that do not move
27
+ // Xero's change time, and a webhook that never came.
28
+ //
29
+ // Every read goes through the call guard and the governor's sync lane, and
30
+ // leaves the mirror's reserve of the day's calls for drafts and people. Flow
31
+ // never writes to Xero here.
32
+
33
+ export const BY_ID_BATCH = 50;
34
+ // The poll's wait while webhooks arrive, and how recent the last one must be.
35
+ export const WEBHOOK_REFRESH_MS = 60 * 60 * 1000;
36
+ export const WEBHOOK_ACTIVE_MS = 24 * 60 * 60 * 1000;
37
+ export const DAILY_CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
38
+ // A daily check that failed is tried again after this wait.
39
+ const DAILY_CHECK_RETRY_MS = 60 * 60 * 1000;
40
+ const SUMMARY_PAGE = 1000;
41
+ // At most this many records a day go back for a read by id (5 calls), so a
42
+ // check that finds the whole copy different cannot spend the day. The rest
43
+ // wait for the next day's check.
44
+ export const DAILY_FLAG_MAX = 250;
45
+ const MAX_SUMMARY_PAGES = 50;
46
+ // Contacts and items are read again at most this often for contact events.
47
+ const CONTACTS_EVERY_MS = 60 * 60 * 1000;
48
+
49
+ const CATEGORIES = { INVOICE: 'invoices', CREDITNOTE: 'credit_notes', CREDIT_NOTE: 'credit_notes', CONTACT: 'contacts' };
50
+
51
+ // Xero signs the raw body with HMAC-SHA256 and the webhook key, in base64
52
+ // (x-xero-signature). Compared in constant time.
53
+ export function signatureValid(key, rawBody, signature) {
54
+ if (!key || typeof signature !== 'string' || !signature) return false;
55
+ const expected = createHmac('sha256', key).update(rawBody).digest();
56
+ let given;
57
+ try {
58
+ given = Buffer.from(signature, 'base64');
59
+ } catch {
60
+ return false;
61
+ }
62
+ return given.length === expected.length && timingSafeEqual(given, expected);
63
+ }
64
+
65
+ // Saves the events of one verified delivery. An event for an organisation not
66
+ // connected in this mode is dropped. Answers how many were saved.
67
+ export async function saveEvents(db, runtime, payload, { now = new Date() } = {}) {
68
+ const events = Array.isArray(payload?.events) ? payload.events : [];
69
+ if (!events.length) return 0;
70
+ const { rows } = await db.execute(sql`select organisation_id, tenant_id from xero_connections where mode = ${runtime.settings.mode}`);
71
+ const byTenant = new Map(rows.map((r) => [r.tenant_id, r.organisation_id]));
72
+ let saved = 0;
73
+ for (const event of events) {
74
+ const organisationId = byTenant.get(event?.tenantId);
75
+ if (!organisationId || typeof event.resourceId !== 'string' || !event.resourceId) continue;
76
+ const resource = CATEGORIES[String(event.eventCategory ?? '').toUpperCase()] ?? 'other';
77
+ const at = Date.parse(`${String(event.eventDateUtc ?? '').replace(/Z?$/, 'Z')}`);
78
+ await flag(db, organisationId, resource, event.resourceId, {
79
+ source: 'webhook', eventType: event.eventType ?? null, eventAt: Number.isNaN(at) ? null : new Date(at), now,
80
+ });
81
+ saved += 1;
82
+ }
83
+ return saved;
84
+ }
85
+
86
+ // Marks one record as waiting to be read by id.
87
+ async function flag(db, organisationId, resource, resourceId, { source, eventType = null, eventAt = null, now = new Date() }) {
88
+ await db.execute(sql`
89
+ insert into xero_webhook_events (organisation_id, resource, resource_id, event_type, source, event_at, received_at)
90
+ values (${organisationId}, ${resource}, ${resourceId}, ${eventType}, ${source}, ${eventAt}, ${now})
91
+ on conflict (organisation_id, resource, resource_id) do update set
92
+ event_type = excluded.event_type, source = excluded.source, event_at = excluded.event_at,
93
+ received_at = excluded.received_at, pending = true`);
94
+ }
95
+
96
+ async function markRead(db, organisationId, resource, ids = null) {
97
+ const only = ids ? sql`and resource_id = any(${sql.param(ids)}::text[])` : sql``;
98
+ await db.execute(sql`update xero_webhook_events set pending = false, read_at = now()
99
+ where organisation_id = ${organisationId} and resource = ${resource} and pending ${only}`);
100
+ }
101
+
102
+ // Whether a webhook for this organisation arrived recently: the poll can wait.
103
+ export async function webhooksActive(db, runtime, organisationId, { now = Date.now() } = {}) {
104
+ if (!runtime.settings.webhookKey) return false;
105
+ const { rows: [row] } = await db.execute(sql`select max(received_at) as at from xero_webhook_events
106
+ where organisation_id = ${organisationId} and source = 'webhook'`);
107
+ return Boolean(row?.at && Number(now) - new Date(row.at).getTime() <= WEBHOOK_ACTIVE_MS);
108
+ }
109
+
110
+ // Calls left above the reserve the mirror keeps for drafts and people.
111
+ function overReserve(runtime, tenantId) {
112
+ const budget = runtime.governor.status(tenantId);
113
+ return budget.dayRemaining > Math.ceil(budget.dayLimit * runtime.settings.mirrorReserveFraction);
114
+ }
115
+
116
+ async function pendingIds(db, organisationId) {
117
+ const { rows } = await db.execute(sql`select resource, resource_id from xero_webhook_events
118
+ where organisation_id = ${organisationId} and pending order by received_at`);
119
+ const out = new Map();
120
+ for (const r of rows) out.set(r.resource, [...(out.get(r.resource) ?? []), r.resource_id]);
121
+ return out;
122
+ }
123
+
124
+ // Reads every waiting record of one organisation. Answers counts, or a
125
+ // `stopped` reason: 'calls_stopped' (the call guard) or 'budget'.
126
+ export async function readInbox(db, runtime, organisationId, { now = Date.now } = {}) {
127
+ const waiting = await pendingIds(db, organisationId);
128
+ const result = { invoices: { read: 0, inserted: 0, changed: 0, unchanged: 0 }, creditNotes: 0, contacts: false, sent: null };
129
+ if (!waiting.size) return result;
130
+ const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
131
+ where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
132
+ if (!connection) return result;
133
+ try {
134
+ await runtime.guardFor(db).check(connection.tenant_id);
135
+ } catch (error) {
136
+ if (isCallsStopped(error)) return { ...result, stopped: 'calls_stopped' };
137
+ throw error;
138
+ }
139
+
140
+ const invoiceIds = waiting.get('invoices') ?? [];
141
+ if (invoiceIds.length) {
142
+ const counts = result.invoices;
143
+ const stopped = await runtime.serial(organisationId, () => withXeroLane('sync', async () => {
144
+ const { client } = await runtime.clientFor(db, organisationId);
145
+ for (let i = 0; i < invoiceIds.length; i += BY_ID_BATCH) {
146
+ if (!overReserve(runtime, client.tenantId)) return 'budget';
147
+ const batch = invoiceIds.slice(i, i + BY_ID_BATCH);
148
+ const rows = mapRecords('invoices', await client.listInvoicesByIds(batch), runtime.settings);
149
+ await db.transaction(async (tx) => {
150
+ await storeRows(tx, organisationId, 'invoices', rows, counts);
151
+ // An id Xero did not return is read too: there is nothing more to get.
152
+ await markRead(tx, organisationId, 'invoices', batch);
153
+ });
154
+ counts.read += rows.length;
155
+ }
156
+ return null;
157
+ }));
158
+ if (stopped) return { ...result, stopped };
159
+ if (counts.inserted || counts.changed) result.sent = await checkSentInvoices(db, runtime, organisationId);
160
+ }
161
+
162
+ if (waiting.get('credit_notes')?.length) {
163
+ // Credit notes are few: one read of the changes since the cursor gets them.
164
+ const [read] = await mirrorXero(db, runtime, organisationId, { resources: ['credit_notes'] });
165
+ if (read?.stopped === 'budget') return { ...result, stopped: 'budget' };
166
+ result.creditNotes = read?.read ?? 0;
167
+ await markRead(db, organisationId, 'credit_notes');
168
+ }
169
+
170
+ if (waiting.get('contacts')?.length) {
171
+ const last = await lastSync(db, organisationId);
172
+ const at = last?.finishedAt ?? last?.startedAt;
173
+ if (!at || Number(now()) - new Date(at).getTime() >= CONTACTS_EVERY_MS) {
174
+ await syncCatalogue(db, runtime, organisationId);
175
+ await markRead(db, organisationId, 'contacts');
176
+ result.contacts = true;
177
+ }
178
+ }
179
+
180
+ // Events Flow does not copy (other categories) need no read.
181
+ if (waiting.get('other')?.length) await markRead(db, organisationId, 'other');
182
+ return result;
183
+ }
184
+
185
+ // The daily check for one organisation: one summary read of every invoice,
186
+ // compared with the copy. Each row that differs, and each live copy row Xero
187
+ // no longer lists, waits in the inbox to be read by id. Answers counts, or
188
+ // `stopped`.
189
+ export async function dailyCheck(db, runtime, organisationId, { now = () => new Date() } = {}) {
190
+ await db.execute(sql`insert into xero_daily_checks (organisation_id, last_started_at) values (${organisationId}, ${now()})
191
+ on conflict (organisation_id) do update set last_started_at = excluded.last_started_at`);
192
+ try {
193
+ const seen = [];
194
+ const stopped = await runtime.serial(organisationId, () => withXeroLane('sync', async () => {
195
+ const { client } = await runtime.clientFor(db, organisationId);
196
+ for (let page = 1; page <= MAX_SUMMARY_PAGES; page += 1) {
197
+ if (!overReserve(runtime, client.tenantId)) return 'budget';
198
+ const list = await client.listInvoiceSummaries({ page, pageSize: SUMMARY_PAGE });
199
+ seen.push(...list.filter((i) => i.InvoiceID));
200
+ if (list.length < SUMMARY_PAGE) return null;
201
+ }
202
+ return 'max_pages';
203
+ }));
204
+ if (stopped) {
205
+ await db.execute(sql`update xero_daily_checks set last_error = ${stopped} where organisation_id = ${organisationId}`);
206
+ return { read: seen.length, flagged: 0, stopped };
207
+ }
208
+ const { rows: copy } = await db.execute(sql`select xero_invoice_id, status, total_cents, amount_due_cents,
209
+ amount_paid_cents, amount_credited_cents, xero_updated_at from xero_invoices where organisation_id = ${organisationId}`);
210
+ const byId = new Map(copy.map((r) => [r.xero_invoice_id, r]));
211
+ const flagged = new Set();
212
+ for (const x of seen) {
213
+ const row = byId.get(x.InvoiceID);
214
+ byId.delete(x.InvoiceID);
215
+ if (!row || summaryDiffers(row, x)) flagged.add(x.InvoiceID);
216
+ }
217
+ // A live copy row Xero no longer lists: read it by id to learn its state.
218
+ for (const row of byId.values()) if (!['DELETED', 'VOIDED'].includes(row.status)) flagged.add(row.xero_invoice_id);
219
+ const at = now();
220
+ const list = [...flagged].slice(0, DAILY_FLAG_MAX);
221
+ for (const id of list) await flag(db, organisationId, 'invoices', id, { source: 'daily_check', now: at });
222
+ await db.execute(sql`update xero_daily_checks set last_finished_at = ${at}, last_read = ${seen.length},
223
+ last_flagged = ${flagged.size}, last_error = null where organisation_id = ${organisationId}`);
224
+ return { read: seen.length, flagged: flagged.size, queued: list.length, stopped: null };
225
+ } catch (error) {
226
+ await db.execute(sql`update xero_daily_checks set last_error = ${`${error.code ?? 'error'}: ${String(error.message).slice(0, 300)}`}
227
+ where organisation_id = ${organisationId}`);
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ const cents = (value) => (value === null || value === undefined ? null : Number(value));
233
+
234
+ // Whether a summary from Xero differs from the copy row in what the summary
235
+ // carries: status, money, and the change time.
236
+ export function summaryDiffers(row, summary) {
237
+ if (row.status !== summary.Status) return true;
238
+ for (const [column, field] of [['total_cents', 'Total'], ['amount_due_cents', 'AmountDue'], ['amount_paid_cents', 'AmountPaid'], ['amount_credited_cents', 'AmountCredited']]) {
239
+ if (summary[field] !== undefined && cents(row[column]) !== toCents(summary[field])) return true;
240
+ }
241
+ // Xero changed it after the copy's version (whole seconds, as Xero compares).
242
+ const changed = parseXeroDate(summary.UpdatedDateUTC);
243
+ const copied = row.xero_updated_at ? new Date(row.xero_updated_at) : null;
244
+ return Boolean(changed && (!copied || Math.floor(changed / 1000) > Math.floor(copied / 1000)));
245
+ }
246
+
247
+ // The organisations whose daily check is due: a complete copy, and no check
248
+ // finished in the last day (or none tried in the last hour after a failure).
249
+ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
250
+ const { rows } = await db.execute(sql`select c.organisation_id, c.tenant_id, d.last_started_at, d.last_finished_at
251
+ from xero_connections c left join xero_daily_checks d on d.organisation_id = c.organisation_id
252
+ where c.mode = ${runtime.settings.mode}`);
253
+ const due = [];
254
+ for (const r of rows) {
255
+ const finished = r.last_finished_at ? new Date(r.last_finished_at).getTime() : null;
256
+ const started = r.last_started_at ? new Date(r.last_started_at).getTime() : null;
257
+ if (finished && Number(now) - finished < DAILY_CHECK_EVERY_MS) continue;
258
+ if (started && (!finished || started > finished) && Number(now) - started < DAILY_CHECK_RETRY_MS) continue;
259
+ if (!(await mirrorState(db, r.organisation_id, { now: new Date(Number(now)) })).complete) continue;
260
+ due.push({ organisationId: r.organisation_id, tenantId: r.tenant_id });
261
+ }
262
+ return due;
263
+ }
264
+
265
+ // After a failed read, an organisation waits 1 minute, then 2, 4 and up to an
266
+ // hour, so a failing read is never tried every tick.
267
+ export const RETRY_FIRST_MS = 60 * 1000;
268
+ export const RETRY_MAX_MS = 60 * 60 * 1000;
269
+
270
+ // One worker step: reads each organisation's inbox, then runs any daily check
271
+ // that is due. A stop by the call guard skips that organisation quietly; the
272
+ // guard has reported it. A failure is logged once as an error; the same
273
+ // failure again is a warning. `holds` keeps each organisation's wait between
274
+ // steps.
275
+ export async function runWebhookWork(db, runtime, { log = console, now = Date.now, holds = new Map() } = {}) {
276
+ const failed = (organisationId, what, error) => {
277
+ const held = holds.get(organisationId);
278
+ const failures = (held?.failures ?? 0) + 1;
279
+ holds.set(organisationId, { failures, until: Number(now()) + Math.min(RETRY_MAX_MS, RETRY_FIRST_MS * 2 ** (failures - 1)), code: error?.code ?? 'error' });
280
+ const line = `[xero] ${what} for organisation ${organisationId} failed: ${error?.code ?? 'error'}`;
281
+ if (held?.code === (error?.code ?? 'error')) log.warn?.(line);
282
+ else log.error?.(line);
283
+ };
284
+ const waiting = (organisationId) => (holds.get(organisationId)?.until ?? 0) > Number(now());
285
+
286
+ const { rows } = await db.execute(sql`select distinct e.organisation_id from xero_webhook_events e
287
+ join xero_connections c on c.organisation_id = e.organisation_id and c.mode = ${runtime.settings.mode}
288
+ where e.pending`);
289
+ for (const { organisation_id: organisationId } of rows) {
290
+ if (waiting(organisationId)) continue;
291
+ try {
292
+ const read = await readInbox(db, runtime, organisationId, { now });
293
+ holds.delete(organisationId);
294
+ if (read.invoices.read || read.creditNotes || read.contacts) {
295
+ log.info?.(`[xero] read waiting changes for organisation ${organisationId}: ${read.invoices.read} invoices, ${read.creditNotes} credit notes${read.contacts ? ', contacts' : ''}`);
296
+ }
297
+ } catch (error) {
298
+ if (!isCallsStopped(error)) failed(organisationId, 'reading waiting changes', error);
299
+ }
300
+ }
301
+ for (const { organisationId, tenantId } of await dueDailyChecks(db, runtime, { now: now() })) {
302
+ try {
303
+ await runtime.guardFor(db).check(tenantId);
304
+ const check = await dailyCheck(db, runtime, organisationId, { now: () => new Date(Number(now())) });
305
+ log.info?.(`[xero] daily check for organisation ${organisationId}: ${check.read} invoices, ${check.flagged} to read again${check.stopped ? `; stopped (${check.stopped})` : ''}`);
306
+ } catch (error) {
307
+ // dueDailyChecks waits an hour after a failed check.
308
+ if (!isCallsStopped(error)) log.error?.(`[xero] daily check for organisation ${organisationId} failed: ${error?.code ?? 'error'}`);
309
+ }
310
+ }
311
+ }
@@ -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,11 +211,12 @@ 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"/>',
212
218
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
213
- menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
219
+ sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
214
220
  palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
215
221
  };
216
222
 
@@ -220,10 +226,12 @@ function icon(name) {
220
226
  return node;
221
227
  }
222
228
 
223
- // The menu button at the top of the sidebar. On a wide screen it folds the
224
- // sidebar to its icons and back, and the choice stays in this browser. On a
225
- // narrow screen the sidebar is a bar across the top, and the button opens
226
- // the menu below it; choosing a page, Escape or a click outside closes it.
229
+ // The sidebar button at the top of the sidebar (a panel icon, not a
230
+ // hamburger; folded or narrow, the logo stands in for it). On a wide screen it folds the sidebar to its icons and back,
231
+ // and the choice stays in this browser. On a narrow screen the sidebar is a
232
+ // bar across the top, and the button slides the menu in from the left as a
233
+ // drawer over a dimmed page; choosing a page, Escape or a click on the page
234
+ // closes it.
227
235
  const NARROW = window.matchMedia('(max-width: 820px)');
228
236
  const FOLDED_KEY = 'flow_sidebar_folded';
229
237
 
@@ -237,7 +245,7 @@ function drawMenu() {
237
245
  shell.toggleAttribute('data-folded', folded);
238
246
  const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
239
247
  const toggle = $('menu-toggle');
240
- const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the menu' : 'Fold the menu');
248
+ const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
241
249
  toggle.setAttribute('aria-expanded', String(open));
242
250
  toggle.setAttribute('aria-label', label);
243
251
  toggle.title = label;
@@ -368,6 +376,7 @@ function renderShell() {
368
376
  let group = null;
369
377
  for (const [name, view] of Object.entries(VIEWS)) {
370
378
  if (view.ownerOnly && me?.person.role !== 'owner') continue;
379
+ if (view.sidebar === false) continue;
371
380
  if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
372
381
  group = view.group;
373
382
  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)));
@@ -377,7 +386,13 @@ function renderShell() {
377
386
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
378
387
  icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
379
388
  nav.replaceChildren(...items, report);
380
- $('menu-toggle').replaceChildren(icon('menu'));
389
+ // Folded or narrow, the logo is the sidebar button: it shows the mark and
390
+ // turns into the sidebar icon under the pointer or keyboard focus.
391
+ const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
392
+ mark.innerHTML = document.querySelector('.fx-side__head .fx-brand svg')?.outerHTML ?? '';
393
+ const sidebar = icon('sidebar');
394
+ sidebar.classList.add('fx-toggle__icon');
395
+ $('menu-toggle').replaceChildren(mark, sidebar);
381
396
  $('menu-toggle').onclick = toggleMenu;
382
397
  drawMenu();
383
398
  // The sidebar foot shows who is signed in: photo or initial, then the name
@@ -539,6 +554,7 @@ async function renderIntegrations(main) {
539
554
  const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
540
555
  main.append(
541
556
  h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
557
+ alertStrip(ui(), 'integrations').element,
542
558
  h('div', { class: 'fx-int-bar' }, tabs, search),
543
559
  notices, content);
544
560
  let body;
@@ -623,8 +639,24 @@ async function renderIntegration(main, id) {
623
639
  return;
624
640
  }
625
641
  const canChange = Boolean(me.ownerActions?.allowed);
642
+ // The quiet alert line for this integration only (server/ui/alerts.js).
643
+ // While a danger alert is open, even one this person hid, the status says
644
+ // calls are stopped instead of the integration's state.
645
+ let stopped = false;
646
+ const statusPill = (current) => (stopped && current.state === 'ready'
647
+ ? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
648
+ : h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
649
+ let shown = found;
650
+ const alerts = alertStrip(ui(), 'integrations', {
651
+ scope: id,
652
+ onLoad: ({ danger }) => {
653
+ stopped = danger;
654
+ for (const pill of host.querySelectorAll('[data-int-status]')) pill.replaceWith(statusPill(shown));
655
+ },
656
+ });
626
657
 
627
658
  const draw = (current, progress = null) => {
659
+ shown = current;
628
660
  const inputs = new Map();
629
661
  const rows = [];
630
662
  for (const field of current.fields) {
@@ -732,10 +764,11 @@ async function renderIntegration(main, id) {
732
764
  h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
733
765
  h('div', { class: 'fx-int-hero__actions' },
734
766
  primary,
735
- on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state) : null,
767
+ on ? statusPill(current) : null,
736
768
  progress ? connectProgress(progress) : null))),
737
769
  h('div', { class: 'fx-int-page__grid' },
738
770
  h('div', { class: 'fx-int-page__main' }, ...[
771
+ alerts.element,
739
772
  !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
773
  !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
774
  current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
@@ -753,7 +786,7 @@ async function renderIntegration(main, id) {
753
786
  ].filter(Boolean)),
754
787
  h('aside', { class: 'fx-int-page__meta' },
755
788
  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)],
789
+ ['Status', statusPill(current)],
757
790
  ['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
758
791
  modeLabel ? ['Mode', modeLabel] : null,
759
792
  ['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
@@ -768,11 +801,11 @@ async function renderIntegration(main, id) {
768
801
  // One setting, laid out the same on every settings screen: a short name, one
769
802
  // line of smaller text under it, and the control on the right. Longer help
770
803
  // 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 }) {
804
+ // its own line under the name. `tone: 'danger'` marks a row that needs a person.
805
+ function settingRow({ label, hint, control, wide = false, tone = null }) {
773
806
  const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
774
807
  const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
775
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}` },
808
+ return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
776
809
  h('div', { class: 'fx-setting__text' },
777
810
  id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
778
811
  hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
@@ -800,16 +833,25 @@ function xeroConnection(organisations, canChange) {
800
833
  (async () => {
801
834
  let hint;
802
835
  let control = null;
836
+ let stopped = false;
803
837
  try {
804
838
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
805
- if (status.connected) {
839
+ if (status.connected && status.calls?.stopped) {
840
+ // A refused connection stopped calls (the call guard): say so, and
841
+ // make Connect again the main button, which starts them again.
842
+ const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
843
+ hint = status.calls.all
844
+ ? `Xero refused Flow${at}. Flow stopped all calls.`
845
+ : `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow stopped those calls.`;
846
+ stopped = true;
847
+ } else if (status.connected) {
806
848
  const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
807
849
  hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
808
850
  } else {
809
851
  hint = 'Not connected.';
810
852
  }
811
853
  if (canChange) {
812
- control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
854
+ control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
813
855
  try {
814
856
  const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
815
857
  location.assign(authorizeUrl);
@@ -821,7 +863,7 @@ function xeroConnection(organisations, canChange) {
821
863
  } catch (error) {
822
864
  hint = error.message;
823
865
  }
824
- row.replaceWith(settingRow({ label: organisation.name, hint, control }));
866
+ row.replaceWith(settingRow({ label: organisation.name, hint, control, tone: stopped ? 'danger' : null }));
825
867
  })();
826
868
  }
827
869
  return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
@@ -854,9 +896,9 @@ async function invitePerson() {
854
896
  // security), with the person's Flow role above it.
855
897
  function renderAccount(main) {
856
898
  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);
899
+ // Clerk's card has its own "Account" heading; Flow's page heading is for
900
+ // screen readers only, so the title shows once.
901
+ main.append(h('h1', { class: 'fx-sr' }, 'Account'), host);
860
902
  // Clerk keeps its own pages (profile, security) in memory, so the URL
861
903
  // stays Flow's.
862
904
  mountClerk('profile', host, { routing: 'virtual' });
@@ -1011,7 +1053,7 @@ drawModeToggles();
1011
1053
  // the Clerk instance is called.
1012
1054
  const LOCALIZATION = {
1013
1055
  signIn: {
1014
- start: { title: 'Sign in to Flow', subtitle: 'Welcome back. Choose how to sign in.' },
1056
+ start: { title: 'Sign in to Flow', subtitle: 'Choose how to sign in.' },
1015
1057
  password: { subtitle: 'Enter the password for your account.' },
1016
1058
  },
1017
1059
  signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },