@arcforgelabs/flow 2026.10.48 → 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 +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +7 -3
- package/server/core/automations.js +34 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/pipeline.js +46 -6
- package/server/core/sending.js +27 -9
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -0
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/contact-sync.js +289 -0
- package/server/integrations/xero/create-contact.js +42 -2
- package/server/integrations/xero/drafts.js +14 -6
- package/server/integrations/xero/import.js +5 -1
- package/server/integrations/xero/index.js +16 -2
- package/server/integrations/xero/lookup.js +15 -2
- package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +74 -42
- 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 +48 -40
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +125 -41
- package/server/ui/invoice-rules.js +3 -5
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +69 -84
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +49 -4
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
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",
|
|
@@ -1254,18 +1254,18 @@
|
|
|
1254
1254
|
"license": "MIT"
|
|
1255
1255
|
},
|
|
1256
1256
|
"node_modules/body-parser": {
|
|
1257
|
-
"version": "2.
|
|
1258
|
-
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.
|
|
1259
|
-
"integrity": "sha512-
|
|
1257
|
+
"version": "2.4.0",
|
|
1258
|
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
|
1259
|
+
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
|
1260
1260
|
"license": "MIT",
|
|
1261
1261
|
"dependencies": {
|
|
1262
1262
|
"bytes": "^3.1.2",
|
|
1263
|
-
"content-type": "^2.
|
|
1263
|
+
"content-type": "^2.1.0",
|
|
1264
1264
|
"debug": "^4.4.3",
|
|
1265
1265
|
"http-errors": "^2.0.1",
|
|
1266
|
-
"iconv-lite": "^0.7.
|
|
1266
|
+
"iconv-lite": "^0.7.3",
|
|
1267
1267
|
"on-finished": "^2.4.1",
|
|
1268
|
-
"qs": "^6.
|
|
1268
|
+
"qs": "^6.16.1",
|
|
1269
1269
|
"raw-body": "^3.0.2",
|
|
1270
1270
|
"type-is": "^2.1.0"
|
|
1271
1271
|
},
|
|
@@ -2031,9 +2031,9 @@
|
|
|
2031
2031
|
"license": "ISC"
|
|
2032
2032
|
},
|
|
2033
2033
|
"node_modules/ip-address": {
|
|
2034
|
-
"version": "10.7.
|
|
2035
|
-
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.
|
|
2036
|
-
"integrity": "sha512-
|
|
2034
|
+
"version": "10.7.5",
|
|
2035
|
+
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
|
|
2036
|
+
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
|
|
2037
2037
|
"license": "MIT",
|
|
2038
2038
|
"engines": {
|
|
2039
2039
|
"node": ">= 12"
|
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');
|
|
@@ -22,6 +22,10 @@ import { refreshReferences } from './references.js';
|
|
|
22
22
|
// on the Invoices page, until a person changes it or moves it back.
|
|
23
23
|
// reference_from_rule: a draft made from a job keeps the Reference the
|
|
24
24
|
// lab's Reference rule gives (references.js), as the job's fields arrive.
|
|
25
|
+
// add_contacts, add_xero_contacts: a customer of the work system, or of the
|
|
26
|
+
// accounting system, that no Flow contact is named like becomes one.
|
|
27
|
+
// sync_contact_edits: contact edits on either side of the Xero link reach
|
|
28
|
+
// the other (server/integrations/xero/contact-sync.js).
|
|
25
29
|
|
|
26
30
|
const BATCH = 500;
|
|
27
31
|
|
|
@@ -71,6 +75,36 @@ export const AUTOMATIONS = Object.freeze([
|
|
|
71
75
|
readConfig: (config) => readMappingConfig(config),
|
|
72
76
|
remap: true,
|
|
73
77
|
},
|
|
78
|
+
{
|
|
79
|
+
// Runs inside the pipeline's read of the work system's customers
|
|
80
|
+
// (pipeline.js, linkCustomers), before its jobs are read.
|
|
81
|
+
key: 'add_contacts',
|
|
82
|
+
place: 'seazona',
|
|
83
|
+
name: 'Add new clients',
|
|
84
|
+
hint: 'A new Seazona client becomes a contact.',
|
|
85
|
+
defaults: { enabled: true, config: {} },
|
|
86
|
+
readConfig: () => ({}),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
// Runs inside the pipeline's read of the accounting system's customers
|
|
90
|
+
// (pipeline.js, linkCustomers). Off until a person turns it on.
|
|
91
|
+
key: 'add_xero_contacts',
|
|
92
|
+
place: 'xero',
|
|
93
|
+
name: 'Add Xero clients',
|
|
94
|
+
hint: 'Each Xero client becomes a contact.',
|
|
95
|
+
defaults: { enabled: false, config: {} },
|
|
96
|
+
readConfig: () => ({}),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// Runs on the Xero adapter's own worker (xero/contact-sync.js), which
|
|
100
|
+
// writes to Xero only while writes are allowed. Off until a person turns it on.
|
|
101
|
+
key: 'sync_contact_edits',
|
|
102
|
+
place: 'xero',
|
|
103
|
+
name: 'Two-way edits',
|
|
104
|
+
hint: 'A contact edit in Flow or Xero reaches the other. A clash waits for you.',
|
|
105
|
+
defaults: { enabled: false, config: {} },
|
|
106
|
+
readConfig: () => ({}),
|
|
107
|
+
},
|
|
74
108
|
{
|
|
75
109
|
key: 'mark_ready_from_work_status',
|
|
76
110
|
place: 'seazona',
|
|
@@ -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/pipeline.js
CHANGED
|
@@ -10,6 +10,7 @@ import { observeJob, observeWithdrawn, recordAccountingEvidence, setHold } from
|
|
|
10
10
|
import { loadMappingContext, mapLines } from './line-mapping.js';
|
|
11
11
|
import { checkPrices, readCatalogues } from './price-list.js';
|
|
12
12
|
import { linkRecord, priorLinkFor } from './links.js';
|
|
13
|
+
import { createContact, readContact } from './contacts.js';
|
|
13
14
|
import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
|
|
14
15
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
15
16
|
|
|
@@ -88,20 +89,29 @@ function byName(records) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Links each customer of one system to the Flow customer with the same name,
|
|
91
|
-
// when one record on each side has it and neither side is linked yet.
|
|
92
|
-
|
|
92
|
+
// when one record on each side has it and neither side is linked yet. With
|
|
93
|
+
// `adopt`, a customer no Flow contact is named like becomes a new Flow
|
|
94
|
+
// contact, linked to it (`imported`), so its jobs find their customer
|
|
95
|
+
// (docs/CONTACTS.md). Two customers of one name stay for a person.
|
|
96
|
+
export async function linkCustomers(db, organisationId, { system, customers, adopt = false }) {
|
|
93
97
|
const flow = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
94
98
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
|
|
95
99
|
const links = await activeLinks(db, organisationId, { system, entity: 'contact' });
|
|
96
100
|
const linkedFlow = new Set(links.map((l) => l.flowId));
|
|
97
101
|
const linkedExternal = new Set(links.map((l) => l.externalId));
|
|
98
102
|
const flowByName = byName(flow);
|
|
99
|
-
const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0 };
|
|
103
|
+
const result = { linked: 0, alreadyLinked: 0, unmatched: 0, sameName: 0, added: 0 };
|
|
100
104
|
for (const [key, external] of byName(customers)) {
|
|
101
105
|
const own = flowByName.get(key) ?? [];
|
|
102
106
|
for (const customer of external) {
|
|
103
107
|
if (linkedExternal.has(String(customer.id))) result.alreadyLinked += 1;
|
|
104
108
|
}
|
|
109
|
+
if (adopt && own.length === 0 && external.length === 1 && external[0].adoptable !== false && !linkedExternal.has(String(external[0].id))) {
|
|
110
|
+
if (await adoptCustomer(db, organisationId, { system, customer: external[0] })) {
|
|
111
|
+
result.added += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
105
115
|
if (external.length !== 1 || own.length !== 1) {
|
|
106
116
|
const waiting = external.filter((c) => !linkedExternal.has(String(c.id))).length;
|
|
107
117
|
if (own.length === 0) result.unmatched += waiting;
|
|
@@ -119,6 +129,32 @@ export async function linkCustomers(db, organisationId, { system, customers }) {
|
|
|
119
129
|
return result;
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
// The customer's own fields when Flow can keep them all, else its name only.
|
|
133
|
+
async function adoptCustomer(db, organisationId, { system, customer }) {
|
|
134
|
+
const name = String(customer.name ?? '').trim();
|
|
135
|
+
const offered = { ...(customer.contact ?? {}), kind: 'customer', name };
|
|
136
|
+
let values;
|
|
137
|
+
try {
|
|
138
|
+
values = readContact(offered);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof FlowError)) throw error;
|
|
141
|
+
values = readContact({ kind: 'customer', name });
|
|
142
|
+
}
|
|
143
|
+
return db.transaction(async (tx) => {
|
|
144
|
+
let row;
|
|
145
|
+
try {
|
|
146
|
+
row = await createContact(tx, organisationId, values);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof FlowError && error.code === 'conflict') return false;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const { outcome } = await linkRecord(tx, organisationId, {
|
|
152
|
+
entity: 'contact', flowId: row.id, system, externalId: String(customer.id), matchedBy: 'imported',
|
|
153
|
+
});
|
|
154
|
+
return outcome === 'linked' || outcome === 'existing';
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
122
158
|
async function readCursor(db, organisationId, name) {
|
|
123
159
|
const [row] = await db.select().from(pipelineCursors)
|
|
124
160
|
.where(and(eq(pipelineCursors.organisationId, organisationId), eq(pipelineCursors.name, name)));
|
|
@@ -500,9 +536,12 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
500
536
|
if (work) {
|
|
501
537
|
let restart = (summary.prices?.linked ?? 0) > 0;
|
|
502
538
|
if (typeof work.capabilities.listCustomers === 'function') {
|
|
503
|
-
|
|
539
|
+
const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_contacts');
|
|
540
|
+
summary.workCustomers = await linkCustomers(db, organisationId, {
|
|
541
|
+
system: work.id, customers: await work.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
|
|
542
|
+
});
|
|
504
543
|
// New customer links reach jobs read before them.
|
|
505
|
-
restart ||= summary.workCustomers.linked > 0;
|
|
544
|
+
restart ||= summary.workCustomers.linked + summary.workCustomers.added > 0;
|
|
506
545
|
}
|
|
507
546
|
summary.work = await ingestWork(db, organisationId, { adapter: work, restart });
|
|
508
547
|
// After the live feed, so a job both report is the live one.
|
|
@@ -512,8 +551,9 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
512
551
|
}
|
|
513
552
|
if (accounting) {
|
|
514
553
|
if (typeof accounting.capabilities.listCustomers === 'function') {
|
|
554
|
+
const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_xero_contacts');
|
|
515
555
|
summary.accountingCustomers = await linkCustomers(db, organisationId, {
|
|
516
|
-
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
|
|
556
|
+
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
|
|
517
557
|
});
|
|
518
558
|
}
|
|
519
559
|
const settings = await readInvoicingSettings(db, organisationId);
|
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.`
|
|
@@ -32,7 +32,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
32
32
|
'personSettings', // { personId }: each person's own choices in this integration, as [{ key, label, hint, kind, value }]
|
|
33
33
|
'setPersonSetting', // { personId, key, value }: change one, answers the new list
|
|
34
34
|
'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
|
|
35
|
-
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
35
|
+
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts). An optional `contact` is the Flow contact a new customer becomes; `adoptable` false keeps one from becoming a contact (docs/CONTACTS.md)
|
|
36
36
|
'createCustomer', // { organisationId, contactId, actor }: create the system's customer from the Flow contact. Answers { outcome, contact }. Xero, when Create missing clients is on.
|
|
37
37
|
'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
|
|
38
38
|
'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
|
|
@@ -134,12 +134,19 @@ export async function readBillableOrder(db, orderId) {
|
|
|
134
134
|
// with no company name is listed by its full name. A client the client list
|
|
135
135
|
// never gave (the list can be closed to an API client) is listed by the
|
|
136
136
|
// company name its orders carry, so its jobs still find their customer.
|
|
137
|
+
// `contact` is the Flow contact a new client becomes (docs/CONTACTS.md).
|
|
137
138
|
export async function listClients(db) {
|
|
138
|
-
const rows = await db.select({
|
|
139
|
-
.
|
|
139
|
+
const rows = await db.select({
|
|
140
|
+
id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active,
|
|
141
|
+
accountNumber: seazonaClients.accountNumber, email: seazonaClients.email, phone: seazonaClients.phone,
|
|
142
|
+
address: seazonaClients.address, taxRate: seazonaClients.taxRate, doctors: seazonaClients.doctors,
|
|
143
|
+
}).from(seazonaClients);
|
|
140
144
|
const listed = rows
|
|
141
145
|
.filter((r) => r.active !== false)
|
|
142
|
-
.map((r) =>
|
|
146
|
+
.map((r) => {
|
|
147
|
+
const name = (r.company || r.fullName || '').trim();
|
|
148
|
+
return { id: r.id, name, contact: clientContact(r, name) };
|
|
149
|
+
})
|
|
143
150
|
.filter((r) => r.name);
|
|
144
151
|
const known = new Set(rows.map((r) => r.id));
|
|
145
152
|
const fromOrders = await db.selectDistinctOn([seazonaOrders.clientId], { id: seazonaOrders.clientId, company: seazonaOrders.company })
|
|
@@ -148,10 +155,40 @@ export async function listClients(db) {
|
|
|
148
155
|
.orderBy(seazonaOrders.clientId, sql`${seazonaOrders.lastChangedAt} desc`);
|
|
149
156
|
return [
|
|
150
157
|
...listed,
|
|
151
|
-
...fromOrders.filter((r) => !known.has(r.id)).map((r) =>
|
|
158
|
+
...fromOrders.filter((r) => !known.has(r.id)).map((r) => {
|
|
159
|
+
const name = r.company.trim();
|
|
160
|
+
return { id: r.id, name, contact: { kind: 'customer', name } };
|
|
161
|
+
}),
|
|
152
162
|
];
|
|
153
163
|
}
|
|
154
164
|
|
|
165
|
+
// A Seazona client as a Flow contact: the billing fields and the doctors as
|
|
166
|
+
// practitioners. A field Flow cannot keep is left out.
|
|
167
|
+
const cut = (value, max) => {
|
|
168
|
+
const text = String(value ?? '').trim();
|
|
169
|
+
return text && text.length <= max ? text : null;
|
|
170
|
+
};
|
|
171
|
+
export function clientContact(row, name) {
|
|
172
|
+
const address = row.address ?? {};
|
|
173
|
+
const kept = {
|
|
174
|
+
line1: cut(address.address1, 255), line2: cut(address.address2, 255), city: cut(address.city, 255),
|
|
175
|
+
region: cut(address.region, 255), postalCode: cut(address.postalCode, 255), country: cut(address.countryCode, 255),
|
|
176
|
+
};
|
|
177
|
+
const practitioners = (Array.isArray(row.doctors) ? row.doctors : [])
|
|
178
|
+
.map((doctor) => cut(doctor?.fullName, 255)).filter(Boolean).slice(0, 30).map((person) => ({ name: person }));
|
|
179
|
+
const contact = { kind: 'customer', name, practitioners };
|
|
180
|
+
const email = cut(row.email, 255);
|
|
181
|
+
if (email) contact.email = email;
|
|
182
|
+
const phone = cut(row.phone, 50);
|
|
183
|
+
if (phone) contact.phone = phone;
|
|
184
|
+
const account = cut(row.accountNumber, 50);
|
|
185
|
+
if (account) contact.accountNumber = account;
|
|
186
|
+
const taxRate = cut(row.taxRate, 50);
|
|
187
|
+
if (taxRate) contact.taxRate = taxRate;
|
|
188
|
+
if (Object.values(kept).some(Boolean)) contact.address = Object.fromEntries(Object.entries(kept).filter(([, v]) => v));
|
|
189
|
+
return contact;
|
|
190
|
+
}
|
|
191
|
+
|
|
155
192
|
// Seazona's products, from Flow's copy (the products lane reads them daily),
|
|
156
193
|
// for the price list's links and price check (server/core/price-list.js).
|
|
157
194
|
// Seazona says only whether a product is taxable: taxable reads as GST.
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
// withdraws it, so its invoice goes to review.
|
|
37
37
|
// clients the whole list (GET clients/?lastModified=), read on from the
|
|
38
38
|
// last row when it is full.
|
|
39
|
-
// invoices every change since the
|
|
39
|
+
// invoices every change since the invoice lane first read, read on the
|
|
40
40
|
// same way.
|
|
41
41
|
//
|
|
42
42
|
// A client or invoice that Flow holds and the whole list leaves out is read
|
|
@@ -282,12 +282,15 @@ const WHOLE_LISTS = {
|
|
|
282
282
|
list: (client, since) => client.get('invoices/', { lastModified: since }),
|
|
283
283
|
// The guide: on or after lastModified (inclusive).
|
|
284
284
|
stepBackMs: 0,
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
285
|
+
// Where the invoice lane first read: the start of the UTC day of its
|
|
286
|
+
// first run, less SEAZONA_INVOICE_LOOKBACK_DAYS (lanes.js). An invoice
|
|
287
|
+
// last changed before that was never the lane's to read, so it is not a
|
|
288
|
+
// miss (FLOW-HORIZON-K: a day's margin before it counted such invoices).
|
|
289
|
+
// The first row Flow stored marks the first run.
|
|
290
|
+
async firstSince(db, sync) {
|
|
291
|
+
const [{ first }] = await db.select({ first: sql`min(${seazonaInvoices.firstSeenAt})` }).from(seazonaInvoices);
|
|
292
|
+
const firstMs = first ? new Date(first).getTime() : NaN;
|
|
293
|
+
return Number.isFinite(firstMs) ? new Date(floorUtcDay(firstMs) - sync.invoiceLookbackDays * DAY_MS).toISOString() : null;
|
|
291
294
|
},
|
|
292
295
|
},
|
|
293
296
|
};
|
|
@@ -416,7 +419,7 @@ async function sweepWholeList(ctx, resource, cursor, laneStates, helpers, found)
|
|
|
416
419
|
const readsFrom = laneReadsFrom(laneStates.get(resource), sync);
|
|
417
420
|
if (readsFrom === null) return;
|
|
418
421
|
if (!cursor[resource]?.cycleStartedAt) {
|
|
419
|
-
const since = await spec.firstSince(db);
|
|
422
|
+
const since = await spec.firstSince(db, sync);
|
|
420
423
|
if (since === null) {
|
|
421
424
|
cursor[resource] = { lastFinishedAt: at.toISOString(), listed: 0 };
|
|
422
425
|
return;
|
|
@@ -47,8 +47,8 @@ export const SENTRY_SETUP = {
|
|
|
47
47
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
48
48
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
49
49
|
{ name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
|
|
50
|
-
{ name: 'SENTRY_FEEDBACK', label: 'Report
|
|
51
|
-
{ name: 'SENTRY_REPLAY', label: 'Replay
|
|
50
|
+
{ name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
51
|
+
{ name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
|
|
52
52
|
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
53
53
|
],
|
|
54
54
|
};
|
|
@@ -233,6 +233,9 @@ export function createXeroClient({
|
|
|
233
233
|
async listContacts({ maxPages } = {}) {
|
|
234
234
|
return collectPages(async (page) => (await request('GET', 'Contacts', { query: { page } })).Contacts, { maxPages });
|
|
235
235
|
},
|
|
236
|
+
async getContact(contactId) {
|
|
237
|
+
return first((await request('GET', `Contacts/${encodeURIComponent(contactId)}`)).Contacts);
|
|
238
|
+
},
|
|
236
239
|
async findContactsByName(name) {
|
|
237
240
|
return (await request('GET', 'Contacts', { query: { where: `Name==${quote(name)}` } })).Contacts ?? [];
|
|
238
241
|
},
|
|
@@ -329,6 +332,26 @@ export function createXeroClient({
|
|
|
329
332
|
}
|
|
330
333
|
return created;
|
|
331
334
|
},
|
|
335
|
+
// Changes the fields in `changes` on one contact (contact-sync.js). The
|
|
336
|
+
// body holds only those fields and the id; Xero keeps every other field.
|
|
337
|
+
async updateContact(contactId, changes, { idempotencyKey }) {
|
|
338
|
+
if (!contactId) throw new TypeError('updateContact: contactId is required');
|
|
339
|
+
if (!idempotencyKey) throw new TypeError('updateContact: idempotencyKey is required');
|
|
340
|
+
const payload = await request('POST', `Contacts/${encodeURIComponent(contactId)}`, {
|
|
341
|
+
query: { summarizeErrors: 'false' },
|
|
342
|
+
body: { Contacts: [{ ...changes, ContactID: contactId }] },
|
|
343
|
+
headers: { 'Idempotency-Key': idempotencyKey },
|
|
344
|
+
});
|
|
345
|
+
const updated = first(payload.Contacts);
|
|
346
|
+
const messages = validationMessages(updated);
|
|
347
|
+
if (!updated?.ContactID || updated.HasErrors || updated.StatusAttributeString === 'ERROR' || messages.length) {
|
|
348
|
+
throw xeroError('xero_validation_failed', messages.join(' ') || 'Xero refused the contact change.', {
|
|
349
|
+
status: 422,
|
|
350
|
+
details: messages.length ? { validationErrors: messages } : undefined,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return updated;
|
|
354
|
+
},
|
|
332
355
|
async createInvoice(invoice, { idempotencyKey }) {
|
|
333
356
|
if (!idempotencyKey) throw new TypeError('createInvoice: idempotencyKey is required');
|
|
334
357
|
const payload = await request('POST', 'Invoices', {
|