@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
@@ -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,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
  }
@@ -23,6 +23,12 @@ import { withXeroLane } from './governor.js';
23
23
 
24
24
  export const MIRROR_RESOURCES = Object.freeze(['invoices', 'credit_notes', 'payments']);
25
25
 
26
+ // The mapper's version. Raise it when a mapper copies a new field: each
27
+ // resource is then read again from the beginning, so old rows get the field
28
+ // (docs/XERO.md, "Copy version").
29
+ export const COPY_VERSION = 1;
30
+ const copyVersion = (keepReference) => `${COPY_VERSION}:${keepReference ? 'text' : 'no_text'}`;
31
+
26
32
  // A Xero amount (a JSON number with up to 4 decimals) to whole cents, half away
27
33
  // from zero, without float drift.
28
34
  export function toCents(amount) {
@@ -202,13 +208,18 @@ export async function storeRows(tx, organisationId, resource, rows, counts) {
202
208
  // would keep the free text they were read with. The rows stay; each is
203
209
  // written again only if it differs.
204
210
  async function readCursor(db, organisationId, resource, { keepReference }) {
205
- await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource, kept_reference)
206
- values (${organisationId}, ${resource}, ${keepReference}) on conflict do nothing`);
207
- await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, kept_reference = ${keepReference}
208
- where organisation_id = ${organisationId} and resource = ${resource} and kept_reference is distinct from ${keepReference}`);
211
+ const version = copyVersion(keepReference);
212
+ await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource, kept_reference, copy_version)
213
+ values (${organisationId}, ${resource}, ${keepReference}, ${version}) on conflict do nothing`);
214
+ await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, kept_reference = ${keepReference}, copy_version = ${version}
215
+ where organisation_id = ${organisationId} and resource = ${resource}
216
+ and (kept_reference is distinct from ${keepReference} or (copy_version is not null and copy_version <> ${version}))`);
217
+ // A copy read before Flow noted its version takes the version as it is.
218
+ await db.execute(sql`update xero_mirror_cursors set copy_version = ${version}
219
+ where organisation_id = ${organisationId} and resource = ${resource} and copy_version is null`);
209
220
  const { rows: [row] } = await db.execute(sql`select * from xero_mirror_cursors
210
221
  where organisation_id = ${organisationId} and resource = ${resource}`);
211
- return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page };
222
+ return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page, rereadAt: row.reread_at ?? null };
212
223
  }
213
224
 
214
225
  // Where the next page starts, from the page just read. A full page moves the
@@ -255,18 +266,29 @@ export async function mirrorResource(db, runtime, organisationId, resource, { ma
255
266
  }
256
267
  const rows = mapRecords(resource, raw, settings);
257
268
  const next = nextPosition(position, rows, pageSize);
258
- await db.transaction(async (tx) => {
269
+ const asked = await db.transaction(async (tx) => {
259
270
  await storeRows(tx, organisationId, resource, rows, counts);
271
+ // An owner asked for a read again while this pass ran: keep the
272
+ // cursor at the start they set, and stop.
273
+ const { rows: [cursor] } = await tx.execute(sql`select reread_at from xero_mirror_cursors
274
+ where organisation_id = ${organisationId} and resource = ${resource} for update`);
275
+ if (String(cursor?.reread_at ?? '') !== String(position.rereadAt ?? '')) {
276
+ await tx.execute(sql`update xero_mirror_cursors set rows_read = rows_read + ${raw.length}, last_read_at = now(), last_error = null
277
+ where organisation_id = ${organisationId} and resource = ${resource}`);
278
+ return true;
279
+ }
260
280
  await tx.execute(sql`update xero_mirror_cursors set
261
281
  modified_since = ${next.modifiedSince}, page = ${next.page}, rows_read = rows_read + ${raw.length},
262
282
  last_read_at = now(), last_error = null,
263
283
  passes = passes + ${next.done ? 1 : 0},
264
284
  last_pass_finished_at = case when ${next.done} then now() else last_pass_finished_at end
265
285
  where organisation_id = ${organisationId} and resource = ${resource}`);
286
+ return false;
266
287
  });
267
288
  counts.pages += 1;
268
289
  counts.read += raw.length;
269
- position = next;
290
+ if (asked) return { resource, ...counts, stopped: 'reread' };
291
+ position = { ...next, rereadAt: position.rereadAt };
270
292
  if (next.done) return { resource, ...counts, stopped: 'done' };
271
293
  }
272
294
  });
