@arcforgelabs/flow 2026.10.32-beta.1 → 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 (34) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +16 -5
  4. package/server/core/automations.js +17 -3
  5. package/server/core/invoices.js +2 -2
  6. package/server/core/invoicing-rules.js +14 -1
  7. package/server/core/ledger.js +3 -2
  8. package/server/core/pipeline.js +1 -1
  9. package/server/core/references.js +99 -0
  10. package/server/core/sending.js +11 -5
  11. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  12. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  13. package/server/db/migrations/meta/_journal.json +7 -0
  14. package/server/db/schema.js +2 -0
  15. package/server/integrations/port.js +1 -0
  16. package/server/integrations/seazona/feed.js +8 -1
  17. package/server/integrations/seazona/index.js +9 -0
  18. package/server/integrations/seazona/lanes.js +1 -1
  19. package/server/integrations/seazona/patients.js +30 -0
  20. package/server/integrations/xero/client.js +12 -0
  21. package/server/integrations/xero/import.js +9 -3
  22. package/server/integrations/xero/index.js +3 -1
  23. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/mirror.js +16 -8
  26. package/server/integrations/xero/routes.js +20 -0
  27. package/server/integrations/xero/settings.js +3 -0
  28. package/server/integrations/xero/webhooks.js +311 -0
  29. package/server/ui/app.js +15 -7
  30. package/server/ui/flow.css +47 -7
  31. package/server/ui/invoice-notes.js +26 -0
  32. package/server/ui/invoicing-settings.js +3 -1
  33. package/server/ui/invoicing.js +136 -57
  34. 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
+ }
package/server/ui/app.js CHANGED
@@ -216,7 +216,7 @@ const ICON_PATHS = {
216
216
  person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
217
217
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
218
218
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
219
- 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"/>',
220
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"/>',
221
221
  };
222
222
 
@@ -226,10 +226,12 @@ function icon(name) {
226
226
  return node;
227
227
  }
228
228
 
229
- // The menu button at the top of the sidebar. On a wide screen it folds the
230
- // sidebar to its icons and back, and the choice stays in this browser. On a
231
- // narrow screen the sidebar is a bar across the top, and the button opens
232
- // 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.
233
235
  const NARROW = window.matchMedia('(max-width: 820px)');
234
236
  const FOLDED_KEY = 'flow_sidebar_folded';
235
237
 
