@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 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, on desktop first, with a simpler view on phones. The server limits
66
- each person to their practice.
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 keep as little patient data as it can?
114
+ ### Why does Flow treat all its data as health data?
114
115
 
115
- Dental cases carry health information, which Australian privacy law treats as
116
- sensitive. Flow keeps the case number and the practice. It keeps a patient
117
- reference only when a practice needs one printed on its invoice, and it keeps
118
- no clinical notes or images. A form photo the intake reader reads is not kept
119
- after its job is updated.
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
- - Patient records, clinical notes, scans and images.
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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.49",
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.49",
9
+ "version": "2026.10.50",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.49",
3
+ "version": "2026.10.50",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 === 'pending') continue;
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 = [
@@ -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.
@@ -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 && (r.action.kind === 'pending' || r.action.kind === 'skip'));
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: Problem (a check failed; the Pending tab)
130
- // or Ready (its checks pass). An issued invoice is Sending while
131
- // queued, and a Problem when the accounting system refused it. Answers
132
- // { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(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 (authorisation code with offline_access). Bodies that carry
5
- // tokens never reach a log or an error message.
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 connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
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
- try {
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
- const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
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'),
@@ -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
  }
@@ -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
- @media (max-width: 639px) { .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; } }
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'];
@@ -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
- const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['void', 'Void']];
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 and Ready. An issued
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 (isNew) {
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' },
@@ -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' }) : '—');
@@ -585,7 +585,7 @@ export const RULES = {
585
585
  "layout": "scroll"
586
586
  },
587
587
  "tablet": {
588
- "layout": "tabs"
588
+ "layout": "scroll"
589
589
  },
590
590
  "desk": {
591
591
  "layout": "tabs"