Просмотр исходного кода

Use a Xero contact found by email only when its name matches too (#573)

* Use a Xero contact found by email only when its name matches too

Several customers can share an email address (a family, a company's
accounts inbox), so a Xero contact found by email is now used only when its
name agrees with the customer: compared without case, accents, punctuation
or company forms such as Pty Ltd, against the contact's name and its first
and last name. Otherwise the customer gets a contact of its own and the log
names the contact that shares the address. A new setting, "Customers with
the same email", keeps the old behaviour for workshops that trust email
alone.

Customers an earlier version linked to another customer's contact this way
are checked once on their next send and moved to a contact of their own, so
their unpaid invoices follow.

* Compare people by their whole name, and recheck links whose match record was lost

A person's name must now be the same on both sides, so John Smith and John
Smith Jr stay apart; a business may still carry a company form or a few more
words. A company form only comes off the end of a business name, and
apostrophes are ignored. A contact update keeps how its link was made, links
without that record are checked too, and a customer edited here since it was
linked keeps its link, as the new name may be a rename, with a note in the log.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
454ba362a9

+ 4 - 0
messages/de/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "Die Rechnung, die der Kunde von Torqvoice erhalten hat, wird an die Rechnung in Xero angehängt.",
         "updateContacts": "Kundendaten in Xero aktuell halten",
         "updateContactsHelp": "Eine Änderung an Name, E-Mail, Telefon, Adresse oder Steuernummer eines Kunden hier wird in seinen Kontakt in Xero übernommen. Ausgeschaltet werden Kontakte nur angelegt und danach so gelassen, wie der Buchhalter sie führt.",
+        "sameEmail": "Kunden mit derselben E-Mail",
+        "sameEmailHelp": "Mehrere Kunden können sich eine E-Mail-Adresse teilen, etwa eine Familie oder das Buchhaltungspostfach einer Firma. Bei der ersten Wahl wird ein per E-Mail gefundener Kontakt in Xero nur verwendet, wenn auch sein Name zum Kunden passt; sonst bekommt der Kunde einen neuen Kontakt, und das Protokoll nennt den anderen, damit du beide in Xero zusammenführen kannst, falls es derselbe Kunde ist.",
+        "sameEmailCreate": "Neuen Kontakt anlegen, wenn der Name abweicht",
+        "sameEmailReuse": "Vorhandenen Kontakt verwenden, auch wenn der Name abweicht",
         "voidOnDelete": "Xero-Rechnung stornieren, wenn der Auftrag hier gelöscht wird",
         "voidOnDeleteHelp": "Eine Rechnung mit Zahlungen in Xero wird nie storniert; das Protokoll vermerkt, dass sie unverändert blieb.",
         "pushPayments": "Zahlungen in Xero erfassen",

+ 4 - 0
messages/en/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "The invoice the customer received from Torqvoice is attached to the invoice in Xero.",
         "updateContacts": "Keep customer details up to date in Xero",
         "updateContactsHelp": "A change to a customer's name, email, phone, address or tax number here is copied to their contact in Xero. Off, contacts are only created and then left as the bookkeeper has them.",
+        "sameEmail": "Customers with the same email",
+        "sameEmailHelp": "Several customers can share an email address, such as a family or a company's accounts inbox. With the first choice, a Xero contact found by email is used only when its name matches the customer too; otherwise the customer gets a new contact, and the log names the other one so they can be merged in Xero if they are the same customer.",
+        "sameEmailCreate": "Create a new contact when the name is different",
+        "sameEmailReuse": "Use the existing contact, even if the name is different",
         "voidOnDelete": "Void the Xero invoice when the job is deleted here",
         "voidOnDeleteHelp": "An invoice with payments in Xero is never voided; the log says it was left alone.",
         "pushPayments": "Record payments in Xero",

+ 4 - 0
messages/es/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "La factura que el cliente recibió de Torqvoice se adjunta a la factura en Xero.",
         "updateContacts": "Mantener al día los datos de los clientes en Xero",
         "updateContactsHelp": "Un cambio aquí en el nombre, correo, teléfono, dirección o número fiscal de un cliente se copia a su contacto en Xero. Desactivado, los contactos solo se crean y luego se dejan como los tenga el contable.",
+        "sameEmail": "Clientes con el mismo correo",
+        "sameEmailHelp": "Varios clientes pueden compartir un correo, como una familia o el buzón de contabilidad de una empresa. Con la primera opción, un contacto de Xero encontrado por correo solo se usa si su nombre también coincide con el cliente; si no, el cliente recibe un contacto nuevo y el registro indica el otro, para que puedas combinarlos en Xero si son el mismo cliente.",
+        "sameEmailCreate": "Crear un contacto nuevo si el nombre es distinto",
+        "sameEmailReuse": "Usar el contacto existente aunque el nombre sea distinto",
         "voidOnDelete": "Anular la factura de Xero cuando el trabajo se elimine aquí",
         "voidOnDeleteHelp": "Una factura con pagos en Xero nunca se anula; el registro indica que se dejó como estaba.",
         "pushPayments": "Registrar pagos en Xero",

+ 4 - 0
messages/fr/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "La facture reçue par le client depuis Torqvoice est jointe à la facture dans Xero.",
         "updateContacts": "Garder à jour les coordonnées des clients dans Xero",
         "updateContactsHelp": "Une modification ici du nom, de l'e-mail, du téléphone, de l'adresse ou du numéro fiscal d'un client est copiée sur son contact dans Xero. Désactivé, les contacts sont seulement créés, puis laissés tels que le comptable les tient.",
+        "sameEmail": "Clients avec le même e-mail",
+        "sameEmailHelp": "Plusieurs clients peuvent partager une adresse e-mail, comme une famille ou la boîte comptable d'une entreprise. Avec le premier choix, un contact Xero trouvé par e-mail n'est utilisé que si son nom correspond aussi au client ; sinon, le client reçoit un nouveau contact et le journal indique l'autre, pour que vous puissiez les fusionner dans Xero s'il s'agit du même client.",
+        "sameEmailCreate": "Créer un nouveau contact si le nom est différent",
+        "sameEmailReuse": "Utiliser le contact existant, même si le nom est différent",
         "voidOnDelete": "Annuler la facture Xero quand le travail est supprimé ici",
         "voidOnDeleteHelp": "Une facture avec des paiements dans Xero n'est jamais annulée ; le journal indique qu'elle a été laissée telle quelle.",
         "pushPayments": "Enregistrer les paiements dans Xero",

+ 4 - 0
messages/it/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "La fattura che il cliente ha ricevuto da Torqvoice viene allegata alla fattura in Xero.",
         "updateContacts": "Mantieni aggiornati i dati dei clienti in Xero",
         "updateContactsHelp": "Una modifica qui a nome, email, telefono, indirizzo o partita IVA di un cliente viene copiata nel suo contatto in Xero. Disattivato, i contatti vengono solo creati e poi lasciati come li tiene il contabile.",
+        "sameEmail": "Clienti con la stessa email",
+        "sameEmailHelp": "Più clienti possono condividere un indirizzo email, come una famiglia o la casella amministrativa di un'azienda. Con la prima scelta, un contatto Xero trovato tramite email viene usato solo se anche il suo nome corrisponde al cliente; altrimenti il cliente riceve un nuovo contatto e il registro indica l'altro, così puoi unirli in Xero se sono lo stesso cliente.",
+        "sameEmailCreate": "Crea un nuovo contatto se il nome è diverso",
+        "sameEmailReuse": "Usa il contatto esistente anche se il nome è diverso",
         "voidOnDelete": "Annulla la fattura Xero quando il lavoro viene eliminato qui",
         "voidOnDeleteHelp": "Una fattura con pagamenti in Xero non viene mai annullata; il registro indica che è stata lasciata invariata.",
         "pushPayments": "Registra i pagamenti in Xero",

+ 4 - 0
messages/lt/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "Kliento iš Torqvoice gauta sąskaita pridedama prie sąskaitos Xero sistemoje.",
         "updateContacts": "Atnaujinti klientų duomenis Xero sistemoje",
         "updateContactsHelp": "Čia pakeisti kliento vardas, el. paštas, telefonas, adresas ar PVM kodas nukopijuojami į jo kontaktą Xero sistemoje. Išjungus kontaktai tik sukuriami ir paliekami tokie, kokius juos tvarko buhalteris.",
+        "sameEmail": "Klientai su tuo pačiu el. paštu",
+        "sameEmailHelp": "Keli klientai gali naudoti tą patį el. pašto adresą, pavyzdžiui, šeima ar įmonės buhalterijos pašto dėžutė. Pasirinkus pirmą variantą, pagal el. paštą rastas Xero kontaktas naudojamas tik tada, kai ir jo vardas sutampa su kliento; kitu atveju klientui sukuriamas naujas kontaktas, o žurnale nurodomas kitas, kad galėtumėte juos sujungti Xero sistemoje, jei tai tas pats klientas.",
+        "sameEmailCreate": "Sukurti naują kontaktą, jei vardas skiriasi",
+        "sameEmailReuse": "Naudoti esamą kontaktą, net jei vardas skiriasi",
         "voidOnDelete": "Anuliuoti Xero sąskaitą, kai darbas čia ištrinamas",
         "voidOnDeleteHelp": "Sąskaita, kuriai Xero sistemoje yra mokėjimų, niekada neanuliuojama; žurnale nurodoma, kad ji palikta nepakeista.",
         "pushPayments": "Registruoti mokėjimus Xero",

+ 4 - 0
messages/nb/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "Fakturaen kunden fikk fra Torqvoice, legges ved fakturaen i Xero.",
         "updateContacts": "Hold kundeopplysningene oppdatert i Xero",
         "updateContactsHelp": "Endrer du navn, e-post, telefon, adresse eller organisasjonsnummer på en kunde her, kopieres det til kontakten i Xero. Avslått blir kontakter bare opprettet, og deretter står de slik regnskapsføreren har dem.",
+        "sameEmail": "Kunder med samme e-post",
+        "sameEmailHelp": "Flere kunder kan dele en e-postadresse, for eksempel en familie eller regnskapsadressen til et firma. Med det første valget brukes en kontakt i Xero funnet via e-post bare når også navnet stemmer med kunden; ellers får kunden en ny kontakt, og loggen nevner den andre, så du kan slå dem sammen i Xero hvis det er samme kunde.",
+        "sameEmailCreate": "Opprett ny kontakt når navnet er forskjellig",
+        "sameEmailReuse": "Bruk den eksisterende kontakten selv om navnet er forskjellig",
         "voidOnDelete": "Annuller Xero-fakturaen når jobben slettes her",
         "voidOnDeleteHelp": "En faktura med betalinger i Xero annulleres aldri; loggen viser at den ble stående urørt.",
         "pushPayments": "Registrer betalinger i Xero",

+ 4 - 0
messages/nl/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "De factuur die de klant van Torqvoice ontving, wordt bij de factuur in Xero gevoegd.",
         "updateContacts": "Klantgegevens in Xero actueel houden",
         "updateContactsHelp": "Wijzigingen die je hier aanbrengt in de naam, het e-mailadres, het telefoonnummer, het adres of het btw-nummer van een klant, worden naar het contact in Xero gekopieerd. Uit worden contacten alleen aangemaakt en daarna gelaten zoals de boekhouder ze heeft.",
+        "sameEmail": "Klanten met hetzelfde e-mailadres",
+        "sameEmailHelp": "Meerdere klanten kunnen een e-mailadres delen, zoals een gezin of de administratiemailbox van een bedrijf. Bij de eerste keuze wordt een contact in Xero dat op e-mail is gevonden alleen gebruikt als ook de naam bij de klant past; anders krijgt de klant een nieuw contact en noemt het logboek het andere, zodat je ze in Xero kunt samenvoegen als het dezelfde klant is.",
+        "sameEmailCreate": "Nieuw contact aanmaken als de naam afwijkt",
+        "sameEmailReuse": "Bestaand contact gebruiken, ook als de naam afwijkt",
         "voidOnDelete": "De Xero-factuur ongeldig maken als de klus hier wordt verwijderd",
         "voidOnDeleteHelp": "Een factuur met betalingen in Xero wordt nooit ongeldig gemaakt; het logboek meldt dat hij ongemoeid is gelaten.",
         "pushPayments": "Betalingen in Xero registreren",

+ 4 - 0
messages/pl/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "Faktura, którą klient otrzymał z Torqvoice, zostaje dołączona do faktury w Xero.",
         "updateContacts": "Aktualizuj dane klientów w Xero",
         "updateContactsHelp": "Zmiana nazwy, e-maila, telefonu, adresu lub numeru NIP klienta wprowadzona tutaj jest kopiowana do jego kontaktu w Xero. Wyłączone: kontakty są tylko tworzone, a potem zostają takie, jakie prowadzi księgowy.",
+        "sameEmail": "Klienci z tym samym e-mailem",
+        "sameEmailHelp": "Kilku klientów może mieć ten sam adres e-mail, na przykład rodzina albo skrzynka księgowości firmy. Przy pierwszym wyborze kontakt w Xero znaleziony po e-mailu jest używany tylko wtedy, gdy także jego nazwa pasuje do klienta; w przeciwnym razie klient dostaje nowy kontakt, a dziennik wskazuje ten drugi, żebyś mógł je scalić w Xero, jeśli to ten sam klient.",
+        "sameEmailCreate": "Utwórz nowy kontakt, gdy nazwa się różni",
+        "sameEmailReuse": "Użyj istniejącego kontaktu, nawet gdy nazwa się różni",
         "voidOnDelete": "Unieważniaj fakturę w Xero, gdy zlecenie zostanie tu usunięte",
         "voidOnDeleteHelp": "Faktura z płatnościami w Xero nigdy nie jest unieważniana; dziennik informuje, że została pominięta.",
         "pushPayments": "Rejestruj płatności w Xero",

+ 4 - 0
messages/pt-BR/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "A fatura que o cliente recebeu do Torqvoice é anexada à fatura no Xero.",
         "updateContacts": "Manter os dados dos clientes atualizados no Xero",
         "updateContactsHelp": "Uma mudança no nome, e-mail, telefone, endereço ou identificação fiscal de um cliente aqui é copiada para o contato dele no Xero. Desligado, os contatos só são criados e depois ficam como o contador os deixou.",
+        "sameEmail": "Clientes com o mesmo e-mail",
+        "sameEmailHelp": "Vários clientes podem compartilhar um endereço de e-mail, como uma família ou a caixa de contabilidade de uma empresa. Com a primeira opção, um contato do Xero encontrado pelo e-mail só é usado quando o nome também corresponde ao cliente; caso contrário, o cliente recebe um contato novo e o registro informa o outro, para que você possa mesclá-los no Xero se forem o mesmo cliente.",
+        "sameEmailCreate": "Criar um contato novo quando o nome for diferente",
+        "sameEmailReuse": "Usar o contato existente, mesmo com nome diferente",
         "voidOnDelete": "Anular a fatura no Xero quando o serviço for excluído aqui",
         "voidOnDeleteHelp": "Uma fatura com pagamentos no Xero nunca é anulada; o registro informa que ela foi deixada como estava.",
         "pushPayments": "Registrar pagamentos no Xero",

+ 4 - 0
messages/pt-PT/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "A fatura que o cliente recebeu do Torqvoice é anexada à fatura no Xero.",
         "updateContacts": "Manter os dados dos clientes atualizados no Xero",
         "updateContactsHelp": "Uma alteração ao nome, e-mail, telefone, morada ou número de contribuinte de um cliente aqui é copiada para o respetivo contacto no Xero. Se estiver desativado, os contactos só são criados e depois ficam como o contabilista os deixou.",
+        "sameEmail": "Clientes com o mesmo e-mail",
+        "sameEmailHelp": "Vários clientes podem partilhar um endereço de e-mail, como uma família ou a caixa de contabilidade de uma empresa. Com a primeira opção, um contacto do Xero encontrado pelo e-mail só é usado quando o nome também corresponde ao cliente; caso contrário, o cliente recebe um contacto novo e o registo indica o outro, para que os possa juntar no Xero se forem o mesmo cliente.",
+        "sameEmailCreate": "Criar um contacto novo quando o nome for diferente",
+        "sameEmailReuse": "Usar o contacto existente, mesmo com nome diferente",
         "voidOnDelete": "Anular a fatura no Xero quando o serviço for eliminado aqui",
         "voidOnDeleteHelp": "Uma fatura com pagamentos no Xero nunca é anulada; o registo indica que foi deixada como estava.",
         "pushPayments": "Registar pagamentos no Xero",

+ 4 - 0
messages/ru/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "Счёт, который клиент получил от Torqvoice, прикрепляется к счёту в Xero.",
         "updateContacts": "Обновлять данные клиентов в Xero",
         "updateContactsHelp": "Изменение имени, электронной почты, телефона, адреса или ИНН клиента здесь копируется в его контакт в Xero. Выключено: контакты только создаются, а затем остаются такими, какими их ведёт бухгалтер.",
+        "sameEmail": "Клиенты с одинаковым e-mail",
+        "sameEmailHelp": "Несколько клиентов могут использовать один адрес электронной почты, например семья или бухгалтерский ящик компании. При первом варианте контакт в Xero, найденный по почте, используется, только если его имя тоже совпадает с клиентом; иначе клиенту создаётся новый контакт, а журнал называет другой, чтобы вы могли объединить их в Xero, если это один и тот же клиент.",
+        "sameEmailCreate": "Создавать новый контакт, если имя отличается",
+        "sameEmailReuse": "Использовать существующий контакт, даже если имя отличается",
         "voidOnDelete": "Аннулировать счёт в Xero, когда заказ удалён здесь",
         "voidOnDeleteHelp": "Счёт с платежами в Xero никогда не аннулируется; журнал сообщает, что его оставили без изменений.",
         "pushPayments": "Записывать платежи в Xero",

+ 4 - 0
messages/tr/integrations.json

@@ -816,6 +816,10 @@
         "attachPdfHelp": "Müşterinin Torqvoice'tan aldığı fatura, Xero'daki faturaya eklenir.",
         "updateContacts": "Müşteri bilgilerini Xero'da güncel tut",
         "updateContactsHelp": "Burada bir müşterinin adında, e-postasında, telefonunda, adresinde veya vergi numarasında yapılan değişiklik Xero'daki kişisine kopyalanır. Kapalıysa kişiler yalnızca oluşturulur, sonra muhasebecinin bıraktığı gibi kalır.",
+        "sameEmail": "Aynı e-postaya sahip müşteriler",
+        "sameEmailHelp": "Birden fazla müşteri aynı e-posta adresini paylaşabilir, örneğin bir aile veya bir şirketin muhasebe kutusu. İlk seçenekte e-postayla bulunan bir Xero kişisi yalnızca adı da müşteriyle eşleştiğinde kullanılır; aksi halde müşteri için yeni bir kişi oluşturulur ve günlük diğerini belirtir, böylece aynı müşteriyse ikisini Xero'da birleştirebilirsiniz.",
+        "sameEmailCreate": "Ad farklıysa yeni kişi oluştur",
+        "sameEmailReuse": "Ad farklı olsa da mevcut kişiyi kullan",
         "voidOnDelete": "İş burada silindiğinde Xero faturasını iptal et",
         "voidOnDeleteHelp": "Xero'da ödemesi olan bir fatura asla iptal edilmez; günlük, faturaya dokunulmadığını bildirir.",
         "pushPayments": "Ödemeleri Xero'ya kaydet",

+ 169 - 1
src/__tests__/features/integrations/xero-server.test.ts

@@ -11,6 +11,7 @@ import {
   type LinkRecord,
   type LogLevel,
 } from '@/features/integrations/Lib/types'
+import { buildContact, checksumOf, contactName } from '@/integrations/xero/mapping'
 import { salesRates } from '@/integrations/xero/tax'
 
 /**
@@ -351,7 +352,7 @@ describe('pushing an invoice', () => {
       answer: emptyOrganisation({
         'GET /Contacts': (c) =>
           c.query.where?.startsWith('EmailAddress')
-            ? { Contacts: [{ ContactID: 'contact-old' }] }
+            ? { Contacts: [{ ContactID: 'contact-old', Name: 'Anna Berg' }] }
             : { Contacts: [] },
       }),
     })
@@ -721,3 +722,170 @@ describe('an invoice voided in Xero', () => {
     expect(links.get('ServiceRecord:svc1')?.metadata).not.toHaveProperty('goneInXero')
   })
 })
+
+describe('a customer whose email another Xero contact has', () => {
+  /** The checksum of the customer as it is: linked and not edited since. */
+  const unchanged = checksumOf(buildContact(customer, contactName(customer)))
+  const push = async (ctx: ConnectorContext) =>
+    (await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })) as JobOutcome
+  const contactOf = (calls: Call[]) =>
+    (
+      (api(calls, 'PUT', '/Invoices')[0]?.body?.Invoices ??
+        api(calls, 'POST', '/Invoices/inv-new')[0]?.body?.Invoices) as Record<string, unknown>[]
+    )[0].Contact
+  /** Xero's contacts list: the given contacts for an email search, none by name. */
+  const withEmail = (contacts: Record<string, unknown>[]) => ({
+    'GET /Contacts': (call: Call) =>
+      String(call.query.where).startsWith('EmailAddress==')
+        ? { Contacts: contacts }
+        : { Contacts: [] },
+  })
+
+  it('gives the customer a contact of its own when the one with the email has another name', async () => {
+    const { ctx, calls, links, logs } = makeCtx({
+      answer: emptyOrganisation(
+        withEmail([
+          { ContactID: 'dummy', Name: 'Dummy Customer', EmailAddress: 'anna@example.com' },
+        ])
+      ),
+    })
+    await push(ctx)
+    expect(api(calls, 'PUT', '/Contacts')).toHaveLength(1)
+    expect(contactOf(calls)).toEqual({ ContactID: 'contact-new' })
+    expect(links.get('Customer:cus1')?.remoteId).toBe('contact-new')
+    expect(logs.find((l) => l.level === 'warn')?.message).toContain(
+      'the contact "Dummy Customer" has the same email address but another name'
+    )
+  })
+
+  it('uses the contact with the email when its name, or its person, is the customer', async () => {
+    const { ctx, calls, links } = makeCtx({
+      answer: emptyOrganisation(
+        withEmail([
+          { ContactID: 'dummy', Name: 'Dummy Customer' },
+          { ContactID: 'anna-xero', Name: 'Berg Logistics', FirstName: 'Anna', LastName: 'Berg' },
+        ])
+      ),
+    })
+    await push(ctx)
+    expect(api(calls, 'PUT', '/Contacts')).toHaveLength(0)
+    expect(contactOf(calls)).toEqual({ ContactID: 'anna-xero' })
+    expect(links.get('Customer:cus1')?.metadata).toMatchObject({ reused: true, verified: true })
+  })
+
+  it('trusts the email alone when the workshop chose to', async () => {
+    const { ctx, calls } = makeCtx({
+      settings: { sameEmail: 'reuse' },
+      answer: emptyOrganisation(withEmail([{ ContactID: 'dummy', Name: 'Dummy Customer' }])),
+    })
+    await push(ctx)
+    expect(api(calls, 'PUT', '/Contacts')).toHaveLength(0)
+    expect(contactOf(calls)).toEqual({ ContactID: 'dummy' })
+  })
+
+  it('moves a customer an earlier version linked by email to another customer onto a contact of its own', async () => {
+    const { ctx, calls, links, logs } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Contacts/dummy': () => ({
+          Contacts: [{ ContactID: 'dummy', Name: 'Dummy Customer' }],
+        }),
+      }),
+    })
+    await ctx.links.set('Customer', 'cus1', {
+      remoteId: 'dummy',
+      checksum: unchanged,
+      metadata: { tenantId: 'tenant-1', reused: true },
+    })
+    await push(ctx)
+    expect(links.get('Customer:cus1')?.remoteId).toBe('contact-new')
+    expect(contactOf(calls)).toEqual({ ContactID: 'contact-new' })
+    expect(
+      logs.some((l) => l.message.includes('linked to the Xero contact "Dummy Customer"'))
+    ).toBe(true)
+    // The other customer's contact was never written to.
+    expect(api(calls, 'POST', '/Contacts/dummy')).toHaveLength(0)
+  })
+
+  it('checks a link to a contact that was already in Xero only once', async () => {
+    const { ctx, calls, links } = makeCtx({
+      settings: { updateContacts: false },
+      answer: emptyOrganisation({
+        'GET /Contacts/anna-xero': () => ({
+          Contacts: [{ ContactID: 'anna-xero', Name: 'Anna Berg' }],
+        }),
+      }),
+    })
+    await ctx.links.set('Customer', 'cus1', {
+      remoteId: 'anna-xero',
+      checksum: 'old',
+      metadata: { tenantId: 'tenant-1', reused: true },
+    })
+    await push(ctx)
+    await push(ctx)
+    expect(api(calls, 'GET', '/Contacts/anna-xero')).toHaveLength(1)
+    expect(links.get('Customer:cus1')).toMatchObject({
+      remoteId: 'anna-xero',
+      metadata: { reused: true, verified: true },
+    })
+  })
+
+  it('keeps a link whose customer was changed since, as that may be a rename, and says so', async () => {
+    const { ctx, calls, links, logs } = makeCtx({
+      settings: { updateContacts: false },
+      answer: emptyOrganisation({
+        'GET /Contacts/old-name': () => ({
+          Contacts: [{ ContactID: 'old-name', Name: 'Anna Hansen' }],
+        }),
+      }),
+    })
+    await ctx.links.set('Customer', 'cus1', {
+      remoteId: 'old-name',
+      checksum: 'before-the-rename',
+      metadata: { tenantId: 'tenant-1', reused: true },
+    })
+    await push(ctx)
+    expect(links.get('Customer:cus1')).toMatchObject({
+      remoteId: 'old-name',
+      metadata: { verified: true },
+    })
+    expect(contactOf(calls)).toEqual({ ContactID: 'old-name' })
+    expect(logs.some((l) => l.message.includes('this may be a rename and the link was kept'))).toBe(
+      true
+    )
+  })
+
+  it('checks an old link whose record of how it was made a contact update replaced', async () => {
+    const { ctx, links } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Contacts/dummy': () => ({
+          Contacts: [{ ContactID: 'dummy', Name: 'Dummy Customer' }],
+        }),
+      }),
+    })
+    await ctx.links.set('Customer', 'cus1', {
+      remoteId: 'dummy',
+      checksum: unchanged,
+      metadata: { tenantId: 'tenant-1' },
+    })
+    await push(ctx)
+    expect(links.get('Customer:cus1')?.remoteId).toBe('contact-new')
+  })
+
+  it('keeps how a link was made when the contact is updated', async () => {
+    const { ctx, calls, links } = makeCtx({
+      answer: emptyOrganisation({
+        'POST /Contacts/anna-xero': () => ({
+          Contacts: [{ ContactID: 'anna-xero', Name: 'Anna Berg' }],
+        }),
+      }),
+    })
+    await ctx.links.set('Customer', 'cus1', {
+      remoteId: 'anna-xero',
+      checksum: 'before-an-edit',
+      metadata: { tenantId: 'tenant-1', reused: true, verified: true },
+    })
+    await push(ctx)
+    expect(api(calls, 'POST', '/Contacts/anna-xero')).toHaveLength(1)
+    expect(links.get('Customer:cus1')?.metadata).toMatchObject({ reused: true, verified: true })
+  })
+})

