@arcforgelabs/flow 2026.10.32 → 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 +56 -24
- package/server/app.js +4 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +60 -3
- package/server/integrations/seazona/lanes.js +81 -8
- 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/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/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/flow.css +9 -0
- package/server/ui/invoicing.js +101 -17
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -119,6 +119,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
119
119
|
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
|
|
120
120
|
toast(...done(res));
|
|
121
121
|
state.ticked.clear();
|
|
122
|
+
seen.clear();
|
|
122
123
|
await load();
|
|
123
124
|
alerts.refresh();
|
|
124
125
|
} catch (error) {
|
|
@@ -165,23 +166,56 @@ export async function renderInvoiceList(main, ui) {
|
|
|
165
166
|
bar.hidden = !parts.length;
|
|
166
167
|
}
|
|
167
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;
|
|
168
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;
|
|
169
188
|
try {
|
|
170
|
-
|
|
171
|
-
body = await flow(`/api/invoicing/invoices?${query}`);
|
|
189
|
+
answer = await flow(`/api/invoicing/invoices?${query}`);
|
|
172
190
|
} catch (error) {
|
|
191
|
+
if (mine !== asked) return;
|
|
192
|
+
host.removeAttribute('aria-busy');
|
|
193
|
+
delete host.dataset.stale;
|
|
173
194
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
174
195
|
return;
|
|
175
196
|
}
|
|
176
|
-
|
|
177
|
-
|
|
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) {
|
|
178
205
|
tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
|
|
179
206
|
type: 'button',
|
|
180
207
|
role: 'tab',
|
|
181
208
|
'aria-selected': String(state.status === id),
|
|
182
|
-
...(id === 'problem' &&
|
|
183
|
-
onclick: () => openTab(id),
|
|
184
|
-
}, 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);
|
|
185
219
|
drawBar();
|
|
186
220
|
if (!body.invoices.length) {
|
|
187
221
|
host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
|
|
@@ -289,12 +323,12 @@ function navBar(ui, id) {
|
|
|
289
323
|
}
|
|
290
324
|
|
|
291
325
|
// After a send, the next invoice of the list opens; at its end, the list.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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 ?? [];
|
|
295
330
|
const at = ids.indexOf(id);
|
|
296
|
-
const next = at >= 0 ?
|
|
297
|
-
if (at >= 0) saveNav({ ...nav, ids: ids.filter((other) => other !== id) });
|
|
331
|
+
const next = at >= 0 ? ids[at + 1] : null;
|
|
298
332
|
if (next) ui.go(`/invoices/${next}`);
|
|
299
333
|
else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
|
|
300
334
|
}
|
|
@@ -612,7 +646,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
612
646
|
h('div', { class: 'fx-ph__text' },
|
|
613
647
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
614
648
|
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
615
|
-
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
|
|
649
|
+
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
|
|
616
650
|
problems,
|
|
617
651
|
h('div', { class: 'fx-invoice__grid' },
|
|
618
652
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -639,6 +673,39 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
639
673
|
});
|
|
640
674
|
}
|
|
641
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');
|
|
707
|
+
}
|
|
708
|
+
|
|
642
709
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
643
710
|
function issuedView(invoice, ui, context, redraw) {
|
|
644
711
|
const { h, flow, toast } = ui;
|
|
@@ -658,10 +725,12 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
658
725
|
const organisationId = context.organisations?.[0]?.id;
|
|
659
726
|
const xeroHost = h('div');
|
|
660
727
|
let xeroStatus = null;
|
|
728
|
+
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
661
729
|
const loadXero = async () => {
|
|
662
730
|
if (!context.xero) return;
|
|
663
731
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
664
732
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
733
|
+
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
665
734
|
};
|
|
666
735
|
loadXero();
|
|
667
736
|
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
@@ -677,7 +746,6 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
677
746
|
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
678
747
|
}, 'Void'),
|
|
679
748
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
680
|
-
sendToXero(invoice, ui, context),
|
|
681
749
|
] : [];
|
|
682
750
|
return [
|
|
683
751
|
navBar(ui, invoice.id),
|
|
@@ -687,7 +755,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
687
755
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
688
756
|
h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
|
|
689
757
|
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
|
|
690
|
-
h('div', { class: 'fx-ph__actions' }, actions)),
|
|
758
|
+
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
691
759
|
h('div', { class: 'fx-invoice__grid' },
|
|
692
760
|
h('div', { class: 'fx-invoice__main' },
|
|
693
761
|
h('section', { class: 'fx-card fx-doc' },
|
|
@@ -763,6 +831,7 @@ function sendToXero(invoice, ui, context) {
|
|
|
763
831
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
764
832
|
onclick: async () => {
|
|
765
833
|
button.disabled = true;
|
|
834
|
+
let sent = false;
|
|
766
835
|
try {
|
|
767
836
|
const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
|
|
768
837
|
const result = res.invoices?.[0];
|
|
@@ -772,18 +841,33 @@ function sendToXero(invoice, ui, context) {
|
|
|
772
841
|
else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
|
|
773
842
|
else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
|
|
774
843
|
else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
|
|
844
|
+
sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
|
|
775
845
|
} catch (error) {
|
|
776
846
|
toast(error.message, 'danger');
|
|
777
847
|
} finally {
|
|
778
|
-
|
|
848
|
+
if (sent) showSent(button);
|
|
849
|
+
else button.disabled = false;
|
|
779
850
|
}
|
|
780
851
|
},
|
|
781
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
|
+
}
|
|
782
858
|
// The preview reads only Flow's copy of Xero: it costs no Xero calls.
|
|
783
859
|
flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
|
|
784
860
|
.then((res) => {
|
|
785
|
-
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';
|
|
786
862
|
})
|
|
787
863
|
.catch(() => {});
|
|
788
864
|
return button;
|
|
789
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).
|