@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.
Files changed (33) 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/seazona/sweep.js +58 -2
  20. package/server/integrations/xero/create-contact.js +15 -1
  21. package/server/integrations/xero/delete-invoice.js +1 -1
  22. package/server/integrations/xero/drafts.js +20 -5
  23. package/server/integrations/xero/ledger.js +7 -4
  24. package/server/integrations/xero/links.js +85 -18
  25. package/server/integrations/xero/lookup.js +7 -4
  26. package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
  27. package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  29. package/server/integrations/xero/mirror.js +63 -8
  30. package/server/integrations/xero/reconcile.js +1 -1
  31. package/server/integrations/xero/routes.js +22 -2
  32. package/server/ui/app.js +31 -2
  33. package/server/ui/contacts.js +150 -15
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.8",
3
+ "version": "2026.11.10",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.11.8",
9
+ "version": "2026.11.10",
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.11.8",
3
+ "version": "2026.11.10",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,6 +35,7 @@ import { isHistoryHeld } from '../core/history.js';
35
35
  import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
36
36
  import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
37
37
  import { createContact, getContact, listContacts, presentContact, updateContact } from '../core/contacts.js';
38
+ import { mergeContacts } from '../core/merge-contacts.js';
38
39
  import {
39
40
  billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
40
41
  } from '../db/schema.js';
