@arcforgelabs/flow 2026.11.9 → 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.
Files changed (32) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +18 -2
  4. package/server/core/contacts.js +79 -5
  5. package/server/core/invoice-rules.js +14 -1
  6. package/server/core/invoices.js +2 -0
  7. package/server/core/invoicing-rules.js +55 -4
  8. package/server/core/ledger.js +25 -2
  9. package/server/core/merge-contacts.js +182 -0
  10. package/server/core/pipeline.js +77 -13
  11. package/server/db/migrations/0030_contact_hidden.sql +1 -0
  12. package/server/db/migrations/0031_bill_to.sql +10 -0
  13. package/server/db/migrations/0032_contact_merge.sql +12 -0
  14. package/server/db/migrations/meta/0030_snapshot.json +5076 -0
  15. package/server/db/migrations/meta/0031_snapshot.json +5123 -0
  16. package/server/db/migrations/meta/0032_snapshot.json +5167 -0
  17. package/server/db/migrations/meta/_journal.json +21 -0
  18. package/server/db/schema.js +28 -0
  19. package/server/integrations/xero/create-contact.js +15 -1
  20. package/server/integrations/xero/delete-invoice.js +1 -1
  21. package/server/integrations/xero/drafts.js +20 -5
  22. package/server/integrations/xero/ledger.js +7 -4
  23. package/server/integrations/xero/links.js +85 -18
  24. package/server/integrations/xero/lookup.js +7 -4
  25. package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
  26. package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
  27. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  28. package/server/integrations/xero/mirror.js +63 -8
  29. package/server/integrations/xero/reconcile.js +1 -1
  30. package/server/integrations/xero/routes.js +22 -2
  31. package/server/ui/app.js +31 -2
  32. package/server/ui/contacts.js +150 -15
@@ -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
- map.set(key, [...(map.get(key) ?? []), record]);
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
- const flow = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
126
- .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
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
- // Each customer's unlinked jobs, so two jobs at the same price share out
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 = [...new Set(due.map((o) => o.contactId).filter(Boolean))];
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) rivalsOf.set(o.contactId, [...(rivalsOf.get(o.contactId) ?? []), o]);
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: obligation.contactId ? xeroContactOf.get(obligation.contactId) ?? null : null,
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(obligation.contactId) ?? [],
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
- summary.accountingCustomers = await linkCustomers(db, organisationId, {
615
- system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }), adopt: Boolean(adding?.enabled),
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$;