Răsfoiți Sursa

Send customer notes to Xero and QuickBooks as plain text and let Xero place them above the charges (#572)

* Send customer notes to Xero and QuickBooks as plain text and let Xero place them above the charges

The notes editor stores HTML, and the accounting loader handed it over as
it was, so a Xero invoice line read "<p>... &amp; ...</p>" and the
QuickBooks memo carried the same markup. The loader now turns the notes
into plain text once for every connector: a paragraph is a line, a list
item starts with "- " or its number, entities become their characters.

Xero gets a setting for where the notes line goes: below the charges, as
before, or above them, right after the vehicle line.

* Keep bracketed addresses and a numbered list's start in plain-text notes

A plain note such as "Send the receipt to <accounts@example.com>" lost
the address, because a word boundary also sits before the @ and the
address was taken for a tag. A tag's name now has to end in a space, a
slash or the closing bracket.

A numbered list that starts at 4 was renumbered from 1. The counter now
starts from the list's start attribute.
Bernt Christian Egeland 15 ore în urmă
părinte
comite
ae3555855e

+ 5 - 1
messages/de/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Fahrzeug auf der Rechnung nennen",
         "includeVehicleHelp": "Eine Textzeile über den Positionen mit Baujahr, Marke, Modell, Kennzeichen und Kilometerstand.",
         "includeNotes": "Hinweise an den Kunden übernehmen",
-        "includeNotesHelp": "Die Hinweise der Rechnung an den Kunden, als Textzeile unter den Positionen.",
+        "includeNotesHelp": "Die Hinweise der Rechnung an den Kunden, als Textzeile auf der Rechnung.",
+        "notesPosition": "Position der Hinweise an den Kunden",
+        "notesPositionHelp": "Über den Positionen stellt die Hinweise direkt hinter die Fahrzeugzeile, vor Arbeit und Teile. Unter den Positionen stellt sie ans Ende.",
+        "notesPositionBelow": "Unter den Positionen",
+        "notesPositionAbove": "Über den Positionen",
         "includePartNumbers": "Teilenummern in Teilepositionen anzeigen",
         "includePartNumbersHelp": "Die Beschreibung jeder Teileposition beginnt mit ihrer Teilenummer.",
         "brandingThemeId": "Branding-Theme",

+ 5 - 1
messages/en/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Name the vehicle on the invoice",
         "includeVehicleHelp": "A text line above the charges with the year, make, model, plate and mileage.",
         "includeNotes": "Include the customer notes",
-        "includeNotesHelp": "The invoice's notes to the customer, as a text line below the charges.",
+        "includeNotesHelp": "The invoice's notes to the customer, as a text line on the invoice.",
+        "notesPosition": "Position of the customer notes",
+        "notesPositionHelp": "Above the charges puts the notes right after the vehicle line, before labour and parts. Below the charges puts them last.",
+        "notesPositionBelow": "Below the charges",
+        "notesPositionAbove": "Above the charges",
         "includePartNumbers": "Show part numbers on part lines",
         "includePartNumbersHelp": "Each part line's description starts with its part number.",
         "brandingThemeId": "Branding theme",

+ 5 - 1
messages/es/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Indicar el vehículo en la factura",
         "includeVehicleHelp": "Una línea de texto encima de los cargos con año, marca, modelo, matrícula y kilometraje.",
         "includeNotes": "Incluir las notas para el cliente",
-        "includeNotesHelp": "Las notas de la factura para el cliente, como una línea de texto debajo de los cargos.",
+        "includeNotesHelp": "Las notas de la factura para el cliente, como una línea de texto en la factura.",
+        "notesPosition": "Posición de las notas para el cliente",
+        "notesPositionHelp": "Encima de los cargos coloca las notas justo después de la línea del vehículo, antes de la mano de obra y las piezas. Debajo de los cargos las coloca al final.",
+        "notesPositionBelow": "Debajo de los cargos",
+        "notesPositionAbove": "Encima de los cargos",
         "includePartNumbers": "Mostrar los números de pieza en las líneas de piezas",
         "includePartNumbersHelp": "La descripción de cada línea de pieza empieza por su número de pieza.",
         "brandingThemeId": "Tema de marca",

+ 5 - 1
messages/fr/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Indiquer le véhicule sur la facture",
         "includeVehicleHelp": "Une ligne de texte au-dessus des lignes facturées avec l'année, la marque, le modèle, la plaque et le kilométrage.",
         "includeNotes": "Inclure les notes au client",
-        "includeNotesHelp": "Les notes de la facture destinées au client, en ligne de texte sous les lignes facturées.",
+        "includeNotesHelp": "Les notes de la facture destinées au client, en ligne de texte sur la facture.",
+        "notesPosition": "Position des notes au client",
+        "notesPositionHelp": "Au-dessus des lignes facturées place les notes juste après la ligne du véhicule, avant la main-d'œuvre et les pièces. Sous les lignes facturées les place en dernier.",
+        "notesPositionBelow": "Sous les lignes facturées",
+        "notesPositionAbove": "Au-dessus des lignes facturées",
         "includePartNumbers": "Afficher les numéros de pièce sur les lignes de pièces",
         "includePartNumbersHelp": "La description de chaque ligne de pièce commence par son numéro de pièce.",
         "brandingThemeId": "Thème de marque",

+ 5 - 1
messages/it/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Indicare il veicolo sulla fattura",
         "includeVehicleHelp": "Una riga di testo sopra le voci con anno, marca, modello, targa e chilometraggio.",
         "includeNotes": "Includi le note per il cliente",
-        "includeNotesHelp": "Le note per il cliente della fattura, come riga di testo sotto le voci.",
+        "includeNotesHelp": "Le note per il cliente della fattura, come riga di testo sulla fattura.",
+        "notesPosition": "Posizione delle note per il cliente",
+        "notesPositionHelp": "Sopra le voci mette le note subito dopo la riga del veicolo, prima di manodopera e ricambi. Sotto le voci le mette in fondo.",
+        "notesPositionBelow": "Sotto le voci",
+        "notesPositionAbove": "Sopra le voci",
         "includePartNumbers": "Mostra i numeri ricambio sulle righe dei ricambi",
         "includePartNumbersHelp": "La descrizione di ogni riga di ricambio inizia con il suo numero ricambio.",
         "brandingThemeId": "Tema grafico",

+ 5 - 1
messages/lt/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Nurodyti transporto priemonę sąskaitoje",
         "includeVehicleHelp": "Teksto eilutė virš pozicijų su metais, marke, modeliu, numeriu ir rida.",
         "includeNotes": "Įtraukti pastabas klientui",
-        "includeNotesHelp": "Sąskaitos pastabos klientui kaip teksto eilutė po pozicijomis.",
+        "includeNotesHelp": "Sąskaitos pastabos klientui kaip teksto eilutė sąskaitoje.",
+        "notesPosition": "Pastabų klientui vieta",
+        "notesPositionHelp": "Pasirinkus Virš pozicijų, pastabos dedamos iškart po transporto priemonės eilutės, prieš darbus ir dalis. Pasirinkus Po pozicijomis, jos dedamos paskutinės.",
+        "notesPositionBelow": "Po pozicijomis",
+        "notesPositionAbove": "Virš pozicijų",
         "includePartNumbers": "Rodyti dalių numerius dalių eilutėse",
         "includePartNumbersHelp": "Kiekvienos dalies eilutės aprašymas prasideda dalies numeriu.",
         "brandingThemeId": "Firminio stiliaus tema",

+ 5 - 1
messages/nb/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Oppgi kjøretøyet på fakturaen",
         "includeVehicleHelp": "En tekstlinje over postene med årsmodell, merke, modell, skilt og kilometerstand.",
         "includeNotes": "Ta med notatene til kunden",
-        "includeNotesHelp": "Fakturaens notater til kunden, som en tekstlinje under postene.",
+        "includeNotesHelp": "Fakturaens notater til kunden, som en tekstlinje på fakturaen.",
+        "notesPosition": "Plassering av notatene til kunden",
+        "notesPositionHelp": "Med Over postene kommer notatene rett etter kjøretøylinjen, før arbeid og deler. Med Under postene kommer de sist.",
+        "notesPositionBelow": "Under postene",
+        "notesPositionAbove": "Over postene",
         "includePartNumbers": "Vis delenummer på delelinjer",
         "includePartNumbersHelp": "Beskrivelsen på hver delelinje starter med delenummeret.",
         "brandingThemeId": "Fakturamal",

+ 5 - 1
messages/nl/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Het voertuig op de factuur vermelden",
         "includeVehicleHelp": "Een tekstregel boven de posten met bouwjaar, merk, model, kenteken en kilometerstand.",
         "includeNotes": "Opmerkingen voor de klant meenemen",
-        "includeNotesHelp": "De opmerkingen voor de klant op de factuur, als tekstregel onder de posten.",
+        "includeNotesHelp": "De opmerkingen voor de klant, als tekstregel op de factuur.",
+        "notesPosition": "Positie van de opmerkingen voor de klant",
+        "notesPositionHelp": "Met Boven de posten komen de opmerkingen direct na de voertuigregel, vóór arbeid en onderdelen. Met Onder de posten komen ze als laatste.",
+        "notesPositionBelow": "Onder de posten",
+        "notesPositionAbove": "Boven de posten",
         "includePartNumbers": "Onderdeelnummers op onderdelenregels tonen",
         "includePartNumbersHelp": "De omschrijving van elke onderdelenregel begint met het onderdeelnummer.",
         "brandingThemeId": "Huisstijlthema",

+ 5 - 1
messages/pl/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Podaj pojazd na fakturze",
         "includeVehicleHelp": "Wiersz tekstu nad pozycjami z rocznikiem, marką, modelem, rejestracją i przebiegiem.",
         "includeNotes": "Dołącz notatki dla klienta",
-        "includeNotesHelp": "Notatki dla klienta z faktury jako wiersz tekstu pod pozycjami.",
+        "includeNotesHelp": "Notatki dla klienta z faktury jako wiersz tekstu na fakturze.",
+        "notesPosition": "Miejsce notatek dla klienta",
+        "notesPositionHelp": "Opcja Nad pozycjami umieszcza notatki zaraz po wierszu pojazdu, przed robocizną i częściami. Opcja Pod pozycjami umieszcza je na końcu.",
+        "notesPositionBelow": "Pod pozycjami",
+        "notesPositionAbove": "Nad pozycjami",
         "includePartNumbers": "Pokazuj numery części w wierszach części",
         "includePartNumbersHelp": "Opis każdego wiersza części zaczyna się od numeru części.",
         "brandingThemeId": "Motyw firmowy",

+ 5 - 1
messages/pt-BR/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Indicar o veículo na fatura",
         "includeVehicleHelp": "Uma linha de texto acima dos itens com ano, marca, modelo, placa e quilometragem.",
         "includeNotes": "Incluir as notas para o cliente",
-        "includeNotesHelp": "As notas da fatura para o cliente, como uma linha de texto abaixo dos itens.",
+        "includeNotesHelp": "As notas da fatura para o cliente, como uma linha de texto na fatura.",
+        "notesPosition": "Posição das notas para o cliente",
+        "notesPositionHelp": "Acima dos itens coloca as notas logo após a linha do veículo, antes da mão de obra e das peças. Abaixo dos itens as coloca por último.",
+        "notesPositionBelow": "Abaixo dos itens",
+        "notesPositionAbove": "Acima dos itens",
         "includePartNumbers": "Mostrar números de peça nas linhas de peças",
         "includePartNumbersHelp": "A descrição de cada linha de peça começa com o número da peça.",
         "brandingThemeId": "Tema de marca",

+ 5 - 1
messages/pt-PT/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Indicar a viatura na fatura",
         "includeVehicleHelp": "Uma linha de texto acima dos itens com ano, marca, modelo, matrícula e quilometragem.",
         "includeNotes": "Incluir as notas para o cliente",
-        "includeNotesHelp": "As notas da fatura para o cliente, numa linha de texto abaixo dos itens.",
+        "includeNotesHelp": "As notas da fatura para o cliente, numa linha de texto na fatura.",
+        "notesPosition": "Posição das notas para o cliente",
+        "notesPositionHelp": "Acima dos itens coloca as notas logo a seguir à linha da viatura, antes da mão de obra e das peças. Abaixo dos itens coloca-as em último lugar.",
+        "notesPositionBelow": "Abaixo dos itens",
+        "notesPositionAbove": "Acima dos itens",
         "includePartNumbers": "Mostrar a referência da peça nas linhas de peças",
         "includePartNumbersHelp": "A descrição de cada linha de peça começa pela referência da peça.",
         "brandingThemeId": "Tema de marca",

+ 5 - 1
messages/ru/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Указывать автомобиль в счёте",
         "includeVehicleHelp": "Текстовая строка над позициями с годом, маркой, моделью, номером и пробегом.",
         "includeNotes": "Добавлять заметки для клиента",
-        "includeNotesHelp": "Заметки для клиента из счёта, текстовой строкой под позициями.",
+        "includeNotesHelp": "Заметки для клиента из счёта, текстовой строкой в счёте.",
+        "notesPosition": "Расположение заметок для клиента",
+        "notesPositionHelp": "«Над позициями» ставит заметки сразу после строки с автомобилем, перед работами и запчастями. «Под позициями» ставит их в конец.",
+        "notesPositionBelow": "Под позициями",
+        "notesPositionAbove": "Над позициями",
         "includePartNumbers": "Показывать артикулы в строках запчастей",
         "includePartNumbersHelp": "Описание каждой строки запчасти начинается с её артикула.",
         "brandingThemeId": "Тема оформления",

+ 5 - 1
messages/tr/integrations.json

@@ -775,7 +775,11 @@
         "includeVehicle": "Aracı faturada belirt",
         "includeVehicleHelp": "Kalemlerin üzerinde yıl, marka, model, plaka ve kilometreyi içeren bir metin satırı.",
         "includeNotes": "Müşteri notlarını ekle",
-        "includeNotesHelp": "Faturanın müşteriye yönelik notları, kalemlerin altında bir metin satırı olarak.",
+        "includeNotesHelp": "Faturanın müşteriye yönelik notları, faturada bir metin satırı olarak.",
+        "notesPosition": "Müşteri notlarının konumu",
+        "notesPositionHelp": "Kalemlerin üzerinde seçeneği notları araç satırının hemen arkasına, işçilik ve parçalardan önce koyar. Kalemlerin altında seçeneği onları en sona koyar.",
+        "notesPositionBelow": "Kalemlerin altında",
+        "notesPositionAbove": "Kalemlerin üzerinde",
         "includePartNumbers": "Parça satırlarında parça numaralarını göster",
         "includePartNumbersHelp": "Her parça satırının açıklaması parça numarasıyla başlar.",
         "brandingThemeId": "Marka teması",

+ 54 - 2
src/__tests__/features/integrations/accounting-sync.test.ts

@@ -1,8 +1,9 @@
 import { describe, expect, it, vi } from 'vitest'
 
-vi.mock('@/lib/db', () => ({ db: {} }))
+const findRecord = vi.hoisted(() => vi.fn())
+vi.mock('@/lib/db', () => ({ db: { serviceRecord: { findFirst: findRecord } } }))
 
-import { issuedAtOf } from '@/features/integrations/Lib/accounting-sync'
+import { issuedAtOf, loadInvoiceForAccounting } from '@/features/integrations/Lib/accounting-sync'
 
 const base = {
   issuedAt: null,
@@ -40,3 +41,54 @@ describe('issuedAtOf', () => {
     expect(issuedAtOf({ ...base, manuallyPaid: true, invoiceDate: null })).toEqual(base.serviceDate)
   })
 })
