@arcforgelabs/flow 2026.10.26 → 2026.10.27

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 (42) hide show
  1. package/README.md +5 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +231 -20
  5. package/server/app.js +28 -2
  6. package/server/core/automations.js +140 -0
  7. package/server/core/invoice-rules.js +224 -0
  8. package/server/core/invoices.js +4 -1
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/ledger.js +45 -12
  11. package/server/core/monitoring.js +34 -0
  12. package/server/core/pipeline.js +8 -1
  13. package/server/core/sending.js +282 -0
  14. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  15. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  16. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  17. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  18. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  19. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  20. package/server/db/migrations/meta/_journal.json +21 -0
  21. package/server/db/schema.js +115 -0
  22. package/server/index.js +12 -4
  23. package/server/integrations/adapters.js +2 -1
  24. package/server/integrations/port.js +5 -0
  25. package/server/integrations/seazona/ledger.js +2 -0
  26. package/server/integrations/sentry/index.js +105 -0
  27. package/server/integrations/xero/client.js +5 -0
  28. package/server/integrations/xero/drafts.js +123 -16
  29. package/server/integrations/xero/index.js +11 -1
  30. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  31. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  32. package/server/ui/app-page.js +2 -1
  33. package/server/ui/app.js +14 -1
  34. package/server/ui/automations.js +96 -0
  35. package/server/ui/flow.css +88 -0
  36. package/server/ui/invoice-rules.js +237 -0
  37. package/server/ui/invoicing-settings.js +108 -0
  38. package/server/ui/invoicing.js +166 -20
  39. package/server/ui/jobs.js +19 -0
  40. package/server/ui/logos/sentry.svg +1 -0
  41. package/server/ui/monitoring.js +53 -0
  42. package/server/ui/routes.js +2 -2
