@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
@@ -211,6 +211,27 @@
211
211
  "when": 1794463202822,
212
212
  "tag": "0029_job_remake",
213
213
  "breakpoints": true
214
+ },
215
+ {
216
+ "idx": 30,
217
+ "version": "7",
218
+ "when": 1794487419926,
219
+ "tag": "0030_contact_hidden",
220
+ "breakpoints": true
221
+ },
222
+ {
223
+ "idx": 31,
224
+ "version": "7",
225
+ "when": 1794488676084,
226
+ "tag": "0031_bill_to",
227
+ "breakpoints": true
228
+ },
229
+ {
230
+ "idx": 32,
231
+ "version": "7",
232
+ "when": 1794491329391,
233
+ "tag": "0032_contact_merge",
234
+ "breakpoints": true
214
235
  }
215
236
  ]
216
237
  }
@@ -61,10 +61,27 @@ export const contacts = pgTable('contacts', {
61
61
  address: jsonb('address'),
62
62
  practitioners: jsonb('practitioners').notNull().default([]),
63
63
  active: boolean('active').notNull().default(true),
64
+ // Not a client: Flow made the contact from an accounting contact that the
65
+ // accounting system does not mark a customer, and nothing in Flow makes it
66
+ // one. It stays, out of the lists (docs/CONTACTS.md, "Hidden contacts").
67
+ hidden: boolean('hidden').notNull().default(false),
68
+ // Merge (docs/CONTACTS.md, "Merge two contacts"): a person merged this
69
+ // contact into another. Its jobs, invoices and links moved there; the row
70
+ // stays, out of every list. The kept contact holds its name in `aliases`.
71
+ mergedIntoId: uuid('merged_into_id'),
72
+ mergedAt: timestamp('merged_at', { withTimezone: true }),
73
+ mergedBy: text('merged_by'),
74
+ aliases: jsonb('aliases').notNull().default([]),
64
75
  createdAt: createdAt(),
65
76
  updatedAt: updatedAt(),
66
77
  }, (t) => [
67
78
  unique('contacts_org_id').on(t.organisationId, t.id),
79
+ foreignKey({
80
+ name: 'contacts_merged_into_same_org',
81
+ columns: [t.organisationId, t.mergedIntoId],
82
+ foreignColumns: [t.organisationId, t.id],
83
+ }),
84
+ check('contacts_merged_into_other', sql`${t.mergedIntoId} is null or ${t.mergedIntoId} <> ${t.id}`),
68
85
  unique('contacts_org_kind_name').on(t.organisationId, t.kind, t.name),
69
86
  check('contacts_kind_valid', sql`${t.kind} in ('customer', 'supplier')`),
70
87
  ]);
@@ -678,6 +695,10 @@ export const customerBillingSettings = pgTable('customer_billing_settings', {
678
695
  autoDraft: boolean('auto_draft'),
679
696
  sendMode: text('send_mode'),
680
697
  paymentTermsDays: integer('payment_terms_days'),
698
+ // The payer: another Flow customer whose accounting contact this
699
+ // customer's work is billed to (a parent organisation). Many customers may
700
+ // name one payer. Null bills the customer itself (docs/CONTACTS.md, "Bill to").
701
+ billToContactId: uuid('bill_to_contact_id'),
681
702
  updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
682
703
  updatedAt: updatedAt(),
683
704
  }, (t) => [
@@ -687,6 +708,13 @@ export const customerBillingSettings = pgTable('customer_billing_settings', {
687
708
  columns: [t.organisationId, t.contactId],
688
709
  foreignColumns: [contacts.organisationId, contacts.id],
689
710
  }),
711
+ foreignKey({
712
+ name: 'customer_billing_settings_bill_to_same_org',
713
+ columns: [t.organisationId, t.billToContactId],
714
+ foreignColumns: [contacts.organisationId, contacts.id],
715
+ }),
716
+ index('customer_billing_settings_bill_to').on(t.organisationId, t.billToContactId),
717
+ check('customer_billing_settings_bill_to_other', sql`${t.billToContactId} is null or ${t.billToContactId} <> ${t.contactId}`),
690
718
  check('customer_billing_settings_send_mode_valid', sql`${t.sendMode} is null or ${t.sendMode} in ('accounting_draft', 'approve_and_email')`),
691
719
  check('customer_billing_settings_terms_valid', sql`${t.paymentTermsDays} is null or ${t.paymentTermsDays} between 0 and 365`),
692
720
  ]);