@@ -64,7 +65,7 @@ import {
64
65
  // POST /invoices/:id/void owner or admin
65
66
  // DELETE /invoices/:id owner or admin: a draft that was never issued.
66
67
  // Frees its job, and the lab's rules then decide.
67
- // GET /contacts?q=&limit= customers, by name
68
+ // GET /contacts?q=&limit= customers, by name (hidden=true: the contacts that are not clients)
68
69
  // GET /price-items?q= the price list, by code
69
70
  // GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
70
71
  // a job with no invoice lists the invoicing rules it waits on;
@@ -695,10 +696,13 @@ export function createInvoicingRouter({
695
696
  if (kind && !['customer', 'supplier', 'all'].includes(kind)) throw invalid('kind must be customer, supplier or all');
696
697
  const active = text(req.query.active);
697
698
  if (active && !['true', 'false', 'all'].includes(active)) throw invalid('active must be true, false or all');
699
+ const hidden = text(req.query.hidden);
700
+ if (hidden && !['true', 'false', 'all'].includes(hidden)) throw invalid('hidden must be true, false or all');
698
701
  const rows = await listContacts(db, organisationId, {
699
702
  q: text(req.query.q),
700
703
  kind: kind === 'all' ? null : (kind || 'customer'),
701
704
  active: active === 'all' ? null : active === 'false' ? false : true,
705
+ hidden: hidden === 'all' ? null : hidden === 'true',
702
706
  limit: limitOf(req.query.limit, 50, 500),
703
707
  offset: offsetOf(req.query.offset),
704
708
  });
@@ -724,6 +728,18 @@ export function createInvoicingRouter({
724
728
  res.json({ ok: true, contact });
725
729
  }));
726
730
 
731
+ // Merge two contacts: this one stays; `fromId` moves into it
732
+ // (docs/CONTACTS.md, "Merge two contacts").
733
+ router.post('/contacts/:id/merge', ...manager, handle(log, async (req, res) => {
734
+ const organisationId = await organisationOf(req);
735
+ const keepId = pathId(req.params.id, 'contact');
736
+ const fromId = text(req.body?.fromId);
737
+ if (!UUID.test(fromId)) throw invalid('fromId must name the contact to merge');
738
+ const result = await mergeContacts(db, organisationId, { keepId, fromId, actor: `person:${req.person.id}` });
739
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} merged contact ${fromId} into ${keepId}`);
740
+ res.json({ ok: true, ...result, contact: presentContact(await getContact(db, organisationId, keepId)) });
741
+ }));
742
+
727
743
  router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
728
744
  const organisationId = await organisationOf(req);
729
745
  const q = text(req.query.q);
@@ -988,7 +1004,7 @@ export function createInvoicingRouter({
988
1004
  const [statuses, customers, items, practitioners] = await Promise.all([
989
1005
  workStatuses(db, organisationId),
990
1006
  db.select({ value: contacts.id, label: contacts.name }).from(contacts)
991
- .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
1007
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer'), isNull(contacts.mergedIntoId)))
992
1008
  .orderBy(asc(contacts.name)).limit(1000),
993
1009
  db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
994
1010
  .where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
@@ -1,5 +1,5 @@
1
- import { and, asc, eq, ilike, or, sql } from 'drizzle-orm';
2
- import { contacts, CONTACT_KINDS } from '../db/schema.js';
1
+ import { and, asc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
2
+ import { contacts, CONTACT_KINDS, customerBillingSettings } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
 
5
5
  // Flow's own contacts (docs/CONTACTS.md). A contact is a customer or a
@@ -79,6 +79,11 @@ export function readContact(input, { partial = false } = {}) {
79
79
  if (input.active != null && typeof input.active !== 'boolean') throw invalid('active must be true or false');
80
80
  out.active = input.active !== false;
81
81
  }
82
+ // Only when sent: a new contact is never hidden by default.
83
+ if (has('hidden')) {
84
+ if (typeof input.hidden !== 'boolean') throw invalid('hidden must be true or false');
85
+ out.hidden = input.hidden;
86
+ }
82
87
  return out;
83
88
  }
84
89
 
@@ -88,6 +93,22 @@ function duplicate(error, kind) {
88
93
  throw error;
89
94
  }
90
95
 
96
+ // A hidden contact of that name (not a client, docs/CONTACTS.md) is the usual
97
+ // reason when the lists do not show it: say so. Read before the write, so a
98
+ // caller's transaction is never left aborted.
99
+ async function refuseHiddenName(db, organisationId, { kind, name }) {
100
+ // Another name of a contact (a merge kept it): that contact is the one.
101
+ const [alias] = await db.select({ id: contacts.id, name: contacts.name }).from(contacts).where(and(
102
+ eq(contacts.organisationId, organisationId), eq(contacts.kind, kind), isNull(contacts.mergedIntoId),
103
+ sql`${contacts.aliases} @> ${JSON.stringify([name])}::jsonb`,
104
+ )).limit(1);
105
+ if (alias) throw new FlowError('conflict', `${alias.name} has this name as another name. Use that one.`, { contactId: alias.id });
106
+ const [same] = await db.select({ id: contacts.id }).from(contacts).where(and(
107
+ eq(contacts.organisationId, organisationId), eq(contacts.kind, kind), eq(contacts.name, name), eq(contacts.hidden, true),
108
+ ));
109
+ if (same) throw new FlowError('conflict', `A hidden ${kind} with this name exists. Show it in Contacts, Hidden.`, { contactId: same.id });
110
+ }
111
+
91
112
  export function presentContact(row) {
92
113
  return {
93
114
  id: row.id,
@@ -104,6 +125,9 @@ export function presentContact(row) {
104
125
  address: row.address ?? null,
105
126
  practitioners: row.practitioners ?? [],
106
127
  active: row.active !== false,
128
+ hidden: row.hidden === true,
129
+ aliases: row.aliases ?? [],
130
+ mergedIntoId: row.mergedIntoId ?? null,
107
131
  createdAt: row.createdAt,
108
132
  updatedAt: row.updatedAt,
109
133
  };
@@ -111,6 +135,7 @@ export function presentContact(row) {
111
135
 
112
136
  export async function createContact(db, organisationId, input) {
113
137
  const values = readContact(input);
138
+ await refuseHiddenName(db, organisationId, values);
114
139
  try {
115
140
  const [row] = await db.insert(contacts).values({ organisationId, ...values }).returning();
116
141
  return row;
@@ -130,6 +155,16 @@ export async function updateContact(db, organisationId, id, input) {
130
155
  const current = await getContact(db, organisationId, id);
131
156
  const patch = readContact(input, { partial: true });
132
157
  if (!Object.keys(patch).length) return current;
158
+ if (current.mergedIntoId) throw invalid('this contact was merged into another; change that one');
159
+ // Bill to joins customers only (docs/CONTACTS.md).
160
+ if (patch.kind && patch.kind !== current.kind) {
161
+ const [billing] = await db.select({ id: customerBillingSettings.contactId }).from(customerBillingSettings).where(and(
162
+ eq(customerBillingSettings.organisationId, organisationId),
163
+ or(and(eq(customerBillingSettings.contactId, id), isNotNull(customerBillingSettings.billToContactId)),
164
+ eq(customerBillingSettings.billToContactId, id)),
165
+ )).limit(1);
166
+ if (billing) throw invalid('this customer is in a Bill to; clear Bill to first');
167
+ }
133
168
  try {
134
169
  const [row] = await db.update(contacts)
135
170
  .set({ ...patch, updatedAt: sql`now()` })
@@ -142,15 +177,21 @@ export async function updateContact(db, organisationId, id, input) {
142
177
  }
143
178
 
144
179
  // `kind` null lists customers and suppliers. `active` null lists both.
145
- export async function listContacts(db, organisationId, { q = '', kind = 'customer', active = true, limit = 200, offset = 0 } = {}) {
146
- const where = [eq(contacts.organisationId, organisationId)];
180
+ // `hidden` false (the default) leaves out the contacts that are not clients
181
+ // (docs/CONTACTS.md, "Hidden contacts"); true lists only those; null, both.
182
+ export async function listContacts(db, organisationId, { q = '', kind = 'customer', active = true, hidden = false, limit = 200, offset = 0 } = {}) {
183
+ // A merged contact is in no list (docs/CONTACTS.md, "Merge two contacts").
184
+ const where = [eq(contacts.organisationId, organisationId), isNull(contacts.mergedIntoId)];
147
185
  if (kind) where.push(eq(contacts.kind, kind));
148
186
  if (active === true) where.push(eq(contacts.active, true));
149
187
  if (active === false) where.push(eq(contacts.active, false));
188
+ if (hidden === true) where.push(eq(contacts.hidden, true));
189
+ if (hidden === false) where.push(eq(contacts.hidden, false));
150
190
  const needle = String(q ?? '').trim().replace(/[\\%_]/g, (c) => `\\${c}`);
151
191
  if (needle) {
152
192
  const like = `%${needle}%`;
153
- where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like)));
193
+ where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like),
194
+ sql`${contacts.aliases}::text ilike ${like}`));
154
195
  }
155
196
  const rows = await db.select().from(contacts).where(and(...where))
156
197
  .orderBy(asc(contacts.name), asc(contacts.id))
@@ -158,3 +199,36 @@ export async function listContacts(db, organisationId, { q = '', kind = 'custome
158
199
  .offset(offset);
159
200
  return rows.map(presentContact);
160
201
  }
202
+
203
+ // Bill to (docs/CONTACTS.md): the contact each customer's work is billed to
204
+ // in the accounting system. A customer with a payer bills to the payer; any
205
+ // other bills to itself. Answers a map for every id given.
206
+ export async function payersOf(db, organisationId, contactIds) {
207
+ const ids = [...new Set(contactIds.filter(Boolean))];
208
+ const out = new Map(ids.map((id) => [id, id]));
209
+ if (!ids.length) return out;
210
+ const rows = await db.select({ contactId: customerBillingSettings.contactId, payer: customerBillingSettings.billToContactId })
211
+ .from(customerBillingSettings)
212
+ .where(and(eq(customerBillingSettings.organisationId, organisationId), inArray(customerBillingSettings.contactId, ids),
213
+ isNotNull(customerBillingSettings.billToContactId)));
214
+ for (const r of rows) out.set(r.contactId, r.payer);
215
+ return out;
216
+ }
217
+
218
+ // The customers that bill to each payer given, the payer included.
219
+ export async function billedBy(db, organisationId, payerIds) {
220
+ const ids = [...new Set(payerIds.filter(Boolean))];
221
+ const out = new Map(ids.map((id) => [id, new Set([id])]));
222
+ if (!ids.length) return out;
223
+ const rows = await db.select({ contactId: customerBillingSettings.contactId, payer: customerBillingSettings.billToContactId })
224
+ .from(customerBillingSettings)
225
+ .where(and(eq(customerBillingSettings.organisationId, organisationId), inArray(customerBillingSettings.billToContactId, ids)));
226
+ for (const r of rows) out.get(r.payer).add(r.contactId);
227
+ return out;
228
+ }
229
+
230
+ // The contact one customer is billed as: its payer, else itself.
231
+ export async function billedContact(db, organisationId, contactId) {
232
+ const payer = (await payersOf(db, organisationId, [contactId])).get(contactId);
233
+ return getContact(db, organisationId, payer);
234
+ }
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
5
- import { billingObligations, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
5
+ import { billingObligations, contacts, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
7
  import { getInvoice, updateDraftInvoice } from './invoices.js';
8
8
 
@@ -466,9 +466,21 @@ async function checkRuleItem(db, organisationId, rule) {
466
466
  if (item.archivedAt) throw invalid(`price item ${code} is archived`);
467
467
  }
468
468
 
469
+ // A rule never names a merged contact: it could never match
470
+ // (docs/CONTACTS.md, "Merge two contacts").
471
+ async function checkRuleCustomers(db, organisationId, rule) {
472
+ const ids = rule.conditions.filter((c) => c.field === 'customer' && c.value != null && !['contains', 'does_not_contain'].includes(c.test))
473
+ .flatMap((c) => (Array.isArray(c.value) ? c.value : [c.value])).filter((id) => /^[0-9a-f-]{36}$/i.test(id));
474
+ if (!ids.length) return;
475
+ const [merged] = await db.select({ name: contacts.name }).from(contacts).where(and(eq(contacts.organisationId, organisationId),
476
+ inArray(contacts.id, ids), sql`${contacts.mergedIntoId} is not null`)).limit(1);
477
+ if (merged) throw invalid('a customer in this rule was merged into another; pick that one');
478
+ }
479
+
469
480
  export async function createRule(db, organisationId, raw, { personId = null } = {}) {
470
481
  const rule = readRule(raw);
471
482
  await checkRuleItem(db, organisationId, rule);
483
+ await checkRuleCustomers(db, organisationId, rule);
472
484
  const existing = await listRules(db, organisationId);
473
485
  if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
474
486
  const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
@@ -489,6 +501,7 @@ export async function updateRule(db, organisationId, ruleId, changes, { personId
489
501
  if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
490
502
  const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
491
503
  if (rule.action.addItem && rule.action.addItem !== row.action?.addItem) await checkRuleItem(db, organisationId, rule);
504
+ if (Object.prototype.hasOwnProperty.call(changes, 'conditions')) await checkRuleCustomers(db, organisationId, rule);
492
505
  const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
493
506
  .where(eq(invoiceRules.id, row.id)).returning();
494
507
  return shape(saved);
@@ -81,6 +81,7 @@ export async function createDraftInvoice(db, organisationId, { contactId, refere
81
81
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
82
82
  if (!contact) throw new FlowError('invalid', 'contact not found');
83
83
  if (contact.kind !== 'customer') throw new FlowError('invalid', 'an invoice goes to a customer contact');
84
+ if (contact.mergedIntoId) throw new FlowError('invalid', 'this contact was merged into another; pick that one');
84
85
  const resolved = await resolveLines(tx, organisationId, lines);
85
86
  const [invoice] = await tx.insert(invoices)
86
87
  .values({ organisationId, contactId, reference, dueDate })
@@ -99,6 +100,7 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
99
100
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, changes.contactId)));
100
101
  if (!contact) throw new FlowError('invalid', 'contact not found');
101
102
  if (contact.kind !== 'customer') throw new FlowError('invalid', 'an invoice goes to a customer contact');
103
+ if (contact.mergedIntoId) throw new FlowError('invalid', 'this contact was merged into another; pick that one');
102
104
  fields.contactId = changes.contactId;
103
105
  }
104
106
  if ('reference' in changes) fields.reference = changes.reference;
@@ -2,6 +2,7 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
2
2
  import {
3
3
  billingObligations, contacts, customerBillingSettings, externalLinks, invoicingSettings, SEND_MODES,
4
4
  } from '../db/schema.js';
5
+ import { payersOf } from './contacts.js';
5
6
  import { FlowError, notFound } from './errors.js';
6
7
  import { HISTORY_HOLD_REASON } from './history.js';
7
8
  import { accountingItemsFor, listRules, ruleNonChargeable, ruleReasons } from './invoice-rules.js';
@@ -38,8 +39,9 @@ export const INVOICING_DEFAULTS = Object.freeze({
38
39
  });
39
40
 
40
41
  const CUSTOMER_DEFAULTS = Object.freeze({
41
- billable: true, defaultPractitioner: null, autoDraft: null, sendMode: null, paymentTermsDays: null,
42
+ billable: true, defaultPractitioner: null, autoDraft: null, sendMode: null, paymentTermsDays: null, billToContactId: null,
42
43
  });
44
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
45
 
44
46
  const pick = (row, keys) => Object.fromEntries(keys.map((k) => [k, row[k]]));
45
47
  const SETTING_KEYS = Object.keys(INVOICING_DEFAULTS);
@@ -108,10 +110,11 @@ export async function saveInvoicingSettings(db, organisationId, changes, { perso
108
110
  }
109
111
 
110
112
  async function requireCustomer(db, organisationId, contactId) {
111
- const [contact] = await db.select({ id: contacts.id, kind: contacts.kind }).from(contacts)
113
+ const [contact] = await db.select({ id: contacts.id, kind: contacts.kind, mergedIntoId: contacts.mergedIntoId }).from(contacts)
112
114
  .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
113
115
  if (!contact) throw notFound('customer');
114
116
  if (contact.kind !== 'customer') throw invalid('invoicing settings are for a customer contact');
117
+ return contact;
115
118
  }
116
119
 
117
120
  // One customer's own settings. A null value takes the organisation's setting.
@@ -124,7 +127,23 @@ export async function readCustomerBilling(db, organisationId, contactId) {
124
127
 
125
128
  export async function saveCustomerBilling(db, organisationId, contactId, changes, { personId = null } = {}) {
126
129
  if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the settings must be an object');
127
- await requireCustomer(db, organisationId, contactId);
130
+ const customer = await requireCustomer(db, organisationId, contactId);
131
+ if (customer.mergedIntoId) throw invalid('this customer was merged into another; change that one');
132
+ // Bill to is checked and saved under one lock per organisation, so two
133
+ // saves at once never make a chain of payers.
134
+ if (Object.prototype.hasOwnProperty.call(changes, 'billToContactId')) {
135
+ return db.transaction(async (tx) => {
136
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`bill_to:${organisationId}`}))`);
137
+ // A merge takes the same lock: read again inside it.
138
+ if ((await requireCustomer(tx, organisationId, contactId)).mergedIntoId) throw invalid('this customer was merged into another; change that one');
139
+ return saveBilling(tx, organisationId, contactId, changes, personId);
140
+ });
141
+ }
142
+ return saveBilling(db, organisationId, contactId, changes, personId);
143
+ }
144
+
145
+ async function saveBilling(db, organisationId, contactId, changes, personId) {
146
+ const before = (await readCustomerBilling(db, organisationId, contactId)).billToContactId;
128
147
  const fields = {};
129
148
  for (const [key, value] of Object.entries(changes)) {
130
149
  if (key === 'billable') fields[key] = flag(value, key);
@@ -132,6 +151,7 @@ export async function saveCustomerBilling(db, organisationId, contactId, changes
132
151
  else if (key === 'autoDraft') fields[key] = value === null ? null : flag(value, key);
133
152
  else if (key === 'sendMode') fields[key] = value === null ? null : sendMode(value, key);
134
153
  else if (key === 'paymentTermsDays') fields[key] = value === null ? null : wholeNumber(value, key, 0, 365);
154
+ else if (key === 'billToContactId') fields[key] = await billTo(db, organisationId, contactId, value);
135
155
  else throw invalid(`unknown setting: ${key}`);
136
156
  }
137
157
  await db.insert(customerBillingSettings)
@@ -140,9 +160,38 @@ export async function saveCustomerBilling(db, organisationId, contactId, changes
140
160
  target: [customerBillingSettings.organisationId, customerBillingSettings.contactId],
141
161
  set: { ...fields, updatedBy: personId, updatedAt: sql`now()` },
142
162
  });
163
+ // A new payer is a new place to look: the matcher checks this customer's
164
+ // unbilled jobs again on its next pass.
165
+ if ('billToContactId' in fields && fields.billToContactId !== before) {
166
+ await db.update(billingObligations).set({ accountingCheckedAt: null, updatedAt: sql`now()` }).where(and(
167
+ eq(billingObligations.organisationId, organisationId), eq(billingObligations.contactId, contactId),
168
+ isNull(billingObligations.accountingRef), isNull(billingObligations.invoiceId),
169
+ ));
170
+ }
143
171
  return readCustomerBilling(db, organisationId, contactId);
144
172
  }