@@ -0,0 +1,108 @@
1
+ // Settings, Invoicing (/invoicing-settings): payment terms, the draft
2
+ // automation, and the lab's own rules a ready job passes before Flow drafts
3
+ // its invoice (invoice-rules.js, docs/INVOICE_RULES.md). Invoicing needs no integration: how an invoice
4
+ // goes to Xero is on the Xero integration page (sendModeSection).
5
+ // Draft when ready is an automation (server/core/automations.js); the
6
+ // Seazona integration page holds the rule that marks a job ready.
7
+ // One row for each setting: its name, one short line, and its control. A
8
+ // change saves at once. Everyone can read them; an owner or admin changes
9
+ // them. The full rules are in docs/INVOICING_RULES.md. app.js passes in its
10
+ // shared pieces as `ui`.
11
+
12
+ import { automationSection } from '/assets/ui/automations.js';
13
+ import { rulesSection } from '/assets/ui/invoice-rules.js';
14
+
15
+ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
16
+
17
+ const SEND_MODES = [
18
+ { value: 'accounting_draft', label: 'Xero draft' },
19
+ { value: 'approve_and_email', label: 'Approved and emailed' },
20
+ ];
21
+
22
+ export async function renderInvoicingSettings(main, ui) {
23
+ const { h, flow, toast, notice, pageHeader, me } = ui;
24
+ const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
25
+ main.append(pageHeader('Invoicing', { subtitle: 'How Flow drafts invoices. No integration needed.' }), host);
26
+ let body;
27
+ try {
28
+ body = await flow('/api/invoicing/settings');
29
+ } catch (error) {
30
+ host.replaceChildren(notice(error.message, 'danger'));
31
+ return;
32
+ }
33
+ const disabled = !['owner', 'admin'].includes(me?.person.role);
34
+ let saved = body.settings;
35
+
36
+ async function save(changes, control) {
37
+ control.disabled = true;
38
+ try {
39
+ saved = (await flow('/api/invoicing/settings', { method: 'PUT', body: JSON.stringify(changes) })).settings;
40
+ toast('Saved.', 'success');
41
+ return true;
42
+ } catch (error) {
43
+ toast(error.message, 'danger');
44
+ return false;
45
+ } finally {
46
+ control.disabled = false;
47
+ }
48
+ }
49
+
50
+ const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
51
+ h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
52
+ h('div', { class: 'fx-set__control' }, control));
53
+
54
+ // A text or number box saves when it loses focus with a new value.
55
+ const box = (key, label, { type = 'text', read = (v) => v, show = (v) => v ?? '', placeholder } = {}) => {
56
+ const input = h('input', { class: 'fx-input', type, value: show(saved[key]), 'aria-label': label, disabled, placeholder });
57
+ input.addEventListener('change', async () => {
58
+ if (!(await save({ [key]: read(input.value) }, input))) input.value = show(saved[key]);
59
+ });
60
+ return input;
61
+ };
62
+ const days = (key, label) => box(key, label, { type: 'number', read: Number });
63
+
64
+ const section = (title, ...rows) => h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), ...rows);
65
+ host.replaceChildren(...[
66
+ disabled ? notice('Only an owner or admin can change these.', 'info') : null,
67
+ section('Drafts',
68
+ row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))),
69
+ automationSection(ui, 'invoicing', 'Automations'),
70
+ rulesSection(ui),
71
+ ].filter(Boolean));
72
+ }
73
+
74
+ // The Xero integration page's "Sending" section: how a checked invoice goes to
75
+ // Xero. A customer can have its own mode (/api/invoicing/contacts/:id/billing).
76
+ export function sendModeSection(ui) {
77
+ const { h, flow, toast, notice, me } = ui;
78
+ const disabled = !['owner', 'admin'].includes(me?.person.role);
79
+ const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
80
+ SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
81
+ const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Sending'),
82
+ h('div', { class: 'fx-set__row' },
83
+ h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, 'Send to Xero as'), h('span', { class: 'fx-set__hint' }, 'Draft: approve in Xero. Approved: Xero emails it.')),
84
+ h('div', { class: 'fx-set__control' }, select)),
85
+ h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How sending works')));
86
+ let saved = null;
87
+ (async () => {
88
+ try {
89
+ saved = (await flow('/api/invoicing/settings')).settings;
90
+ select.value = saved.sendMode;
91
+ } catch (error) {
92
+ section.append(notice(error.message, 'danger'));
93
+ }
94
+ })();
95
+ select.addEventListener('change', async () => {
96
+ select.disabled = true;
97
+ try {
98
+ saved = (await flow('/api/invoicing/settings', { method: 'PUT', body: JSON.stringify({ sendMode: select.value }) })).settings;
99
+ toast('Saved.', 'success');
100
+ } catch (error) {
101
+ toast(error.message, 'danger');
102
+ if (saved) select.value = saved.sendMode;
103
+ } finally {
104
+ select.disabled = disabled;
105
+ }
106
+ });
107
+ return section;
108
+ }
@@ -8,7 +8,20 @@
8
8
 
9
9
  const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
10
10
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
11
- const TABS = [['draft', 'Drafts'], ['issued', 'Issued'], ['void', 'Void']];
11
+ // Drafts (to check), Problem and Ready are views (server/core/sending.js):
12
+ // every draft is in one of them. A person marks a checked draft ready; any
13
+ // later change sends it back. An issued invoice that failed to send is a
14
+ // problem.
15
+ const TABS = [['to_check', 'Drafts'], ['problem', 'Problem'], ['ready', 'Ready'], ['issued', 'Issued'], ['void', 'Void']];
16
+ const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
17
+ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
18
+ const EMPTY = {
19
+ to_check: 'No drafts to check. Press New invoice to start one.',
20
+ problem: 'No problems.',
21
+ ready: 'Nothing is ready. Tick drafts and press Mark ready.',
22
+ };
23
+ // Tabs whose rows can be ticked, and what the ticked rows can do.
24
+ const TICKS = { to_check: true, ready: true };
12
25
 
13
26
  const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
