@arcforgelabs/flow 2026.11.8 → 2026.11.10
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +18 -2
- package/server/core/contacts.js +79 -5
- package/server/core/invoice-rules.js +14 -1
- package/server/core/invoices.js +2 -0
- package/server/core/invoicing-rules.js +55 -4
- package/server/core/ledger.js +25 -2
- package/server/core/merge-contacts.js +182 -0
- package/server/core/pipeline.js +77 -13
- package/server/db/migrations/0030_contact_hidden.sql +1 -0
- package/server/db/migrations/0031_bill_to.sql +10 -0
- package/server/db/migrations/0032_contact_merge.sql +12 -0
- package/server/db/migrations/meta/0030_snapshot.json +5076 -0
- package/server/db/migrations/meta/0031_snapshot.json +5123 -0
- package/server/db/migrations/meta/0032_snapshot.json +5167 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +28 -0
- package/server/integrations/seazona/sweep.js +58 -2
- package/server/integrations/xero/create-contact.js +15 -1
- package/server/integrations/xero/delete-invoice.js +1 -1
- package/server/integrations/xero/drafts.js +20 -5
- package/server/integrations/xero/ledger.js +7 -4
- package/server/integrations/xero/links.js +85 -18
- package/server/integrations/xero/lookup.js +7 -4
- package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
- package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +63 -8
- package/server/integrations/xero/reconcile.js +1 -1
- package/server/integrations/xero/routes.js +22 -2
- package/server/ui/app.js +31 -2
- package/server/ui/contacts.js +150 -15
package/server/core/pipeline.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
2
|
+
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
3
3
|
import {
|
|
4
4
|
billingObligations, contacts, externalLinks, linkRejections, linkSuggestions, obligationEvents, organisations, pipelineCursors,
|
|
5
5
|
} from '../db/schema.js';
|
|
@@ -10,7 +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
|
+
import { billedBy, createContact, payersOf, readContact } from './contacts.js';
|
|
14
14
|
import { HISTORY_ACTOR, HISTORY_HOLD_REASON, isHistoryHeld } from './history.js';
|
|
15
15
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
16
16
|
|
|
@@ -111,7 +111,10 @@ function byName(records) {
|
|
|
111
111
|
for (const record of records) {
|
|
112
112
|
const key = normaliseName(record.name);
|
|
113
113
|
if (!key) continue;
|
|
114
|
-
|
|
114
|
+
const same = map.get(key) ?? [];
|
|
115
|
+
// A name and an alias of one contact are one record.
|
|
116
|
+
if (same.some((r) => r.id === record.id)) continue;
|
|
117
|
+
map.set(key, [...same, record]);
|
|
115
118
|
}
|
|
116
119
|
return map;
|
|
117
120
|
}
|
|
@@ -122,8 +125,11 @@ function byName(records) {
|
|
|
122
125
|
// contact, linked to it (`imported`), so its jobs find their customer
|
|
123
126
|
// (docs/CONTACTS.md). Two customers of one name stay for a person.
|
|
124
127
|
export async function linkCustomers(db, organisationId, { system, customers, adopt = false }) {
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
// A merged contact is gone from the names; the kept one answers to its
|
|
129
|
+
// aliases too (docs/CONTACTS.md, "Merge two contacts").
|
|
130
|
+
const flow = (await db.select({ id: contacts.id, name: contacts.name, aliases: contacts.aliases }).from(contacts)
|
|
131
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer'), isNull(contacts.mergedIntoId))))
|
|
132
|
+
.flatMap((c) => [{ id: c.id, name: c.name }, ...(c.aliases ?? []).map((name) => ({ id: c.id, name }))]);
|
|
127
133
|
const links = await activeLinks(db, organisationId, { system, entity: 'contact' });
|
|
128
134
|
const linkedFlow = new Set(links.map((l) => l.flowId));
|
|
129
135
|
const linkedExternal = new Set(links.map((l) => l.externalId));
|
|
@@ -183,6 +189,52 @@ async function adoptCustomer(db, organisationId, { system, customer }) {
|
|
|
183
189
|
});
|
|
184
190
|
}
|
|
185
191
|
|
|
192
|
+
// Hidden contacts (docs/CONTACTS.md). A Flow customer that Flow made from an
|
|
193
|
+
// accounting contact (`imported`) is a client only when something says so: the
|
|
194
|
+
// accounting system marks that contact a customer (`customer`), a work system
|
|
195
|
+
// has the client, or Flow holds a job or an invoice for it. `customers` is the
|
|
196
|
+
// accounting system's list.
|
|
197
|
+
//
|
|
198
|
+
// - Each pass shows a hidden contact that has become a client.
|
|
199
|
+
// - One time for an organisation, it hides the contacts that are none. After
|
|
200
|
+
// that only a person hides or shows one, so a person's choice stays.
|
|
201
|
+
// - It waits while the work system (`workSystem`) has no client linked yet,
|
|
202
|
+
// so a client known only there is not hidden.
|
|
203
|
+
// - An accounting contact that is not in the list (archived there) is not
|
|
204
|
+
// known to be no customer, so its Flow contact stays.
|
|
205
|
+
const HIDDEN_CURSOR = 'contacts:hidden';
|
|
206
|
+
export async function hideNonClients(db, organisationId, { system, customers, workSystem = null }) {
|
|
207
|
+
const marked = customers.filter((c) => c.customer).map((c) => String(c.id));
|
|
208
|
+
const listed = customers.map((c) => String(c.id));
|
|
209
|
+
const client = sql`(
|
|
210
|
+
exists (select 1 from billing_obligations o where o.organisation_id = c.organisation_id and o.contact_id = c.id)
|
|
211
|
+
or exists (select 1 from invoices i where i.organisation_id = c.organisation_id and i.contact_id = c.id)
|
|
212
|
+
or exists (select 1 from customer_billing_settings s where s.organisation_id = c.organisation_id
|
|
213
|
+
and (s.contact_id = c.id or s.bill_to_contact_id = c.id))
|
|
214
|
+
or exists (select 1 from external_links l where l.organisation_id = c.organisation_id and l.entity = 'contact'
|
|
215
|
+
and l.flow_id = c.id and l.state = 'active'
|
|
216
|
+
and (l.system <> ${system} or l.external_id = any(${sql.param(marked)}::text[]))))`;
|
|
217
|
+
const { rows: shown } = await db.execute(sql`update contacts c set hidden = false
|
|
218
|
+
where c.organisation_id = ${organisationId} and c.hidden and c.merged_into_id is null and ${client} returning c.id`);
|
|
219
|
+
const result = { shown: shown.length, hidden: 0 };
|
|
220
|
+
// An empty list proves nothing: the accounting copy has no contacts yet.
|
|
221
|
+
if (!customers.length || await readCursor(db, organisationId, HIDDEN_CURSOR)) return result;
|
|
222
|
+
if (workSystem) {
|
|
223
|
+
const { rows: [work] } = await db.execute(sql`select 1 as one from external_links where organisation_id = ${organisationId}
|
|
224
|
+
and system = ${workSystem} and entity = 'contact' and state = 'active' limit 1`);
|
|
225
|
+
if (!work) return result;
|
|
226
|
+
}
|
|
227
|
+
const { rows: hidden } = await db.execute(sql`update contacts c set hidden = true
|
|
228
|
+
where c.organisation_id = ${organisationId} and c.kind = 'customer' and not c.hidden
|
|
229
|
+
and exists (select 1 from external_links l where l.organisation_id = c.organisation_id and l.entity = 'contact'
|
|
230
|
+
and l.flow_id = c.id and l.state = 'active' and l.system = ${system} and l.matched_by = 'imported'
|
|
231
|
+
and l.external_id = any(${sql.param(listed)}::text[]))
|
|
232
|
+
and not ${client} returning c.id`);
|
|
233
|
+
await writeCursor(db, organisationId, HIDDEN_CURSOR, 'done');
|
|
234
|
+
result.hidden = hidden.length;
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
186
238
|
async function readCursor(db, organisationId, name) {
|
|
187
239
|
const [row] = await db.select().from(pipelineCursors)
|
|
188
240
|
.where(and(eq(pipelineCursors.organisationId, organisationId), eq(pipelineCursors.name, name)));
|
|
@@ -515,9 +567,16 @@ export async function matchJobs(db, organisationId, { accounting, work = null, n
|
|
|
515
567
|
select accounting_ref, sum(total_cents)::bigint as cents from billing_obligations
|
|
516
568
|
where organisation_id = ${organisationId} and accounting_ref is not null group by accounting_ref`);
|
|
517
569
|
const taken = new Map(linked.map((r) => [r.accounting_ref, Number(r.cents)]));
|
|
518
|
-
//
|
|
570
|
+
// Bill to (docs/CONTACTS.md): a customer with a payer is billed under the
|
|
571
|
+
// payer's accounting contact, so its jobs look there, with the jobs of every
|
|
572
|
+
// other customer of that payer as one pool.
|
|
573
|
+
const payerOf = await payersOf(db, organisationId, due.map((o) => o.contactId));
|
|
574
|
+
const pools = await billedBy(db, organisationId, [...payerOf.values()]);
|
|
575
|
+
const poolOf = new Map();
|
|
576
|
+
for (const [payer, members] of pools) for (const member of members) poolOf.set(member, payer);
|
|
577
|
+
// Each pool's unlinked jobs, so two jobs at the same price share out
|
|
519
578
|
// their invoices by date.
|
|
520
|
-
const contactIds = [...
|
|
579
|
+
const contactIds = [...poolOf.keys()];
|
|
521
580
|
const open = contactIds.length
|
|
522
581
|
? await db.select({
|
|
523
582
|
id: billingObligations.id, contactId: billingObligations.contactId, jobDate: billingObligations.jobDate,
|
|
@@ -529,7 +588,10 @@ export async function matchJobs(db, organisationId, { accounting, work = null, n
|
|
|
529
588
|
))
|
|
530
589
|
: [];
|
|
531
590
|
const rivalsOf = new Map();
|
|
532
|
-
for (const o of open)
|
|
591
|
+
for (const o of open) {
|
|
592
|
+
const pool = poolOf.get(o.contactId);
|
|
593
|
+
rivalsOf.set(pool, [...(rivalsOf.get(pool) ?? []), o]);
|
|
594
|
+
}
|
|
533
595
|
// The patient name the work system gives a job, read from its copy; only
|
|
534
596
|
// PATIENT_READS jobs a pass may ask the work system itself.
|
|
535
597
|
let reads = 0;
|
|
@@ -545,13 +607,14 @@ export async function matchJobs(db, organisationId, { accounting, work = null, n
|
|
|
545
607
|
|
|
546
608
|
for (const obligation of due) {
|
|
547
609
|
result.checked += 1;
|
|
610
|
+
const payer = obligation.contactId ? payerOf.get(obligation.contactId) : null;
|
|
548
611
|
const { evidence, ranked, decision, reason } = await matchJob(db, organisationId, obligation, {
|
|
549
612
|
accounting,
|
|
550
|
-
xeroContactId:
|
|
613
|
+
xeroContactId: payer ? xeroContactOf.get(payer) ?? null : null,
|
|
551
614
|
mirror,
|
|
552
615
|
rejected: rejectedFor.get(obligation.id) ?? new Set(),
|
|
553
616
|
taken,
|
|
554
|
-
rivals: rivalsOf.get(
|
|
617
|
+
rivals: payer ? rivalsOf.get(payer) ?? [] : [],
|
|
555
618
|
patientOf,
|
|
556
619
|
});
|
|
557
620
|
await saveSuggestions(db, organisationId, obligation.id, accounting.id, ranked);
|
|
@@ -611,9 +674,10 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
611
674
|
if (accounting) {
|
|
612
675
|
if (typeof accounting.capabilities.listCustomers === 'function') {
|
|
613
676
|
const adding = (await listAutomations(db, organisationId)).find((a) => a.key === 'add_xero_contacts');
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
677
|
+
const customers = await accounting.capabilities.listCustomers({ organisationId });
|
|
678
|
+
summary.accountingCustomers = await linkCustomers(db, organisationId, { system: accounting.id, customers, adopt: Boolean(adding?.enabled) });
|
|
679
|
+
// After the work system's clients are linked, so a real client stays.
|
|
680
|
+
summary.contacts = await hideNonClients(db, organisationId, { system: accounting.id, customers, workSystem: work?.id ?? null });
|
|
617
681
|
}
|
|
618
682
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
619
683
|
summary.matching = await matchJobs(db, organisationId, { accounting, work: work ?? null, now, staleDays: settings.staleDays });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "hidden" boolean DEFAULT false NOT NULL;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ALTER TABLE "customer_billing_settings" ADD COLUMN IF NOT EXISTS "bill_to_contact_id" uuid;--> statement-breakpoint
|
|
2
|
+
DO $flow_guard$ BEGIN
|
|
3
|
+
ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_bill_to_same_org" FOREIGN KEY ("organisation_id","bill_to_contact_id") REFERENCES "flow"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;
|
|
4
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
5
|
+
END $flow_guard$;--> statement-breakpoint
|
|
6
|
+
CREATE INDEX IF NOT EXISTS "customer_billing_settings_bill_to" ON "customer_billing_settings" USING btree ("organisation_id","bill_to_contact_id");--> statement-breakpoint
|
|
7
|
+
DO $flow_guard$ BEGIN
|
|
8
|
+
ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_bill_to_other" CHECK ("customer_billing_settings"."bill_to_contact_id" is null or "customer_billing_settings"."bill_to_contact_id" <> "customer_billing_settings"."contact_id");
|
|
9
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
10
|
+
END $flow_guard$;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "merged_into_id" uuid;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "merged_at" timestamp with time zone;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "merged_by" text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "aliases" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
|
5
|
+
DO $flow_guard$ BEGIN
|
|
6
|
+
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_merged_into_same_org" FOREIGN KEY ("organisation_id","merged_into_id") REFERENCES "flow"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;
|
|
7
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
8
|
+
END $flow_guard$;--> statement-breakpoint
|
|
9
|
+
DO $flow_guard$ BEGIN
|
|
10
|
+
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_merged_into_other" CHECK ("contacts"."merged_into_id" is null or "contacts"."merged_into_id" <> "contacts"."id");
|
|
11
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
12
|
+
END $flow_guard$;
|