145
173
 
174
+ // Bill to (docs/CONTACTS.md): another customer, the payer, whose accounting
175
+ // contact this customer's work is billed to. One level only: a payer bills
176
+ // to itself, and a customer that is a payer names no payer.
177
+ async function billTo(db, organisationId, contactId, value) {
178
+ if (value === null) return null;
179
+ if (typeof value !== 'string' || !UUID.test(value)) throw invalid('billToContactId must name a customer');
180
+ if (value === contactId) throw invalid('a customer cannot bill to itself; clear Bill to instead');
181
+ const [payer] = await db.select({ id: contacts.id, kind: contacts.kind, name: contacts.name, mergedIntoId: contacts.mergedIntoId }).from(contacts)
182
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, value)));
183
+ if (!payer) throw notFound('customer');
184
+ if (payer.kind !== 'customer') throw invalid('the payer must be a customer');
185
+ if (payer.mergedIntoId) throw invalid(`${payer.name} was merged into another customer; pick that one`);
186
+ const [payerOwn] = await db.select({ billTo: customerBillingSettings.billToContactId }).from(customerBillingSettings)
187
+ .where(and(eq(customerBillingSettings.organisationId, organisationId), eq(customerBillingSettings.contactId, value)));
188
+ if (payerOwn?.billTo) throw invalid(`${payer.name} bills to another customer; pick that one`);
189
+ const [billed] = await db.select({ id: customerBillingSettings.contactId }).from(customerBillingSettings)
190
+ .where(and(eq(customerBillingSettings.organisationId, organisationId), eq(customerBillingSettings.billToContactId, contactId))).limit(1);
191
+ if (billed) throw invalid('other customers bill to this customer, so it cannot bill to another');
192
+ return value;
193
+ }
194
+
146
195
  // The customers' own settings, by contact id.
