@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.33
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.
- package/README.md +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +69 -26
- package/server/app.js +4 -0
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +64 -5
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +245 -8
- package/server/core/pipeline.js +30 -22
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +52 -8
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +5 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +4 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +77 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +69 -3
- package/server/integrations/seazona/lanes.js +82 -9
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +7 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +346 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +56 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +231 -68
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -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;
|
|
14
|
-
//
|
|
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
|
-
|
|
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 }));
|
|
@@ -129,6 +119,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
129
119
|
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
|
|
130
120
|
toast(...done(res));
|
|
131
121
|
state.ticked.clear();
|
|
122
|
+
seen.clear();
|
|
132
123
|
await load();
|
|
133
124
|
alerts.refresh();
|
|
134
125
|
} catch (error) {
|
|
@@ -156,7 +147,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
156
147
|
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
157
148
|
const buttons = [];
|
|
158
149
|
if (ticked.length) {
|
|
159
|
-
buttons.push(action(`
|
|
150
|
+
buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
|
|
160
151
|
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
161
152
|
}
|
|
162
153
|
if (context.canIssue && body.counts.ready > 0) {
|
|
@@ -175,23 +166,56 @@ export async function renderInvoiceList(main, ui) {
|
|
|
175
166
|
bar.hidden = !parts.length;
|
|
176
167
|
}
|
|
177
168
|
|
|
169
|
+
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
170
|
+
// it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
|
|
171
|
+
// newest request draws, so quick clicks never show an older tab's list.
|
|
172
|
+
const seen = new Map();
|
|
173
|
+
let asked = 0;
|
|
178
174
|
async function load() {
|
|
175
|
+
const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
|
|
176
|
+
const key = String(query);
|
|
177
|
+
const mine = ++asked;
|
|
178
|
+
if (seen.has(key)) {
|
|
179
|
+
delete host.dataset.stale;
|
|
180
|
+
draw(seen.get(key));
|
|
181
|
+
} else if (body) {
|
|
182
|
+
drawTabs(body.counts);
|
|
183
|
+
bar.hidden = true;
|
|
184
|
+
host.dataset.stale = '';
|
|
185
|
+
}
|
|
186
|
+
host.setAttribute('aria-busy', 'true');
|
|
187
|
+
let answer;
|
|
179
188
|
try {
|
|
180
|
-
|
|
181
|
-
body = await flow(`/api/invoicing/invoices?${query}`);
|
|
189
|
+
answer = await flow(`/api/invoicing/invoices?${query}`);
|
|
182
190
|
} catch (error) {
|
|
191
|
+
if (mine !== asked) return;
|
|
192
|
+
host.removeAttribute('aria-busy');
|
|
193
|
+
delete host.dataset.stale;
|
|
183
194
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
184
195
|
return;
|
|
185
196
|
}
|
|
186
|
-
|
|
187
|
-
|
|
197
|
+
if (mine !== asked) return;
|
|
198
|
+
seen.set(key, answer);
|
|
199
|
+
host.removeAttribute('aria-busy');
|
|
200
|
+
delete host.dataset.stale;
|
|
201
|
+
draw(answer);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function drawTabs(counts) {
|
|
188
205
|
tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
|
|
189
206
|
type: 'button',
|
|
190
207
|
role: 'tab',
|
|
191
208
|
'aria-selected': String(state.status === id),
|
|
192
|
-
...(id === 'problem' &&
|
|
193
|
-
onclick: () => openTab(id),
|
|
194
|
-
}, label, h('span', { class: 'fx-tabs__count' }, String(
|
|
209
|
+
...(id === 'problem' && counts?.problem ? { 'data-tone': 'danger' } : {}),
|
|
210
|
+
onclick: () => { if (state.status !== id) openTab(id); },
|
|
211
|
+
}, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function draw(answer) {
|
|
215
|
+
body = answer;
|
|
216
|
+
const shown = new Set(body.invoices.map((i) => i.id));
|
|
217
|
+
for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
|
|
218
|
+
drawTabs(body.counts);
|
|
195
219
|
drawBar();
|
|
196
220
|
if (!body.invoices.length) {
|
|
197
221
|
host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
|
|
@@ -199,6 +223,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
199
223
|
: EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
|
|
200
224
|
return;
|
|
201
225
|
}
|
|
226
|
+
// The invoice page steps through this list, in this order (navBar).
|
|
227
|
+
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
228
|
+
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
202
229
|
const ticks = TICKS[state.status];
|
|
203
230
|
const showProblem = state.status === 'problem';
|
|
204
231
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
@@ -231,13 +258,13 @@ export async function renderInvoiceList(main, ui) {
|
|
|
231
258
|
const href = `/invoices/${invoice.id}`;
|
|
232
259
|
const first = invoice.problems?.[0];
|
|
233
260
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
234
|
-
return h('tr', { tabindex: '0', onclick: () =>
|
|
261
|
+
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
235
262
|
ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
|
|
236
|
-
h('td', {}, h('a', { class: 'fx-strong', href, onclick:
|
|
263
|
+
h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
|
|
237
264
|
h('td', {}, invoice.contactName ?? '—'),
|
|
238
265
|
showProblem
|
|
239
266
|
? 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.
|
|
267
|
+
: h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
|
|
241
268
|
h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
|
|
242
269
|
h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
|
|
243
270
|
(() => {
|
|
@@ -259,6 +286,51 @@ export async function renderInvoiceList(main, ui) {
|
|
|
259
286
|
}
|
|
260
287
|
}
|
|
261
288
|
await load();
|
|
289
|
+
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The list an invoice was opened from: its tab, search, invoice ids in order,
|
|
293
|
+
// and scroll. The invoice page's bar steps through it and goes back to it.
|
|
294
|
+
const NAV = 'flow_invoice_nav';
|
|
295
|
+
const NAV_BACK = 'flow_invoice_back';
|
|
296
|
+
function readNav() {
|
|
297
|
+
try { return JSON.parse(sessionStorage.getItem(NAV) ?? 'null'); } catch { return null; }
|
|
298
|
+
}
|
|
299
|
+
function saveNav(nav) {
|
|
300
|
+
try { sessionStorage.setItem(NAV, JSON.stringify(nav)); } catch { /* the bar then shows Back only */ }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
304
|
+
// next invoice of that list.
|
|
305
|
+
function navBar(ui, id) {
|
|
306
|
+
const { h, go } = ui;
|
|
307
|
+
const nav = readNav();
|
|
308
|
+
const ids = nav?.ids ?? [];
|
|
309
|
+
const at = ids.indexOf(id);
|
|
310
|
+
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
311
|
+
const step = (to, label, path) => {
|
|
312
|
+
const button = h('button', {
|
|
313
|
+
class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
|
|
314
|
+
onclick: () => go(`/invoices/${to}`),
|
|
315
|
+
});
|
|
316
|
+
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>`;
|
|
317
|
+
return button;
|
|
318
|
+
};
|
|
319
|
+
return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
|
|
320
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
|
|
321
|
+
at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
|
|
322
|
+
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);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// After a send, the next invoice of the list opens; at its end, the list.
|
|
326
|
+
// The sent invoice stays in the bar's list, so the count holds (2 of 35) and
|
|
327
|
+
// Previous opens it again, now sent. Back reloads the list without it.
|
|
328
|
+
export function openNext(ui, id) {
|
|
329
|
+
const ids = readNav()?.ids ?? [];
|
|
330
|
+
const at = ids.indexOf(id);
|
|
331
|
+
const next = at >= 0 ? ids[at + 1] : null;
|
|
332
|
+
if (next) ui.go(`/invoices/${next}`);
|
|
333
|
+
else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
|
|
262
334
|
}
|
|
263
335
|
|
|
264
336
|
// ── One invoice ────────────────────────────────────────────────────────────
|
|
@@ -285,17 +357,11 @@ export async function renderInvoice(main, ui, id) {
|
|
|
285
357
|
const draw = (current) => {
|
|
286
358
|
host.replaceChildren(...(current?.status && current.status !== 'draft'
|
|
287
359
|
? issuedView(current, ui, context, draw)
|
|
288
|
-
: draftView(current, ui, context, draw)));
|
|
360
|
+
: draftView(current, ui, context, draw)).filter(Boolean));
|
|
289
361
|
};
|
|
290
362
|
draw(invoice);
|
|
291
363
|
}
|
|
292
364
|
|
|
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
365
|
function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
300
366
|
const { h } = ui;
|
|
301
367
|
return h('dl', { class: 'fx-totals' },
|
|
@@ -304,13 +370,13 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
|
304
370
|
h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
|
|
305
371
|
}
|
|
306
372
|
|
|
373
|
+
// The job an invoice bills. The case id stays on the record, not on screen.
|
|
307
374
|
function jobCard(ui, job) {
|
|
308
375
|
const { h } = ui;
|
|
309
376
|
if (!job) return null;
|
|
310
377
|
return h('section', { class: 'fx-card fx-side-card' },
|
|
311
378
|
h('h3', { class: 'fx-label' }, 'Job'),
|
|
312
379
|
h('dl', { class: 'fx-facts' },
|
|
313
|
-
h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
|
|
314
380
|
job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
|
|
315
381
|
job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
|
|
316
382
|
h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
|
|
@@ -471,24 +537,56 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
471
537
|
}
|
|
472
538
|
};
|
|
473
539
|
|
|
540
|
+
// Save shows only when there is something to save: a new invoice, or a
|
|
541
|
+
// change since the page opened. Saving keeps the invoice where it is.
|
|
474
542
|
const saveButton = h('button', {
|
|
475
|
-
class: 'fx-btn', type: 'button',
|
|
543
|
+
class: 'fx-btn', type: 'button', hidden: !isNew,
|
|
476
544
|
onclick: async () => {
|
|
477
545
|
const saved = await save();
|
|
478
546
|
if (!saved) return;
|
|
479
|
-
toast('Draft saved.', 'success');
|
|
547
|
+
toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
|
|
480
548
|
if (isNew) go(`/invoices/${saved.id}`, { replace: true });
|
|
481
549
|
else redraw(saved);
|
|
482
550
|
},
|
|
483
|
-
}, 'Save draft');
|
|
484
|
-
const
|
|
551
|
+
}, isNew ? 'Save draft' : 'Save changes');
|
|
552
|
+
const opened = JSON.stringify(body());
|
|
553
|
+
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
554
|
+
const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
|
|
555
|
+
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
556
|
+
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
557
|
+
// and keeps it. A draft that is not ready has no send: mark it ready first.
|
|
558
|
+
const to = invoice?.sendsTo ?? null;
|
|
559
|
+
const ready = invoice?.view === 'ready';
|
|
560
|
+
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
561
|
+
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
562
|
+
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
563
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
564
|
+
onclick: async () => {
|
|
565
|
+
if (changed() && !(await save())) return;
|
|
566
|
+
const ok = await ui.dialog({
|
|
567
|
+
title: `Send this invoice to ${to}?`,
|
|
568
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
|
|
569
|
+
confirm: emails ? 'Send and email' : 'Send',
|
|
570
|
+
});
|
|
571
|
+
if (!ok) return;
|
|
572
|
+
try {
|
|
573
|
+
await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
|
|
574
|
+
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
575
|
+
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
576
|
+
openNext(ui, invoice.id);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
toast(error.message, 'danger');
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
582
|
+
const issueButton = context.canIssue && !to ? h('button', {
|
|
485
583
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
486
584
|
onclick: async () => {
|
|
487
585
|
const saved = await save();
|
|
488
586
|
if (!saved) return;
|
|
489
587
|
const ok = await ui.dialog({
|
|
490
588
|
title: 'Issue this invoice?',
|
|
491
|
-
body: h('p', {}, `It gets the next number (${
|
|
589
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
|
|
492
590
|
confirm: 'Issue',
|
|
493
591
|
});
|
|
494
592
|
if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
|
|
@@ -504,40 +602,51 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
504
602
|
}, 'Issue invoice') : null;
|
|
505
603
|
|
|
506
604
|
// Mark ready: the draft is saved, then Flow checks it; one with a problem
|
|
507
|
-
// stays a draft.
|
|
508
|
-
const
|
|
509
|
-
const readyButton = isNew ? null : h('button', {
|
|
605
|
+
// stays a draft. Move to Drafts, on the Status card, undoes it.
|
|
606
|
+
const markReadyButton = isNew || ready ? null : h('button', {
|
|
510
607
|
class: 'fx-btn', type: 'button',
|
|
511
608
|
onclick: async () => {
|
|
512
|
-
|
|
513
|
-
if (!current) return;
|
|
609
|
+
if (!(await save())) return;
|
|
514
610
|
try {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
}
|
|
611
|
+
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
612
|
+
if (res.marked) toast('Ready to send.', 'success');
|
|
613
|
+
else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
523
614
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
524
615
|
redraw(fresh);
|
|
525
616
|
} catch (error) {
|
|
526
617
|
toast(error.message, 'danger');
|
|
527
618
|
}
|
|
528
619
|
},
|
|
529
|
-
},
|
|
620
|
+
}, 'Mark ready');
|
|
621
|
+
// A ready invoice can go back to Drafts. It is a status change, so it sits
|
|
622
|
+
// with the status, not with the page's main buttons.
|
|
623
|
+
const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
|
|
624
|
+
h('h3', { class: 'fx-label' }, 'Status'),
|
|
625
|
+
h('p', {}, 'Ready to send.'),
|
|
626
|
+
h('button', {
|
|
627
|
+
class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
|
|
628
|
+
onclick: async () => {
|
|
629
|
+
try {
|
|
630
|
+
await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
631
|
+
toast('Moved to Drafts.', 'success');
|
|
632
|
+
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
633
|
+
redraw(fresh);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
toast(error.message, 'danger');
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
}, 'Move to Drafts')) : null;
|
|
530
639
|
const problems = invoice?.problems?.length
|
|
531
640
|
? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
|
|
532
641
|
: null;
|
|
533
642
|
|
|
534
643
|
return [
|
|
535
|
-
|
|
644
|
+
navBar(ui, invoice?.id ?? null),
|
|
536
645
|
h('header', { class: 'fx-ph' },
|
|
537
646
|
h('div', { class: 'fx-ph__text' },
|
|
538
647
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
539
|
-
h('p', { class: 'fx-ph__sub' },
|
|
540
|
-
h('div', { class: 'fx-ph__actions' }, saveButton,
|
|
648
|
+
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
649
|
+
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
|
|
541
650
|
problems,
|
|
542
651
|
h('div', { class: 'fx-invoice__grid' },
|
|
543
652
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -556,8 +665,45 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
556
665
|
h('div', { class: 'fx-lines__foot' },
|
|
557
666
|
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
667
|
totalsHost))),
|
|
559
|
-
h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
|
|
560
|
-
]
|
|
668
|
+
h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
|
|
669
|
+
].map((part) => {
|
|
670
|
+
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
671
|
+
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
672
|
+
return part;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Preview: the invoice as a PDF in a new tab (GET /api/invoicing/invoices/:id/pdf).
|
|
677
|
+
// The tab opens at once, because a browser blocks a tab opened after a wait,
|
|
678
|
+
// and shows the PDF when it is ready. If Flow cannot make it, the tab closes
|
|
679
|
+
// and a toast says why.
|
|
680
|
+
function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
|
|
681
|
+
const { h, toast } = ui;
|
|
682
|
+
return h('button', {
|
|
683
|
+
class: 'fx-btn', type: 'button', title,
|
|
684
|
+
onclick: async () => {
|
|
685
|
+
const tab = window.open('', '_blank');
|
|
686
|
+
if (tab) {
|
|
687
|
+
tab.document.title = 'Preparing PDF';
|
|
688
|
+
tab.document.body.textContent = 'Preparing PDF…';
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const token = await window.Clerk.session?.getToken();
|
|
692
|
+
const res = await fetch(`/api/invoicing/invoices/${invoiceId}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
|
|
693
|
+
if (!res.ok) {
|
|
694
|
+
const body = await res.json().catch(() => ({}));
|
|
695
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
696
|
+
}
|
|
697
|
+
const url = URL.createObjectURL(await res.blob());
|
|
698
|
+
if (tab) tab.location.href = url;
|
|
699
|
+
else window.location.assign(url);
|
|
700
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
tab?.close();
|
|
703
|
+
toast(`The PDF could not be made. ${error.message}`, 'danger');
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
}, 'Preview');
|
|
561
707
|
}
|
|
562
708
|
|
|
563
709
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
@@ -579,10 +725,12 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
579
725
|
const organisationId = context.organisations?.[0]?.id;
|
|
580
726
|
const xeroHost = h('div');
|
|
581
727
|
let xeroStatus = null;
|
|
728
|
+
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
582
729
|
const loadXero = async () => {
|
|
583
730
|
if (!context.xero) return;
|
|
584
731
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
585
732
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
733
|
+
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
586
734
|
};
|
|
587
735
|
loadXero();
|
|
588
736
|
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
@@ -598,17 +746,16 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
598
746
|
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
599
747
|
}, 'Void'),
|
|
600
748
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
601
|
-
sendToXero(invoice, ui, context),
|
|
602
749
|
] : [];
|
|
603
750
|
return [
|
|
604
|
-
|
|
751
|
+
navBar(ui, invoice.id),
|
|
605
752
|
h('header', { class: 'fx-ph' },
|
|
606
753
|
h('div', { class: 'fx-ph__text' },
|
|
607
754
|
h('div', { class: 'fx-row' },
|
|
608
755
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
609
756
|
h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
|
|
610
757
|
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
|
|
611
|
-
h('div', { class: 'fx-ph__actions' }, actions)),
|
|
758
|
+
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
612
759
|
h('div', { class: 'fx-invoice__grid' },
|
|
613
760
|
h('div', { class: 'fx-invoice__main' },
|
|
614
761
|
h('section', { class: 'fx-card fx-doc' },
|
|
@@ -674,7 +821,7 @@ function xeroCard(invoice, ui, context, status, reload) {
|
|
|
674
821
|
}
|
|
675
822
|
|
|
676
823
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
677
|
-
// (Settings,
|
|
824
|
+
// (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
|
|
678
825
|
// there, or an approved invoice that Xero emails to the customer.
|
|
679
826
|
function sendToXero(invoice, ui, context) {
|
|
680
827
|
const { h, flow, toast } = ui;
|
|
@@ -684,6 +831,7 @@ function sendToXero(invoice, ui, context) {
|
|
|
684
831
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
685
832
|
onclick: async () => {
|
|
686
833
|
button.disabled = true;
|
|
834
|
+
let sent = false;
|
|
687
835
|
try {
|
|
688
836
|
const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
|
|
689
837
|
const result = res.invoices?.[0];
|
|
@@ -693,18 +841,33 @@ function sendToXero(invoice, ui, context) {
|
|
|
693
841
|
else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
|
|
694
842
|
else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
|
|
695
843
|
else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
|
|
844
|
+
sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
|
|
696
845
|
} catch (error) {
|
|
697
846
|
toast(error.message, 'danger');
|
|
698
847
|
} finally {
|
|
699
|
-
|
|
848
|
+
if (sent) showSent(button);
|
|
849
|
+
else button.disabled = false;
|
|
700
850
|
}
|
|
701
851
|
},
|
|
702
852
|
}, 'Send to Xero');
|
|
853
|
+
// Sent or on its way (Send, or Send all ready): it cannot be sent twice.
|
|
854
|
+
if (['queued', 'sent'].includes(invoice.sendState)) {
|
|
855
|
+
showSent(button);
|
|
856
|
+
return button;
|
|
857
|
+
}
|
|
703
858
|
// The preview reads only Flow's copy of Xero: it costs no Xero calls.
|
|
704
859
|
flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
|
|
705
860
|
.then((res) => {
|
|
706
|
-
if (res.invoices?.[0]?.sendMode === 'approve_and_email') button.textContent = 'Send to Xero and email';
|
|
861
|
+
if (res.invoices?.[0]?.sendMode === 'approve_and_email' && !button.disabled) button.textContent = 'Send to Xero and email';
|
|
707
862
|
})
|
|
708
863
|
.catch(() => {});
|
|
709
864
|
return button;
|
|
710
865
|
}
|
|
866
|
+
|
|
867
|
+
// A sent invoice's Send button stays, greyed, and says it went.
|
|
868
|
+
function showSent(button) {
|
|
869
|
+
if (!button) return;
|
|
870
|
+
button.disabled = true;
|
|
871
|
+
button.textContent = 'Sent to Xero';
|
|
872
|
+
button.title = 'Flow sent this invoice to Xero.';
|
|
873
|
+
}
|
package/server/ui/job-board.js
CHANGED
|
@@ -91,7 +91,8 @@ function reasonFix(reason, acct) {
|
|
|
91
91
|
if (reason === 'the work system report is out of date') {
|
|
92
92
|
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
|
|
93
93
|
}
|
|
94
|
-
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice.');
|
|
94
|
+
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice. If only the job changed, use its new lines.');
|
|
95
|
+
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
|
|
95
96
|
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
|
|
96
97
|
if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
|
|
97
98
|
if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
|
package/server/ui/jobs.js
CHANGED
|
@@ -18,6 +18,7 @@ const ACTIVITY = {
|
|
|
18
18
|
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
|
|
19
19
|
accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
|
|
20
20
|
held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
|
|
21
|
+
status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
|
|
21
22
|
writeback_sent: 'Status sent to the lab system', writeback_failed: 'Status not sent to the lab system',
|
|
22
23
|
writeback_not_supported: 'The lab system does not take the status',
|
|
23
24
|
};
|
|
@@ -172,7 +173,24 @@ export async function renderJobs(main, ui) {
|
|
|
172
173
|
if (!o) return [];
|
|
173
174
|
if (o.invoiceId) {
|
|
174
175
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
175
|
-
|
|
176
|
+
const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
|
|
177
|
+
// Only the job changed and the draft is not sent (ledger.js): one tap
|
|
178
|
+
// puts the job's new lines on Flow's own draft.
|
|
179
|
+
if (!o.canUseNewLines) return [open];
|
|
180
|
+
return [h('button', {
|
|
181
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
182
|
+
onclick: async (event) => {
|
|
183
|
+
event.currentTarget.disabled = true;
|
|
184
|
+
try {
|
|
185
|
+
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
186
|
+
ui.toast('The draft has the new job lines.', 'success');
|
|
187
|
+
} catch (error) {
|
|
188
|
+
ui.toast(error.message, 'danger');
|
|
189
|
+
}
|
|
190
|
+
close();
|
|
191
|
+
await load();
|
|
192
|
+
},
|
|
193
|
+
}, 'Use new lines'), open];
|
|
176
194
|
}
|
|
177
195
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
178
196
|
// automation does (Settings, Automations).
|
package/server/ui/routes.js
CHANGED
|
@@ -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
|