@arcforgelabs/flow 2026.10.37 → 2026.10.39
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 +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
|
@@ -38,9 +38,10 @@ function readEnv(env, name) {
|
|
|
38
38
|
return typeof value === 'string' ? value.trim() : '';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function readOnOff(env, name) {
|
|
41
|
+
function readOnOff(env, name, fallback = false) {
|
|
42
42
|
const raw = readEnv(env, name).toLowerCase();
|
|
43
|
-
if (raw === ''
|
|
43
|
+
if (raw === '') return fallback;
|
|
44
|
+
if (raw === 'off') return false;
|
|
44
45
|
if (raw === 'on') return true;
|
|
45
46
|
throw new Error(`${name} must be "on", "off" or unset`);
|
|
46
47
|
}
|
|
@@ -166,9 +167,9 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
166
167
|
defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
|
|
167
168
|
invoiceNumber,
|
|
168
169
|
replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
|
|
169
|
-
// Free text on Xero records (references, line descriptions)
|
|
170
|
-
//
|
|
171
|
-
keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE'),
|
|
170
|
+
// Free text on Xero records (references, line descriptions) is the lab's
|
|
171
|
+
// own data, and the Import matches on it. On unless the lab turns it off.
|
|
172
|
+
keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE', true),
|
|
172
173
|
mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
|
|
173
174
|
// The share of the day's calls the mirror leaves for drafts and people.
|
|
174
175
|
mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -13,8 +13,11 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
|
+
import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
|
|
16
17
|
import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
17
18
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
19
|
+
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
20
|
+
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
18
21
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
19
22
|
import { openSearch } from '/assets/ui/search.js';
|
|
20
23
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
@@ -124,10 +127,41 @@ async function flow(path, options = {}) {
|
|
|
124
127
|
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
125
128
|
});
|
|
126
129
|
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
127
|
-
|
|
130
|
+
// A button that calls Xero or Seazona waits between presses (the action
|
|
131
|
+
// guard): a press too soon says how long (retryAfterMs), and a good run
|
|
132
|
+
// says its own wait (Flow-Cooldown-Ms). holdButton shows the wait.
|
|
133
|
+
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details, retryAfterMs: body.retryAfterMs });
|
|
134
|
+
const cooldownMs = Number(res.headers.get('Flow-Cooldown-Ms'));
|
|
135
|
+
if (cooldownMs > 0 && body && typeof body === 'object') body.cooldownMs = cooldownMs;
|
|
128
136
|
return body;
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
// Keeps a button off for `ms`, with the time left on it, then puts it back.
|
|
140
|
+
function holdButton(button, ms) {
|
|
141
|
+
if (!button || !(ms > 0)) return;
|
|
142
|
+
clearInterval(button.fxHold);
|
|
143
|
+
const label = button.dataset.label ?? button.textContent;
|
|
144
|
+
button.dataset.label = label;
|
|
145
|
+
const until = Date.now() + ms;
|
|
146
|
+
const draw = () => {
|
|
147
|
+
const left = Math.ceil((until - Date.now()) / 1000);
|
|
148
|
+
if (left <= 0) {
|
|
149
|
+
clearInterval(button.fxHold);
|
|
150
|
+
button.textContent = label;
|
|
151
|
+
delete button.dataset.label;
|
|
152
|
+
button.removeAttribute('title');
|
|
153
|
+
button.disabled = false;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
157
|
+
button.disabled = true;
|
|
158
|
+
button.textContent = `${label} · ${time}`;
|
|
159
|
+
button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
|
|
160
|
+
};
|
|
161
|
+
draw();
|
|
162
|
+
button.fxHold = setInterval(draw, 1000);
|
|
163
|
+
}
|
|
164
|
+
|
|
131
165
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
132
166
|
|
|
133
167
|
function pageHeader(title, { subtitle, actions = [] } = {}) {
|
|
@@ -200,13 +234,16 @@ let me = null;
|
|
|
200
234
|
const VIEWS = {
|
|
201
235
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
202
236
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
237
|
+
reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
203
238
|
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
204
|
-
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
239
|
+
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
205
240
|
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
206
241
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
207
242
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
208
243
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
209
244
|
// Clerk profile. The page stays for links and Search.
|
|
245
|
+
// Opened from the Xero integration page: Xero invoices against Flow's own.
|
|
246
|
+
'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
|
|
210
247
|
account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
|
|
211
248
|
appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
|
|
212
249
|
};
|
|
@@ -215,6 +252,7 @@ const VIEWS = {
|
|
|
215
252
|
const ICON_PATHS = {
|
|
216
253
|
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
217
254
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
255
|
+
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
218
256
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
219
257
|
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
220
258
|
fields: '<rect x="3" y="4" width="18" height="5" rx="1.5"/><rect x="3" y="13" width="18" height="5" rx="1.5"/><path d="M7 6.5h4M7 15.5h7"/>',
|
|
@@ -365,7 +403,7 @@ function navigate(event) {
|
|
|
365
403
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
366
404
|
// takes a path, or a link's click event.
|
|
367
405
|
const ui = () => ({
|
|
368
|
-
h, flow, toast, notice, pageHeader, dialog, me,
|
|
406
|
+
h, flow, toast, notice, pageHeader, dialog, me, holdButton,
|
|
369
407
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
370
408
|
});
|
|
371
409
|
|
|
@@ -825,6 +863,7 @@ async function renderIntegration(main, id) {
|
|
|
825
863
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
826
864
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
827
865
|
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
866
|
+
current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
|
|
828
867
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
829
868
|
guide ? h('section', { class: 'fx-int-section' },
|
|
830
869
|
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
@@ -904,6 +943,53 @@ function connectProgress({ steps, started }) {
|
|
|
904
943
|
step.label))));
|
|
905
944
|
}
|
|
906
945
|
|
|
946
|
+
// Xero › Import, in settings rows: how many Xero invoices to check (Review
|
|
947
|
+
// opens them one at a time), the two import settings, and the history. The
|
|
948
|
+
// counts come from Flow's copy of Xero; the pages are server/ui/xero-import.js.
|
|
949
|
+
function xeroImportSection(organisations) {
|
|
950
|
+
const organisationId = organisations?.[0]?.id;
|
|
951
|
+
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
952
|
+
const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
|
|
953
|
+
const copied = h('span', {}, 'Every Xero invoice Flow copied. Search it with no Xero call.');
|
|
954
|
+
const switchFor = (name, label) => {
|
|
955
|
+
const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
|
|
956
|
+
button.addEventListener('click', async () => {
|
|
957
|
+
const on = button.getAttribute('aria-checked') !== 'true';
|
|
958
|
+
button.disabled = true;
|
|
959
|
+
try {
|
|
960
|
+
const { settings } = await flow('/api/xero/import/settings', { method: 'PUT', body: JSON.stringify({ organisationId, [name]: on }) });
|
|
961
|
+
button.setAttribute('aria-checked', String(settings[name]));
|
|
962
|
+
toast('Saved.', 'success');
|
|
963
|
+
} catch (error) {
|
|
964
|
+
toast(error.message, 'danger');
|
|
965
|
+
}
|
|
966
|
+
button.disabled = !canAct;
|
|
967
|
+
});
|
|
968
|
+
return button;
|
|
969
|
+
};
|
|
970
|
+
const autoLink = switchFor('autoLink', 'Link sure matches');
|
|
971
|
+
const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
|
|
972
|
+
const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
|
|
973
|
+
h('h2', { class: 'fx-set__title' }, 'Import'),
|
|
974
|
+
settingRow({ label: 'To check', hint: 'Xero invoices that fit a Flow invoice or a Flow job.',
|
|
975
|
+
control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
|
|
976
|
+
settingRow({ label: 'Link sure matches', hint: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
|
|
977
|
+
settingRow({ label: 'Make Flow invoices for the rest', hint: 'Off: they stay in the Xero history.', control: makeUnmatched }),
|
|
978
|
+
settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
|
|
979
|
+
if (organisationId) {
|
|
980
|
+
flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
|
|
981
|
+
.then(({ counts: c, settings }) => {
|
|
982
|
+
count.replaceChildren(String(c.job_fits + c.invoice_fits));
|
|
983
|
+
copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} Xero invoices copied, ${c.linked} in Flow. Search with no Xero call.`);
|
|
984
|
+
autoLink.setAttribute('aria-checked', String(settings.autoLink));
|
|
985
|
+
makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
|
|
986
|
+
autoLink.disabled = makeUnmatched.disabled = !canAct;
|
|
987
|
+
})
|
|
988
|
+
.catch(() => count.replaceChildren('—'));
|
|
989
|
+
}
|
|
990
|
+
return section;
|
|
991
|
+
}
|
|
992
|
+
|
|
907
993
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
908
994
|
// Connect, which goes to Xero and comes back.
|
|
909
995
|
function xeroConnection(organisations, canChange) {
|
package/server/ui/bulk-bar.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// its name on hover and to a screen reader. More opens every action, with a
|
|
6
6
|
// search box, grouped under small heads.
|
|
7
7
|
//
|
|
8
|
-
// bulkBar(ui) answers { element, draw(state) }.
|
|
8
|
+
// bulkBar(ui, { label }) answers { element, draw(state) }. label names the
|
|
9
|
+
// bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
|
|
9
10
|
// groups: [[action]], more: [{ head, actions: [action] }] }; an action is
|
|
10
11
|
// { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
|
|
11
12
|
// long name, for the hover tip and the More menu. A count of 0 hides the
|
|
@@ -14,10 +15,10 @@ import { lucide } from '/assets/ui/custom-fields.js';
|
|
|
14
15
|
|
|
15
16
|
let barCount = 0;
|
|
16
17
|
|
|
17
|
-
export function bulkBar(ui) {
|
|
18
|
+
export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
|
|
18
19
|
const { h } = ui;
|
|
19
20
|
const id = `fx-bulk-more-${++barCount}`;
|
|
20
|
-
const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label':
|
|
21
|
+
const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
|
|
21
22
|
const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
|
|
22
23
|
let current = null;
|
|
23
24
|
let busy = false;
|