14
27
  export const formatCents = (cents) => money.format((cents ?? 0) / 100);
@@ -38,16 +51,21 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
38
51
  // ── The list ───────────────────────────────────────────────────────────────
39
52
 
40
53
  export async function renderInvoiceList(main, ui) {
41
- const { h, flow, pageHeader, notice, go } = ui;
42
- const state = { status: sessionStorage.getItem('flow_invoice_tab') || 'draft', q: '' };
54
+ const { h, flow, pageHeader, notice, go, toast } = ui;
55
+ const saved = sessionStorage.getItem('flow_invoice_tab');
56
+ const state = { status: TABS.some(([id]) => id === saved) ? saved : 'to_check', q: '', ticked: new Set() };
43
57
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
44
58
  const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
59
+ const bar = h('div', { class: 'fx-sendbar', hidden: true });
45
60
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
61
+ const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
62
+ let body = null;
46
63
  main.append(
47
64
  pageHeader('Invoices', {
48
65
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
49
66
  }),
50
67
  h('div', { class: 'fx-toolbar' }, tabs, search),
68
+ bar,
51
69
  host);
52
70
 
53
71
  let timer;
@@ -56,8 +74,62 @@ export async function renderInvoiceList(main, ui) {
56
74
  timer = setTimeout(() => { state.q = search.value.trim(); load(); }, 200);
57
75
  });
58
76
 
