@arcforgelabs/flow 2026.10.26 → 2026.10.29
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 +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -2
- package/server/api/invoicing.js +313 -46
- package/server/app.js +30 -2
- package/server/core/automations.js +154 -0
- package/server/core/invoice-rules.js +226 -0
- package/server/core/invoices.js +8 -3
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +57 -17
- package/server/core/line-mapping.js +337 -0
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +16 -3
- package/server/core/sending.js +283 -0
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +72 -0
- package/server/db/migrations/0011_invoice_rules.sql +27 -0
- package/server/db/migrations/0012_invoice_sending.sql +18 -0
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +28 -0
- package/server/db/schema.js +158 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +17 -5
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +127 -17
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +7 -4
- package/server/ui/app.js +122 -6
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +243 -30
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +110 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +232 -84
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +61 -0
- package/server/ui/routes.js +2 -2
- package/server/ui/search.js +145 -0
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
import { lineMappingSection } from '/assets/ui/line-mappings.js';
|
|
15
|
+
|
|
16
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
|
|
17
|
+
|
|
18
|
+
const SEND_MODES = [
|
|
19
|
+
{ value: 'accounting_draft', label: 'Xero draft' },
|
|
20
|
+
{ value: 'approve_and_email', label: 'Approved and emailed' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export async function renderInvoicingSettings(main, ui) {
|
|
24
|
+
const { h, flow, toast, notice, pageHeader, me } = ui;
|
|
25
|
+
const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
|
|
26
|
+
main.append(pageHeader('Invoicing', { subtitle: 'How Flow drafts invoices. No integration needed.' }), host);
|
|
27
|
+
let body;
|
|
28
|
+
try {
|
|
29
|
+
body = await flow('/api/invoicing/settings');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
35
|
+
let saved = body.settings;
|
|
36
|
+
|
|
37
|
+
async function save(changes, control) {
|
|
38
|
+
control.disabled = true;
|
|
39
|
+
try {
|
|
40
|
+
saved = (await flow('/api/invoicing/settings', { method: 'PUT', body: JSON.stringify(changes) })).settings;
|
|
41
|
+
toast('Saved.', 'success');
|
|
42
|
+
return true;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
toast(error.message, 'danger');
|
|
45
|
+
return false;
|
|
46
|
+
} finally {
|
|
47
|
+
control.disabled = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
52
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
53
|
+
h('div', { class: 'fx-set__control' }, control));
|
|
54
|
+
|
|
55
|
+
// A text or number box saves when it loses focus with a new value.
|
|
56
|
+
const box = (key, label, { type = 'text', read = (v) => v, show = (v) => v ?? '', placeholder } = {}) => {
|
|
57
|
+
const input = h('input', { class: 'fx-input', type, value: show(saved[key]), 'aria-label': label, disabled, placeholder });
|
|
58
|
+
input.addEventListener('change', async () => {
|
|
59
|
+
if (!(await save({ [key]: read(input.value) }, input))) input.value = show(saved[key]);
|
|
60
|
+
});
|
|
61
|
+
return input;
|
|
62
|
+
};
|
|
63
|
+
const days = (key, label) => box(key, label, { type: 'number', read: Number });
|
|
64
|
+
|
|
65
|
+
const section = (title, ...rows) => h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), ...rows);
|
|
66
|
+
host.replaceChildren(...[
|
|
67
|
+
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
68
|
+
section('Drafts',
|
|
69
|
+
row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))),
|
|
70
|
+
lineMappingSection(ui),
|
|
71
|
+
automationSection(ui, 'invoicing', 'Automations'),
|
|
72
|
+
rulesSection(ui),
|
|
73
|
+
].filter(Boolean));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The Xero integration page's "Sending" section: how a checked invoice goes to
|
|
77
|
+
// Xero. A customer can have its own mode (/api/invoicing/contacts/:id/billing).
|
|
78
|
+
export function sendModeSection(ui) {
|
|
79
|
+
const { h, flow, toast, notice, me } = ui;
|
|
80
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
81
|
+
const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
|
|
82
|
+
SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
|
|
83
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Sending'),
|
|
84
|
+
h('div', { class: 'fx-set__row' },
|
|
85
|
+
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.')),
|
|
86
|
+
h('div', { class: 'fx-set__control' }, select)),
|
|
87
|
+
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How sending works')));
|
|
88
|
+
let saved = null;
|
|
89
|
+
(async () => {
|
|
90
|
+
try {
|
|
91
|
+
saved = (await flow('/api/invoicing/settings')).settings;
|
|
92
|
+
select.value = saved.sendMode;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
section.append(notice(error.message, 'danger'));
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
select.addEventListener('change', async () => {
|
|
98
|
+
select.disabled = true;
|
|
99
|
+
try {
|
|
100
|
+
saved = (await flow('/api/invoicing/settings', { method: 'PUT', body: JSON.stringify({ sendMode: select.value }) })).settings;
|
|
101
|
+
toast('Saved.', 'success');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
toast(error.message, 'danger');
|
|
104
|
+
if (saved) select.value = saved.sendMode;
|
|
105
|
+
} finally {
|
|
106
|
+
select.disabled = disabled;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return section;
|
|
110
|
+
}
|
package/server/ui/invoicing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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', {
|
|
83
|
-
h('th', {},
|
|
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
|
-
|
|
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', {},
|
|
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
|
|
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
|
-
|
|
570
|
+
const button = h('button', {
|
|
434
571
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
435
|
-
onclick: async (
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The Jobs board's rules, with no page code, so tests can read them
|
|
2
|
+
// (test/job-board.test.js). server/ui/jobs.js draws the page.
|
|
3
|
+
//
|
|
4
|
+
// The board's columns are the work system's own job statuses, one to one.
|
|
5
|
+
// A job's invoice has a stage of its own (invoiceStage), shown in the job's
|
|
6
|
+
// detail panel; a job whose invoice needs a person has a problem (fixes).
|
|
7
|
+
|
|
8
|
+
// Where a status sits on the board when the work system gives no order:
|
|
9
|
+
// first the statuses of new work, then the ones Flow does not know (in the
|
|
10
|
+
// order Flow first saw them), then checking, complete, shipped, delivered,
|
|
11
|
+
// and cancelled last.
|
|
12
|
+
const STATUS_RANKS = [
|
|
13
|
+
[/\b(new|draft|received|booked|submitted|ordered|pending)\b/i, 0],
|
|
14
|
+
[/\b(scan|scanned|design|designed|model|wax|impression)/i, 1],
|
|
15
|
+
[/\b(qc|quality|check|checked|checking|finish|finished|finishing)\b/i, 3],
|
|
16
|
+
[/\bcomplete(d)?\b|\bready\b/i, 4],
|
|
17
|
+
[/\b(shipped|dispatched|sent)\b/i, 5],
|
|
18
|
+
[/\bdelivered\b/i, 6],
|
|
19
|
+
[/\b(cancell?ed|void|closed|archived|deleted)\b/i, 7],
|
|
20
|
+
];
|
|
21
|
+
const UNKNOWN_RANK = 2;
|
|
22
|
+
// From this rank on, the work is out of the lab: shipped, delivered, cancelled.
|
|
23
|
+
export const FINAL_RANK = 5;
|
|
24
|
+
export const NO_STATUS = 'No status';
|
|
25
|
+
|
|
26
|
+
export function statusRank(label) {
|
|
27
|
+
return STATUS_RANKS.find(([pattern]) => pattern.test(label ?? ''))?.[1] ?? UNKNOWN_RANK;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The board's column labels, left to right. `statuses` is the work system's
|
|
31
|
+
// list ({ label, firstSeenAt }); `jobs` adds any status only a job names.
|
|
32
|
+
export function orderStatuses(statuses = [], jobs = []) {
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
for (const s of statuses) if (s?.label && !seen.has(s.label)) seen.set(s.label, s.firstSeenAt ?? null);
|
|
35
|
+
for (const job of jobs) {
|
|
36
|
+
const label = job.status ?? NO_STATUS;
|
|
37
|
+
if (!seen.has(label)) seen.set(label, null);
|
|
38
|
+
}
|
|
39
|
+
return [...seen].sort(([a, at], [b, bt]) => statusRank(a) - statusRank(b)
|
|
40
|
+
|| String(at ?? '9').localeCompare(String(bt ?? '9'))
|
|
41
|
+
|| a.localeCompare(b)).map(([label]) => label);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const INVOICE_STAGES = {
|
|
45
|
+
drafted: 'Drafted', problem: 'Problem', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
|
|
49
|
+
// A rule's reason, as ruleBlockers writes it (server/core/invoicing-rules.js).
|
|
50
|
+
const isRule = (reason) => reason !== 'the job is not marked ready to invoice';
|
|
51
|
+
|
|
52
|
+
// The stage of a job's invoice, from its billing obligation (the API's job
|
|
53
|
+
// shape, server/core/job-board.js), or null while the job has none or is
|
|
54
|
+
// still in work.
|
|
55
|
+
// Drafted a draft in Flow that nobody marked ready, or a job
|
|
56
|
+
// that passes every check and waits for the automation
|
|
57
|
+
// Problem something a person must fix: the job's checks, a rule
|
|
58
|
+
// that stops the draft, or a problem with the draft
|
|
59
|
+
// Ready to invoice a person marked the draft ready
|
|
60
|
+
// Invoiced issued, sent, or in the accounting system
|
|
61
|
+
// Paid paid in full
|
|
62
|
+
export function invoiceStage(o) {
|
|
63
|
+
if (!o || o.view === 'in_work') return null;
|
|
64
|
+
if (o.view === 'paid') return 'paid';
|
|
65
|
+
if (PROBLEM_VIEWS.includes(o.view)) return 'problem';
|
|
66
|
+
if (o.view === 'invoiced') return 'invoiced';
|
|
67
|
+
if (o.invoiceView === 'problem' || o.invoiceStatus === 'void') return 'problem';
|
|
68
|
+
if (o.invoiceStatus === 'issued') return 'invoiced';
|
|
69
|
+
if (o.invoiceView === 'ready') return 'ready';
|
|
70
|
+
if (o.view === 'ready') return (o.waitingFor ?? []).some(isRule) ? 'problem' : 'drafted';
|
|
71
|
+
if (o.view === 'drafted') return 'drafted';
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const capital = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
76
|
+
const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
|
|
77
|
+
|
|
78
|
+
// One ledger reason (server/core/ledger.js) as a fix: a short label, what is
|
|
79
|
+
// wrong, and what to do.
|
|
80
|
+
function reasonFix(reason, acct) {
|
|
81
|
+
const fix = (label, todo) => ({ label, text: sentence(reason), todo });
|
|
82
|
+
if (reason === 'the accounting system was not checked') {
|
|
83
|
+
return { label: `Not checked in ${acct}`, text: `Flow did not check ${acct} for an invoice for this job yet.`, todo: `Wait for the next check, a few minutes. If it stays, check ${acct} under Settings, Integrations.` };
|
|
84
|
+
}
|
|
85
|
+
if (reason === 'a line does not map to a price item') {
|
|
86
|
+
return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.' };
|
|
87
|
+
}
|
|
88
|
+
if (reason === 'the job has no customer contact') {
|
|
89
|
+
return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.' };
|
|
90
|
+
}
|
|
91
|
+
if (reason === 'the work system report is out of date') {
|
|
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
|
+
}
|
|
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.');
|
|
95
|
+
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
|
|
96
|
+
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
|
+
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.`);
|
|
98
|
+
if (/^invoice .* was (voided|deleted)$/.test(reason)) return fix(`Invoice ${/voided$/.test(reason) ? 'voided' : 'deleted'}`, 'Make a new invoice, or close the job.');
|
|
99
|
+
if (/^the accounting system reports /.test(reason)) return fix(`Already in ${acct}`, `Check the invoice in ${acct}. Link it to this job if it is the right one.`);
|
|
100
|
+
if (/^the job is held$/.test(reason)) return fix('On hold', 'Release the hold when the job can be invoiced.');
|
|
101
|
+
if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.');
|
|
102
|
+
if (reason === 'held') return fix('On hold', 'Release the hold when the job can be invoiced.');
|
|
103
|
+
return fix('Needs review', 'Check the job and its invoice.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One draft problem (server/core/sending.js, and the accounting system's) as a fix.
|
|
107
|
+
function draftFix(problem, acct) {
|
|
108
|
+
const code = problem.code ?? '';
|
|
109
|
+
const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo });
|
|
110
|
+
if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
|
|
111
|
+
if (code === 'no_amount') return fix('Total is $0', 'Open the invoice and correct the amounts.');
|
|
112
|
+
if (code === 'line_no_item') return fix('No price code', 'Open the invoice and choose a price item for each line.');
|
|
113
|
+
if (code.endsWith('_item_missing')) return fix(`Item not in ${acct}`, `Add the item in ${acct}, then sync.`);
|
|
114
|
+
if (code.endsWith('_contact_missing')) return fix(`Client not in ${acct}`, `Add the client in ${acct}, then sync.`);
|
|
115
|
+
if (code.endsWith('_contact_ambiguous')) return fix(`Two ${acct} clients`, `Merge or rename the clients in ${acct}, then sync.`);
|
|
116
|
+
if (code.includes('earlier_version')) return fix(`Old copy in ${acct}`, 'Open the invoice for the steps.');
|
|
117
|
+
if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) return fix('Not sent', 'Open the invoice and send it again.');
|
|
118
|
+
return fix('Invoice problem', 'Open the invoice to correct it.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// What a person must fix on a job, in order: [{ label, text, todo }]. Empty
|
|
122
|
+
// unless invoiceStage is Problem. `accountingName` names the accounting
|
|
123
|
+
// system (for example Xero).
|
|
124
|
+
export function fixes(o, { accountingName = null } = {}) {
|
|
125
|
+
if (invoiceStage(o) !== 'problem') return [];
|
|
126
|
+
const acct = accountingName ?? 'the accounting system';
|
|
127
|
+
const found = [];
|
|
128
|
+
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, acct)));
|
|
129
|
+
if (o.view === 'ready') {
|
|
130
|
+
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
131
|
+
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoicing, Rules.',
|
|
132
|
+
})));
|
|
133
|
+
}
|
|
134
|
+
if (o.invoiceStatus === 'void') {
|
|
135
|
+
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.' });
|
|
136
|
+
}
|
|
137
|
+
found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p, acct)));
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The short words for a job's problems: the first label, and "+N" for more.
|
|
142
|
+
export function problemSummary(found) {
|
|
143
|
+
const labels = [...new Set(found.map((f) => f.label))];
|
|
144
|
+
if (!labels.length) return '';
|
|
145
|
+
return labels.length > 1 ? `${labels[0]} +${labels.length - 1}` : labels[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Does a job match the search text? Case, client, practitioner or invoice
|
|
149
|
+
// number; the case is not shown on the board, but a person can find it.
|
|
150
|
+
export function matches(job, q) {
|
|
151
|
+
if (!q) return true;
|
|
152
|
+
const needle = q.toLowerCase();
|
|
153
|
+
return [job.caseRef, job.sourceJobRef, job.clientName, job.workClientName, job.practitioner, job.obligation?.invoiceNumber]
|
|
154
|
+
.some((value) => value && String(value).toLowerCase().includes(needle));
|
|
155
|
+
}
|