+ 45 - 0
src/__tests__/features/integrations/xero.test.ts

@@ -15,6 +15,7 @@ import {
   modifiedSinceHeader,
   parseXeroDate,
   referenceFor,
+  sameName,
 } from '@/integrations/xero/mapping'
 import { pickTaxedRate, pickZeroRate, salesRates } from '@/integrations/xero/tax'
 
@@ -724,3 +725,47 @@ describe('review fixes', () => {
     expect((line.Quantity as number) * (line.UnitAmount as number)).toBeCloseTo(49.98, 10)
   })
 })
+
+describe('telling customers apart by name', () => {
+  const anna = { name: 'Anna Berg', company: null }
+  it('matches the same name written another way', () => {
+    expect(sameName(anna, { Name: 'anna  berg' })).toBe(true)
+    expect(sameName(anna, { Name: 'Berg family', FirstName: 'Anna', LastName: 'Berg' })).toBe(true)
+    expect(
+      sameName(
+        { name: 'Bob', company: 'Campbell Transport' },
+        { Name: 'Campbell Transport Services Pty Ltd' }
+      )
+    ).toBe(true)
+    expect(
+      sameName({ name: 'Bob', company: 'Smith & Sons Ltd.' }, { Name: 'SMITH AND SONS' })
+    ).toBe(true)
+    expect(sameName({ name: 'Zoë Ødegård', company: null }, { Name: 'Zoe Odegard' })).toBe(true)
+  })
+  it('does not take another person who shares the address for the customer', () => {
+    expect(sameName(anna, { Name: 'Dummy Customer' })).toBe(false)
+    expect(sameName(anna, { Name: 'Lars Berg', FirstName: 'Lars', LastName: 'Berg' })).toBe(false)
+    expect(sameName({ name: 'Berg', company: null }, { Name: 'Lars Berg' })).toBe(false)
+    expect(sameName({ name: 'John Smith', company: null }, { Name: 'John Smith Jr' })).toBe(false)
+    expect(
+      sameName({ name: 'John Smith', company: null }, { FirstName: 'John', LastName: 'Smith Jr' })
+    ).toBe(false)
+  })
+  it('only takes a company form off the end of a business name', () => {
+    expect(sameName({ name: 'Ricardo Sá Pinto', company: null }, { Name: 'Ricardo Pinto' })).toBe(
+      false
+    )
+    expect(sameName({ name: 'Ann', company: 'The Company Store' }, { Name: 'The Store' })).toBe(
+      false
+    )
+    expect(
+      sameName({ name: 'Ann', company: 'Berg Logistics AS' }, { Name: 'Berg Logistics' })
+    ).toBe(true)
+  })
+  it('reads an apostrophe as no letter at all', () => {
+    expect(sameName({ name: "Anna O'Brien", company: null }, { Name: 'Anna OBrien' })).toBe(true)
+    expect(sameName({ name: 'Anna O\u2019Brien', company: null }, { Name: "Anna O'Brien" })).toBe(
+      true
+    )
+  })
+})