@@ -1,4 +1,4 @@
1
- import { and, asc, eq, gt, gte, inArray, isNull, like, lt, or, sql } from 'drizzle-orm';
1
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, or, sql } from 'drizzle-orm';
2
2
  import {
3
3
  clientListSignature,
4
4
  invoiceListSignature,
@@ -492,6 +492,61 @@ async function readHighWater(db, laneStates, sync, nowMs) {
492
492
 
493
493
  // The sweep lane's run. `helpers` come from lanes.js: readOrder (issue #12
494
494
  // for each order read), isLaneLevel, describeReadFailure, emptyResponse and
495
+ // Orders whose invoice Flow's copy does not hold. The invoice lane reads
496
+ // invoices changed since its first run, so an invoice last changed before
497
+ // that was never read, and its job took the order's products alone: no
498
+ // Shipping/Handling or Rush, which are service lines on the invoice. Seazona
499
+ // gives an order's invoice the order's own id. The sweep reads it and keeps it
500
+ // when its number is the order's; the feed then gives the job the invoice's
501
+ // lines and totals. An order Seazona answers 404 for, or with another number,
502
+ // is noted (`no_invoice`) and not asked again.
503
+ export const ORDER_INVOICE_KIND = 'no_invoice';
504
+ async function readOrderInvoices(ctx, helpers, found) {
505
+ const { db, client, sync, at } = ctx;
506
+ const rows = await db.select({ id: seazonaOrders.id, invoiceNumber: seazonaOrders.invoiceNumber })
507
+ .from(seazonaOrders)
508
+ .leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
509
+ .leftJoin(seazonaSweepFindings, and(
510
+ eq(seazonaSweepFindings.resource, 'orders'),
511
+ eq(seazonaSweepFindings.recordKey, seazonaOrders.id),
512
+ eq(seazonaSweepFindings.kind, ORDER_INVOICE_KIND),
513
+ ))
514
+ .where(and(
515
+ isNull(seazonaOrders.deletedInSourceAt),
516
+ isNotNull(seazonaOrders.invoiceNumber),
517
+ isNull(seazonaInvoices.invoiceNumber),
518
+ isNull(seazonaSweepFindings.recordKey),
519
+ ))
520
+ .orderBy(desc(seazonaOrders.lastChangedAt), seazonaOrders.id)
521
+ .limit(CONFIRM_BATCH);
522
+ for (const row of rows) {
523
+ if (!client.hasCall()) throw new SweepBudgetSpent();
524
+ let detail;
525
+ try {
526
+ detail = await client.get(`invoices/${encodeURIComponent(row.id)}`);
527
+ } catch (error) {
528
+ if (error instanceof SweepBudgetSpent || helpers.isLaneLevel(error)) throw error;
529
+ if (error?.statusCode === 404) {
530
+ await recordFinding(db, 'orders', row.id, ORDER_INVOICE_KIND, { at, detail: { status: 404 } });
531
+ continue;
532
+ }
533
+ // Any other failure: try again on a later run.
534
+ return;
535
+ }
536
+ const invoice = detail && typeof detail === 'object' && !Array.isArray(detail)
537
+ ? mapInvoice({}, detail, { keepPatientReference: sync.keepPatientReference }) : null;
538
+ if (!invoice || invoice.invoiceNumber !== row.invoiceNumber) {
539
+ await recordFinding(db, 'orders', row.id, ORDER_INVOICE_KIND, { at, detail: { otherNumber: Boolean(invoice?.invoiceNumber) } });
540
+ continue;
541
+ }
542
+ const summary = await upsertRecords(db, 'invoices', [invoice], { at });
543
+ if (summary.created) {
544
+ found.created += 1;
545
+ await recordFinding(db, 'invoices', invoice.invoiceNumber, 'created', { at, detail: { lastModified: invoice.lastModifiedText } });
546
+ }
547
+ }
548
+ }
549
+
495
550
  // LaneError.
496
551
  export async function runSweep(ctx, helpers) {
497
552
  const { db, sync, at, state } = ctx;
@@ -506,6 +561,7 @@ export async function runSweep(ctx, helpers) {
506
561
  const save = () => ({ ...cursor, budget: { day: today, calls: spentToday + budget.spent } });
507
562
 
508
563
  try {
564
+ await readOrderInvoices(sweepCtx, helpers, found);
509
565
  for (const kind of SWEEP_KINDS) {
510
566
  if (budget.left <= 0) break;
511
567
  if (!isDue(cursor[kind], at, sync, kind)) continue;
@@ -557,7 +613,7 @@ export async function readSweepHealth(db, settings, { now = Date.now } = {}) {
557
613
  .groupBy(seazonaSweepFindings.resource, seazonaSweepFindings.kind);
558
614
  const foundLastWeek = {};
559
615
  for (const row of counts) {
560
- if (row.kind === 'missing') continue;
616
+ if (row.kind === 'missing' || row.kind === ORDER_INVOICE_KIND) continue;
561
617
  foundLastWeek[row.resource] = { ...foundLastWeek[row.resource], [row.kind]: row.records };
562
618
  }
563
619
  const missingRows = await db.select({ resource: seazonaSweepFindings.resource, key: seazonaSweepFindings.recordKey })
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { sql } from 'drizzle-orm';
3
- import { getContact } from '../../core/contacts.js';
3
+ import { billedContact, getContact } from '../../core/contacts.js';
4
4
  import { flowIdFor, linkRecord, supersedeLink } from '../../core/links.js';
5
5
  import { mapContact, matchContactFor, normaliseName, upsertContact } from './catalogue.js';
6
6
  import { xeroError } from './client.js';
@@ -79,8 +79,20 @@ export async function matchCustomer(db, runtime, { organisationId, contactId })
79
79
  const connected = Boolean(connection && connection.mode === runtime.settings.mode);
80
80
  if (!connected) return { state: 'off', offerCreate: false, offerLink: false, xeroContactId: null, name: null, differs: [] };
81
81
  const contact = await getContact(db, organisationId, contactId);
82
+ // Bill to: a customer with a payer is billed as the payer. Link and Create
83
+ // are for the payer's own page.
84
+ const payer = await billedContact(db, organisationId, contactId);
85
+ if (payer.id !== contactId) {
86
+ const billed = await matchContactFor(db, organisationId, { contactId: payer.id, name: payer.name });
87
+ return {
88
+ state: billed.state, offerCreate: false, offerLink: false, differs: [],
89
+ xeroContactId: billed.contact?.xeroContactId ?? null, name: billed.contact?.name ?? null,
90
+ billTo: { id: payer.id, name: payer.name },
91
+ };
92
+ }
82
93
  const match = await matchContactFor(db, organisationId, { contactId, name: contact.name });
83
94
  return {
95
+ billTo: null,
84
96
  // Fields both sides changed (Two-way edits, contact-sync.js): alert only.
85
97
  differs: match.state === 'matched' ? (await contactClashes(db, organisationId, contactId)).map(({ field, flow, xero }) => ({ field, flow, xero })) : [],
86
98
  state: match.state,
@@ -97,6 +109,7 @@ export async function createCustomer(db, runtime, { organisationId, contactId, a
97
109
  }
98
110
  runtime.assertWritesAllowed();
99
111
  const flowContact = await getContact(db, organisationId, contactId);
112
+ if (flowContact.mergedIntoId) throw xeroError('contact_merged', 'This contact was merged into another. Use that one.', { status: 409 });
100
113
  const known = await matchContactFor(db, organisationId, { contactId, name: flowContact.name });
101
114
  if (known.state === 'ambiguous') {
102
115
  throw xeroError('xero_contact_ambiguous', `More than one Xero contact is named "${flowContact.name}". Merge or rename them in Xero, then sync.`, { status: 409 });
@@ -140,6 +153,7 @@ export async function linkCustomer(db, runtime, { organisationId, contactId, xer
140
153
  throw xeroError('xero_not_connected', 'Connect Xero first.', { status: 409 });
141
154
  }
142
155
  const flowContact = await getContact(db, organisationId, contactId);
156
+ if (flowContact.mergedIntoId) throw xeroError('contact_merged', 'This contact was merged into another. Use that one.', { status: 409 });
143
157
  const { rows: [row] } = await db.execute(sql`select xero_contact_id, name, status from xero_contacts
144
158
  where organisation_id = ${organisationId} and xero_contact_id = ${xeroContactId}`);
145
159
  if (!row) throw xeroError('xero_contact_unknown', 'Flow has no copy of that Xero contact. Sync, then try again.', { status: 404 });
@@ -30,7 +30,7 @@ async function linkedXeroId(db, organisationId, invoiceId) {
30
30
  if (pushes[0]?.xero_invoice_id) return pushes[0].xero_invoice_id;
31
31
  const { rows: links } = await db.execute(sql`
32
32
  select xero_invoice_id from xero_invoice_links
33
- where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
33
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and released_at is null`);
34
34
  return links[0]?.xero_invoice_id ?? null;
35
35
  }
36
36
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { isCallsStopped } from '../call-guard.js';
4
+ import { billedContact } from '../../core/contacts.js';
4
5
  import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
5
6
  import { sendModeFor } from '../../core/invoicing-rules.js';
6
7
  import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
@@ -325,15 +326,29 @@ export async function previewDrafts(db, runtime, organisationId, invoiceIds) {
325
326
  return { syncedAt, warnings, invoices: results };
326
327
  }
327
328
 
329
+ // The Xero contact an invoice goes to: its customer's, or its payer's when the
330
+ // customer bills to another customer (docs/CONTACTS.md, "Bill to"). A missing
331
+ // payer is linked or made on the payer's own contact page, not here.
332
+ async function accountingContactFor(db, organisationId, invoice, document) {
333
+ const payer = await billedContact(db, organisationId, invoice.contactId);
334
+ const own = payer.id === invoice.contactId;
335
+ const name = own ? document.contact.name : payer.name;
336
+ return { match: await matchContactFor(db, organisationId, { contactId: payer.id, name }), name, own };
337
+ }
338
+
339
+ const payerMissing = (name) => problem('xero_contact_missing',
340
+ `This client is billed to ${name}, and no Xero contact is named "${name}". Link ${name} to its Xero client on its contact page, then check again.`,
341
+ { short: 'Payer not in Xero' });
342
+
328
343
  async function previewOne(db, runtime, organisationId, invoiceId) {
329
344
  const { invoice, version, document } = await documentFor(db, organisationId, invoiceId);
330
345
  const problems = [];
331
346
  if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
332
347
  if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
333
- const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
348
+ const { match, name, own } = await accountingContactFor(db, organisationId, invoice, document);
334
349
  const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
335
- if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
336
- if (match.state === 'ambiguous') problems.push(twiceContact(document.contact.name));
350
+ if (match.state === 'missing') problems.push(own ? missingContact(name, { offerCreate }) : payerMissing(name));
351
+ if (match.state === 'ambiguous') problems.push(own ? twiceContact(name) : { ...twiceContact(name), offerLink: false });
337
352
  const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
338
353
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
339
354
  const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
@@ -513,10 +528,10 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
513
528
  }
514
529
  }
515
530
 
516
- const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
531
+ const { match, name, own } = await accountingContactFor(db, organisationId, invoice, document);
517
532
  if (match.state !== 'matched') {
518
533
  const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
519
- const found = match.state === 'missing' ? missingContact(document.contact.name, { offerCreate }) : twiceContact(document.contact.name);
534
+ const found = match.state === 'missing' ? (own ? missingContact(name, { offerCreate }) : payerMissing(name)) : { ...twiceContact(name), offerLink: own };
520
535
  throw xeroError(found.code, found.message, { status: 409, short: found.short });
521
536
  }
522
537
  const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
@@ -1,7 +1,8 @@
1
1
  import { and, eq, inArray, sql } from 'drizzle-orm';
2
+ import { payersOf } from '../../core/contacts.js';
2
3
  import { recordAccountingEvidence, recordPayment } from '../../core/ledger.js';
3
4
  import { billingObligations, contacts as flowContacts } from '../../db/schema.js';
4
- import { matchContact, parseXeroDate } from './catalogue.js';
5
+ import { matchContactFor, parseXeroDate } from './catalogue.js';
5
6
  import { withXeroLane } from './governor.js';
6
7
 
7
8
  // What the billing obligation ledger (server/core/ledger.js) needs from Xero:
@@ -136,9 +137,11 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
136
137
  if (references.length) {
137
138
  let customer = null;
138
139
  if (obligation.contactId) {
140
+ // A customer with a payer is billed under the payer's name (Bill to).
141
+ const payer = (await payersOf(db, organisationId, [obligation.contactId])).get(obligation.contactId);
139
142
  const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
140
- .where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id, obligation.contactId)));
141
- const match = contact ? await matchContact(db, organisationId, contact.name) : { state: 'missing' };
143
+ .where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id, payer)));
144
+ const match = contact ? await matchContactFor(db, organisationId, { contactId: payer, name: contact.name }) : { state: 'missing' };
142
145
  customer = match.state === 'matched' ? match.contact.xeroContactId : null;
143
146
  if (match.state === 'ambiguous') {
144
147
  const error = new Error(`More than one Xero contact is named "${contact.name}"; Flow cannot tell which invoices are this customer's.`);
@@ -231,7 +234,7 @@ export async function recordXeroPayments(db, runtime, organisationId, { actor, s
231
234
  const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
232
235
  where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])
233
236
  union all select xero_invoice_id, invoice_id from xero_invoice_links
234
- where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
237
+ where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[]) and released_at is null`);
235
238
  for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
236
239
  }
237
240
  const flowInvoiceIds = [...new Set(byFlowInvoice.values())];
@@ -1,10 +1,12 @@
1
- import { and, eq, sql } from 'drizzle-orm';
1
+ import { and, eq, isNull, sql } from 'drizzle-orm';
2
2
  import { billingObligations, contacts, invoices, obligationEvents, priceItems } from '../../db/schema.js';
3
+ import { payersOf } from '../../core/contacts.js';
3
4
  import { createContact } from '../../core/directory.js';
4
5
  import { FlowError } from '../../core/errors.js';
5
6
  import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
6
- import { linkJobToInvoice } from '../../core/ledger.js';
7
+ import { linkJobToInvoice, recordAccountingEvidence, releaseAccountingRef } from '../../core/ledger.js';
7
8
  import { normaliseName } from './catalogue.js';
9
+ import { findInvoices } from './lookup.js';
8
10
  import { canFit, markRivals, scoreFit, sureFits } from './fit.js';
9
11
 
10
12
  // The Import page (docs/XERO_IMPORT.md): each sales invoice in Flow's copy of
@@ -54,7 +56,7 @@ async function classify(db, organisationId) {
54
56
  select xero_invoice_id, invoice_id, 'sent' as matched_by from xero_invoice_pushes
55
57
  where organisation_id = ${organisationId} and xero_invoice_id is not null
56
58
  union all
57
- select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId}`);
59
+ select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId} and released_at is null`);
58
60
  const linkOf = new Map();
59
61
  const linkedInvoices = new Set();
60
62
  for (const r of linkRows) {
@@ -63,13 +65,23 @@ async function classify(db, organisationId) {
63
65
  }
64
66
  // Flow's customers by name, as the drafts match them (only a name with one
65
67
  // contact counts).
66
- const people = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
67
- .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
68
+ // A merged contact is gone; the kept one answers to its aliases too.
69
+ const people = await db.select({ id: contacts.id, name: contacts.name, aliases: contacts.aliases }).from(contacts)
70
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer'), isNull(contacts.mergedIntoId)));
68
71
  const byName = new Map();
69
72
  for (const p of people) {
70
- const key = normaliseName(p.name);
71
- byName.set(key, byName.has(key) ? null : p.id);
73
+ for (const name of [p.name, ...(p.aliases ?? [])]) {
74
+ const key = normaliseName(name);
75
+ if (byName.has(key) && byName.get(key) === p.id) continue;
76
+ byName.set(key, byName.has(key) ? null : p.id);
77
+ }
72
78
  }
79
+ // The identity-map link first, so a Xero contact of another name finds its
80
+ // Flow customer, then the name.
81
+ const { rows: contactLinks } = await db.execute(sql`select l.external_id, l.flow_id from external_links l
82
+ join contacts c on c.organisation_id = l.organisation_id and c.id = l.flow_id and c.kind = 'customer'
83
+ where l.organisation_id = ${organisationId} and l.system = 'xero' and l.entity = 'contact' and l.state = 'active'`);
84
+ const byLink = new Map(contactLinks.map((l) => [l.external_id, l.flow_id]));
73
85
  const { rows: flowInvoices } = await db.execute(sql`
74
86
  select i.id, i.number, i.contact_id, i.total_cents, i.subtotal_cents, i.reference, i.status, coalesce(i.issue_date, i.created_at::date) as date,
75
87
  coalesce((select array_agg(distinct p.code) from invoice_lines l join price_items p on p.id = l.price_item_id
@@ -83,6 +95,11 @@ async function classify(db, organisationId) {
83
95
  select o.id, o.contact_id, o.total_cents, o.job_date, o.practitioner, o.case_ref, o.accounting_ref, o.source_invoice_ref, o.lines
84
96
  from billing_obligations o
85
97
  where o.organisation_id = ${organisationId} and o.invoice_id is null`);
98
+ // Bill to (docs/CONTACTS.md): a customer with a payer is billed under the
99
+ // payer's Xero contact, so its jobs and invoices fit that contact's invoices.
100
+ // A Xero row of a client's own Xero contact fits as that client's payer too.
101
+ const payerOf = await payersOf(db, organisationId, people.map((p) => p.id));
102
+ const billedAs = (contactId) => (contactId ? payerOf.get(contactId) ?? contactId : null);
86
103
  const jobByRef = new Map(jobs.filter((j) => j.accounting_ref).map((j) => [j.accounting_ref, j]));
87
104
  // The work system's own invoice number, as Xero numbers it (a trailing R is
88
105
  // a reissue). Only a number one job holds counts.
@@ -95,7 +112,7 @@ async function classify(db, organisationId) {
95
112
  }
96
113
 
97
114
  const rows = xero.map((x) => {
98
- const contactId = x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null;
115
+ const contactId = byLink.get(x.xero_contact_id) ?? (x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null);
99
116
  const row = {
100
117
  xeroInvoiceId: x.xero_invoice_id,
101
118
  number: x.invoice_number,
@@ -117,10 +134,11 @@ async function classify(db, organisationId) {
117
134
  return { ...row, state: 'linked' };
118
135
  }
119
136
  row.itemCodes = [...new Set((x.lines ?? []).map((l) => l.itemCode).filter(Boolean))];
120
- const xeroFit = { id: x.xero_invoice_id, number: x.invoice_number, contactId, totalCents: row.totalCents, issuedOn: row.issuedOn,
137
+ const billedTo = billedAs(contactId);
138
+ const xeroFit = { id: x.xero_invoice_id, number: x.invoice_number, contactId: billedTo, totalCents: row.totalCents, issuedOn: row.issuedOn,
121
139
  itemCodes: (x.lines ?? []).map((l) => l.itemCode).filter(Boolean), reference: x.reference };
122
140
  row.invoices = open.map((i) => {
123
- const flowFit = { id: i.id, contactId: i.contact_id, totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
141
+ const flowFit = { id: i.id, contactId: billedAs(i.contact_id), totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
124
142
  itemCodes: i.item_codes ?? [], reference: i.reference, jobs: i.jobs ?? [] };
125
143
  const scored = scoreFit(xeroFit, flowFit);
126
144
  return canFit(xeroFit, flowFit, scored)
@@ -129,8 +147,8 @@ async function classify(db, organisationId) {
129
147
  : null;
130
148
  }).filter(Boolean);
131
149
  const tied = jobByRef.get(x.xero_invoice_id) ?? (x.invoice_number ? jobByNumber.get(workNumber(x.invoice_number)) : null) ?? null;
132
- const fits = tied ? [tied] : contactId
133
- ? jobs.filter((j) => !j.accounting_ref && j.contact_id === contactId && num(j.total_cents) === row.totalCents && near(day(j.job_date), row.issuedOn))
150
+ const fits = tied ? [tied] : billedTo
151
+ ? jobs.filter((j) => !j.accounting_ref && billedAs(j.contact_id) === billedTo && num(j.total_cents) === row.totalCents && near(day(j.job_date), row.issuedOn))
134
152
  : [];
135
153
  row.jobs = fits.map((j) => ({ id: j.id, jobDate: day(j.job_date), practitioner: j.practitioner, caseRef: j.case_ref, totalCents: num(j.total_cents), tied: j === tied,
136
154
  itemCodes: [...new Set((j.lines ?? []).map((l) => l.itemCode).filter(Boolean))] }));
@@ -208,9 +226,16 @@ export async function saveImportSettings(db, organisationId, { autoLink, makeUnm
208
226
  }
209
227
 
210
228
  async function insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy, actor }) {
229
+ // A released link (releaseXeroLink) gives its row to the new one.
230
+ const { rows: held } = await db.execute(sql`select 1 from xero_invoice_links where organisation_id = ${organisationId}
231
+ and invoice_id = ${invoiceId} and xero_invoice_id <> ${xeroInvoiceId} limit 1`);
232
+ if (held.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
211
233
  const { rows } = await db.execute(sql`insert into xero_invoice_links (organisation_id, xero_invoice_id, invoice_id, matched_by, linked_by)
212
234
  values (${organisationId}, ${xeroInvoiceId}, ${invoiceId}, ${matchedBy}, ${actor})
213
- on conflict do nothing returning xero_invoice_id`);
235
+ on conflict (organisation_id, xero_invoice_id) do update set invoice_id = excluded.invoice_id, matched_by = excluded.matched_by,
236
+ linked_by = excluded.linked_by, created_at = now(), released_by = null, released_at = null, deleted_by = null, deleted_at = null
237
+ where xero_invoice_links.released_at is not null
238
+ returning xero_invoice_id`);
214
239
  if (!rows.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
215
240
  }
216
241
 
@@ -251,7 +276,8 @@ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoic
251
276
  if (!invoice) throw new FlowError('not_found', 'Flow invoice not found');
252
277
  // A copy voided earlier, still holding its job, gives way the same way.
253
278
  if (!['issued', 'void'].includes(invoice.status)) throw new FlowError('invalid', 'only an approved or void Flow invoice can give way to a Xero invoice');
254
- if (row.contactId !== invoice.contact_id) throw new FlowError('invalid', 'the Xero invoice is for another client');
279
+ const payers = await payersOf(tx, organisationId, [invoice.contact_id, row.contactId]);
280
+ if (!row.contactId || payers.get(row.contactId) !== payers.get(invoice.contact_id)) throw new FlowError('invalid', 'the Xero invoice is for another client');
255
281
  const { rows: pushed } = await tx.execute(sql`select 1 from xero_invoice_pushes where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id is not null limit 1`);
256
282
  if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
257
283
  const jobs = await tx.select({ id: billingObligations.id }).from(billingObligations)
@@ -267,7 +293,8 @@ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoic
267
293
  evidence: { invoiceId, reason: 'used_xero_invoice', xeroInvoiceId, xeroNumber: row.number ?? null },
268
294
  });
269
295
  }
270
- const made = await makeFrom(tx, organisationId, row, { jobId: jobs[0]?.id ?? null, actor });
296
+ // The new Flow invoice stays with the client of the one it replaces.
297
+ const made = await makeFrom(tx, organisationId, row, { jobId: jobs[0]?.id ?? null, contactId: invoice.contact_id, actor });
271
298
  return { voided: { id: invoiceId, number: invoice.number }, invoice: { id: made.invoice.id, number: made.invoice.number }, xeroInvoiceId, jobId: jobs[0]?.id ?? null };
272
299
  });
273
300
  }
