@arcforgelabs/flow 2026.10.49 → 2026.10.50
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/VISION.md +18 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +7 -3
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +27 -9
- 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 +4 -1
- 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/VISION.md
CHANGED
|
@@ -62,8 +62,9 @@ Two parts follow the board:
|
|
|
62
62
|
changes until a person accepts. The model steps are optional and cost money
|
|
63
63
|
per page, so the manager sees the month's cost.
|
|
64
64
|
- **A practice portal** (#268). Practice staff sign in to follow their own
|
|
65
|
-
jobs,
|
|
66
|
-
|
|
65
|
+
jobs, send requests, scans and case notes, chat with the lab and manage
|
|
66
|
+
their own team. It works on a phone first and on a desktop. The server
|
|
67
|
+
limits each person to their practice.
|
|
67
68
|
|
|
68
69
|
Job management is a bundled module, built the same way as the integrations,
|
|
69
70
|
and a lab can remove it. Where the case system cannot take a change through
|
|
@@ -110,13 +111,20 @@ record in Flow with who, when and what they saw.
|
|
|
110
111
|
What a customer was sent is a commercial commitment. A change makes a new
|
|
111
112
|
version; the sent one stays as it was, attributed to whoever sent it.
|
|
112
113
|
|
|
113
|
-
### Why does Flow
|
|
114
|
+
### Why does Flow treat all its data as health data?
|
|
114
115
|
|
|
115
|
-
Dental
|
|
116
|
-
sensitive.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
Dental work carries health information, which Australian privacy law treats as
|
|
117
|
+
sensitive. A lab or a practice decides what it puts into Flow: case notes,
|
|
118
|
+
scans, photos and patient names can all arrive. So Flow treats every record
|
|
119
|
+
and every file as sensitive health data:
|
|
120
|
+
|
|
121
|
+
- The server limits each person to what their role and practice allow.
|
|
122
|
+
- Files are encrypted at rest. A download uses a short-lived signed link.
|
|
123
|
+
- Flow records each read and change of practice data in an audit log.
|
|
124
|
+
- Each installation sets how long files and notes are kept. Flow deletes them
|
|
125
|
+
after that time.
|
|
126
|
+
- Each installation picks its hosting region. Flow documents what a host needs
|
|
127
|
+
for health data.
|
|
120
128
|
|
|
121
129
|
### Why does Flow keep its records in PostgreSQL only?
|
|
122
130
|
|
|
@@ -137,7 +145,8 @@ its own data, configuration and secrets.
|
|
|
137
145
|
- Replacing the case system, for now. The case system owns the case record.
|
|
138
146
|
Flow's job board shows and updates the work, and does not do clinical
|
|
139
147
|
case management or production scheduling.
|
|
140
|
-
-
|
|
148
|
+
- Clinical decisions. Flow keeps the notes and files that practices send; it
|
|
149
|
+
does not diagnose or plan treatment.
|
|
141
150
|
- Acting as the general ledger. Accounting software owns the ledger and the
|
|
142
151
|
bank feed.
|
|
143
152
|
- Browser automation of another product. Integrations use published APIs.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.50",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.50",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
//
|
|
44
44
|
// GET /context the organisations, and whether this person may issue
|
|
45
45
|
// GET /invoices?organisationId=&status=&q=&limit= newest first, with counts by tab;
|
|
46
|
-
// status is a tab: pending, ready, draft, approved, sent or void (TABS)
|
|
46
|
+
// status is a tab: pending, ready, draft, approved, sent, non_chargeable or void (TABS)
|
|
47
47
|
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
48
48
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
49
49
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
@@ -118,7 +118,7 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
118
118
|
// issued invoice, by its stage in the accounting system (its invoiceStages;
|
|
119
119
|
// Draft while it is on its way); with no accounting system, an issued invoice
|
|
120
120
|
// is Approved. Void: void in Flow or in the accounting system.
|
|
121
|
-
const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'void'];
|
|
121
|
+
const TABS = ['pending', 'ready', 'draft', 'approved', 'sent', 'non_chargeable', 'void'];
|
|
122
122
|
// Earlier names a saved link may still use.
|
|
123
123
|
const OLD_TABS = { problem: 'pending', to_check: 'ready', issued: 'approved' };
|
|
124
124
|
const VIEWS = ['in_work', 'ready', 'pending', 'drafted', 'invoiced', 'paid', 'held', 'review_required'];
|
|
@@ -332,6 +332,7 @@ async function invoiceTabs(db, organisationId, system) {
|
|
|
332
332
|
for (const { id } of issued) tabOf.set(id, issuedTab(id, { views, stages, system }));
|
|
333
333
|
for (const { id } of voided) tabOf.set(id, 'void');
|
|
334
334
|
for (const id of views.problems.keys()) tabOf.set(id, 'pending');
|
|
335
|
+
for (const id of views.nonChargeable.keys()) tabOf.set(id, 'non_chargeable');
|
|
335
336
|
const ids = Object.fromEntries(TABS.map((t) => [t, []]));
|
|
336
337
|
for (const [id, tab] of tabOf) ids[tab].push(id);
|
|
337
338
|
return { tabOf, ids, problems: views.problems, sending: views.sending };
|
|
@@ -348,6 +349,7 @@ function issuedTab(id, { views, stages, system }) {
|
|
|
348
349
|
async function tabOfInvoice(db, organisationId, invoice, view, accounting) {
|
|
349
350
|
if (invoice.status === 'void') return 'void';
|
|
350
351
|
if (view === 'problem') return 'pending';
|
|
352
|
+
if (view === 'non_chargeable') return 'non_chargeable';
|
|
351
353
|
if (invoice.status === 'draft') return 'ready';
|
|
352
354
|
if (!accounting) return 'approved';
|
|
353
355
|
if (view === 'sending') return 'draft';
|
|
@@ -626,7 +628,9 @@ export function createInvoicingRouter({
|
|
|
626
628
|
// A draft whose job changed after it was made waits for a person to fix
|
|
627
629
|
// the job at its source or adopt the change (ledger.js), as Move to
|
|
628
630
|
// Ready and Send do.
|
|
629
|
-
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
631
|
+
const { problems, nonChargeable } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
632
|
+
// A non-chargeable draft is never billed (invoice-rules.js).
|
|
633
|
+
if (nonChargeable?.length) throw new FlowError('not_ready', `${nonChargeable[0].message} Nothing was approved.`);
|
|
630
634
|
// A lab's pending rule, or a skip rule the job matches (invoice-rules.js),
|
|
631
635
|
// is a gate too: fix its cause, void the draft, or turn the rule off.
|
|
632
636
|
const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
|
|
@@ -12,7 +12,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
12
12
|
// Then wait: the job waits for a person; skip: rules never draft it;
|
|
13
13
|
// pending: Flow drafts it, and the draft is Pending with the rule's
|
|
14
14
|
// note, which says what to fix (for example a Reference with no
|
|
15
|
-
// real practitioner)
|
|
15
|
+
// real practitioner); non_chargeable: Flow drafts it and keeps it on
|
|
16
|
+
// the Non-chargeable tab. The work is done but never billed (Xero's
|
|
17
|
+
// word for such work), so the draft is never sent. Its value stays
|
|
18
|
+
// in Flow, for the lab's output.
|
|
16
19
|
//
|
|
17
20
|
// The rules read only Flow's own records, so they work with or without an
|
|
18
21
|
// accounting integration. The earlier fixed settings (old job limit, required
|
|
@@ -70,7 +73,7 @@ export const TESTS = Object.freeze({
|
|
|
70
73
|
share: ['is', 'is_not'],
|
|
71
74
|
});
|
|
72
75
|
|
|
73
|
-
export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
|
|
76
|
+
export const ACTIONS = Object.freeze(['wait', 'skip', 'pending', 'non_chargeable']);
|
|
74
77
|
// Tests that take no value.
|
|
75
78
|
const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word']);
|
|
76
79
|
|
|
@@ -187,7 +190,7 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
187
190
|
const context = { customer, now };
|
|
188
191
|
const reasons = [];
|
|
189
192
|
for (const rule of rules) {
|
|
190
|
-
if (!rule.enabled || rule.trigger !== 'job_ready' || rule.action.kind
|
|
193
|
+
if (!rule.enabled || rule.trigger !== 'job_ready' || ['pending', 'non_chargeable'].includes(rule.action.kind)) continue;
|
|
191
194
|
if (!rule.conditions.every((c) => matches(c, job, context))) continue;
|
|
192
195
|
reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
|
|
193
196
|
}
|
|
@@ -225,6 +228,17 @@ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
|
225
228
|
}));
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// The enabled non-chargeable rules that match one draft's job (sending.js):
|
|
232
|
+
// the draft is on the Non-chargeable tab, never on Pending or Ready, and
|
|
233
|
+
// Send never sends it.
|
|
234
|
+
export function ruleNonChargeable(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
235
|
+
const context = { customer, now };
|
|
236
|
+
return rules
|
|
237
|
+
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'non_chargeable')
|
|
238
|
+
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
239
|
+
.map((r) => ({ code: 'non_chargeable', message: `Rule "${r.name}": non-chargeable. Flow keeps it and never sends it.`, short: r.name.slice(0, 40), rule: r.id ?? null }));
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
// The starter rules made from the earlier fixed settings.
|
|
229
243
|
export function starterRules(settings) {
|
|
230
244
|
const rules = [
|
package/server/core/job-board.js
CHANGED
|
@@ -50,7 +50,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
50
50
|
const ready = new Set(views?.ready ?? []);
|
|
51
51
|
const toCheck = new Set(views?.toCheck ?? []);
|
|
52
52
|
const invoiceView = (id) => (!id || !views ? null
|
|
53
|
-
: views.problems.has(id) ? 'problem' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
|
|
53
|
+
: views.problems.has(id) ? 'problem' : views.nonChargeable.has(id) ? 'non_chargeable' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
|
|
54
54
|
: views.sending.has(id) ? 'sending' : null);
|
|
55
55
|
// The invoicing rules a job with no invoice waits on, for every view a
|
|
56
56
|
// draft can still come from.
|
package/server/core/sending.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
-
import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
|
|
5
|
+
import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
7
7
|
import { issueInvoice } from './invoices.js';
|
|
8
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
@@ -20,7 +20,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
20
20
|
// rules (invoice-rules.js) add their notes. An enabled skip rule does too:
|
|
21
21
|
// a draft whose job it matches stays Pending, even when the draft was made
|
|
22
22
|
// by hand or before the rule existed. A draft with a problem is
|
|
23
|
-
// Pending (the "problem" view). A draft without one is Ready.
|
|
23
|
+
// Pending (the "problem" view). A draft without one is Ready. A draft whose
|
|
24
|
+
// job a non-chargeable rule matches is neither: it is Non-chargeable, and
|
|
25
|
+
// nothing sends it.
|
|
24
26
|
//
|
|
25
27
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
26
28
|
// accounting system is connected, queues it to go there. The send worker
|
|
@@ -97,14 +99,14 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
97
99
|
// The lab's rules (invoice-rules.js). A pending rule puts its note on the
|
|
98
100
|
// draft. A skip rule holds a draft that already exists: void it, or change
|
|
99
101
|
// the rule. Matching stays in invoice-rules.js.
|
|
100
|
-
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled &&
|
|
102
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && ['pending', 'skip', 'non_chargeable'].includes(r.action.kind));
|
|
101
103
|
if (rules.length && jobs.length) {
|
|
102
104
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
103
105
|
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
104
106
|
for (const { obligation, reference, customerName } of jobs) {
|
|
105
107
|
const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
|
|
106
108
|
const job = { ...obligation, reference };
|
|
107
|
-
const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
109
|
+
const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
108
110
|
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
109
111
|
}
|
|
110
112
|
}
|
|
@@ -125,17 +127,28 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
125
127
|
return problems;
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// A draft a non-chargeable rule matches has nothing to fix: it is never sent.
|
|
131
|
+
const nonChargeableOf = (found) => found.filter((p) => p.code === 'non_chargeable');
|
|
132
|
+
|
|
128
133
|
// The views of the Invoices page. `accounting` is the accounting adapter, or
|
|
129
|
-
// null. Every draft is in one view:
|
|
130
|
-
//
|
|
131
|
-
// queued, and a Problem when
|
|
132
|
-
// { toCheck: [ids], ready: [ids],
|
|
134
|
+
// null. Every draft is in one view: Non-chargeable (a non-chargeable rule
|
|
135
|
+
// matches its job), Problem (a check failed; the Pending tab) or Ready (its
|
|
136
|
+
// checks pass). An issued invoice is Sending while queued, and a Problem when
|
|
137
|
+
// the accounting system refused it. Answers { toCheck: [ids], ready: [ids],
|
|
138
|
+
// problems: Map(id -> problems), nonChargeable: Map(id -> rules), sending: Set(ids) }.
|
|
133
139
|
export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
|
|
134
140
|
const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents, readyAt: invoices.readyAt }).from(invoices)
|
|
135
141
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft')))
|
|
136
142
|
.orderBy(asc(invoices.createdAt), asc(invoices.id))
|
|
137
143
|
.limit(CHECK_LIMIT);
|
|
138
144
|
const problems = await checkDrafts(db, organisationId, drafts, accounting);
|
|
145
|
+
const nonChargeable = new Map();
|
|
146
|
+
for (const [id, found] of problems) {
|
|
147
|
+
const rules = nonChargeableOf(found);
|
|
148
|
+
if (!rules.length) continue;
|
|
149
|
+
nonChargeable.set(id, rules);
|
|
150
|
+
problems.delete(id);
|
|
151
|
+
}
|
|
139
152
|
const sending = new Set();
|
|
140
153
|
const issued = await db.select({ id: invoices.id, sendState: invoices.sendState, sendError: invoices.sendError }).from(invoices)
|
|
141
154
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), inArray(invoices.sendState, ['queued', 'failed'])))
|
|
@@ -146,11 +159,12 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
|
|
|
146
159
|
}
|
|
147
160
|
// A draft whose checks pass is ready (Samuel, 2026-10-20): no person
|
|
148
161
|
// needs to mark it. toCheck stays for callers that still read it.
|
|
149
|
-
const clean = drafts.filter((d) => !problems.has(d.id));
|
|
162
|
+
const clean = drafts.filter((d) => !problems.has(d.id) && !nonChargeable.has(d.id));
|
|
150
163
|
return {
|
|
151
164
|
toCheck: [],
|
|
152
165
|
ready: clean.map((d) => d.id),
|
|
153
166
|
problems,
|
|
167
|
+
nonChargeable,
|
|
154
168
|
sending,
|
|
155
169
|
};
|
|
156
170
|
}
|
|
@@ -164,6 +178,8 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
|
|
|
164
178
|
return { view: null, problems: [] };
|
|
165
179
|
}
|
|
166
180
|
const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
|
|
181
|
+
const rules = nonChargeableOf(found);
|
|
182
|
+
if (rules.length) return { view: 'non_chargeable', problems: [], nonChargeable: rules };
|
|
167
183
|
return { view: found.length ? 'problem' : 'ready', problems: found };
|
|
168
184
|
}
|
|
169
185
|
|
|
@@ -279,6 +295,8 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
279
295
|
ready = ready.filter((id) => wanted.has(id));
|
|
280
296
|
held = wanted.size - ready.length;
|
|
281
297
|
if (!ready.length) {
|
|
298
|
+
const kept = invoiceIds.map((id) => views.nonChargeable.get(id)?.[0]?.message).find(Boolean);
|
|
299
|
+
if (kept) throw new FlowError('not_ready', `Not sent: ${kept}`);
|
|
282
300
|
const reason = invoiceIds.map((id) => views.problems.get(id)?.[0]?.message).find(Boolean);
|
|
283
301
|
throw new FlowError('not_ready', reason
|
|
284
302
|
? `Not sent: ${reason.replace(/\.?$/, '.')} The invoice is on Pending.`
|
|
@@ -10,6 +10,8 @@ import { xeroError } from './client.js';
|
|
|
10
10
|
// settings and are never stored. Tokens are never logged.
|
|
11
11
|
|
|
12
12
|
const FORMAT = 'v1';
|
|
13
|
+
// What a Custom Connection's row seals in place of a refresh token.
|
|
14
|
+
export const CUSTOM_CONNECTION = 'custom-connection';
|
|
13
15
|
|
|
14
16
|
function aad(organisationId) {
|
|
15
17
|
return Buffer.from(`flow:xero:refresh-token:${organisationId}`);
|
|
@@ -40,6 +40,7 @@ export const XERO_SETUP = {
|
|
|
40
40
|
},
|
|
41
41
|
fields: [
|
|
42
42
|
{ name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
|
|
43
|
+
{ name: 'XERO_CONNECTION', label: 'Xero app type', kind: 'choice', choices: [{ value: 'app', label: 'Web app' }, { value: 'custom', label: 'Custom connection (Demo Company only)' }], help: 'Custom connection: no Xero sign-in step. Sandbox mode only.' },
|
|
43
44
|
{ name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
|
|
44
45
|
{ name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
|
|
45
46
|
{ name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { DEFAULT_TIMEOUT_MS, xeroError } from './client.js';
|
|
3
3
|
|
|
4
|
-
// Xero OAuth 2.0
|
|
5
|
-
//
|
|
4
|
+
// Xero OAuth 2.0: the authorisation code with offline_access, or a Custom
|
|
5
|
+
// Connection's client credentials (settings.js, XERO_CONNECTION). Bodies that
|
|
6
|
+
// carry tokens never reach a log or an error message.
|
|
6
7
|
|
|
7
8
|
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
8
9
|
|
|
@@ -58,7 +59,7 @@ async function tokenRequest(settings, fetchImpl, form, what) {
|
|
|
58
59
|
oauthError: typeof body?.error === 'string' ? body.error.slice(0, 40) : null,
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
if (!body?.access_token || !body?.refresh_token) {
|
|
62
|
+
if (!body?.access_token || (form.grant_type !== 'client_credentials' && !body?.refresh_token)) {
|
|
62
63
|
throw xeroError('xero_token_failed', `Xero's answer to ${what} had no tokens.`, { status: 502 });
|
|
63
64
|
}
|
|
64
65
|
return body;
|
|
@@ -73,6 +74,12 @@ export function refreshTokens(settings, fetchImpl, refreshToken) {
|
|
|
73
74
|
return tokenRequest(settings, fetchImpl, { grant_type: 'refresh_token', refresh_token: refreshToken }, 'the token refresh');
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// A Custom Connection's access token. There is no refresh token: Flow asks
|
|
78
|
+
// again when the token runs out.
|
|
79
|
+
export function clientCredentials(settings, fetchImpl) {
|
|
80
|
+
return tokenRequest(settings, fetchImpl, { grant_type: 'client_credentials', scope: settings.scope }, 'the Custom Connection token');
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
// Best effort: a disconnect deletes Flow's copy even when Xero is down.
|
|
77
84
|
export async function revokeToken(settings, fetchImpl, refreshToken) {
|
|
78
85
|
const basic = Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString('base64');
|
|
@@ -12,8 +12,8 @@ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchX
|
|
|
12
12
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
13
13
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
14
14
|
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
15
|
-
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
|
-
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
15
|
+
import { CUSTOM_CONNECTION, decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
|
+
import { authorizeUrl, clientCredentials, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
17
17
|
import { writesAllowed } from './settings.js';
|
|
18
18
|
import { readXeroUsage } from './usage.js';
|
|
19
19
|
import { saveEvents, signatureValid } from './webhooks.js';
|
|
@@ -23,7 +23,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
23
23
|
// The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
|
|
24
24
|
//
|
|
25
25
|
// GET /status?organisationId= any signed-in person: mode and connection
|
|
26
|
-
// POST /connect owner: returns Xero's approval address
|
|
26
|
+
// POST /connect owner: returns Xero's approval address, or
|
|
27
|
+
// connects a Custom Connection at once
|
|
27
28
|
// GET /oauth/callback Xero sends the owner back here
|
|
28
29
|
// POST /disconnect owner: revokes and forgets the connection
|
|
29
30
|
// POST /sync owner or admin: copies Xero contacts and items
|
|
@@ -182,8 +183,59 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
182
183
|
});
|
|
183
184
|
}));
|
|
184
185
|
|
|
186
|
+
// Keeps the one Xero organisation these tokens reach, after proving it is
|
|
187
|
+
// one Flow may use (a Demo Company in sandbox). A refused code-flow grant
|
|
188
|
+
// is revoked at Xero. Then the history import starts.
|
|
189
|
+
async function connectWith(tokens, { organisationId, personId }) {
|
|
190
|
+
const connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
|
|
191
|
+
let chosen;
|
|
192
|
+
try {
|
|
193
|
+
chosen = selectConnection(connections, settings);
|
|
194
|
+
const probe = createXeroClient({
|
|
195
|
+
tenantId: chosen.tenantId,
|
|
196
|
+
accessToken: async () => tokens.access_token,
|
|
197
|
+
fetch: runtime.transport,
|
|
198
|
+
governor: runtime.governor,
|
|
199
|
+
apiBase: settings.endpoints.api,
|
|
200
|
+
log,
|
|
201
|
+
});
|
|
202
|
+
await runtime.assertEnvironment(probe);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Refused: Xero keeps no grant for an organisation Flow will not use.
|
|
205
|
+
if (tokens.refresh_token) await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const previous = await readConnection(db, organisationId);
|
|
209
|
+
const connection = await saveConnection(db, {
|
|
210
|
+
organisationId,
|
|
211
|
+
mode: settings.mode,
|
|
212
|
+
tenantId: chosen.tenantId,
|
|
213
|
+
tenantName: chosen.tenantName ?? null,
|
|
214
|
+
scope: tokens.scope ?? settings.scope,
|
|
215
|
+
// A Custom Connection has no refresh token; the column holds a sealed
|
|
216
|
+
// marker that is never read (runtime.js asks Xero again instead).
|
|
217
|
+
refreshToken: tokens.refresh_token ?? CUSTOM_CONNECTION,
|
|
218
|
+
connectedBy: personId,
|
|
219
|
+
key: settings.tokenKey,
|
|
220
|
+
});
|
|
221
|
+
runtime.remember(organisationId, tokens);
|
|
222
|
+
// A new connection is a person acting: calls stopped before resume.
|
|
223
|
+
await runtime.guardFor(db).resume();
|
|
224
|
+
// Another Xero organisation starts its history from the beginning.
|
|
225
|
+
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, organisationId);
|
|
226
|
+
await queueHistoryImport(db, organisationId, { reason: 'connected' });
|
|
227
|
+
log.info?.(`[xero] organisation ${organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}, ${settings.connection}) by person ${personId}`);
|
|
228
|
+
return connection;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A Custom Connection connects here at once; a Xero app answers with the
|
|
232
|
+
// approval address the browser opens.
|
|
185
233
|
router.post('/connect', ...owner, handle(log, async (req, res) => {
|
|
186
234
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
235
|
+
if (settings.connection === 'custom') {
|
|
236
|
+
const connection = await connectWith(await clientCredentials(settings, runtime.transport), { organisationId, personId: req.person.id });
|
|
237
|
+
return res.json({ ok: true, connected: true, connection: describeConnection(connection), mode: settings.mode });
|
|
238
|
+
}
|
|
187
239
|
if (!settings.redirectUri) {
|
|
188
240
|
throw Object.assign(new FlowError('xero_redirect_uri_missing', 'Set FLOW_PUBLIC_ORIGIN (or XERO_REDIRECT_URI) to this installation\'s address, and register its /api/xero/oauth/callback on the Xero app.'), { status: 503 });
|
|
189
241
|
}
|
|
@@ -202,42 +254,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
202
254
|
}
|
|
203
255
|
try {
|
|
204
256
|
const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
|
|
205
|
-
const
|
|
206
|
-
let chosen;
|
|
207
|
-
try {
|
|
208
|
-
chosen = selectConnection(connections, settings);
|
|
209
|
-
const probe = createXeroClient({
|
|
210
|
-
tenantId: chosen.tenantId,
|
|
211
|
-
accessToken: async () => tokens.access_token,
|
|
212
|
-
fetch: runtime.transport,
|
|
213
|
-
governor: runtime.governor,
|
|
214
|
-
apiBase: settings.endpoints.api,
|
|
215
|
-
log,
|
|
216
|
-
});
|
|
217
|
-
await runtime.assertEnvironment(probe);
|
|
218
|
-
} catch (error) {
|
|
219
|
-
// Refused: Xero keeps no grant for an organisation Flow will not use.
|
|
220
|
-
await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
const previous = await readConnection(db, entry.organisationId);
|
|
224
|
-
const connection = await saveConnection(db, {
|
|
225
|
-
organisationId: entry.organisationId,
|
|
226
|
-
mode: settings.mode,
|
|
227
|
-
tenantId: chosen.tenantId,
|
|
228
|
-
tenantName: chosen.tenantName ?? null,
|
|
229
|
-
scope: tokens.scope ?? settings.scope,
|
|
230
|
-
refreshToken: tokens.refresh_token,
|
|
231
|
-
connectedBy: entry.personId,
|
|
232
|
-
key: settings.tokenKey,
|
|
233
|
-
});
|
|
234
|
-
runtime.remember(entry.organisationId, tokens);
|
|
235
|
-
// A new connection is a person acting: calls stopped before resume.
|
|
236
|
-
await runtime.guardFor(db).resume();
|
|
237
|
-
// Another Xero organisation starts its history from the beginning.
|
|
238
|
-
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
239
|
-
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
240
|
-
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
257
|
+
const connection = await connectWith(tokens, entry);
|
|
241
258
|
const organisation = connection.tenantName || 'your Xero organisation';
|
|
242
259
|
return res.type('html').send(page(
|
|
243
260
|
'Xero is connected',
|
|
@@ -257,7 +274,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
257
274
|
const connection = await readConnection(db, organisationId);
|
|
258
275
|
if (!connection) return res.json({ ok: true, disconnected: false });
|
|
259
276
|
let revoked = false;
|
|
260
|
-
|
|
277
|
+
// A Custom Connection has no token to revoke: it ends in Xero's My Apps.
|
|
278
|
+
if (settings.connection !== 'custom') try {
|
|
261
279
|
revoked = await revokeToken(settings, runtime.transport, decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId));
|
|
262
280
|
} catch (error) {
|
|
263
281
|
log.warn?.(`[xero] could not revoke the Xero token for organisation ${organisationId}: ${error.code ?? 'error'}`);
|
|
@@ -2,7 +2,7 @@ import { createCallGuard } from '../call-guard.js';
|
|
|
2
2
|
import { createXeroClient, xeroError } from './client.js';
|
|
3
3
|
import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
|
|
4
4
|
import { createGovernor } from './governor.js';
|
|
5
|
-
import { createStateStore, refreshTokens } from './oauth.js';
|
|
5
|
+
import { clientCredentials, createStateStore, refreshTokens } from './oauth.js';
|
|
6
6
|
import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from './replay.js';
|
|
7
7
|
import { resolveXeroSettings, writesAllowed } from './settings.js';
|
|
8
8
|
|
|
@@ -101,10 +101,13 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
101
101
|
return db.transaction(async (tx) => {
|
|
102
102
|
const connection = usable(await readConnection(tx, organisationId, { lock: true }));
|
|
103
103
|
await guard.check(connection.tenantId, { resource: 'token' });
|
|
104
|
-
|
|
104
|
+
// A Custom Connection has no refresh token: Flow asks with the client
|
|
105
|
+
// id and secret again, and nothing rotates.
|
|
106
|
+
const custom = settings.connection === 'custom';
|
|
107
|
+
const refreshToken = custom ? null : decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
|
|
105
108
|
let tokens;
|
|
106
109
|
try {
|
|
107
|
-
tokens = await refreshTokens(settings, transport, refreshToken);
|
|
110
|
+
tokens = custom ? await clientCredentials(settings, transport) : await refreshTokens(settings, transport, refreshToken);
|
|
108
111
|
} catch (error) {
|
|
109
112
|
// invalid_grant: the connection is gone. invalid_client: the app's
|
|
110
113
|
// client id or secret is wrong. Neither clears without a person.
|
|
@@ -116,7 +119,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
116
119
|
throw error;
|
|
117
120
|
}
|
|
118
121
|
await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
|
|
119
|
-
await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
122
|
+
if (!custom) await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
120
123
|
remember(organisationId, tokens);
|
|
121
124
|
return tokens.access_token;
|
|
122
125
|
});
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
// is not DEMO, so a sandbox can never write to a real ledger.
|
|
11
11
|
// replay no network: recorded, synthetic Xero responses
|
|
12
12
|
// (server/integrations/xero/fixtures). Development and CI.
|
|
13
|
+
//
|
|
14
|
+
// XERO_CONNECTION says how Flow gets its Xero access tokens.
|
|
15
|
+
// app (default) a Xero web app: an owner approves in the browser, and
|
|
16
|
+
// Flow keeps the rotating refresh token.
|
|
17
|
+
// custom a Xero Custom Connection (client credentials): no browser step
|
|
18
|
+
// and no refresh token. Sandbox only: Xero makes Custom
|
|
19
|
+
// Connections free on the Demo Company, so a demo or a test
|
|
20
|
+
// session connects with the client id and secret alone.
|
|
13
21
|
|
|
14
22
|
import { backgroundJobsOn } from '../../core/host.js';
|
|
15
23
|
|
|
@@ -29,6 +37,8 @@ export const XERO_ENDPOINTS = Object.freeze({
|
|
|
29
37
|
export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
|
|
30
38
|
// accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
|
|
31
39
|
export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
|
|
40
|
+
// A Custom Connection has no refresh token, so it asks for no offline_access.
|
|
41
|
+
export const DEFAULT_CUSTOM_SCOPE = DEFAULT_XERO_SCOPE.replace('offline_access ', '');
|
|
32
42
|
export const DEFAULT_XERO_DAY_LIMIT = 1000;
|
|
33
43
|
export const DEFAULT_DUE_DAYS = 30;
|
|
34
44
|
export const DEFAULT_MIRROR_PAGE_SIZE = 200;
|
|
@@ -121,6 +131,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
121
131
|
if (!mode) return null;
|
|
122
132
|
if (!XERO_MODES.includes(mode)) throw new Error('XERO_MODE must be "live", "sandbox", "replay" or unset');
|
|
123
133
|
|
|
134
|
+
const connection = (readEnv(env, 'XERO_CONNECTION') || 'app').toLowerCase();
|
|
135
|
+
if (!['app', 'custom'].includes(connection)) throw new Error('XERO_CONNECTION must be "app", "custom" or unset');
|
|
136
|
+
if (connection === 'custom' && mode !== 'sandbox') {
|
|
137
|
+
throw new Error('XERO_CONNECTION=custom works only with XERO_MODE=sandbox (a Xero Demo Company)');
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
const clientId = readEnv(env, 'XERO_CLIENT_ID');
|
|
125
141
|
const clientSecret = readEnv(env, 'XERO_CLIENT_SECRET');
|
|
126
142
|
const tokenKey = decodeTokenKey(readEnv(env, 'XERO_TOKEN_KEY'));
|
|
@@ -150,11 +166,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
150
166
|
|
|
151
167
|
return {
|
|
152
168
|
mode,
|
|
169
|
+
connection,
|
|
153
170
|
clientId: clientId || (mode === 'replay' ? 'replay-client-id' : ''),
|
|
154
171
|
clientSecret: clientSecret || (mode === 'replay' ? 'replay-client-secret' : ''),
|
|
155
172
|
tokenKey,
|
|
156
173
|
redirectUri: resolveRedirectUri(env),
|
|
157
|
-
scope: readEnv(env, 'XERO_SCOPE') || DEFAULT_XERO_SCOPE,
|
|
174
|
+
scope: readEnv(env, 'XERO_SCOPE') || (connection === 'custom' ? DEFAULT_CUSTOM_SCOPE : DEFAULT_XERO_SCOPE),
|
|
158
175
|
tenantId,
|
|
159
176
|
tenantName,
|
|
160
177
|
liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
|
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', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -1014,8 +1014,11 @@ function xeroConnection(organisations, canChange) {
|
|
|
1014
1014
|
if (canChange) {
|
|
1015
1015
|
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
1016
1016
|
try {
|
|
1017
|
+
// A Custom Connection connects at once; a Xero app sends the
|
|
1018
|
+
// browser to Xero to approve.
|
|
1017
1019
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
1018
|
-
location.assign(authorizeUrl);
|
|
1020
|
+
if (authorizeUrl) location.assign(authorizeUrl);
|
|
1021
|
+
else location.reload();
|
|
1019
1022
|
} catch (error) {
|
|
1020
1023
|
toast(error.message, 'danger');
|
|
1021
1024
|
}
|
package/server/ui/flow.css
CHANGED
|
@@ -440,7 +440,12 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
440
440
|
.fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
|
|
441
441
|
.fx-tabs { display: flex; gap: 2px; }
|
|
442
442
|
.fx-tabs button { flex: none; }
|
|
443
|
-
|
|
443
|
+
/* Phones and tablets: the tabs scroll in one row and each is 44px tall, so
|
|
444
|
+
seven tabs never run past the page and a thumb hits each one. */
|
|
445
|
+
@media (max-width: 1023px) {
|
|
446
|
+
.fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
|
|
447
|
+
.fx-tabs button { min-height: 44px; }
|
|
448
|
+
}
|
|
444
449
|
.fx-tabs button {
|
|
445
450
|
display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
|
|
446
451
|
border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
|
|
@@ -1346,7 +1351,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1346
1351
|
/* Touch: every control a thumb uses is 44px (docs/MOBILE_TESTS.md). The
|
|
1347
1352
|
icons and text keep their size; the box around them grows. */
|
|
1348
1353
|
#menu-toggle, #search { width: 44px; height: 44px; }
|
|
1349
|
-
.fx-tabs button { min-height: 44px; }
|
|
1350
1354
|
/* The notice's buttons grow to 44px into its padding, so the notice
|
|
1351
1355
|
stays one short line. */
|
|
1352
1356
|
.fx-alerts__close { width: 44px; height: 44px; margin: -12px -8px; }
|
|
@@ -14,7 +14,7 @@ const TEST_LABELS = {
|
|
|
14
14
|
is_not_one_of: 'is not one of', contains: 'contains', does_not_contain: 'does not contain',
|
|
15
15
|
starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', number_in_words: 'has a number beside words', one_word: 'is one word',
|
|
16
16
|
};
|
|
17
|
-
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note' };
|
|
17
|
+
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note', non_chargeable: 'Non-chargeable' };
|
|
18
18
|
const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word'];
|
|
19
19
|
const MANY = ['is_one_of', 'is_not_one_of'];
|
|
20
20
|
const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
|
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' }) : '—');
|