+ 12 - 0
src/integrations/xero/manifest.ts

@@ -295,6 +295,18 @@ export const manifest: ConnectorManifest = {
       default: true,
       showWhen: { key: 'pushInvoices', equals: true },
     },
+    {
+      key: 'sameEmail',
+      type: 'select',
+      label: 'sameEmail',
+      help: 'sameEmailHelp',
+      default: 'create',
+      options: [
+        { value: 'create', label: 'sameEmailCreate' },
+        { value: 'reuse', label: 'sameEmailReuse' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
     {
       key: 'voidOnDelete',
       type: 'boolean',

+ 92 - 0
src/integrations/xero/mapping.ts

@@ -45,6 +45,8 @@ export interface XeroOrganisation {
 export interface XeroContact {
   ContactID: string
   Name?: string
+  FirstName?: string
+  LastName?: string
   ContactNumber?: string
   EmailAddress?: string
   ContactStatus?: string
@@ -215,6 +217,96 @@ export function contactNumber(c: AccountingCustomer): string {
   return `${CONTACT_NUMBER_PREFIX}${c.customerNumber ?? c.id}`.slice(0, 50)
 }
 
+/** Company forms that may end a business name in one system and not the other. */
+const LEGAL_FORMS = new Set([
+  'pty',
+  'proprietary',
+  'ltd',
+  'limited',
+  'inc',
+  'incorporated',
+  'llc',
+  'llp',
+  'plc',
+  'corp',
+  'corporation',
+  'co',
+  'company',
+  'gmbh',
+  'ag',
+  'as',
+  'asa',
+  'ab',
+  'oy',
+  'bv',
+  'nv',
+  'sa',
+  'sarl',
+  'srl',
+  'spa',
+  'sl',
+  'kg',
+])
+
+/** A name as two systems may both write it: no case, accents, apostrophes or punctuation. */
+export function normalName(value: string | null | undefined): string {
+  return (
+    (value ?? '')
+      .normalize('NFKD')
+      .replace(/[\u0300-\u036f]/g, '')
+      .toLowerCase()
+      // Letters no decomposition takes apart.
+      .replace(/[øœ]/g, 'o')
+      .replace(/æ/g, 'ae')
+      .replace(/ß/g, 'ss')
+      .replace(/[łđ]/g, (ch) => (ch === 'ł' ? 'l' : 'd'))
+      .replace(/þ/g, 'th')
+      // O'Brien and OBrien are the same name.
+      .replace(/['\u2018\u2019\u02bc`]/g, '')
+      .replace(/&/g, ' and ')
+      .replace(/[^\p{L}\p{N}]+/gu, ' ')
+      .replace(/\s+/g, ' ')
+      .trim()
+  )
+}
+
+/** A business name without the company form at its end: "Smith and Sons Pty Ltd" is "smith and sons". */
+function businessName(value: string | null | undefined): string {
+  const words = normalName(value).split(' ').filter(Boolean)
+  while (words.length > 1 && LEGAL_FORMS.has(words[words.length - 1])) words.pop()
+  return words.join(' ')
+}
+
+/**
+ * Two business names for the same business: equal, or one of two words or
+ * more found whole inside the other ("Campbell Transport" and "Campbell
+ * Transport Services"). A single word alone is not enough.
+ */
+function businessesAgree(a: string, b: string): boolean {
+  if (!a || !b) return false
+  if (a === b) return true
+  const [short, long] = a.length <= b.length ? [a, b] : [b, a]
+  return short.split(' ').length >= 2 && ` ${long} `.includes(` ${short} `)
+}
+
+/**
+ * Whether a Xero contact is plausibly this customer by name. A person must
+ * have the same name, as the contact's name or its first and last name, so
+ * John Smith and John Smith Jr stay apart; a business may carry a company
+ * form or a few more words on either side. An email address alone can be
+ * shared (a family, a company's accounts inbox), so it only counts with this.
+ */
+export function sameName(
+  c: Pick<AccountingCustomer, 'name' | 'company'>,
+  contact: Pick<XeroContact, 'Name' | 'FirstName' | 'LastName'>
+): boolean {
+  const person = normalName(c.name)
+  const theirName = normalName(contact.Name)
+  const theirPerson = normalName([contact.FirstName, contact.LastName].filter(Boolean).join(' '))
+  if (person && (person === theirName || person === theirPerson)) return true
+  return businessesAgree(businessName(c.company), businessName(contact.Name))
+}
+
 /**
  * The customer as a Xero contact. The address is one free-text block here
  * and up to four lines there, so it is split on line breaks rather than

+ 99 - 12
src/integrations/xero/server.ts

@@ -57,6 +57,7 @@ import {
   parseXeroDate,
   paymentDay,
   referenceFor,
+  sameName,
   xeroDayKey,
   round2,
   whereString,
@@ -137,6 +138,8 @@ function settingsOf(ctx: ConnectorContext) {
     markAsSent: s.markAsSent !== false,
     attachPdf: s.attachPdf !== false,
     updateContacts: s.updateContacts !== false,
+    /** Use a contact found by email even when its name is another. */
+    reuseByEmail: s.sameEmail === 'reuse',
     voidOnDelete: s.voidOnDelete !== false,
     pushPayments: s.pushPayments !== false,
     paymentAccount: str('paymentAccount'),
@@ -404,6 +407,15 @@ async function findContact(ctx: ConnectorContext, where: string): Promise<XeroCo
   return (res.Contacts ?? []).find((c) => c.ContactStatus !== 'ARCHIVED') ?? null
 }
 
+/**
+ * Every live contact a where-clause finds, in full: the summary form leaves
+ * out the first and last name, which matching by name needs.
+ */
+async function findContacts(ctx: ConnectorContext, where: string): Promise<XeroContact[]> {
+  const res = await api<{ Contacts?: XeroContact[] }>(ctx, 'Contacts', { query: { where } })
+  return (res.Contacts ?? []).filter((c) => c.ContactStatus !== 'ARCHIVED')
+}
+
 /** A contact made here before, by the code this app gave it; Xero looks that up by path. */
 async function contactByNumber(ctx: ConnectorContext, number: string): Promise<XeroContact | null> {
   try {
@@ -465,11 +477,66 @@ async function createContact(ctx: ConnectorContext, c: AccountingCustomer): Prom
   }
 }
 
+/**
+ * A link made before a shared email stopped being enough is checked once.
+ * Links this app made with a contact of its own are fine; any other, or one
+ * whose record of how it was made was lost, is compared with the contact in
+ * Xero. When the contact is neither one this app made nor one with the
+ * customer's name, it is another customer behind the same address: the link
+ * is dropped so the customer gets a contact of its own. That only happens
+ * while the customer is as it was when linked (the stored checksum is
+ * today's); a customer edited here since may simply have been renamed, so
+ * that link is kept and the log asks for a look.
+ */
+async function verifiedLink(
+  ctx: ConnectorContext,
+  c: AccountingCustomer,
+  link: LinkRecord,
+  checksum: string
+): Promise<LinkRecord | null> {
+  if (link.metadata?.verified === true || link.metadata?.reused === false) return link
+  const settings = settingsOf(ctx)
+  if (!settings.reuseByEmail) {
+    let contact: XeroContact | null = null
+    try {
+      const res = await api<{ Contacts?: XeroContact[] }>(ctx, `Contacts/${link.remoteId}`)
+      contact = res.Contacts?.[0] ?? null
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+    }
+    if (!contact) {
+      await ctx.links.remove(CUSTOMER_ENTITY, c.id)
+      return null
+    }
+    const label = contact.Name ?? link.remoteId
+    if (contact.ContactNumber !== contactNumber(c) && !sameName(c, contact)) {
+      if (link.checksum === checksum) {
+        await ctx.links.remove(CUSTOMER_ENTITY, c.id)
+        await ctx.log(
+          'warn',
+          `Customer ${c.name} was linked to the Xero contact "${label}" because they share an email address, but the names differ; the customer now gets a contact of its own, and its unpaid invoices move to it when next sent.`
+        )
+        return null
+      }
+      await ctx.log(
+        'warn',
+        `Customer ${c.name} is linked to the Xero contact "${label}", whose name is different. The customer was changed here after it was linked, so this may be a rename and the link was kept. If "${label}" is another customer, give this customer its own contact in Xero.`
+      )
+    }
+  }
+  const next = { ...link, metadata: { ...(link.metadata ?? {}), verified: true } }
+  await ctx.links.set(CUSTOMER_ENTITY, c.id, next)
+  return next
+}
+
 /**
  * The Xero contact for a customer, made or updated as needed. A contact
  * already in Xero is found by the number this app gives its contacts, then
  * by email, then by name, so a bookkeeper's existing contact is reused
- * rather than doubled.
+ * rather than doubled. An email address can be shared by several customers,
+ * so a contact found by email is used only when its name agrees too, unless
+ * the workshop chose to trust email alone; otherwise the customer gets a new
+ * contact and the log names the one that shares the address.
  */
 async function ensureContact(
   ctx: ConnectorContext,
@@ -480,17 +547,25 @@ async function ensureContact(
   const org = await loadOrg(ctx)
   const name = contactName(c)
   const checksum = checksumOf(buildContact(c, name))
-  const link = await linkFor(ctx, CUSTOMER_ENTITY, c.id)
+  const found = await linkFor(ctx, CUSTOMER_ENTITY, c.id)
+  const link = found ? await verifiedLink(ctx, c, found, checksum) : null
   const result = (id: string) => ({ id, email: c.email, taxExempt: c.taxExempt })
   if (link) {
     if (link.checksum === checksum || !settings.updateContacts) return result(link.remoteId)
     try {
       await updateContact(ctx, link.remoteId, c, name)
-      await setLink(ctx, CUSTOMER_ENTITY, c.id, {
-        remoteId: link.remoteId,
-        remoteUrl: contactUrl(org.shortCode, link.remoteId),
-        checksum,
-      })
+      // How the link was made, and that it was checked, stay with it.
+      await setLink(
+        ctx,
+        CUSTOMER_ENTITY,
+        c.id,
+        {
+          remoteId: link.remoteId,
+          remoteUrl: contactUrl(org.shortCode, link.remoteId),
+          checksum,
+        },
+        link.metadata ?? {}
+      )
       return result(link.remoteId)
     } catch (err) {
       if (!isNotFound(err)) throw err
@@ -498,11 +573,23 @@ async function ensureContact(
     }
   }
   const email = c.email?.trim()
-  const existing =
-    (await contactByNumber(ctx, contactNumber(c))) ??
-    (email ? await findContact(ctx, `EmailAddress==${whereString(email)}`) : null) ??
-    (await findContact(ctx, `Name==${whereString(name)}`))
+  let existing = await contactByNumber(ctx, contactNumber(c))
+  let sharesEmail: XeroContact | null = null
+  if (!existing && email) {
+    const withEmail = await findContacts(ctx, `EmailAddress==${whereString(email)}`)
+    existing =
+      withEmail.find((x) => sameName(c, x)) ??
+      (settings.reuseByEmail ? (withEmail[0] ?? null) : null)
+    if (!existing) sharesEmail = withEmail[0] ?? null
+  }
+  existing ??= await findContact(ctx, `Name==${whereString(name)}`)
   const remote = existing ?? (await createContact(ctx, c))
+  if (!existing && sharesEmail) {
+    await ctx.log(
+      'warn',
+      `Customer ${c.name} got a new Xero contact, "${remote.Name ?? name}": the contact "${sharesEmail.Name ?? sharesEmail.ContactID}" has the same email address but another name. If they are the same customer, merge the two contacts in Xero, or choose to use the existing contact under "Customers with the same email" in the integration settings.`
+    )
+  }
   await setLink(
     ctx,
     CUSTOMER_ENTITY,
@@ -512,7 +599,7 @@ async function ensureContact(
       remoteUrl: contactUrl(org.shortCode, remote.ContactID),
       checksum,
     },
-    { reused: Boolean(existing) }
+    { reused: Boolean(existing), verified: true }
   )
   return result(remote.ContactID)
 }