147
196
  export async function customerBillingByContact(db, organisationId, contactIds) {
148
197
  const ids = [...new Set(contactIds.filter(Boolean))];
@@ -263,9 +312,11 @@ const SAME_REFERENCE_DAYS = 90;
263
312
  export function copyCheck(accounting, organisationId, { db = null } = {}) {
264
313
  const contactOf = async (contactId) => {
265
314
  if (!db || !contactId) return null;
315
+ // A customer with a payer is billed under the payer's contact (Bill to).
316
+ const payer = (await payersOf(db, organisationId, [contactId])).get(contactId);
266
317
  const [link] = await db.select({ externalId: externalLinks.externalId }).from(externalLinks).where(and(
267
318
  eq(externalLinks.organisationId, organisationId), eq(externalLinks.system, accounting.id),
268
- eq(externalLinks.entity, 'contact'), eq(externalLinks.state, 'active'), eq(externalLinks.flowId, contactId),
319
+ eq(externalLinks.entity, 'contact'), eq(externalLinks.state, 'active'), eq(externalLinks.flowId, payer),
269
320
  ));
270
321
  return link?.externalId ?? null;
271
322
  };
@@ -828,6 +828,26 @@ export async function freeVoidedJob(db, organisationId, obligationId, { actor, r
828
828
  });
829
829
  }
830
830
 
831
+ // A person found that the accounting invoice a job points at is not its bill
832
+ // (a wrong pair). The job lets go of it: it is unchecked again, and the
833
+ // matcher never pairs the two again. Nothing else on the job changes. A job
834
+ // that still holds a Flow invoice keeps it; free that first.
835
+ export async function releaseAccountingRef(db, organisationId, obligationId, { accountingRef, system, actor, reason }) {
836
+ requireActor(actor);
837
+ if (!reason?.trim()) throw new FlowError('invalid', 'say why the accounting invoice is released');
838
+ return db.transaction(async (tx) => {
839
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
840
+ if (obligation.accountingRef !== accountingRef) return obligation;
841
+ if (obligation.invoiceId) throw new FlowError('invalid', 'the job still holds a Flow invoice: free it first');
842
+ const row = await update(tx, obligation, {
843
+ accountingState: 'unknown', accountingRef: null, accountingUrl: null, accountingMatchedBy: null, accountingCheckedAt: null,
844
+ });
845
+ await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: accountingRef, actor }).onConflictDoNothing();
846
+ await addEvent(tx, row, 'accounting_released', actor, { released: accountingRef, reason: reason.trim(), from: obligation.accountingState });
847
+ return row;
848
+ });
849
+ }
850
+
831
851
  // A job with no invoice of its own whose accounting evidence is ambiguous:
832
852
  // the matcher found accounting invoices that may be this job's, but none
833
853
  // exactly. Or the accounting invoice the matcher linked was voided or deleted
@@ -912,13 +932,16 @@ export async function linkJobToInvoice(db, organisationId, obligationId, { invoi
912
932
  // A job that waited for a person (no invoice found, or more than one fit)
913
933
  // is settled now: the matcher reads it again on its next pass and records
914
934
  // the invoice's stage. A link made from an accounting invoice
915
- // (`accountingRef`) is exact, so the matcher follows that invoice.
935
+ // (`accountingRef`) is exact, so the matcher follows that invoice: the
936
+ // job is due on its next pass, whatever state it was in (a job with no
937
+ // accounting contact was `unknown`, checked, and would wait for the copy
938
+ // to change).
916
939
  const settled = ['not_found', 'ambiguous'].includes(obligation.accountingState);
917
940
  const row = await update(tx, obligation, {
918
941
  invoiceId: invoice.id,
919
942
  draftBasis: basisOf(obligation),
920
943
  ...(settled ? { accountingState: 'unknown', accountingCheckedAt: null } : {}),
921
- ...(accountingRef && !obligation.accountingRef ? { accountingRef } : {}),
944
+ ...(accountingRef && !obligation.accountingRef ? { accountingRef, accountingCheckedAt: null } : {}),
922
945
  ...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
923
946
  });
924
947
  await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
@@ -0,0 +1,182 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { FlowError, notFound } from './errors.js';
3
+
4
+ // Merge two contacts (docs/CONTACTS.md, "Merge two contacts"). One client is
5
+ // sometimes two Flow contacts: the work system's name and the accounting
6
+ // system's longer name. A person merges them in one step. The kept contact
7
+ // gets the other's jobs, invoices, links, rule values and Bill to rows, and
8
+ // keeps its name as an alias. The merged row stays, out of every list; Flow
9
+ // never deletes it and never merges without a person.
10
+
11
+ const invalid = (message, details) => new FlowError('invalid', message, details);
12
+ const conflict = (message, details) => new FlowError('conflict', message, details);
13
+
14
+ const ID_TESTS = new Set(['is', 'is_not', 'is_one_of', 'is_not_one_of']);
15
+
16
+ // A rule's conditions with the merged contact's id changed to the kept one,
17
+ // or null when no condition names it.
18
+ export function rewriteConditions(conditions, fromId, keepId) {
19
+ let changed = false;
20
+ const out = (conditions ?? []).map((c) => {
21
+ if (c?.field !== 'customer' || !ID_TESTS.has(c.test)) return c;
22
+ if (Array.isArray(c.value)) {
23
+ if (!c.value.includes(fromId)) return c;
24
+ changed = true;
25
+ return { ...c, value: [...new Set(c.value.map((v) => (v === fromId ? keepId : v)))] };
26
+ }
27
+ if (c.value !== fromId) return c;
28
+ changed = true;
29
+ return { ...c, value: keepId };
30
+ });
31
+ return changed ? out : null;
32
+ }
33
+
34
+ async function lockedContact(tx, organisationId, id) {
35
+ const { rows: [row] } = await tx.execute(sql`select id, kind, name, aliases, merged_into_id from contacts
36
+ where organisation_id = ${organisationId} and id = ${id} for update`);
37
+ if (!row) throw notFound('contact');
38
+ return row;
39
+ }
40
+
41
+ // Answers what moved: { keep, from, moved: { jobs, invoices, links, rules, billTo } }.
42
+ export async function mergeContacts(db, organisationId, { keepId, fromId, actor }) {
43
+ if (!actor) throw new TypeError('actor is required');
44
+ if (!keepId || !fromId) throw invalid('keepId and fromId are required');
45
+ if (keepId === fromId) throw invalid('pick two different contacts');
46
+ return db.transaction(async (tx) => {
47
+ // The same lock as Bill to, so a Bill to save and a merge take turns.
48
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`bill_to:${organisationId}`}))`);
49
+ const [first, second] = [keepId, fromId].sort();
50
+ const rows = new Map();
51
+ for (const id of [first, second]) rows.set(id, await lockedContact(tx, organisationId, id));
52
+ const keep = rows.get(keepId);
53
+ const from = rows.get(fromId);
54
+ if (keep.merged_into_id || from.merged_into_id) throw conflict('one of these contacts was already merged');
55
+ if (keep.kind !== from.kind) throw invalid('a customer merges only with a customer, a supplier only with a supplier');
56
+
57
+ // Links: the kept contact may hold one active link for each system.
58
+ const { rows: links } = await tx.execute(sql`select id, system, external_id, state, flow_id from external_links
59
+ where organisation_id = ${organisationId} and entity = 'contact' and flow_id in (${keepId}, ${fromId}) and state <> 'superseded'`);
60
+ const activeKeep = new Map(links.filter((l) => l.flow_id === keepId && l.state === 'active').map((l) => [l.system, l]));
61
+ for (const l of links) {
62
+ if (l.flow_id === fromId && l.state === 'active' && activeKeep.has(l.system)) {
63
+ throw conflict(`both contacts are linked in ${l.system}; keep one link first`, { system: l.system });
64
+ }
65
+ }
66
+
67
+ // Bill to stays one level (docs/CONTACTS.md, "Bill to").
68
+ const { rows: billing } = await tx.execute(sql`select contact_id, bill_to_contact_id from customer_billing_settings
69
+ where organisation_id = ${organisationId} and (contact_id in (${keepId}, ${fromId}) or bill_to_contact_id in (${keepId}, ${fromId}))`);
70
+ const own = (id) => billing.find((b) => b.contact_id === id) ?? null;
71
+ const payerOf = (id) => own(id)?.bill_to_contact_id ?? null;
72
+ const outside = (id) => (id === keepId || id === fromId ? null : id);
73
+ const keepPayer = outside(payerOf(keepId));
74
+ const fromPayer = outside(payerOf(fromId));
75
+ if (keepPayer && fromPayer && keepPayer !== fromPayer) throw conflict('the two contacts bill to different payers; clear one Bill to first');
76
+ // The kept contact bills to the payer either one had.
77
+ const payer = keepPayer ?? fromPayer;
78
+ const clients = billing.filter((b) => [keepId, fromId].includes(b.bill_to_contact_id) && ![keepId, fromId].includes(b.contact_id));
79
+ if (payer && clients.length) throw conflict('the merged contact would bill to a payer and be a payer; clear Bill to first');
80
+
81
+ // Jobs: each moved job notes the merge, and an unbilled one goes back to
82
+ // the matcher under its new customer.
83
+ const { rows: jobs } = await tx.execute(sql`update billing_obligations set contact_id = ${keepId},
84
+ draft_basis = case when draft_basis->>'contactId' = ${fromId} then jsonb_set(draft_basis, '{contactId}', to_jsonb(${keepId}::text)) else draft_basis end,
85
+ updated_at = now()
86
+ where organisation_id = ${organisationId} and contact_id = ${fromId} returning id`);
87
+ if (jobs.length) {
88
+ const evidence = JSON.stringify({ from: fromId, to: keepId });
89
+ await tx.execute(sql`insert into obligation_events (organisation_id, obligation_id, kind, actor, evidence)
90
+ select ${organisationId}, id, 'contact_merged', ${actor}, ${evidence}::jsonb from unnest(${sql.param(jobs.map((j) => j.id))}::uuid[]) as id`);
91
+ }
92
+ const { rows: invoices } = await tx.execute(sql`update invoices set contact_id = ${keepId}, updated_at = now()
93
+ where organisation_id = ${organisationId} and contact_id = ${fromId} returning id`);
94
+
95
+ // Links move; a guess that the kept contact already answers is closed,
96
+ // with its review item.
97
+ let movedLinks = 0;
98
+ for (const l of links.filter((x) => x.flow_id === fromId)) {
99
+ if (l.state === 'proposed' && activeKeep.has(l.system)) {
100
+ await tx.execute(sql`update external_links set state = 'superseded', updated_at = now() where id = ${l.id}`);
101
+ await tx.execute(sql`update review_items set state = 'resolved', resolution = 'contact_merged', resolved_by = ${actor},
102
+ resolved_at = now(), updated_at = now()
103
+ where organisation_id = ${organisationId} and entity = 'contact' and flow_id = ${fromId} and state = 'open'
104
+ and kind = 'link.proposed' and evidence->>'linkId' = ${l.id}`);
105
+ continue;
106
+ }
107
+ await tx.execute(sql`update external_links set flow_id = ${keepId}, updated_at = now() where id = ${l.id}`);
108
+ movedLinks += 1;
109
+ }
110
+
111
+ // Rules that name the merged contact name the kept one.
112
+ const { rows: rules } = await tx.execute(sql`select id, conditions from invoice_rules
113
+ where organisation_id = ${organisationId} and conditions::text like ${`%${fromId}%`}`);
114
+ let movedRules = 0;
115
+ for (const rule of rules) {
116
+ const next = rewriteConditions(rule.conditions, fromId, keepId);
117
+ if (!next) continue;
118
+ await tx.execute(sql`update invoice_rules set conditions = ${JSON.stringify(next)}::jsonb, updated_at = now() where id = ${rule.id}`);
119
+ movedRules += 1;
120
+ }
121
+
122
+ // Bill to: clients of the merged contact bill to the kept one. The kept
123
+ // contact takes the merged one's own row when it has none, and bills to
124
+ // the payer either had. The merged contact's leftover row names no payer.
125
+ const { rows: billTo } = await tx.execute(sql`update customer_billing_settings
126
+ set bill_to_contact_id = case when contact_id = ${keepId} then null else ${keepId}::uuid end, updated_at = now()
127
+ where organisation_id = ${organisationId} and bill_to_contact_id = ${fromId} returning contact_id`);
128
+ let movedBillTo = billTo.filter((b) => b.contact_id !== keepId).length;
129
+ if (own(fromId) && !own(keepId)) {
130
+ await tx.execute(sql`update customer_billing_settings set contact_id = ${keepId}, bill_to_contact_id = ${payer}, updated_at = now()
131
+ where organisation_id = ${organisationId} and contact_id = ${fromId}`);
132
+ movedBillTo += 1;
133
+ } else {
134
+ if (own(keepId)) {
135
+ await tx.execute(sql`update customer_billing_settings set bill_to_contact_id = ${payer}, updated_at = now()
136
+ where organisation_id = ${organisationId} and contact_id = ${keepId}`);
137
+ }
138
+ if (own(fromId)) {
139
+ await tx.execute(sql`update customer_billing_settings set bill_to_contact_id = null, updated_at = now()
140
+ where organisation_id = ${organisationId} and contact_id = ${fromId}`);
141
+ }
142
+ }
143
+
144
+ // Every unbilled job of the kept contact, and of the clients that bill to
145
+ // it, goes back to the matcher: its links and payer may have changed.
146
+ await tx.execute(sql`update billing_obligations set accounting_checked_at = null
147
+ where organisation_id = ${organisationId} and accounting_ref is null and invoice_id is null
148
+ and (contact_id = ${keepId} or contact_id in (select contact_id from customer_billing_settings
149
+ where organisation_id = ${organisationId} and bill_to_contact_id = ${keepId}))`);
150
+
151
+ // Open review items of the merged contact go to the kept one, unless it
152
+ // has one of the same kind open; those close.
153
+ await tx.execute(sql`update review_items r set flow_id = ${keepId}, updated_at = now()
154
+ where r.organisation_id = ${organisationId} and r.entity = 'contact' and r.flow_id = ${fromId} and r.state = 'open'
155
+ and not exists (select 1 from review_items o where o.organisation_id = r.organisation_id and o.kind = r.kind
156
+ and o.entity = 'contact' and o.flow_id = ${keepId} and o.state = 'open')`);
157
+ await tx.execute(sql`update review_items set state = 'resolved', resolution = 'contact_merged', resolved_by = ${actor},
158
+ resolved_at = now(), updated_at = now()
159
+ where organisation_id = ${organisationId} and entity = 'contact' and flow_id = ${fromId} and state = 'open'`);
160
+
161
+ // The names: the kept contact answers to the merged one's names too.
162
+ const names = [from.name, ...(from.aliases ?? []), ...(keep.aliases ?? [])];
163
+ const aliases = [...new Set(names.filter((n) => n && n !== keep.name))].slice(0, 20);
164
+ await tx.execute(sql`update contacts set aliases = ${JSON.stringify(aliases)}::jsonb, updated_at = now()
165
+ where organisation_id = ${organisationId} and id = ${keepId}`);
166
+ // The merged row frees its name, so the kept contact can take it; the
167
+ // name lives on as the kept contact's alias. Earlier merges into the
168
+ // merged contact point at the kept one.
169
+ const freed = `${from.name.slice(0, 230)} (merged ${fromId.slice(0, 8)})`;
170
+ await tx.execute(sql`update contacts set merged_into_id = ${keepId}, merged_at = now(), merged_by = ${actor},
171
+ name = ${freed}, hidden = true, active = false, updated_at = now()
172
+ where organisation_id = ${organisationId} and id = ${fromId}`);
173
+ await tx.execute(sql`update contacts set merged_into_id = ${keepId}, updated_at = now()
174
+ where organisation_id = ${organisationId} and merged_into_id = ${fromId}`);
175
+
176
+ return {
177
+ keep: keepId,
178
+ from: fromId,
179
+ moved: { jobs: jobs.length, invoices: invoices.length, links: movedLinks, rules: movedRules, billTo: movedBillTo },
180
+ };
181
+ });
182
+ }