+
+describe('loadInvoiceForAccounting', () => {
+  const record = {
+    id: 'svc1',
+    vehicleId: 'veh1',
+    title: 'Clutch',
+    invoiceNumber: 'INV-1',
+    status: 'completed',
+    issuedAt: new Date('2026-11-11T00:00:00Z'),
+    sentAt: null,
+    invoiceDate: new Date('2026-11-11T00:00:00Z'),
+    startDateTime: null,
+    serviceDate: new Date('2026-11-11T00:00:00Z'),
+    invoiceDueDate: null,
+    mileage: null,
+    invoiceNotes: null as string | null,
+    subtotal: 100,
+    taxableSubtotal: null,
+    discountType: null,
+    discountValue: 0,
+    discountAmount: 0,
+    taxRate: 10,
+    taxAmount: 10,
+    taxInclusive: false,
+    totalAmount: 110,
+    cost: 110,
+    manuallyPaid: false,
+    customer: null,
+    vehicle: null,
+    laborItems: [],
+    partItems: [],
+    payments: [],
+  }
+
+  it('hands the customer notes over as the text that was typed, not the editor markup', async () => {
+    // What the notes editor stores for a single typed line.
+    findRecord.mockResolvedValueOnce({
+      ...record,
+      invoiceNotes: '<p>Clutch is worn &amp; should be replaced.</p><p>Quote to follow.</p>',
+    })
+    const inv = await loadInvoiceForAccounting('org1', 'svc1')
+    expect(inv?.notes).toBe('Clutch is worn & should be replaced.\nQuote to follow.')
+  })
+
+  it('has no notes when the editor was left empty', async () => {
+    findRecord.mockResolvedValueOnce({ ...record, invoiceNotes: '<p></p>' })
+    expect((await loadInvoiceForAccounting('org1', 'svc1'))?.notes).toBeNull()
+    findRecord.mockResolvedValueOnce({ ...record, invoiceNotes: null })
+    expect((await loadInvoiceForAccounting('org1', 'svc1'))?.notes).toBeNull()
+  })
+})

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