77
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
78
+ const action = (label, path, payload, done, tone = 'primary') => {
79
+ const el = h('button', {
80
+ class: `fx-btn fx-btn--${tone}`, type: 'button',
81
+ onclick: async () => {
82
+ el.disabled = true;
83
+ try {
84
+ const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
85
+ toast(...done(res));
86
+ state.ticked.clear();
87
+ await load();
88
+ } catch (error) {
89
+ toast(error.message, 'danger');
90
+ el.disabled = false;
91
+ }
92
+ },
93
+ }, label);
94
+ return el;
95
+ };
96
+
97
+ // The bar above the list: what a person can do with this view.
98
+ function drawBar() {
99
+ const to = body.sendsTo;
100
+ const ticked = [...state.ticked];
101
+ const parts = [];
102
+ if (state.status === 'to_check') {
103
+ parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then mark it ready.'));
104
+ if (ticked.length) {
105
+ parts.push(action(`Mark ${ticked.length} ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
106
+ ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
107
+ : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
108
+ }
109
+ } else if (state.status === 'ready') {
110
+ parts.push(h('p', { class: 'fx-dim' }, to ? `Each invoice is issued, then goes to ${to}.` : 'Each invoice is issued. No accounting system is on.'));
111
+ const buttons = [];
112
+ if (ticked.length) {
113
+ buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
114
+ (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
115
+ }
116
+ if (context.canIssue && body.counts.ready > 0) {
117
+ buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Issue all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
118
+ (res) => [`${res.issued} issued.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
119
+ }
120
+ if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
121
+ } else if (state.status === 'problem') {
122
+ const failed = body.invoices.filter((i) => i.status === 'issued').length;
123
+ if (context.canIssue && to && failed > 0) {
124
+ parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'issued invoice', 'issued invoices')} did not reach ${to}. Fix the cause, then send again.`));
125
+ parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
126
+ }
127
+ }
128
+ bar.replaceChildren(...parts);
129
+ bar.hidden = !parts.length;
130
+ }
131
+
59
132
  async function load() {
60
- let body;
61
133
  try {
62
134
  const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
63
135
  body = await flow(`/api/invoicing/invoices?${query}`);
@@ -65,31 +137,65 @@ export async function renderInvoiceList(main, ui) {
65
137
  host.replaceChildren(notice(error.message, 'danger'));
66
138
  return;
67
139
  }
140
+ const shown = new Set(body.invoices.map((i) => i.id));
141
+ for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
68
142
  tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
69
143
  type: 'button',
70
144
  role: 'tab',
71
145
  'aria-selected': String(state.status === id),
72
- onclick: () => { state.status = id; sessionStorage.setItem('flow_invoice_tab', id); load(); },
146
+ ...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
147
+ onclick: () => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); },
73
148
  }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
149
+ drawBar();
74
150
  if (!body.invoices.length) {
75
151
  host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
76
152
  ? 'No invoice matches that search.'
77
- : state.status === 'draft' ? 'No drafts. Press New invoice to start one.' : `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
153
+ : EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
78
154
  return;
79
155
  }
156
+ const ticks = TICKS[state.status];
157
+ const showProblem = state.status === 'problem';
158
+ const created = ['to_check', 'problem', 'ready'].includes(state.status);
159
+ const boxes = [];
160
+ const tickBox = (invoice) => {
161
+ const box = h('input', {
162
+ type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${invoice.number ?? 'draft'} for ${invoice.contactName ?? 'no client'}`,
163
+ ...(state.ticked.has(invoice.id) ? { checked: true } : {}),
164
+ onclick: (event) => event.stopPropagation(),
165
+ onchange: () => { if (box.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); all.checked = state.ticked.size === body.invoices.length; drawBar(); },
166
+ });
167
+ boxes.push(box);
168
+ return box;
169
+ };
170
+ const all = h('input', {
171
+ type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every invoice shown',
172
+ onchange: () => {
173
+ for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
174
+ for (const box of boxes) box.checked = all.checked;
175
+ drawBar();
176
+ },
177
+ });
80
178
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
81
179
  h('thead', {}, h('tr', {},
82
- h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, 'Reference'),
83
- h('th', {}, state.status === 'draft' ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
180
+ ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
181
+ h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
182
+ h('th', {}, created ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
84
183
  h('tbody', {}, body.invoices.map((invoice) => {
85
184
  const href = `/invoices/${invoice.id}`;
185
+ const first = invoice.problems?.[0];
186
+ const view = invoice.status === 'void' ? null : invoice.view;
86
187
  return h('tr', { tabindex: '0', onclick: () => go(href), onkeydown: (event) => { if (event.key === 'Enter') go(href); } },
188
+ ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
87
189
  h('td', {}, h('a', { class: 'fx-strong', href, onclick: go }, invoice.number ?? 'Draft')),
88
190
  h('td', {}, invoice.contactName ?? '—'),
89
- h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
191
+ showProblem
192
+ ? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
193
+ : h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
90
194
  h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
91
195
  h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
92
- h('td', {}, h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])));
196
+ h('td', {}, view
197
+ ? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
198
+ : h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])));
93
199
  }))));
94
200
  }
95
201
  await load();
@@ -152,7 +258,7 @@ function jobCard(ui, job) {
152
258
 
153
259
  // A draft, or a new invoice before its first save.
154
260
  function draftView(invoice, ui, context, redraw) {
155
- const { h, flow, toast, go } = ui;
261
+ const { h, flow, toast, go, notice } = ui;
156
262
  const isNew = !invoice;
157
263
  const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
158
264
  if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
@@ -337,13 +443,42 @@ function draftView(invoice, ui, context, redraw) {
337
443
  },
338
444
  }, 'Issue invoice') : null;
339
445
 
446
+ // Mark ready: the draft is saved, then Flow checks it; one with a problem
447
+ // stays a draft. Back to drafts undoes it.
448
+ const ready = invoice?.view === 'ready';
449
+ const readyButton = isNew ? null : h('button', {
450
+ class: 'fx-btn', type: 'button',
451
+ onclick: async () => {
452
+ const current = ready ? invoice : await save();
453
+ if (!current) return;
454
+ try {
455
+ if (ready) {
456
+ await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
457
+ toast('Back in Drafts.', 'success');
458
+ } else {
459
+ const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
460
+ if (res.marked) toast('Ready to send.', 'success');
461
+ else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
462
+ }
463
+ const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
464
+ redraw(fresh);
465
+ } catch (error) {
466
+ toast(error.message, 'danger');
467
+ }
468
+ },
469
+ }, ready ? 'Back to drafts' : 'Mark ready');
470
+ const problems = invoice?.problems?.length
471
+ ? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
472
+ : null;
473
+
340
474
  return [
341
- crumbs(ui, isNew ? 'New invoice' : 'Draft'),
475
+ crumbs(ui, isNew ? 'New invoice' : ready ? 'Ready' : 'Draft'),
342
476
  h('header', { class: 'fx-ph' },
343
477
  h('div', { class: 'fx-ph__text' },
344
- h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : 'Draft invoice'),
345
- h('p', { class: 'fx-ph__sub' }, 'The number is given when you issue it.')),
346
- h('div', { class: 'fx-ph__actions' }, saveButton, issueButton)),
478
+ h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
479
+ 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.')),
480
+ h('div', { class: 'fx-ph__actions' }, saveButton, readyButton, issueButton)),
481
+ problems,
347
482
  h('div', { class: 'fx-invoice__grid' },
348
483
  h('div', { class: 'fx-invoice__main' },
349
484
  h('section', { class: 'fx-card fx-invoice__head' },
@@ -425,20 +560,24 @@ function issuedView(invoice, ui, context, redraw) {
425
560
  ];
426
561
  }
427
562
 
428
- // Send to Xero: a draft in Xero from the issued invoice (POST /api/xero/invoices/push).
563
+ // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
564
+ // (Settings, Invoicing) decides what Xero gets: a draft that a person approves
565
+ // there, or an approved invoice that Xero emails to the customer.
429
566
  function sendToXero(invoice, ui, context) {
430
567
  const { h, flow, toast } = ui;
431
568
  const organisationId = context.organisations[0]?.id;
432
569
  if (!organisationId || !context.xero) return null;
433
- return h('button', {
570
+ const button = h('button', {
434
571
  class: 'fx-btn fx-btn--primary', type: 'button',
435
- onclick: async (event) => {
436
- const button = event.currentTarget;
572
+ onclick: async () => {
437
573
  button.disabled = true;
438
574
  try {
439
575
  const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
440
576
  const result = res.invoices?.[0];
441
- if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
577
+ const email = result?.email;
578
+ if (email && ['failed', 'unknown'].includes(email.state)) toast(email.error, 'danger');
579
+ else if (email?.state === 'sent') toast(`${invoice.number} is approved in Xero, and Xero emailed it to the customer.`, 'success');
580
+ else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
442
581
  else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
443
582
  else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
444
583
  } catch (error) {
@@ -448,4 +587,11 @@ function sendToXero(invoice, ui, context) {
448
587
  }
449
588
  },
450
589
  }, 'Send to Xero');
590
+ // The preview reads only Flow's copy of Xero: it costs no Xero calls.
591
+ flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
592
+ .then((res) => {
593
+ if (res.invoices?.[0]?.sendMode === 'approve_and_email') button.textContent = 'Send to Xero and email';
594
+ })
595
+ .catch(() => {});
596
+ return button;
451
597
  }
package/server/ui/jobs.js CHANGED
@@ -8,6 +8,7 @@ import { formatCents } from '/assets/ui/invoicing.js';
8
8
 
9
9
  // Board columns, left to right. Held jobs sit with the jobs that need review.
10
10
  const STAGES = [
11
+ { id: 'work', label: 'In work', views: ['in_work'], tone: 'mute' },
11
12
  { id: 'review', label: 'Needs review', views: ['review_required', 'held'], tone: 'warn' },
12
13
  { id: 'pending', label: 'Checking', views: ['pending'], tone: 'mute' },
13
14
  { id: 'ready', label: 'Ready to bill', views: ['ready'], tone: 'info' },
@@ -79,6 +80,24 @@ export async function renderJobs(main, ui) {
79
80
  const href = `/invoices/${job.invoiceId}`;
80
81
  return h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href, onclick: ui.go }, job.invoiceNumber ?? 'Open draft');
81
82
  }
83
+ // Flow's own stage: a person marks a job ready to invoice, or an
84
+ // automation does (Settings, Automations).
85
+ if (job.view === 'in_work') {
86
+ return h('button', {
87
+ class: 'fx-btn fx-btn--sm', type: 'button',
88
+ onclick: async (event) => {
89
+ event.stopPropagation();
90
+ event.currentTarget.disabled = true;
91
+ try {
92
+ await flow(`/api/invoicing/jobs/${job.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) });
93
+ ui.toast('Marked ready to invoice.', 'success');
94
+ } catch (error) {
95
+ ui.toast(error.message, 'danger');
96
+ }
97
+ await load();
98
+ },
99
+ }, 'Mark ready');
100
+ }
82
101
  if (job.view !== 'ready' || !state.canDraft) return null;