@@ -311,10 +333,39 @@ export async function mirrorXero(db, runtime, organisationId, { resources = MIRR
311
333
  });
312
334
  }
313
335
 
336
+ // A person asks for the whole copy to be read again (an owner only): the next
337
+ // read of each resource starts from the beginning. The rows stay; each is
338
+ // written again only if it differs. No call is made here.
339
+ export async function rereadCopy(db, organisationId, { resources = MIRROR_RESOURCES, actor }) {
340
+ if (!actor) throw new TypeError('actor is required');
341
+ for (const resource of resources) {
342
+ await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource) values (${organisationId}, ${resource}) on conflict do nothing`);
343
+ await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, reread_by = ${actor}, reread_at = now()
344
+ where organisation_id = ${organisationId} and resource = ${resource}`);
345
+ }
346
+ return mirrorStatus(db, organisationId);
347
+ }
348
+
349
+ // For each of the last 12 months, the sales invoices in the copy and how many
350
+ // have a Reference and line item codes: the fields the matcher reads. A low
351
+ // month is a copy that missed them (docs/XERO.md, "Copy version").
352
+ async function completeness(db, organisationId) {
353
+ const { rows } = await db.execute(sql`select to_char(date_trunc('month', issued_on), 'YYYY-MM') as month,
354
+ count(*)::int as invoices,
355
+ count(*) filter (where coalesce(trim(reference), '') <> '')::int as with_reference,
356
+ count(*) filter (where jsonb_typeof(lines) = 'array'
357
+ and exists (select 1 from jsonb_array_elements(lines) l where coalesce(l->>'itemCode', '') <> ''))::int as with_item_codes
358
+ from xero_invoices
359
+ where organisation_id = ${organisationId} and type = 'ACCREC' and status not in ('DELETED', 'VOIDED')
360
+ and issued_on >= date_trunc('month', now()) - interval '11 months'
361
+ group by 1 order by 1`);
362
+ return rows.map((r) => ({ month: r.month, invoices: r.invoices, withReference: r.with_reference, withItemCodes: r.with_item_codes }));
363
+ }
364
+
314
365
  // What the copy holds, and where each cursor stands. No Xero calls.
315
366
  export async function mirrorStatus(db, organisationId) {
316
367
  const { rows: cursors } = await db.execute(sql`select resource, modified_since, page, passes, rows_read,
317
- last_read_at, last_pass_finished_at, last_error from xero_mirror_cursors where organisation_id = ${organisationId}
368
+ last_read_at, last_pass_finished_at, last_error, copy_version, kept_reference, reread_by, reread_at from xero_mirror_cursors where organisation_id = ${organisationId}
318
369
  order by resource`);
319
370
  const { rows: [counts] } = await db.execute(sql`select
320
371
  (select count(*)::int from xero_invoices where organisation_id = ${organisationId}) as invoices,
@@ -331,6 +382,10 @@ export async function mirrorStatus(db, organisationId) {
331
382
  lastReadAt: c.last_read_at,
332
383
  lastPassFinishedAt: c.last_pass_finished_at,
333
384
  lastError: c.last_error,
385
+ copyVersion: c.copy_version ?? null,
386
+ keptReference: c.kept_reference ?? null,
387
+ rereadAt: c.reread_at ?? null,
334
388
  })),
389
+ completeness: await completeness(db, organisationId),
335
390
  };
336
391
  }