@arcforgelabs/flow 2026.10.36 → 2026.10.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +2 -1
- package/server/api/invoicing.js +61 -3
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/custom-fields.js +18 -0
- package/server/core/host.js +24 -0
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +80 -1
- package/server/index.js +9 -3
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +4 -4
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/reconcile.js +17 -11
- package/server/integrations/xero/routes.js +42 -0
- package/server/integrations/xero/settings.js +12 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +142 -23
- package/server/ui/bulk-bar.js +100 -0
- package/server/ui/flow.css +225 -11
- package/server/ui/invoicing.js +234 -30
- package/server/ui/job-board.js +60 -34
- package/server/ui/job-view.js +30 -7
- package/server/ui/jobs.js +469 -38
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ run a lab.
|
|
|
55
55
|
| `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
|
|
56
56
|
| `docs/INTEGRATIONS.md` | How an integration adapter is built, turned on and off, and owns its tables |
|
|
57
57
|
| `docs/XERO.md` | The Xero adapter: modes, settings, rate limits, connecting Xero and draft invoices |
|
|
58
|
+
| `docs/XERO_IMPORT.md` | Xero › Import: each Xero invoice in Flow's copy linked to its Flow invoice, or made into one |
|
|
58
59
|
| `docs/SEAZONA.md` | The Seazona adapter: modes, settings, the rate governor, replay files and its tables |
|
|
59
60
|
| `docs/SENTRY.md` | The Sentry adapter: errors and problem reports to the installation's own Sentry project |
|
|
60
61
|
| `server/integrations/port.js` | The integration port every adapter implements |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.38",
|
|
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.38",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import {
|
|
3
|
-
CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, jobFieldHistory, listFields,
|
|
3
|
+
CUSTOM_FIELD_TYPES, GROUPABLE_TYPES, OPTION_COLORS, archiveField, choosablePeople, createField, ensureAssignee, jobFieldHistory, listFields,
|
|
4
4
|
listJobFieldValues, orderFields, setJobFieldValue, updateField,
|
|
5
5
|
} from '../core/custom-fields.js';
|
|
6
6
|
import { FlowError } from '../core/errors.js';
|
|
@@ -71,6 +71,7 @@ export function createCustomFieldsRouter({ db, personAuth, work = () => [], log
|
|
|
71
71
|
router.get('/', ...signedIn, handle(async (req, res) => {
|
|
72
72
|
const organisationId = await organisationOf(req);
|
|
73
73
|
const archived = req.query.archived === '1' || req.query.archived === 'true';
|
|
74
|
+
await ensureAssignee(db, organisationId);
|
|
74
75
|
const [fields, system, chosen] = await Promise.all([listFields(db, organisationId, { archived }), systemFields(), choosablePeople(db)]);
|
|
75
76
|
res.json({ ok: true, fields, systemFields: system, people: chosen, types: CUSTOM_FIELD_TYPES, groupable: GROUPABLE_TYPES, colors: OPTION_COLORS });
|
|
76
77
|
}));
|
package/server/api/invoicing.js
CHANGED
|
@@ -17,12 +17,14 @@ import {
|
|
|
17
17
|
} from '../core/invoicing-rules.js';
|
|
18
18
|
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
19
|
import { readBeforeAct } from '../core/read-before-act.js';
|
|
20
|
+
import { invoiceMatchDetail, jobMatchDetail, listReconcile } from '../core/job-match.js';
|
|
20
21
|
import { jobReference } from '../core/references.js';
|
|
21
22
|
import {
|
|
22
23
|
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
23
24
|
} from '../core/price-list.js';
|
|
24
25
|
import {
|
|
25
|
-
draftInvoiceForObligation, explainObligation, jobChangeFor,
|
|
26
|
+
confirmNoInvoice, draftInvoiceForObligation, explainObligation, jobChangeFor, linkJobToInvoice, listObligations,
|
|
27
|
+
setReady, useSourceLines,
|
|
26
28
|
} from '../core/ledger.js';
|
|
27
29
|
import { isHistoryHeld } from '../core/history.js';
|
|
28
30
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
@@ -245,6 +247,13 @@ function withLink(job, systems) {
|
|
|
245
247
|
return { ...job, systemName: source?.name ?? job.sourceSystem, link: link ?? null };
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
// The connected systems an invoice can link to: work systems, then the
|
|
251
|
+
// accounting system.
|
|
252
|
+
const linkedSystems = (work, accounting) => [
|
|
253
|
+
...work.map((a) => ({ id: a.id, name: a.name, kind: 'work' })),
|
|
254
|
+
...(accounting ? [{ id: accounting.id, name: accounting.name, kind: 'accounting' }] : []),
|
|
255
|
+
];
|
|
256
|
+
|
|
248
257
|
async function jobsByInvoice(db, organisationId, invoiceIds) {
|
|
249
258
|
if (!invoiceIds.length) return new Map();
|
|
250
259
|
const rows = await db.select().from(billingObligations)
|
|
@@ -291,7 +300,9 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
|
|
|
291
300
|
? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
|
|
292
301
|
: null,
|
|
293
302
|
contact: contact ?? null,
|
|
294
|
-
job: jobs.get(invoiceId) ?? null,
|
|
303
|
+
job: withLink(jobs.get(invoiceId) ?? null, work),
|
|
304
|
+
// The Linked card: each connected system, and this invoice's link there.
|
|
305
|
+
linksTo: linkedSystems(work, accounting),
|
|
295
306
|
versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
|
|
296
307
|
};
|
|
297
308
|
}
|
|
@@ -374,7 +385,7 @@ export function createInvoicingRouter({
|
|
|
374
385
|
sendsTo: system?.name ?? null,
|
|
375
386
|
sendMode: mode,
|
|
376
387
|
// The Linked column: one icon for each connected system.
|
|
377
|
-
linksTo:
|
|
388
|
+
linksTo: linkedSystems(systems, system),
|
|
378
389
|
});
|
|
379
390
|
}));
|
|
380
391
|
|
|
@@ -579,6 +590,53 @@ export function createInvoicingRouter({
|
|
|
579
590
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id, accounting(), work()) });
|
|
580
591
|
}));
|
|
581
592
|
|
|
593
|
+
// A job with no invoice whose match is unclear (ledger.js). The linking
|
|
594
|
+
// tool lists the matcher's suggestions, read from the accounting copy;
|
|
595
|
+
// "Make invoice" says there is none yet; "Link" picks one of them.
|
|
596
|
+
// The Reconcile page: Flow's jobs with no Flow invoice, Flow invoices with
|
|
597
|
+
// no job, and one of them beside its possible other side
|
|
598
|
+
// (core/job-match.js). Flow's own records only; no integration is called.
|
|
599
|
+
router.get('/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
600
|
+
const organisationId = await organisationOf(req);
|
|
601
|
+
res.json({ ok: true, ...await listReconcile(db, organisationId) });
|
|
602
|
+
}));
|
|
603
|
+
|
|
604
|
+
router.get('/jobs/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
605
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
606
|
+
const organisationId = await organisationOf(req);
|
|
607
|
+
const [obligation] = await db.select().from(billingObligations)
|
|
608
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
609
|
+
if (!obligation) throw new FlowError('not_found', 'job not found');
|
|
610
|
+
// The Reference Flow would give its invoice, from Flow's own copy only.
|
|
611
|
+
const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
|
|
612
|
+
const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
|
|
613
|
+
const reference = await jobReference(db, organisationId, obligation, { template, work: source });
|
|
614
|
+
const detail = await jobMatchDetail(db, organisationId, obligationId, { reference, q: text(req.query.q) ?? '' });
|
|
615
|
+
res.json({ ok: true, ...detail, job: { ...detail.job, sourceName: source?.name ?? null } });
|
|
616
|
+
}));
|
|
617
|
+
|
|
618
|
+
router.get('/invoices/:id/reconcile', ...signedIn, handle(log, async (req, res) => {
|
|
619
|
+
const invoiceId = pathId(req.params.id, 'invoice');
|
|
620
|
+
const organisationId = await organisationOf(req);
|
|
621
|
+
res.json({ ok: true, ...await invoiceMatchDetail(db, organisationId, invoiceId, { q: text(req.query.q) ?? '' }) });
|
|
622
|
+
}));
|
|
623
|
+
|
|
624
|
+
router.post('/jobs/:id/no-invoice', ...manager, handle(log, async (req, res) => {
|
|
625
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
626
|
+
const organisationId = await organisationOf(req);
|
|
627
|
+
const job = await confirmNoInvoice(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
|
|
628
|
+
res.json({ ok: true, job: { id: job.id, accountingState: job.accountingState } });
|
|
629
|
+
}));
|
|
630
|
+
|
|
631
|
+
// Links a job to a Flow invoice that already exists.
|
|
632
|
+
router.post('/jobs/:id/link', ...manager, handle(log, async (req, res) => {
|
|
633
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
634
|
+
const organisationId = await organisationOf(req);
|
|
635
|
+
const invoiceId = pathId(text(req.body?.invoiceId) ?? '', 'invoice');
|
|
636
|
+
const job = await linkJobToInvoice(db, organisationId, obligationId, { invoiceId, actor: `person:${req.person.id}` });
|
|
637
|
+
res.json({ ok: true, job: { id: job.id, invoiceId: job.invoiceId } });
|
|
638
|
+
}));
|
|
639
|
+
|
|
582
640
|
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
583
641
|
const obligationId = pathId(req.params.id, 'job');
|
|
584
642
|
const organisationId = await organisationOf(req);
|
package/server/auth/clerk.js
CHANGED
|
@@ -8,6 +8,9 @@ import { normaliseEmail } from './people.js';
|
|
|
8
8
|
// getUser(clerkUserId) the Clerk user (emails, external accounts)
|
|
9
9
|
// findUserIdByEmail(email) the Clerk user with this email, or null
|
|
10
10
|
// createUser({ email }) a Clerk account with no password, for an invitee
|
|
11
|
+
// deleteUser(clerkUserId) removes a Clerk account (an unused invitee's only)
|
|
12
|
+
// inviteByEmail({ email, redirectUrl })
|
|
13
|
+
// Clerk emails an invitation link to `email`
|
|
11
14
|
//
|
|
12
15
|
// The real one is loaded only when FLOW_AUTH=clerk.
|
|
13
16
|
export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, authorizedParties = [] }) {
|
|
@@ -36,9 +39,41 @@ export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, au
|
|
|
36
39
|
const user = await client.users.createUser({ emailAddress: [email], skipPasswordRequirement: true });
|
|
37
40
|
return user.id;
|
|
38
41
|
},
|
|
42
|
+
async deleteUser(clerkUserId) {
|
|
43
|
+
await client.users.deleteUser(clerkUserId);
|
|
44
|
+
},
|
|
45
|
+
// Clerk sends the email. Its link opens `redirectUrl` with a sign-up
|
|
46
|
+
// ticket; a pending invitation for the same address is replaced.
|
|
47
|
+
async inviteByEmail({ email, redirectUrl }) {
|
|
48
|
+
await client.invitations.createInvitation({ emailAddress: email, redirectUrl, notify: true, ignoreExisting: true });
|
|
49
|
+
},
|
|
39
50
|
};
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
// A Clerk account nobody has used: never signed in, no password and no
|
|
54
|
+
// connected account. Flow made it for an invitee (ensureClerkAccount).
|
|
55
|
+
export function unusedClerkAccount(clerkUser) {
|
|
56
|
+
return Boolean(clerkUser)
|
|
57
|
+
&& !clerkUser.lastSignInAt
|
|
58
|
+
&& clerkUser.passwordEnabled === false
|
|
59
|
+
&& !(Array.isArray(clerkUser.externalAccounts) && clerkUser.externalAccounts.length);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Has Clerk email an invitation to `email`. Clerk does not invite an address
|
|
63
|
+
// that already has an account, so an unused account Flow made earlier is
|
|
64
|
+
// removed first; the invitee's account is made when they take up the
|
|
65
|
+
// invitation. An address with a used account gets no email: that person can
|
|
66
|
+
// already sign in. Returns { emailed }.
|
|
67
|
+
export async function sendClerkInvitation(clerk, email, redirectUrl) {
|
|
68
|
+
const existing = await clerk.findUserIdByEmail(email);
|
|
69
|
+
if (existing) {
|
|
70
|
+
if (!unusedClerkAccount(await clerk.getUser(existing))) return { emailed: false };
|
|
71
|
+
await clerk.deleteUser(existing);
|
|
72
|
+
}
|
|
73
|
+
await clerk.inviteByEmail({ email, redirectUrl });
|
|
74
|
+
return { emailed: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
// An invitee needs a Clerk account before their first sign-in, because with
|
|
43
78
|
// public sign-up off Flow's sign-in page never creates one. The account has
|
|
44
79
|
// the invitee's email (verified, since the backend made it) and no password;
|
package/server/auth/routes.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { FlowError } from '../core/errors.js';
|
|
3
3
|
import { rateLimit } from '../rate-limit.js';
|
|
4
|
-
import { ensureClerkAccount } from './clerk.js';
|
|
4
|
+
import { ensureClerkAccount, sendClerkInvitation } from './clerk.js';
|
|
5
5
|
import {
|
|
6
6
|
findPersonByEmail,
|
|
7
|
+
findPersonById,
|
|
7
8
|
invitePerson,
|
|
8
9
|
listPeople,
|
|
9
10
|
normaliseEmail,
|
|
@@ -139,23 +140,61 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
|
|
|
139
140
|
}
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
//
|
|
143
|
-
|
|
143
|
+
// The invitation email's link opens this installation's sign-in page.
|
|
144
|
+
const signInUrl = (req) => `${req.protocol}://${req.get('host')}/sign-in`;
|
|
145
|
+
|
|
146
|
+
// Clerk emails the invitation. If Clerk cannot, Flow makes the invitee's
|
|
147
|
+
// Clerk account itself (with public sign-up off the sign-in page never
|
|
148
|
+
// creates one) and the owner sends the sign-in message by hand.
|
|
149
|
+
async function inviteWithClerk(req, email) {
|
|
150
|
+
try {
|
|
151
|
+
return await sendClerkInvitation(clerk, email, signInUrl(req));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
await ensureClerkAccount(clerk, email);
|
|
156
|
+
return { emailed: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Invite someone.
|
|
144
160
|
people.post('/', async (req, res) => {
|
|
145
161
|
try {
|
|
146
162
|
const email = normaliseEmail(req.body?.email);
|
|
147
163
|
const role = req.body?.role;
|
|
148
164
|
if (!isPlainEmail(email)) throw new FlowError('invalid', 'email must be a plain (ASCII) email address');
|
|
149
165
|
if (await findPersonByEmail(db, email)) throw new FlowError('conflict', 'that email address is already on the people list');
|
|
166
|
+
let invite;
|
|
150
167
|
try {
|
|
151
|
-
await
|
|
168
|
+
invite = await inviteWithClerk(req, email);
|
|
152
169
|
} catch (error) {
|
|
153
170
|
log.error?.(`[people] Clerk account for an invitation failed: ${error.message}`);
|
|
154
171
|
return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Flow could not prepare the sign-in account. Try again shortly.' });
|
|
155
172
|
}
|
|
156
173
|
const person = await invitePerson(db, { email, name: req.body?.name ?? null, role, invitedBy: req.person.id });
|
|
157
|
-
log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role}`);
|
|
158
|
-
res.status(201).json({ ok: true, person: publicPerson(person) });
|
|
174
|
+
log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role} (${invite.emailed ? 'emailed' : 'not emailed'})`);
|
|
175
|
+
res.status(201).json({ ok: true, person: publicPerson(person), invite });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
sendError(res, error, log);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Email an invited person's invitation again. Only for someone who has
|
|
182
|
+
// not signed in yet.
|
|
183
|
+
people.post('/:id/invite', async (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const person = await findPersonById(db, req.params.id);
|
|
186
|
+
if (!person) throw new FlowError('not_found', 'person not found');
|
|
187
|
+
if (person.clerkUserId || person.status !== 'active') throw new FlowError('conflict', 'only an invited person who has not signed in yet gets an invitation email');
|
|
188
|
+
let invite;
|
|
189
|
+
try {
|
|
190
|
+
invite = await sendClerkInvitation(clerk, person.email, signInUrl(req));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
|
|
193
|
+
return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Clerk could not send the invitation email. Try again shortly.' });
|
|
194
|
+
}
|
|
195
|
+
if (!invite.emailed) throw new FlowError('conflict', 'they already have a sign-in account: they can sign in now');
|
|
196
|
+
log.info?.(`[people] ${req.person.id} emailed the invitation for ${person.id} again`);
|
|
197
|
+
res.json({ ok: true, invite });
|
|
159
198
|
} catch (error) {
|
|
160
199
|
sendError(res, error, log);
|
|
161
200
|
}
|
|
@@ -133,6 +133,24 @@ export async function createField(db, organisationId, raw, { personId = null } =
|
|
|
133
133
|
});
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Every lab gets an Assignee: a person field, made once, the first time
|
|
137
|
+
// the lab's fields are read and it has no person field at all (live or
|
|
138
|
+
// archived). The lab can then rename it or archive it like any field, and
|
|
139
|
+
// Flow does not make it again. A field already named Assignee of another
|
|
140
|
+
// type is left alone.
|
|
141
|
+
export const ASSIGNEE = { name: 'Assignee', type: 'person' };
|
|
142
|
+
export async function ensureAssignee(db, organisationId) {
|
|
143
|
+
const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFields)
|
|
144
|
+
.where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.type, 'person')));
|
|
145
|
+
if (count > 0) return null;
|
|
146
|
+
try {
|
|
147
|
+
return await createField(db, organisationId, ASSIGNEE);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === 'conflict' || error.code === 'invalid' || error.code === '23505') return null;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
|
|
137
155
|
const previous = await readRow(db, organisationId, id);
|
|
138
156
|
if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Where this copy of Flow runs. A Replit workspace (the editor's Run button)
|
|
2
|
+
// shares its secrets with the published app, so without a rule it would sync,
|
|
3
|
+
// draft and send next to the live copy: twice the calls to Xero and Seazona,
|
|
4
|
+
// and a second sender. FLOW_BACKGROUND_JOBS decides; unset, it is off in a
|
|
5
|
+
// Replit workspace and on everywhere else (docs/SELF_HOSTING.md).
|
|
6
|
+
|
|
7
|
+
const read = (env, name) => String(env?.[name] ?? '').trim();
|
|
8
|
+
|
|
9
|
+
// Replit sets REPL_ID in the workspace and REPLIT_DEPLOYMENT on the published
|
|
10
|
+
// app only.
|
|
11
|
+
export function isReplitWorkspace(env = process.env) {
|
|
12
|
+
return Boolean(read(env, 'REPL_ID')) && !read(env, 'REPLIT_DEPLOYMENT');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Whether this copy runs the jobs that call or write to other systems: the
|
|
16
|
+
// Seazona sync, the Xero read, the pipeline, the daily reconcile and the send
|
|
17
|
+
// worker. Off, it also never writes to Xero.
|
|
18
|
+
export function backgroundJobsOn(env = process.env) {
|
|
19
|
+
const value = read(env, 'FLOW_BACKGROUND_JOBS').toLowerCase();
|
|
20
|
+
if (value === 'on') return true;
|
|
21
|
+
if (value === 'off') return false;
|
|
22
|
+
if (value) throw new Error('FLOW_BACKGROUND_JOBS must be "on", "off" or unset');
|
|
23
|
+
return !isReplitWorkspace(env);
|
|
24
|
+
}
|
package/server/core/job-board.js
CHANGED
|
@@ -102,7 +102,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
102
102
|
if (status && !statuses.has(status)) statuses.set(status, { label: status, final: false, firstSeenAt: null });
|
|
103
103
|
return {
|
|
104
104
|
sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, status,
|
|
105
|
-
clientName: o.contactName, workClientName: null, practitioner: o.practitioner, department: null,
|
|
105
|
+
clientName: o.contactName, workClientName: null, practitioner: o.practitioner, patient: null, department: null,
|
|
106
106
|
orderedOn: null, dueOn: null, jobDate: o.jobDate, shippedOn: o.shippedOn,
|
|
107
107
|
totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
|
|
108
108
|
};
|
|
@@ -129,6 +129,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
|
|
|
129
129
|
workClientName: item.clientName ?? null,
|
|
130
130
|
practitioner: item.practitioner ?? o?.practitioner ?? null,
|
|
131
131
|
department: item.department ?? null,
|
|
132
|
+
patient: item.patient ?? null,
|
|
132
133
|
orderedOn: item.orderedOn ?? null,
|
|
133
134
|
dueOn: item.dueOn ?? null,
|
|
134
135
|
jobDate: o?.jobDate ?? null,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { billingObligations, contacts, invoiceLines, invoices, priceItems } from '../db/schema.js';
|
|
3
|
+
import { notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// The Reconcile page (docs/JOB_INVOICE_LINKS.md, "Reconcile"): Flow's own
|
|
6
|
+
// jobs against Flow's own invoices. A job that is ready to invoice and has
|
|
7
|
+
// no Flow invoice, and a Flow invoice that no job is linked to, are gaps.
|
|
8
|
+
// For one of them it lists the possible other side, each fact compared. It
|
|
9
|
+
// only reads; linkJobToInvoice and confirmNoInvoice (ledger.js) write.
|
|
10
|
+
// Matching a Flow invoice to one in an accounting system is that
|
|
11
|
+
// integration's own work, not this page's.
|
|
12
|
+
|
|
13
|
+
// A job with no Flow invoice that should have one: ready, not held, and not
|
|
14
|
+
// tied to an accounting invoice by the earlier matcher.
|
|
15
|
+
const JOB_GAP = and(
|
|
16
|
+
isNotNull(billingObligations.readyAt),
|
|
17
|
+
isNull(billingObligations.invoiceId),
|
|
18
|
+
isNull(billingObligations.accountingRef),
|
|
19
|
+
ne(billingObligations.holdState, 'held'),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const hasJob = sql`exists (select 1 from billing_obligations o
|
|
23
|
+
where o.organisation_id = ${invoices.organisationId} and o.invoice_id = ${invoices.id})`;
|
|
24
|
+
|
|
25
|
+
const SAME = 'same';
|
|
26
|
+
const DIFFERS = 'differs';
|
|
27
|
+
const daysApart = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000);
|
|
28
|
+
const norm = (text) => String(text ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
29
|
+
const WINDOW_DAYS = 60;
|
|
30
|
+
// An invoice's date: its issue date, or the day a draft was made.
|
|
31
|
+
const dateOf = (i) => i.issueDate ?? (i.createdAt ? new Date(i.createdAt).toISOString().slice(0, 10) : null);
|
|
32
|
+
const SHOWN = 3;
|
|
33
|
+
const SEARCHED = 8;
|
|
34
|
+
|
|
35
|
+
// One fact against the other side's: `same`, `differs`, or null when either
|
|
36
|
+
// side does not know.
|
|
37
|
+
function compare(ours, theirs) {
|
|
38
|
+
if (ours == null || ours === '' || theirs == null || theirs === '') return null;
|
|
39
|
+
return norm(ours) === norm(theirs) ? SAME : DIFFERS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Two sets of lines, the same price items and amounts in any order.
|
|
43
|
+
function compareLines(a, b) {
|
|
44
|
+
if (!a.length || !b.length) return null;
|
|
45
|
+
const key = (l) => `${l.priceItemId ?? norm(l.code)}|${l.amountCents ?? ''}`;
|
|
46
|
+
return a.map(key).sort().join(',') === b.map(key).sort().join(',') ? SAME : DIFFERS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A job line's amount before tax.
|
|
50
|
+
function lineCents(line) {
|
|
51
|
+
if (line.lineAmountCents != null) return line.lineAmountCents;
|
|
52
|
+
if (line.unitAmountCents == null) return null;
|
|
53
|
+
return line.unitAmountCents * (line.quantity ?? 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every fact of one candidate against the record it may belong to. `a` and
|
|
57
|
+
// `b` are { contactId, reference, date, lines, totalCents }.
|
|
58
|
+
function checksOf(a, b, otherCount) {
|
|
59
|
+
return {
|
|
60
|
+
client: a.contactId && b.contactId ? (a.contactId === b.contactId ? SAME : DIFFERS) : null,
|
|
61
|
+
reference: compare(a.reference, b.reference),
|
|
62
|
+
date: a.date && b.date ? { days: daysApart(a.date, b.date) } : null,
|
|
63
|
+
lines: compareLines(a.lines, b.lines),
|
|
64
|
+
total: a.totalCents == null || b.totalCents == null ? null : a.totalCents === b.totalCents ? SAME : DIFFERS,
|
|
65
|
+
others: otherCount,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Higher is a better match: the total, the lines, the Reference, the date,
|
|
70
|
+
// and not taken by another record.
|
|
71
|
+
function scoreOf(c) {
|
|
72
|
+
return (c.total === SAME ? 4 : 0) + (c.lines === SAME ? 3 : 0) + (c.reference === SAME ? 3 : 0) + (c.client === SAME ? 2 : 0)
|
|
73
|
+
+ (c.date && Math.abs(c.date.days) <= 3 ? 1 : 0) - (c.others ? 2 : 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function codesFor(db, organisationId, ids) {
|
|
77
|
+
if (!ids.length) return new Map();
|
|
78
|
+
const rows = await db.select({ id: priceItems.id, code: priceItems.code }).from(priceItems)
|
|
79
|
+
.where(and(eq(priceItems.organisationId, organisationId), inArray(priceItems.id, ids)));
|
|
80
|
+
return new Map(rows.map((r) => [r.id, r.code]));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function jobLines(o, codes) {
|
|
84
|
+
return (o.lines ?? []).map((l) => ({
|
|
85
|
+
priceItemId: l.priceItemId ?? null, code: l.itemCode ?? codes.get(l.priceItemId) ?? null, description: l.description ?? null, amountCents: lineCents(l),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The two queues, oldest first. jobs: { id, clientName, practitioner,
|
|
90
|
+
// jobDate, totalCents, possible }; invoices: { id, number, clientName,
|
|
91
|
+
// issueDate, status, totalCents, possible }. `possible` counts the other
|
|
92
|
+
// side with the same client and total.
|
|
93
|
+
export async function listReconcile(db, organisationId) {
|
|
94
|
+
const jobs = await db.select({
|
|
95
|
+
id: billingObligations.id,
|
|
96
|
+
clientName: contacts.name,
|
|
97
|
+
practitioner: billingObligations.practitioner,
|
|
98
|
+
jobDate: billingObligations.jobDate,
|
|
99
|
+
totalCents: billingObligations.totalCents,
|
|
100
|
+
possible: sql`(select count(*) from invoices i
|
|
101
|
+
where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
|
|
102
|
+
and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
|
|
103
|
+
and not exists (select 1 from billing_obligations x where x.organisation_id = i.organisation_id and x.invoice_id = i.id))`.mapWith(Number),
|
|
104
|
+
}).from(billingObligations)
|
|
105
|
+
.leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
|
|
106
|
+
.where(and(eq(billingObligations.organisationId, organisationId), JOB_GAP))
|
|
107
|
+
.orderBy(sql`${billingObligations.jobDate} asc nulls last`, asc(billingObligations.createdAt));
|
|
108
|
+
const lonely = await db.select({
|
|
109
|
+
id: invoices.id,
|
|
110
|
+
number: invoices.number,
|
|
111
|
+
clientName: contacts.name,
|
|
112
|
+
issueDate: sql`coalesce(${invoices.issueDate}, ${invoices.createdAt}::date)::text`,
|
|
113
|
+
status: invoices.status,
|
|
114
|
+
totalCents: invoices.totalCents,
|
|
115
|
+
possible: sql`(select count(*) from billing_obligations o
|
|
116
|
+
where o.organisation_id = ${invoices.organisationId} and o.contact_id = ${invoices.contactId}
|
|
117
|
+
and o.invoice_id is null and o.accounting_ref is null and o.total_cents = ${invoices.totalCents})`.mapWith(Number),
|
|
118
|
+
}).from(invoices)
|
|
119
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
120
|
+
.where(and(eq(invoices.organisationId, organisationId), ne(invoices.status, 'void'), sql`not ${hasJob}`))
|
|
121
|
+
.orderBy(sql`${invoices.issueDate} asc nulls last`, asc(invoices.createdAt));
|
|
122
|
+
return { jobs, invoices: lonely };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One job and the Flow invoices that may be its own, best first. With `q`,
|
|
126
|
+
// any Flow invoice whose number, client, Reference or total matches.
|
|
127
|
+
// `reference` is the Reference Flow would give the job's invoice.
|
|
128
|
+
export async function jobMatchDetail(db, organisationId, obligationId, { reference = null, q = '' } = {}) {
|
|
129
|
+
const [row] = await db.select({ obligation: billingObligations, clientName: contacts.name })
|
|
130
|
+
.from(billingObligations)
|
|
131
|
+
.leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
|
|
132
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
133
|
+
if (!row) throw notFound('job');
|
|
134
|
+
const o = row.obligation;
|
|
135
|
+
const codes = await codesFor(db, organisationId, (o.lines ?? []).map((l) => l.priceItemId).filter(Boolean));
|
|
136
|
+
const job = {
|
|
137
|
+
id: o.id,
|
|
138
|
+
clientName: row.clientName,
|
|
139
|
+
practitioner: o.practitioner,
|
|
140
|
+
reference,
|
|
141
|
+
jobDate: o.jobDate,
|
|
142
|
+
sourceSystem: o.sourceSystem,
|
|
143
|
+
sourceJobRef: o.sourceJobRef,
|
|
144
|
+
caseRef: o.caseRef,
|
|
145
|
+
sourceStatus: o.sourceStatus,
|
|
146
|
+
lines: jobLines(o, codes),
|
|
147
|
+
totalCents: o.totalCents,
|
|
148
|
+
open: !o.invoiceId && !o.accountingRef,
|
|
149
|
+
// An unclear accounting match: "Make invoice" settles it first.
|
|
150
|
+
unclear: o.accountingState === 'ambiguous',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const search = String(q ?? '').trim();
|
|
154
|
+
const where = [eq(invoices.organisationId, organisationId), ne(invoices.status, 'void')];
|
|
155
|
+
if (search) where.push(searchInvoices(search));
|
|
156
|
+
else {
|
|
157
|
+
if (!o.contactId) return { job, invoices: [] };
|
|
158
|
+
where.push(eq(invoices.contactId, o.contactId));
|
|
159
|
+
if (o.jobDate) where.push(sql`abs(coalesce(${invoices.issueDate}, ${invoices.createdAt}::date) - ${o.jobDate}::date) <= ${WINDOW_DAYS}`);
|
|
160
|
+
}
|
|
161
|
+
const found = await db.select({ invoice: invoices, clientName: contacts.name }).from(invoices)
|
|
162
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
163
|
+
.where(and(...where)).orderBy(desc(invoices.createdAt)).limit(50);
|
|
164
|
+
const detail = await invoiceFacts(db, organisationId, found.map((f) => f.invoice));
|
|
165
|
+
const ours = { contactId: o.contactId, reference, date: o.jobDate, lines: job.lines, totalCents: o.totalCents };
|
|
166
|
+
const ranked = found.map(({ invoice, clientName }) => {
|
|
167
|
+
const d = detail.get(invoice.id);
|
|
168
|
+
const checks = checksOf(ours, { contactId: invoice.contactId, reference: invoice.reference, date: dateOf(invoice), lines: d.lines, totalCents: invoice.totalCents }, d.jobs.length);
|
|
169
|
+
return {
|
|
170
|
+
id: invoice.id, number: invoice.number, clientName, issueDate: invoice.issueDate, date: dateOf(invoice), status: invoice.status,
|
|
171
|
+
reference: invoice.reference, lines: d.lines, totalCents: invoice.totalCents, jobs: d.jobs, checks, score: scoreOf(checks),
|
|
172
|
+
};
|
|
173
|
+
}).sort((a, b) => b.score - a.score).slice(0, search ? SEARCHED : SHOWN);
|
|
174
|
+
return { job, invoices: ranked.map((c, index) => ({ ...c, best: index === 0 && c.score > 0 })) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// One Flow invoice with no job, and the jobs with no invoice that may be its
|
|
178
|
+
// own, best first.
|
|
179
|
+
export async function invoiceMatchDetail(db, organisationId, invoiceId, { q = '' } = {}) {
|
|
180
|
+
const [row] = await db.select({ invoice: invoices, clientName: contacts.name }).from(invoices)
|
|
181
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
182
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
|
|
183
|
+
if (!row) throw notFound('invoice');
|
|
184
|
+
const i = row.invoice;
|
|
185
|
+
const d = (await invoiceFacts(db, organisationId, [i])).get(i.id);
|
|
186
|
+
const invoice = {
|
|
187
|
+
id: i.id, number: i.number, clientName: row.clientName, issueDate: i.issueDate, date: dateOf(i), status: i.status, reference: i.reference,
|
|
188
|
+
lines: d.lines, totalCents: i.totalCents, jobs: d.jobs, open: d.jobs.length === 0 && i.status !== 'void',
|
|
189
|
+
};
|
|
190
|
+
const search = String(q ?? '').trim();
|
|
191
|
+
const where = [eq(billingObligations.organisationId, organisationId), isNull(billingObligations.invoiceId), isNull(billingObligations.accountingRef)];
|
|
192
|
+
if (search) {
|
|
193
|
+
const like = `%${search.replace(/[%_\\]/g, '\\$&')}%`;
|
|
194
|
+
where.push(or(ilike(contacts.name, like), ilike(billingObligations.practitioner, like), ilike(billingObligations.caseRef, like), ilike(billingObligations.sourceJobRef, like)));
|
|
195
|
+
} else {
|
|
196
|
+
where.push(eq(billingObligations.contactId, i.contactId));
|
|
197
|
+
if (invoice.date) where.push(sql`abs(coalesce(${billingObligations.jobDate}, ${billingObligations.createdAt}::date) - ${invoice.date}::date) <= ${WINDOW_DAYS}`);
|
|
198
|
+
}
|
|
199
|
+
const found = await db.select({ obligation: billingObligations, clientName: contacts.name }).from(billingObligations)
|
|
200
|
+
.leftJoin(contacts, eq(contacts.id, billingObligations.contactId))
|
|
201
|
+
.where(and(...where)).orderBy(desc(billingObligations.createdAt)).limit(50);
|
|
202
|
+
const codes = await codesFor(db, organisationId, found.flatMap((f) => (f.obligation.lines ?? []).map((l) => l.priceItemId)).filter(Boolean));
|
|
203
|
+
const theirs = { contactId: i.contactId, reference: i.reference, date: invoice.date, lines: d.lines, totalCents: i.totalCents };
|
|
204
|
+
const ranked = found.map(({ obligation: o, clientName }) => {
|
|
205
|
+
const lines = jobLines(o, codes);
|
|
206
|
+
const checks = checksOf(theirs, { contactId: o.contactId, reference: null, date: o.jobDate, lines, totalCents: o.totalCents }, 0);
|
|
207
|
+
return {
|
|
208
|
+
id: o.id, clientName, practitioner: o.practitioner, jobDate: o.jobDate, caseRef: o.caseRef, sourceStatus: o.sourceStatus,
|
|
209
|
+
sourceSystem: o.sourceSystem, lines, totalCents: o.totalCents, ready: Boolean(o.readyAt), checks, score: scoreOf(checks),
|
|
210
|
+
};
|
|
211
|
+
}).sort((a, b) => b.score - a.score).slice(0, search ? SEARCHED : SHOWN);
|
|
212
|
+
return { invoice, jobs: ranked.map((c, index) => ({ ...c, best: index === 0 && c.score > 0 })) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A search box's words against Flow invoices: number, client, Reference, or
|
|
216
|
+
// a total in dollars ("150" or "$150.00").
|
|
217
|
+
function searchInvoices(search) {
|
|
218
|
+
const like = `%${search.replace(/[%_\\]/g, '\\$&')}%`;
|
|
219
|
+
const terms = [ilike(invoices.number, like), ilike(contacts.name, like), ilike(invoices.reference, like)];
|
|
220
|
+
const amount = /^\$?\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)$/.exec(search);
|
|
221
|
+
if (amount) terms.push(eq(invoices.totalCents, Math.round(Number(amount[1].replace(/,/g, '')) * 100)));
|
|
222
|
+
return or(...terms);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Each invoice's lines (code and amount before tax) and the jobs linked to it.
|
|
226
|
+
async function invoiceFacts(db, organisationId, rows) {
|
|
227
|
+
const ids = rows.map((r) => r.id);
|
|
228
|
+
const facts = new Map(ids.map((id) => [id, { lines: [], jobs: [] }]));
|
|
229
|
+
if (!ids.length) return facts;
|
|
230
|
+
const lines = await db.select({ invoiceId: invoiceLines.invoiceId, priceItemId: invoiceLines.priceItemId, code: priceItems.code, description: invoiceLines.description, amountCents: invoiceLines.lineAmountCents })
|
|
231
|
+
.from(invoiceLines)
|
|
232
|
+
.leftJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
|
|
233
|
+
.where(and(eq(invoiceLines.organisationId, organisationId), inArray(invoiceLines.invoiceId, ids)))
|
|
234
|
+
.orderBy(asc(invoiceLines.position));
|
|
235
|
+
for (const { invoiceId, ...line } of lines) facts.get(invoiceId).lines.push(line);
|
|
236
|
+
const jobs = await db.select({ id: billingObligations.id, invoiceId: billingObligations.invoiceId, jobDate: billingObligations.jobDate, practitioner: billingObligations.practitioner })
|
|
237
|
+
.from(billingObligations)
|
|
238
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
239
|
+
for (const { invoiceId, ...job } of jobs) facts.get(invoiceId).jobs.push(job);
|
|
240
|
+
return facts;
|
|
241
|
+
}
|