83
102
  return h('button', {
84
103
  class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button',
@@ -0,0 +1 @@
1
+ <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#362D59"><title>Sentry</title><path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z"/></svg>
@@ -0,0 +1,53 @@
1
+ // Errors and problem reports from the browser, to the installation's own
2
+ // Sentry project (docs/SENTRY.md). Only while the Sentry integration is on:
3
+ // GET /api/monitoring says so and gives the project's public DSN. The SDK
4
+ // comes from Sentry's CDN, pinned and checked against its integrity hash.
5
+ // With the integration off, nothing loads and the report button stays hidden.
6
+
7
+ function loadScript({ src, integrity }) {
8
+ return new Promise((resolve, reject) => {
9
+ const script = document.createElement('script');
10
+ script.src = src;
11
+ script.integrity = integrity;
12
+ script.crossOrigin = 'anonymous';
13
+ script.onload = resolve;
14
+ script.onerror = () => reject(new Error('The Sentry SDK could not load.'));
15
+ document.head.append(script);
16
+ });
17
+ }
18
+
19
+ export async function startMonitoring({ flow, person, button }) {
20
+ const config = await flow('/api/monitoring');
21
+ if (!config.on || config.service !== 'sentry') return;
22
+ await loadScript(config.script);
23
+ const Sentry = window.Sentry;
24
+ const integrations = config.feedback ? [Sentry.feedbackIntegration({
25
+ autoInject: false,
26
+ // The form's colours and font come from flow.css (#sentry-feedback), so
27
+ // it follows each person's theme and mode, even when they change it.
28
+ colorScheme: 'light',
29
+ showBranding: false,
30
+ // Flow already knows who is signed in (setUser below).
31
+ showName: false,
32
+ enableScreenshot: true,
33
+ triggerLabel: 'Report a problem',
34
+ formTitle: 'Report a problem',
35
+ messageLabel: 'What went wrong?',
36
+ messagePlaceholder: 'What you did, what you expected, and what happened.',
37
+ submitButtonLabel: 'Send report',
38
+ successMessageText: 'Thank you. Your report is sent.',
39
+ })] : [];
40
+ Sentry.init({
41
+ dsn: config.dsn,
42
+ environment: config.environment,
43
+ release: config.release ?? undefined,
44
+ sendDefaultPii: false,
45
+ integrations,
46
+ });
47
+ // Who reported or met the error, so the lab can follow up.
48
+ Sentry.setUser({ id: person.id, email: person.email });
49
+ if (config.feedback) {
50
+ Sentry.getFeedback()?.attachTo(button);
51
+ button.hidden = false;
52
+ }
53
+ }
@@ -18,9 +18,9 @@ const require = createRequire(import.meta.url);
18
18
  const here = dirname(fileURLToPath(import.meta.url));
19
19
  const themeDir = join(here, '..', 'theme');
20
20
  const logoDir = join(here, 'logos');
21
- const LOGO_FILES = new Set(['xero.svg', 'seazona.svg']);
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', 'jobs.js', 'flow-mark.svg']);
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', 'jobs.js', 'monitoring.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