@@ -105,6 +105,7 @@ const options: InvoiceOptions = {
   url: 'https://shop.example.com/vehicles/veh1/service/svc1',
   includeVehicle: true,
   includeNotes: true,
+  notesAbove: false,
   includePartNumbers: true,
   distanceUnit: 'km',
   accounts: { labor: '210', parts: '200', shopFee: '220', fallback: '200' },
@@ -418,6 +419,27 @@ describe('buildInvoice', () => {
     expect(lines[2]).toMatchObject({ ItemCode: 'LAB', AccountCode: '210' })
   })
 
+  it('puts the customer notes right after the vehicle line when they go above the charges', () => {
+    const notes = { Description: 'Rear pads at 3 mm, replace next visit.' }
+    const below = linesOf(buildInvoice(invoice, options))
+    expect(below[0].Description).toMatch(/^Vehicle: /)
+    expect(below.at(-1)).toEqual(notes)
+    expect(below.filter((l) => l.Description === notes.Description)).toHaveLength(1)
+
+    const above = linesOf(buildInvoice(invoice, { ...options, notesAbove: true }))
+    expect(above[0].Description).toMatch(/^Vehicle: /)
+    expect(above[1]).toEqual(notes)
+    expect(above.filter((l) => l.Description === notes.Description)).toHaveLength(1)
+    // The charges follow, in the same order and to the same amounts.
+    expect(above.slice(2)).toEqual(below.slice(1, -1))
+
+    // Without a vehicle line the notes lead.
+    const alone = linesOf(
+      buildInvoice(invoice, { ...options, notesAbove: true, includeVehicle: false })
+    )
+    expect(alone[0]).toEqual(notes)
+  })
+
   it('leaves out the vehicle, notes and part numbers when switched off', () => {
     const body = buildInvoice(invoice, {
       ...options,

+ 87 - 0
src/__tests__/lib/rich-text-plain.test.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+import { richTextToPlain } from '@/lib/rich-text-plain'
+
+/**
+ * Notes are typed in a rich-text editor and stored as HTML. A ledger shows a
+ * line of text exactly as it is handed over, so what goes there has to read
+ * the way the note read in the editor.
+ */
+describe('richTextToPlain', () => {
+  it('reads a paragraph as its text, with entities as the characters typed', () => {
+    expect(richTextToPlain('<p>Clutch is worn &amp; the bearing is noisy.</p>')).toBe(
+      'Clutch is worn & the bearing is noisy.'
+    )
+    expect(richTextToPlain('<p>Pads &lt; 3&nbsp;mm, &quot;urgent&quot; &#8211; &#x2713;</p>')).toBe(
+      'Pads < 3 mm, "urgent" – ✓'
+    )
+  })
+
+  it('puts each paragraph on a line and keeps the blank line the writer left', () => {
+    expect(richTextToPlain('<p>Replaced rear pads.</p><p>Discs are fine.</p>')).toBe(
+      'Replaced rear pads.\nDiscs are fine.'
+    )
+    expect(richTextToPlain('<p>Replaced rear pads.</p><p></p><p>Next service in May.</p>')).toBe(
+      'Replaced rear pads.\n\nNext service in May.'
+    )
+    expect(richTextToPlain('<p>First line<br>second line</p>')).toBe('First line\nsecond line')
+  })
+
+  it('drops bold, italics and links but keeps their words', () => {
+    expect(
+      richTextToPlain(
+        '<p><strong>Note:</strong> <em>front</em> tyres at <u>2 mm</u>, see <a href="https://example.com/t">the report</a>.</p>'
+      )
+    ).toBe('Note: front tyres at 2 mm, see the report.')
+  })
+
+  it('writes list items one per line, numbered when the list is', () => {
+    expect(
+      richTextToPlain('<p>Done:</p><ul><li><p>Oil change</p></li><li><p>New filter</p></li></ul>')
+    ).toBe('Done:\n- Oil change\n- New filter')
+    expect(
+      richTextToPlain('<ol><li><p>Drain</p></li><li><p>Refill</p></li></ol><p>Checked level.</p>')
+    ).toBe('1. Drain\n2. Refill\nChecked level.')
+    expect(
+      richTextToPlain('<ul><li><p>Brakes</p><ul><li><p>Front pads</p></li></ul></li></ul>')
+    ).toBe('- Brakes\n  - Front pads')
+  })
+
+  it('carries a numbered list on from the number it starts at', () => {
+    expect(
+      richTextToPlain(
+        '<ol start="4"><li><p>Bleed the brakes</p></li><li><p>Road test</p></li></ol>'
+      )
+    ).toBe('4. Bleed the brakes\n5. Road test')
+  })
+
+  it('keeps an address written in angle brackets', () => {
+    expect(richTextToPlain('Send the receipt to <accounts@example.com> after payment.')).toBe(
+      'Send the receipt to <accounts@example.com> after payment.'
+    )
+    // The editor stores the brackets as entities, so they come back as typed.
+    expect(
+      richTextToPlain('<p>Send the receipt to &lt;accounts@example.com&gt; after payment.</p>')
+    ).toBe('Send the receipt to <accounts@example.com> after payment.')
+    // An address is not markup, so the line breaks of a plain note stay.
+    expect(richTextToPlain('Ask <p@example.com>\nabout the invoice.')).toBe(
+      'Ask <p@example.com>\nabout the invoice.'
+    )
+  })
+
+  it('ignores the spacing between tags in markup written by hand', () => {
+    expect(richTextToPlain('<p>One</p>\n  <p>Two\n  words</p>\n')).toBe('One\nTwo words')
+  })
+
+  it('passes a note saved without markup through, line breaks included', () => {
+    expect(richTextToPlain('Thank you.\nPads < 3 mm & worn.\r\n\r\nSee you in May.')).toBe(
+      'Thank you.\nPads < 3 mm & worn.\n\nSee you in May.'
+    )
+  })
+
+  it('is empty for a note with nothing in it', () => {
+    expect(richTextToPlain(null)).toBe('')
+    expect(richTextToPlain('')).toBe('')
+    expect(richTextToPlain('<p></p>')).toBe('')
+    expect(richTextToPlain('<p>   </p><p><br></p>')).toBe('')
+  })
+})

+ 3 - 1
src/features/integrations/Lib/accounting-sync.ts

@@ -11,6 +11,7 @@
 import { db } from '@/lib/db'
 import type { Prisma } from '@/generated/prisma/client'
 import { effectiveInvoiceDate } from '@/lib/invoice-utils'
+import { richTextToPlain } from '@/lib/rich-text-plain'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SHOP_FEE_PRICING } from '@/features/settings/Lib/shopFee'
 import { serviceUrl } from './calendar-sync'
@@ -69,6 +70,7 @@ export interface AccountingInvoice {
   serviceDate: Date
   dueDate: Date | null
   mileage: number | null
+  /** The notes to the customer as plain text, the editor's markup taken out. */
   notes: string | null
   subtotal: number
   /** The sum of the taxable lines, or null when every line is taxed. */
@@ -225,7 +227,7 @@ export async function loadInvoiceForAccounting(
     serviceDate: r.serviceDate,
     dueDate: r.invoiceDueDate,
     mileage: r.mileage,
-    notes: r.invoiceNotes,
+    notes: richTextToPlain(r.invoiceNotes) || null,
     subtotal: r.subtotal,
     taxableSubtotal: r.taxableSubtotal,
     discountType: r.discountType,

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

@@ -135,6 +135,18 @@ export const manifest: ConnectorManifest = {
       default: true,
       showWhen: { key: 'pushInvoices', equals: true },
     },
+    {
+      key: 'notesPosition',
+      type: 'select',
+      label: 'notesPosition',
+      help: 'notesPositionHelp',
+      default: 'below',
+      options: [
+        { value: 'below', label: 'notesPositionBelow' },
+        { value: 'above', label: 'notesPositionAbove' },
+      ],
+      showWhen: { key: 'includeNotes', equals: true },
+    },
     {
       key: 'includePartNumbers',
       type: 'boolean',

+ 6 - 2
src/integrations/xero/mapping.ts

@@ -273,6 +273,8 @@ export interface InvoiceOptions {
   url: string
   includeVehicle: boolean
   includeNotes: boolean
+  /** The notes go above the charges, after the vehicle line, rather than last. */
+  notesAbove: boolean
   includePartNumbers: boolean
   distanceUnit: string
   accounts: { labor: string | null; parts: string | null; shopFee: string | null; fallback: string }
@@ -414,6 +416,9 @@ export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<
   const lines: XeroLine[] = []
   const vehicle = o.includeVehicle ? vehicleDescription(inv, o.distanceUnit) : null
   if (vehicle) lines.push({ Description: `Vehicle: ${vehicle}`.slice(0, 4000) })
+  const notes = o.includeNotes ? inv.notes?.trim() : null
+  const notesLine: XeroLine | null = notes ? { Description: notes.slice(0, 4000) } : null
+  if (notesLine && o.notesAbove) lines.push(notesLine)
 
   priced.forEach((line, i) => {
     const cents = Math.round(amounts[i] * 100)
@@ -460,8 +465,7 @@ export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<
     lines.push(xl)
   })
 
-  const notes = o.includeNotes ? inv.notes?.trim() : null
-  if (notes) lines.push({ Description: notes.slice(0, 4000) })
+  if (notesLine && !o.notesAbove) lines.push(notesLine)
 
   const body: Record<string, unknown> = {
     Type: 'ACCREC',

+ 2 - 0
src/integrations/xero/server.ts

@@ -120,6 +120,7 @@ function settingsOf(ctx: ConnectorContext) {
       : 'vehicle') as ReferenceChoice,
     includeVehicle: s.includeVehicle !== false,
     includeNotes: s.includeNotes !== false,
+    notesAbove: s.notesPosition === 'above',
     includePartNumbers: s.includePartNumbers !== false,
     brandingThemeId: str('brandingThemeId'),
     laborAccount: str('laborAccount'),
@@ -745,6 +746,7 @@ async function buildBody(
     url: appInvoiceUrl(ctx.appUrl, inv),
     includeVehicle: settings.includeVehicle,
     includeNotes: settings.includeNotes,
+    notesAbove: settings.notesAbove,
     includePartNumbers: settings.includePartNumbers,
     distanceUnit: await workshopDistanceUnit(ctx.connection.organizationId),
     accounts: {

+ 120 - 0
src/lib/rich-text-plain.ts

@@ -0,0 +1,120 @@
+/**
+ * A rich-text field as plain text, for a system that shows text as it is: a
+ * ledger line, a memo. The editor stores HTML, so each paragraph becomes a
+ * line, an empty paragraph a blank line, a list item starts with "- " or its
+ * number, and entities turn back into the characters they stand for. Notes
+ * saved without markup (a recurring invoice, an import) pass through with
+ * their line breaks.
+ */
+
+/** Tags that start and end a line of their own. */
+const BLOCKS = new Set([
+  'p',
+  'div',
+  'h1',
+  'h2',
+  'h3',
+  'h4',
+  'h5',
+  'h6',
+  'li',
+  'blockquote',
+  'pre',
+  'tr',
+])
+
+const ENTITIES: Record<string, string> = {
+  amp: '&',
+  lt: '<',
+  gt: '>',
+  quot: '"',
+  apos: "'",
+  nbsp: ' ',
+}
+
+/**
+ * What ends a tag's name. A word boundary is not enough: it also sits before
+ * the @ of "<accounts@example.com>", which is an address someone typed.
+ */
+const NAME_END = '(?=[\\s/>])'
+
+/** Something a browser would lay out, where a line break in the source is only a space. */
+const MARKUP = new RegExp(`<(p|div|br|ul|ol|li|h[1-6]|blockquote|pre|table)${NAME_END}`, 'i')
+
+function decodeEntities(text: string): string {
+  return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => {
+    if (name[0] !== '#') return ENTITIES[name.toLowerCase()] ?? whole
+    const hex = name[1] === 'x' || name[1] === 'X'
+    const code = Number.parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10)
+    // A non-breaking space is a space to whoever reads the line.
+    if (code === 0xa0) return ' '
+    return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole
+  })
+}
+
+export function richTextToPlain(value: string | null | undefined): string {
+  if (!value) return ''
+  const html = value.replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '')
+  const laidOut = MARKUP.test(html)
+  const lists: { ordered: boolean; count: number }[] = []
+  let out = ''
+  /** Where the open block's own text starts, to tell an empty one. */
+  let blockStart = -1
+  let last = 0
+  const text = (chunk: string) => {
+    if (!laidOut) {
+      out += chunk
+      return
+    }
+    const flat = chunk.replace(/\s+/g, ' ')
+    // Space between two blocks in the source is not text.
+    const lineStart = !out || out.endsWith('\n') || out.length === blockStart
+    out += lineStart ? flat.trimStart() : flat
+  }
+  const breakLine = () => {
+    if (out && !out.endsWith('\n')) out += '\n'
+  }
+
+  // Only what looks like a tag is taken out: "pads < 3 mm" is text.
+  for (const m of html.matchAll(new RegExp(`<(/?)([a-z][a-z0-9]*)${NAME_END}[^>]*>`, 'gi'))) {
+    text(html.slice(last, m.index))
+    last = m.index + m[0].length
+    const closing = m[1] === '/'
+    const name = m[2].toLowerCase()
+
+    if (name === 'br' || name === 'hr') {
+      out += '\n'
+    } else if (name === 'ul' || name === 'ol') {
+      if (closing) lists.pop()
+      else {
+        // A numbered list carries on from where the writer started it.
+        const start = /\sstart\s*=\s*["']?(\d+)/i.exec(m[0])
+        lists.push({ ordered: name === 'ol', count: start ? Number(start[1]) - 1 : 0 })
+      }
+    } else if (BLOCKS.has(name)) {
+      if (closing) {
+        // An empty paragraph is the blank line the writer left.
+        if (name === 'p' && out.length === blockStart) out += '\n'
+        else breakLine()
+        blockStart = -1
+      } else {
+        // A paragraph that opens a list item stays on the item's line.
+        if (out.length !== blockStart) breakLine()
+        if (name === 'li') {
+          const list = lists[lists.length - 1]
+          const indent = '  '.repeat(Math.max(0, lists.length - 1))
+          out += list?.ordered ? `${indent}${++list.count}. ` : `${indent}- `
+        }
+        blockStart = out.length
+      }
+    }
+  }
+  text(html.slice(last))
+
+  return decodeEntities(out)
+    .split('\n')
+    .map((line) => line.trimEnd())
+    .join('\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim()
+}