@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
@@ -6,12 +6,13 @@
6
6
  // zero, server/core/money.js). Data comes from /api/invoicing
7
7
  // (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
8
8
  import { alertStrip } from '/assets/ui/alerts.js';
9
+ import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
9
10
 
10
11
  const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
11
12
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
12
13
  // Drafts (to check), Problem and Ready are views (server/core/sending.js):
13
- // every draft is in one of them. A person marks a checked draft ready; any
14
- // later change sends it back. An issued invoice that failed to send is a
14
+ // every draft is in one of them. A person marks a checked draft ready; a
15
+ // saved change keeps it there. An issued invoice that failed to send is a
15
16
  // problem.
16
17
  // Ready first: it opens by default, as the drafts most often wait there to send.
17
18
  const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
@@ -75,29 +76,18 @@ function localDate(date = new Date()) {
75
76
 
76
77
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
77
78
 
78
- // What happens to a ready invoice in the accounting system, one fact per
79
- // sentence. It follows the integration's settings: its connection, whether it
80
- // takes writes, and the send mode on its page.
81
- export function sendModeNote(to, sendMode) {
82
- if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
83
- if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
84
- if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
85
- const text = sendMode?.mode === 'approve_and_email'
86
- ? [`Flow sends each invoice to ${to} as approved.`, `${to} emails it to the customer at once.`]
87
- : [`Flow sends each invoice to ${to} as a draft.`, `You approve and send it in ${to}.`];
88
- const others = sendMode?.otherCustomers ?? 0;
89
- if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
90
- return text.join(' ');
91
- }
92
-
93
79
  // ── The list ───────────────────────────────────────────────────────────────
94
80
 
95
81
  export async function renderInvoiceList(main, ui) {
96
82
  const { h, flow, pageHeader, notice, go, toast } = ui;
97
83
  const saved = sessionStorage.getItem('flow_invoice_tab');
98
- const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: '', ticked: new Set() };
84
+ // Back from an invoice comes here with the tab, search and scroll it left.
85
+ const back = readNav();
86
+ const returning = sessionStorage.getItem(NAV_BACK) === '1';
87
+ sessionStorage.removeItem(NAV_BACK);
88
+ const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
99
89
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
100
- const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
90
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
101
91
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
102
92
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
103
93
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
@@ -156,7 +146,7 @@ export async function renderInvoiceList(main, ui) {
156
146
  parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
157
147
  const buttons = [];
158
148
  if (ticked.length) {
159
- buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
149
+ buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
160
150
  (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
161
151
  }
162
152
  if (context.canIssue && body.counts.ready > 0) {
@@ -199,6 +189,9 @@ export async function renderInvoiceList(main, ui) {
199
189
  : EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
200
190
  return;
201
191
  }
192
+ // The invoice page steps through this list, in this order (navBar).
193
+ saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
194
+ const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
202
195
  const ticks = TICKS[state.status];
203
196
  const showProblem = state.status === 'problem';
204
197
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
@@ -231,13 +224,13 @@ export async function renderInvoiceList(main, ui) {
231
224
  const href = `/invoices/${invoice.id}`;
232
225
  const first = invoice.problems?.[0];
233
226
  const view = invoice.status === 'void' ? null : invoice.view;
234
- return h('tr', { tabindex: '0', onclick: () => go(href), onkeydown: (event) => { if (event.key === 'Enter') go(href); } },
227
+ return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
235
228
  ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
236
- h('td', {}, h('a', { class: 'fx-strong', href, onclick: go }, invoice.number ?? 'Draft')),
229
+ h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
237
230
  h('td', {}, invoice.contactName ?? '—'),
238
231
  showProblem
239
232
  ? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
240
- : h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
233
+ : h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
241
234
  h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
242
235
  h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
243
236
  (() => {
@@ -259,6 +252,51 @@ export async function renderInvoiceList(main, ui) {
259
252
  }
260
253
  }
261
254
  await load();
255
+ if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
256
+ }
257
+
258
+ // The list an invoice was opened from: its tab, search, invoice ids in order,
259
+ // and scroll. The invoice page's bar steps through it and goes back to it.
260
+ const NAV = 'flow_invoice_nav';
261
+ const NAV_BACK = 'flow_invoice_back';
262
+ function readNav() {
263
+ try { return JSON.parse(sessionStorage.getItem(NAV) ?? 'null'); } catch { return null; }
264
+ }
265
+ function saveNav(nav) {
266
+ try { sessionStorage.setItem(NAV, JSON.stringify(nav)); } catch { /* the bar then shows Back only */ }
267
+ }
268
+
269
+ // The bar above one invoice: Back to the list as it was, and the previous and
270
+ // next invoice of that list.
271
+ function navBar(ui, id) {
272
+ const { h, go } = ui;
273
+ const nav = readNav();
274
+ const ids = nav?.ids ?? [];
275
+ const at = ids.indexOf(id);
276
+ const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
277
+ const step = (to, label, path) => {
278
+ const button = h('button', {
279
+ class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
280
+ onclick: () => go(`/invoices/${to}`),
281
+ });
282
+ button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
283
+ return button;
284
+ };
285
+ return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
286
+ h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
287
+ at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
288
+ at >= 0 ? h('span', { class: 'fx-pager__steps' }, step(ids[at - 1], 'Previous invoice', 'M14.5 6l-6 6 6 6'), step(ids[at + 1], 'Next invoice', 'M9.5 6l6 6-6 6')) : null);
289
+ }
290
+
291
+ // After a send, the next invoice of the list opens; at its end, the list.
292
+ function openNext(ui, id) {
293
+ const nav = readNav();
294
+ const ids = nav?.ids ?? [];
295
+ const at = ids.indexOf(id);
296
+ const next = at >= 0 ? (ids[at + 1] ?? ids[at - 1]) : null;
297
+ if (at >= 0) saveNav({ ...nav, ids: ids.filter((other) => other !== id) });
298
+ if (next) ui.go(`/invoices/${next}`);
299
+ else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
262
300
  }
263
301
 
264
302
  // ── One invoice ────────────────────────────────────────────────────────────
@@ -285,17 +323,11 @@ export async function renderInvoice(main, ui, id) {
285
323
  const draw = (current) => {
286
324
  host.replaceChildren(...(current?.status && current.status !== 'draft'
287
325
  ? issuedView(current, ui, context, draw)
288
- : draftView(current, ui, context, draw)));
326
+ : draftView(current, ui, context, draw)).filter(Boolean));
289
327
  };
290
328
  draw(invoice);
291
329
  }
292
330
 
293
- function crumbs(ui, label) {
294
- const { h, go } = ui;
295
- return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
296
- h('a', { href: '/invoices', onclick: go }, 'Invoices'), h('span', { 'aria-hidden': 'true' }, '/'), h('span', {}, label));
297
- }
298
-
299
331
  function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
300
332
  const { h } = ui;
301
333
  return h('dl', { class: 'fx-totals' },
@@ -304,13 +336,13 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
304
336
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
305
337
  }
306
338
 
339
+ // The job an invoice bills. The case id stays on the record, not on screen.
307
340
  function jobCard(ui, job) {
308
341
  const { h } = ui;
309
342
  if (!job) return null;
310
343
  return h('section', { class: 'fx-card fx-side-card' },
311
344
  h('h3', { class: 'fx-label' }, 'Job'),
312
345
  h('dl', { class: 'fx-facts' },
313
- h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
314
346
  job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
315
347
  job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
316
348
  h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
@@ -471,24 +503,56 @@ function draftView(invoice, ui, context, redraw) {
471
503
  }
472
504
  };
473
505
 
506
+ // Save shows only when there is something to save: a new invoice, or a
507
+ // change since the page opened. Saving keeps the invoice where it is.
474
508
  const saveButton = h('button', {
475
- class: 'fx-btn', type: 'button',
509
+ class: 'fx-btn', type: 'button', hidden: !isNew,
476
510
  onclick: async () => {
477
511
  const saved = await save();
478
512
  if (!saved) return;
479
- toast('Draft saved.', 'success');
513
+ toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
480
514
  if (isNew) go(`/invoices/${saved.id}`, { replace: true });
481
515
  else redraw(saved);
482
516
  },
483
- }, 'Save draft');
484
- const issueButton = context.canIssue ? h('button', {
517
+ }, isNew ? 'Save draft' : 'Save changes');
518
+ const opened = JSON.stringify(body());
519
+ const changed = () => !isNew && JSON.stringify(body()) !== opened;
520
+ const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
521
+ // With an accounting system on, a ready invoice is sent there: Flow issues
522
+ // it and queues it, the same as Send all ready. With none, Flow issues it
523
+ // and keeps it. A draft that is not ready has no send: mark it ready first.
524
+ const to = invoice?.sendsTo ?? null;
525
+ const ready = invoice?.view === 'ready';
526
+ const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
527
+ const emails = invoice?.sendMode?.mode === 'approve_and_email';
528
+ const sendButton = context.canIssue && to && ready ? h('button', {
529
+ class: 'fx-btn fx-btn--primary', type: 'button',
530
+ onclick: async () => {
531
+ if (changed() && !(await save())) return;
532
+ const ok = await ui.dialog({
533
+ title: `Send this invoice to ${to}?`,
534
+ body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
535
+ confirm: emails ? 'Send and email' : 'Send',
536
+ });
537
+ if (!ok) return;
538
+ try {
539
+ await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
540
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
541
+ toast(`${fresh.number} is on the way to ${to}.`, 'success');
542
+ openNext(ui, invoice.id);
543
+ } catch (error) {
544
+ toast(error.message, 'danger');
545
+ }
546
+ },
547
+ }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
548
+ const issueButton = context.canIssue && !to ? h('button', {
485
549
  class: 'fx-btn fx-btn--primary', type: 'button',
486
550
  onclick: async () => {
487
551
  const saved = await save();
488
552
  if (!saved) return;
489
553
  const ok = await ui.dialog({
490
554
  title: 'Issue this invoice?',
491
- body: h('p', {}, `It gets the next number (${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}) and is kept exactly as issued. To change it later, revise it.`),
555
+ body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
492
556
  confirm: 'Issue',
493
557
  });
494
558
  if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
@@ -504,40 +568,51 @@ function draftView(invoice, ui, context, redraw) {
504
568
  }, 'Issue invoice') : null;
505
569
 
506
570
  // Mark ready: the draft is saved, then Flow checks it; one with a problem
507
- // stays a draft. Back to drafts undoes it.
508
- const ready = invoice?.view === 'ready';
509
- const readyButton = isNew ? null : h('button', {
571
+ // stays a draft. Move to Drafts, on the Status card, undoes it.
572
+ const markReadyButton = isNew || ready ? null : h('button', {
510
573
  class: 'fx-btn', type: 'button',
511
574
  onclick: async () => {
512
- const current = ready ? invoice : await save();
513
- if (!current) return;
575
+ if (!(await save())) return;
514
576
  try {
515
- if (ready) {
516
- await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
517
- toast('Back in Drafts.', 'success');
518
- } else {
519
- const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
520
- if (res.marked) toast('Ready to send.', 'success');
521
- else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
522
- }
577
+ const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
578
+ if (res.marked) toast('Ready to send.', 'success');
579
+ else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
523
580
  const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
524
581
  redraw(fresh);
525
582
  } catch (error) {
526
583
  toast(error.message, 'danger');
527
584
  }
528
585
  },
529
- }, ready ? 'Back to drafts' : 'Mark ready');
586
+ }, 'Mark ready');
587
+ // A ready invoice can go back to Drafts. It is a status change, so it sits
588
+ // with the status, not with the page's main buttons.
589
+ const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
590
+ h('h3', { class: 'fx-label' }, 'Status'),
591
+ h('p', {}, 'Ready to send.'),
592
+ h('button', {
593
+ class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
594
+ onclick: async () => {
595
+ try {
596
+ await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
597
+ toast('Moved to Drafts.', 'success');
598
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
599
+ redraw(fresh);
600
+ } catch (error) {
601
+ toast(error.message, 'danger');
602
+ }
603
+ },
604
+ }, 'Move to Drafts')) : null;
530
605
  const problems = invoice?.problems?.length
531
606
  ? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
532
607
  : null;
533
608
 
534
609
  return [
535
- crumbs(ui, isNew ? 'New invoice' : ready ? 'Ready' : 'Draft'),
610
+ navBar(ui, invoice?.id ?? null),
536
611
  h('header', { class: 'fx-ph' },
537
612
  h('div', { class: 'fx-ph__text' },
538
613
  h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
539
- h('p', { class: 'fx-ph__sub' }, ready ? 'A person checked it. Send all ready issues it. A change sends it back to Drafts.' : 'The number is given when you issue it.')),
540
- h('div', { class: 'fx-ph__actions' }, saveButton, readyButton, issueButton)),
614
+ h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
615
+ h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
541
616
  problems,
542
617
  h('div', { class: 'fx-invoice__grid' },
543
618
  h('div', { class: 'fx-invoice__main' },
@@ -556,8 +631,12 @@ function draftView(invoice, ui, context, redraw) {
556
631
  h('div', { class: 'fx-lines__foot' },
557
632
  h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
558
633
  totalsHost))),
559
- h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
560
- ];
634
+ h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
635
+ ].map((part) => {
636
+ // Any edit on the page shows Save changes; undoing it hides it again.
637
+ for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
638
+ return part;
639
+ });
561
640
  }
562
641
 
563
642
  // An issued or void invoice: the document as sent, and what can happen next.
@@ -601,7 +680,7 @@ function issuedView(invoice, ui, context, redraw) {
601
680
  sendToXero(invoice, ui, context),
602
681
  ] : [];
603
682
  return [
604
- crumbs(ui, invoice.number),
683
+ navBar(ui, invoice.id),
605
684
  h('header', { class: 'fx-ph' },
606
685
  h('div', { class: 'fx-ph__text' },
607
686
  h('div', { class: 'fx-row' },
@@ -674,7 +753,7 @@ function xeroCard(invoice, ui, context, status, reload) {
674
753
  }
675
754
 
676
755
  // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
677
- // (Settings, Invoicing) decides what Xero gets: a draft that a person approves
756
+ // (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
678
757
  // there, or an approved invoice that Xero emails to the customer.
679
758
  function sendToXero(invoice, ui, context) {
680
759
  const { h, flow, toast } = ui;
@@ -20,7 +20,7 @@ const themeDir = join(here, '..', 'theme');
20
20
  const logoDir = join(here, 'logos');
21
21
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
22
22
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
23
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
23
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
24
24
  const MAX_AGE = '1h';
25
25
 
26
26
  // Send one named file from dir. Passing dir as root limits the dotfile check