@arcforgelabs/flow 2026.10.49 → 2026.10.51
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/SECURITY.md +9 -4
- package/VISION.md +18 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +109 -0
- package/server/api/invoicing.js +7 -3
- package/server/app.js +13 -0
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +144 -0
- package/server/core/captures.js +397 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/reader.js +333 -0
- package/server/core/sending.js +27 -9
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +47 -0
- package/server/index.js +47 -1
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/index.js +1 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +58 -40
- package/server/integrations/xero/runtime.js +7 -4
- package/server/integrations/xero/settings.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +58 -3
- package/server/ui/flow.css +6 -2
- package/server/ui/invoice-rules.js +1 -1
- package/server/ui/invoicing.js +20 -9
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/mobile.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -25,9 +25,10 @@ import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
|
25
25
|
// person must look), Ready (checks pass), then the accounting system's
|
|
26
26
|
// stages: Draft (a draft there, or on its way), Approved, Sent (to the
|
|
27
27
|
// customer). With no accounting system, an issued invoice is Approved.
|
|
28
|
-
|
|
28
|
+
// Non-chargeable: work a lab's rule keeps in Flow and never bills.
|
|
29
|
+
const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['non_chargeable', 'Non-chargeable'], ['void', 'Void']];
|
|
29
30
|
const TAB_LABEL = Object.fromEntries(TABS);
|
|
30
|
-
const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', void: 'mute' };
|
|
31
|
+
const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', non_chargeable: 'mute', void: 'mute' };
|
|
31
32
|
// Tab names an earlier Flow kept in this browser.
|
|
32
33
|
const OLD_TAB = { problem: 'pending', to_check: 'ready', issued: 'approved' };
|
|
33
34
|
const NUMBERED = ['draft', 'approved', 'sent', 'void'];
|
|
@@ -359,7 +360,8 @@ export async function renderInvoiceList(main, ui) {
|
|
|
359
360
|
run: () => voidAll(voidable),
|
|
360
361
|
});
|
|
361
362
|
}
|
|
362
|
-
// A draft that was never issued is deleted on Pending
|
|
363
|
+
// A draft that was never issued is deleted on Pending, Ready and
|
|
364
|
+
// Non-chargeable. An issued
|
|
363
365
|
// invoice is deleted only while Flow's copy of Xero says Draft. Approved
|
|
364
366
|
// or paid stays hidden. A mix runs every ticked row and reports each one.
|
|
365
367
|
const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
@@ -367,7 +369,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
367
369
|
const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
|
|
368
370
|
const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
|
|
369
371
|
const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
|
|
370
|
-
const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
|
|
372
|
+
const onDraftTabs = ['pending', 'ready', 'non_chargeable'].includes(state.status) && fresh.length;
|
|
371
373
|
if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
|
|
372
374
|
danger.push({
|
|
373
375
|
icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
|
|
@@ -554,6 +556,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
554
556
|
...(id === 'pending' && counts?.pending ? { 'data-tone': 'danger' } : {}),
|
|
555
557
|
onclick: () => { if (state.status !== id) openTab(id); },
|
|
556
558
|
}, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
|
|
559
|
+
// On a phone the tabs scroll, and a redraw starts them at the left: keep
|
|
560
|
+
// the selected one in view, so the page says which tab it shows.
|
|
561
|
+
tabs.children[TABS.findIndex(([id]) => id === state.status)]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
557
562
|
}
|
|
558
563
|
|
|
559
564
|
async function draw(answer) {
|
|
@@ -587,7 +592,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
587
592
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
|
|
588
593
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
589
594
|
const showProblem = state.status === 'pending';
|
|
590
|
-
const created = ['pending', 'ready'].includes(state.status);
|
|
595
|
+
const created = ['pending', 'ready', 'non_chargeable'].includes(state.status);
|
|
591
596
|
// An invoice gets its number when it is sent or issued, so only the
|
|
592
597
|
// Numbered tabs show a Number column; the others open on Client.
|
|
593
598
|
const numbered = !created;
|
|
@@ -620,7 +625,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
620
625
|
const statusCell = (invoice) => {
|
|
621
626
|
const cell = h('span', {}, invoice.sending
|
|
622
627
|
? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
|
|
623
|
-
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
|
|
628
|
+
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute', title: TAB_LABEL[invoice.tab] ?? null }, TAB_LABEL[invoice.tab] ?? ''));
|
|
624
629
|
if (!linked) statusCells.set(invoice.id, cell);
|
|
625
630
|
return cell;
|
|
626
631
|
};
|
|
@@ -1108,7 +1113,10 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1108
1113
|
if (!saved) return;
|
|
1109
1114
|
const passes = saved.view === 'ready';
|
|
1110
1115
|
const why = brief(saved.problems?.[0]?.message);
|
|
1111
|
-
if (
|
|
1116
|
+
if (saved.view === 'non_chargeable') {
|
|
1117
|
+
toast('Saved. It is Non-chargeable.', 'success');
|
|
1118
|
+
if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved);
|
|
1119
|
+
} else if (isNew) {
|
|
1112
1120
|
toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
|
|
1113
1121
|
go(`/invoices/${saved.id}`, { replace: true });
|
|
1114
1122
|
} else if (passes && keepsSave) {
|
|
@@ -1135,6 +1143,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1135
1143
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
1136
1144
|
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
1137
1145
|
const ready = invoice?.view === 'ready';
|
|
1146
|
+
// A lab's rule keeps a non-chargeable draft in Flow: nothing sends or approves it.
|
|
1147
|
+
const nonChargeable = invoice?.view === 'non_chargeable';
|
|
1138
1148
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
1139
1149
|
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
1140
1150
|
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
@@ -1159,7 +1169,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1159
1169
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
1160
1170
|
// With no accounting system, Approve gives the draft its number and locks
|
|
1161
1171
|
// it. A changed job blocks it.
|
|
1162
|
-
const issueButton = context.canIssue && !to ? h('button', {
|
|
1172
|
+
const issueButton = context.canIssue && !to && !nonChargeable ? h('button', {
|
|
1163
1173
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1164
1174
|
onclick: async () => {
|
|
1165
1175
|
const saved = await save();
|
|
@@ -1242,7 +1252,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1242
1252
|
|
|
1243
1253
|
const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
|
|
1244
1254
|
const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
|
|
1245
|
-
const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
1255
|
+
const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : nonChargeable ? 'Non-chargeable invoice' : 'Pending invoice', {
|
|
1246
1256
|
actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
1247
1257
|
});
|
|
1248
1258
|
const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
|
|
@@ -1260,6 +1270,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1260
1270
|
chrome.pager,
|
|
1261
1271
|
header,
|
|
1262
1272
|
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
1273
|
+
nonChargeable ? ui.notice(brief(invoice.nonChargeable?.[0]?.message ?? 'Non-chargeable.')) : null,
|
|
1263
1274
|
...problems,
|
|
1264
1275
|
h('div', { class: 'fx-invoice__grid' },
|
|
1265
1276
|
h('div', { class: 'fx-invoice__main' },
|
package/server/ui/job-board.js
CHANGED
|
@@ -42,7 +42,7 @@ export function orderStatuses(statuses = [], jobs = []) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export const INVOICE_STAGES = {
|
|
45
|
-
drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
|
|
45
|
+
drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', non_chargeable: 'Non-chargeable', paid: 'Paid',
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
|
|
@@ -58,6 +58,7 @@ const isRule = (reason) => reason !== 'the job is not marked ready to invoice';
|
|
|
58
58
|
// that stops the draft, or a problem with the draft
|
|
59
59
|
// Ready to invoice a person marked the draft ready
|
|
60
60
|
// Invoiced issued, sent, or in the accounting system
|
|
61
|
+
// Non-chargeable a lab's rule keeps the draft in Flow, never billed
|
|
61
62
|
// Paid paid in full
|
|
62
63
|
export function invoiceStage(o) {
|
|
63
64
|
if (!o || o.view === 'in_work') return null;
|
|
@@ -66,6 +67,7 @@ export function invoiceStage(o) {
|
|
|
66
67
|
if (o.view === 'invoiced') return 'invoiced';
|
|
67
68
|
if (o.invoiceView === 'problem' || o.invoiceStatus === 'void') return 'problem';
|
|
68
69
|
if (o.invoiceStatus === 'issued') return 'invoiced';
|
|
70
|
+
if (o.invoiceView === 'non_chargeable') return 'non_chargeable';
|
|
69
71
|
if (o.invoiceView === 'ready') return 'ready';
|
|
70
72
|
if (o.view === 'ready') return (o.waitingFor ?? []).some(isRule) ? 'problem' : 'drafted';
|
|
71
73
|
if (o.view === 'drafted') return 'drafted';
|
package/server/ui/jobs.js
CHANGED
|
@@ -50,7 +50,7 @@ const ACTIVITY = {
|
|
|
50
50
|
const BY = { person: 'A person', automation: 'An automation' };
|
|
51
51
|
// Grouped by invoice stage: a job with no invoice stage yet is still in work.
|
|
52
52
|
const STAGE_GROUPS = { in_work: 'In work', ...INVOICE_STAGES };
|
|
53
|
-
const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, Paid: 6 };
|
|
53
|
+
const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, 'Non-chargeable': 5, Paid: 6 };
|
|
54
54
|
|
|
55
55
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
56
56
|
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|