@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.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +2 -1
  5. package/server/api/invoicing.js +61 -3
  6. package/server/auth/clerk.js +35 -0
  7. package/server/auth/routes.js +45 -6
  8. package/server/core/custom-fields.js +18 -0
  9. package/server/core/host.js +24 -0
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +80 -1
  13. package/server/index.js +9 -3
  14. package/server/integrations/seazona/feed.js +4 -1
  15. package/server/integrations/seazona/index.js +1 -1
  16. package/server/integrations/seazona/settings.js +5 -2
  17. package/server/integrations/sentry/index.js +5 -2
  18. package/server/integrations/xero/fit.js +108 -0
  19. package/server/integrations/xero/import.js +6 -1
  20. package/server/integrations/xero/index.js +4 -4
  21. package/server/integrations/xero/ledger.js +3 -0
  22. package/server/integrations/xero/links.js +293 -0
  23. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/reconcile.js +17 -11
  26. package/server/integrations/xero/routes.js +42 -0
  27. package/server/integrations/xero/settings.js +12 -5
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +142 -23
  30. package/server/ui/bulk-bar.js +100 -0
  31. package/server/ui/flow.css +225 -11
  32. package/server/ui/invoicing.js +234 -30
  33. package/server/ui/job-board.js +60 -34
  34. package/server/ui/job-view.js +30 -7
  35. package/server/ui/jobs.js +469 -38
  36. package/server/ui/reconcile.js +275 -0
  37. package/server/ui/routes.js +1 -1
  38. 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 |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.36",
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.36",
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
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.36",
3
+ "version": "2026.10.38",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }));
@@ -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, listObligations, setReady, useSourceLines,
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: [...systems.map((a) => ({ id: a.id, name: a.name, kind: 'work' })), ...(system ? [{ id: system.id, name: system.name, kind: 'accounting' }] : [])],
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);
@@ -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;
@@ -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
- // Invite someone. Flow also makes sure they have a Clerk account, because
143
- // with public sign-up off the sign-in page never creates one.
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 ensureClerkAccount(clerk, email);
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
+ }
@@ -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
+ }