@@ -281,14 +308,21 @@ export async function makeFlowInvoice(db, organisationId, xeroInvoiceId, { jobId
281
308
  return makeFrom(db, organisationId, await rowFor(db, organisationId, xeroInvoiceId), { jobId, actor });
282
309
  }
283
310
 
284
- async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
311
+ async function makeFrom(db, organisationId, row, { jobId = null, contactId: chosen = null, actor }) {
285
312
  const { xeroInvoiceId } = row;
286
313
  const { rows: [x] } = await db.execute(sql`select x.lines, x.due_on, c.email from xero_invoices x
287
314
  left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
288
315
  where x.organisation_id = ${organisationId} and x.xero_invoice_id = ${xeroInvoiceId}`);
289
316
  if (!row.clientName) throw new FlowError('invalid', 'the Xero invoice has no contact in the copy; sync contacts first');
290
317
  return db.transaction(async (tx) => {
291
- const contactId = row.contactId ?? (await createContact(tx, organisationId, { kind: 'customer', name: row.clientName, email: x.email ?? null })).id;
318
+ // The job's own customer when it bills to this payer (Bill to), so the
319
+ // Flow invoice stays with the client the work was for.
320
+ const { rows: [job] } = jobId ? await tx.execute(sql`select contact_id from billing_obligations
321
+ where organisation_id = ${organisationId} and id = ${jobId}`) : { rows: [] };
322
+ const payers = await payersOf(tx, organisationId, [job?.contact_id, row.contactId]);
323
+ const samePayer = job?.contact_id && row.contactId && payers.get(job.contact_id) === payers.get(row.contactId);
324
+ const contactId = chosen ?? (samePayer ? job.contact_id : null)
325
+ ?? row.contactId ?? (await createContact(tx, organisationId, { kind: 'customer', name: row.clientName, email: x.email ?? null })).id;
292
326
  const items = await tx.select({ id: priceItems.id, code: priceItems.code, name: priceItems.name }).from(priceItems)
293
327
  .where(eq(priceItems.organisationId, organisationId));
294
328
  const byCode = new Map(items.map((i) => [i.code.toUpperCase(), i]));
@@ -308,7 +342,16 @@ async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
308
342
  const draft = await createDraftInvoice(tx, organisationId, { contactId, reference: row.reference ?? null, dueDate: day(x.due_on), lines });
309
343
  const invoice = await issueInvoice(tx, organisationId, draft.id, { issuedBy: actor, issueDate: row.issuedOn ?? new Date().toISOString().slice(0, 10) });
310
344
  await insertLink(tx, organisationId, { xeroInvoiceId, invoiceId: invoice.id, matchedBy: 'made', actor });
311
- if (jobId) await linkJobToInvoice(tx, organisationId, jobId, { invoiceId: invoice.id, accountingRef: xeroInvoiceId, actor });
345
+ if (jobId) {
346
+ await linkJobToInvoice(tx, organisationId, jobId, { invoiceId: invoice.id, accountingRef: xeroInvoiceId, actor });
347
+ // The job's accounting state in the same step, from the copy the
348
+ // invoice was made from: the ledger says billed or paid at once, not
349
+ // after the matcher's next pass.
350
+ const { invoices: [copy] } = await findInvoices(tx, organisationId, { invoiceIds: [xeroInvoiceId] });
351
+ if (copy?.state) {
352
+ await recordAccountingEvidence(tx, organisationId, jobId, { state: copy.state, accountingRef: xeroInvoiceId, matchedBy: 'accounting_ref', actor });
353
+ }
354
+ }
312
355
  return { xeroInvoiceId, invoice };
313
356
  });
314
357
  }
@@ -329,3 +372,27 @@ export async function matchXero(db, organisationId, { actor = 'automation:xero-i
329
372
  }
330
373
  return result;
331
374
  }
375
+
376
+ // A person releases a wrong pair: the Xero invoice is linked to a Flow invoice
377
+ // that was voided, and is another job's bill. The link stays as a record
378
+ // (released_by, released_at) and the Xero invoice can link again. Each job
379
+ // that points at the Xero invoice and no longer holds a Flow invoice lets go
380
+ // of it, and the matcher never pairs them again. A live Flow invoice keeps
381
+ // its link: void it first.
382
+ export async function releaseXeroLink(db, organisationId, xeroInvoiceId, { actor, reason }) {
383
+ if (!actor) throw new FlowError('invalid', 'actor is required');
384
+ if (!reason?.trim()) throw new FlowError('invalid', 'say why the link is released');
385
+ return db.transaction(async (tx) => {
386
+ const { rows: [link] } = await tx.execute(sql`select l.invoice_id, i.number, i.status from xero_invoice_links l
387
+ join invoices i on i.organisation_id = l.organisation_id and i.id = l.invoice_id
388
+ where l.organisation_id = ${organisationId} and l.xero_invoice_id = ${xeroInvoiceId} and l.released_at is null for update of l`);
389
+ if (!link) throw new FlowError('not_found', 'the Xero invoice has no link to release');
390
+ if (link.status !== 'void') throw new FlowError('invalid', `Flow invoice ${link.number ?? link.invoice_id} is ${link.status}: void it first`);
391
+ await tx.execute(sql`update xero_invoice_links set released_by = ${actor}, released_at = now()
392
+ where organisation_id = ${organisationId} and xero_invoice_id = ${xeroInvoiceId}`);
393
+ const { rows: jobs } = await tx.execute(sql`select id from billing_obligations
394
+ where organisation_id = ${organisationId} and accounting_ref = ${xeroInvoiceId} and invoice_id is null`);
395
+ for (const job of jobs) await releaseAccountingRef(tx, organisationId, job.id, { accountingRef: xeroInvoiceId, system: 'xero', actor, reason });
396
+ return { xeroInvoiceId, invoice: { id: link.invoice_id, number: link.number }, jobs: jobs.map((j) => j.id) };
397
+ });
398
+ }
@@ -15,8 +15,10 @@ export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
15
15
  export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
16
16
 
17
17
  // `contact` is the Flow contact a new client becomes (docs/CONTACTS.md,
18
- // "Add Xero clients"). A contact Xero marks a supplier only still links by
19
- // name, but never becomes a Flow customer (`adoptable` false).
18
+ // "Add Xero clients"). Only a contact Xero marks a customer (it has a sales
19
+ // invoice there) becomes one (`adoptable`). The others (suppliers, staff,
20
+ // names from bank lines) still link by name. `customer` is Xero's mark: the
21
+ // core hides a Flow contact made from one that has none ("Hidden contacts").
20
22
  export async function listCustomers(db, organisationId) {
21
23
  const { rows } = await db.execute(sql`
22
24
  select xero_contact_id, name, first_name, last_name, email, phone, mobile, account_number, tax_number, address,
@@ -27,7 +29,8 @@ export async function listCustomers(db, organisationId) {
27
29
  return rows.map((r) => ({
28
30
  id: r.xero_contact_id,
29
31
  name: r.name,
30
- adoptable: Boolean(r.is_customer || !r.is_supplier),
32
+ adoptable: Boolean(r.is_customer),
33
+ customer: Boolean(r.is_customer),
31
34
  contact: {
32
35
  kind: 'customer', name: r.name, firstName: r.first_name, lastName: r.last_name, email: r.email, phone: r.phone,
33
36
  mobile: r.mobile, accountNumber: r.account_number, taxNumber: r.tax_number, address: r.address,
@@ -135,7 +138,7 @@ export async function findInvoices(db, organisationId, query = {}) {
135
138
  where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
136
139
  union all
137
140
  select invoice_id, 1, 0 from xero_invoice_links
138
- where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
141
+ where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id and released_at is null
139
142
  ) f order by pick, version desc limit 1
140
143
  ) p on true
141
144
  where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
@@ -0,0 +1,7 @@
1
+ -- A person releases a Xero invoice's link to a Flow invoice that was voided
2
+ -- (links.js, releaseXeroLink): the pair was wrong, and the Xero invoice is
3
+ -- free to link again. The row stays, with who and when; a new link takes its
4
+ -- place. Nothing is deleted.
5
+ ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "released_by" text;
6
+ --> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "released_at" timestamp with time zone;
@@ -0,0 +1,11 @@
1
+ -- The version of the copy each resource was read with: the mapper's version
2
+ -- (mirror.js COPY_VERSION) and whether free text is kept. When it changes,
3
+ -- the next read starts again from the beginning, so old rows get a new field.
4
+ -- Null: read before Flow noted it; the first read notes it without starting
5
+ -- again (kept_reference still starts one when the setting differs).
6
+ ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "copy_version" text;
7
+ --> statement-breakpoint
8
+ -- Who asked for a full read again, and when (POST /api/xero/mirror/reread).
9
+ ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "reread_by" text;
10
+ --> statement-breakpoint
11
+ ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "reread_at" timestamp with time zone;
@@ -106,6 +106,20 @@
106
106
  "when": 1792570000000,
107
107
  "tag": "0014_xero_mirror_reference",
108
108
  "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "7",
113
+ "when": 1792580000000,
114
+ "tag": "0015_xero_link_release",
115
+ "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1792590000000,
121
+ "tag": "0016_xero_copy_version",
122
+ "breakpoints": true
109
123
  }
110
124
  ]
111
125
  }