@@ -243,7 +245,7 @@ function drawMenu() {
243
245
  shell.toggleAttribute('data-folded', folded);
244
246
  const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
245
247
  const toggle = $('menu-toggle');
246
- 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');
247
249
  toggle.setAttribute('aria-expanded', String(open));
248
250
  toggle.setAttribute('aria-label', label);
249
251
  toggle.title = label;
@@ -384,7 +386,13 @@ function renderShell() {
384
386
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
385
387
  icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
386
388
  nav.replaceChildren(...items, report);
387
- $('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);
388
396
  $('menu-toggle').onclick = toggleMenu;
389
397
  drawMenu();
390
398
  // The sidebar foot shows who is signed in: photo or initial, then the name
@@ -113,10 +113,17 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
113
113
  display: flex; flex-direction: column; gap: 4px; flex: none;
114
114
  width: 232px; height: 100vh; padding: 16px 12px 12px;
115
115
  background: var(--t-s1); border-right: 1px solid var(--fx-line);
116
+ transition: width .18s ease;
116
117
  }
117
118
  .fx-side__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 14px 8px; }
118
119
  .fx-side__head .fx-brand { min-width: 0; font-size: 19px; }
119
120
  .fx-side__head .fx-mark { width: 26px; height: 26px; }
121
+ /* The sidebar button. Open, it shows the sidebar icon beside the brand.
122
+ Folded or narrow, the brand is hidden and the button shows the logo, which
123
+ turns into the sidebar icon on hover or focus (and while the drawer is
124
+ open), so the two never sit side by side. */
125
+ .fx-toggle__mark { display: none; }
126
+ .fx-toggle__mark .fx-mark { display: block; }
120
127
  .fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
121
128
  .fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
122
129
  .fx-nav__group:first-child { padding-top: 4px; }
@@ -141,7 +148,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
141
148
  @media (min-width: 821px) {
142
149
  .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
143
150
  .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
144
- .fx-shell[data-folded] .fx-brand span { display: none; }
151
+ .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
145
152
  .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
146
153
  .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
147
154
  .fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@@ -659,19 +666,41 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
659
666
  .fx-shell { flex-direction: column; }
660
667
  .fx-side { flex-direction: row; align-items: center; width: auto; height: 60px; padding: 0 12px; border-right: 0; border-bottom: 1px solid var(--fx-line); }
661
668
  .fx-side__head { flex-direction: row-reverse; gap: 6px; padding: 0; }
662
- .fx-side__head .fx-brand span { display: none; }
663
- /* The menu opens as a panel below the bar. */
669
+ .fx-side__head .fx-brand { display: none; }
670
+ /* The menu is a drawer: it slides in from the left, below the bar, over
671
+ a dimmed page. A click on the dimmed page closes it (app.js). */
664
672
  .fx-nav {
665
- display: none; position: absolute; top: 60px; left: 0; right: 0;
666
- max-height: calc(100vh - 60px); padding: 4px 12px 12px;
667
- background: var(--t-s1); border-bottom: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
673
+ position: fixed; top: 60px; bottom: 0; left: 0; z-index: 2;
674
+ width: min(280px, 85vw); padding: 8px 12px 12px;
675
+ background: var(--t-s1); border-right: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
676
+ transform: translateX(-100%); visibility: hidden;
677
+ transition: transform .2s ease, visibility .2s;
678
+ }
679
+ .fx-shell[data-menu-open] .fx-nav { transform: none; visibility: visible; }
680
+ .fx-shell[data-menu-open]::after {
681
+ content: ""; position: fixed; inset: 60px 0 0; z-index: 9;
682
+ background: rgba(0, 0, 0, .32);
668
683
  }
669
- .fx-shell[data-menu-open] .fx-nav { display: flex; }
670
684
  .fx-nav__report { margin-top: 8px; }
671
685
  .fx-side__end { margin-left: auto; padding: 0; border-top: 0; }
672
686
  #user-button { flex: none; }
673
687
  }
674
688
 
689
+ /* Collapsed (folded on wide screens, or narrow): logo at rest, sidebar icon
690
+ on hover, focus or while open. */
691
+ @media (min-width: 821px) {
692
+ .fx-shell[data-folded] .fx-toggle__mark { display: block; }
693
+ .fx-shell[data-folded] .fx-toggle__icon { display: none; }
694
+ .fx-shell[data-folded] #menu-toggle:is(:hover, :focus-visible) .fx-toggle__mark { display: none; }
695
+ .fx-shell[data-folded] #menu-toggle:is(:hover, :focus-visible) .fx-toggle__icon { display: inline-flex; }
696
+ }
697
+ @media (max-width: 820px) {
698
+ .fx-toggle__mark { display: block; }
699
+ .fx-toggle__icon { display: none; }
700
+ #menu-toggle:is(:hover, :focus-visible, [aria-expanded="true"]) .fx-toggle__mark { display: none; }
701
+ #menu-toggle:is(:hover, :focus-visible, [aria-expanded="true"]) .fx-toggle__icon { display: inline-flex; }
702
+ }
703
+
675
704
  /* ── Phones ─────────────────────────────────────────────────────────────── */
676
705
  @media (max-width: 640px) {
677
706
  .fx-main { padding-top: 20px; }
@@ -778,3 +807,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
778
807
  color-scheme: light;
779
808
  }
780
809
  [data-mode="dark"] #sentry-feedback { --interactive-filter: brightness(150%); color-scheme: dark; }
810
+
811
+ /* The bar above one invoice: Back, its place in the list, previous and next.
812
+ Quiet: no frame, dim text, round icon buttons. */
813
+ .fx-pager { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; min-height: 32px; }
814
+ .fx-pager__back { justify-self: start; background: none; border: 0; padding: 4px 0; color: var(--t-dim); font: inherit; font-size: 13.5px; cursor: pointer; transition: color 120ms var(--fx-ease); }
815
+ .fx-pager__back:hover, .fx-pager__back:focus-visible { color: var(--t-strong); }
816
+ .fx-pager__count { grid-column: 2; color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
817
+ .fx-pager__steps { grid-column: 3; justify-self: end; display: flex; gap: 4px; }
818
+ .fx-pager__step { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
819
+ .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
820
+ .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
@@ -0,0 +1,26 @@
1
+ // The notes on the Invoices page and on one invoice (invoicing.js). One fact
2
+ // per sentence; they follow the accounting integration's settings. Kept apart
3
+ // so tests can load them without a browser.
4
+
5
+ // What happens to a ready invoice in the accounting system, one fact per
6
+ // sentence. It follows the integration's settings: its connection, whether it
7
+ // takes writes, and the send mode on its page. `what` names the invoices:
8
+ // "each invoice" on the list, "this invoice" on one invoice.
9
+ export function sendModeNote(to, sendMode, what = 'each invoice') {
10
+ if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
11
+ if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
12
+ if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
13
+ const text = sendMode?.mode === 'approve_and_email'
14
+ ? [`Flow sends ${what} to ${to} as approved.`, `${to} emails it to the customer at once.`]
15
+ : [`Flow sends ${what} to ${to} as a draft.`, `You approve and send it in ${to}.`];
16
+ const others = sendMode?.otherCustomers ?? 0;
17
+ if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
18
+ return text.join(' ');
19
+ }
20
+
21
+ // The line under a draft's title, one fact per sentence.
22
+ export function draftNote(to, invoice, ready) {
23
+ if (!to) return ready ? 'A person checked it. A saved change keeps it ready.' : 'Flow gives it a number when you issue it.';
24
+ if (!ready) return `Mark it ready, then send it to ${to}.`;
25
+ return sendModeNote(to, invoice?.sendMode, 'this invoice');
26
+ }
@@ -66,7 +66,9 @@ export async function renderInvoicingSettings(main, ui) {
66
66
  host.replaceChildren(...[
67
67
  disabled ? notice('Only an owner or admin can change these.', 'info') : null,
68
68
  section('Drafts',
69
- row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))),
69
+ row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days')),
70
+ row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
71
+ box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' }))),
70
72
  lineMappingSection(ui),
71
73
  automationSection(ui, 'invoicing', 'Automations'),
72
74
  rulesSection(ui),