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

Send invoices and payments to Xero, and bring Xero payments back (#558)

* Send invoices and payments to Xero, and bring Xero payments back

A Xero accounting connector on the QuickBooks pattern: issued invoices
with their contacts, lines, GST and PDF go to the chosen organisation,
payments go both ways, and a webhook brings Xero payments back at once.
Every bookkeeping choice is a setting, and the lines are built so Xero's
totals match the printed invoice to the cent (#547).

* Carry printed figures to Xero, keep stock quantities and payment days

Lines are built from the printed total, tax and discount, so Xero lands on
what the customer was asked to pay, old total-only invoices included. A
stock item keeps its quantity, a chosen zero rate must be a zero rate,
payments from Xero keep their day in the workshop's time zone, and after a
reconnect payments already in Xero are matched instead of sent again.

* Pick the only Xero organisation on connect, and keep purchase rates off sales

The OAuth callback now runs a connector's onConnect, so a single allowed
organisation is chosen without asking. Xero's global edition marks its
purchase rate as usable on sales; it is left out of the sales lists.

* Fix what a live Xero walkthrough found

A Reference set to Nothing now clears an earlier one, every line names an
account (an item without a sales account was refused), and an invoice
voided or deleted in Xero stays out instead of failing on its number,
which Xero never reuses; renumbered here, it goes over as new.
Bernt Christian Egeland 2 дней назад
Родитель
Сommit
4df89d8412

+ 6 - 0
.env.example

@@ -72,6 +72,12 @@ QUICKBOOKS_INTEGRATION_CLIENT_SECRET=""
 # development only the Fiken users listed on the app can connect.
 FIKEN_INTEGRATION_CLIENT_ID=""
 FIKEN_INTEGRATION_CLIENT_SECRET=""
+# A Web app registered at developer.xero.com under My Apps. The webhook key is
+# on the app's Webhooks page, pointed at <app url>/api/webhooks/xero; without
+# it payments recorded in Xero arrive with the half-hourly pull instead.
+XERO_INTEGRATION_CLIENT_ID=""
+XERO_INTEGRATION_CLIENT_SECRET=""
+XERO_INTEGRATION_WEBHOOK_KEY=""
 # One Open Labor Project key for every workshop on this install. Unset, each
 # workshop pastes its own key when it connects the labor guide. Set, a
 # workshop that leaves its key empty uses this one, lookups made with it are

+ 7 - 1
.github/workflows/deploy-prod.yml

@@ -96,6 +96,9 @@ jobs:
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
           FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_ID }}
           FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_SECRET }}
+          XERO_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_XERO_INTEGRATION_CLIENT_ID }}
+          XERO_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_XERO_INTEGRATION_CLIENT_SECRET }}
+          XERO_INTEGRATION_WEBHOOK_KEY: ${{ secrets.CLOUD_XERO_INTEGRATION_WEBHOOK_KEY }}
           # Torqvoice Codes, the fault code service, run beside the app. The address
           # is the service's on the server's Docker network (http://torqvoice-codes:8090
           # when it joins the proxy network), and the key is one key with read:codes,
@@ -109,7 +112,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|XERO_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -169,6 +172,9 @@ jobs:
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
                 FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
                 FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
+                XERO_INTEGRATION_CLIENT_ID: ${XERO_INTEGRATION_CLIENT_ID}
+                XERO_INTEGRATION_CLIENT_SECRET: ${XERO_INTEGRATION_CLIENT_SECRET}
+                XERO_INTEGRATION_WEBHOOK_KEY: ${XERO_INTEGRATION_WEBHOOK_KEY}
                 TORQVOICE_CODES_URL: ${TORQVOICE_CODES_URL}
                 TORQVOICE_CODES_API_TOKEN: ${TORQVOICE_CODES_API_TOKEN}
               networks:

+ 7 - 1
.github/workflows/deploy-staging.yml

@@ -78,6 +78,9 @@ jobs:
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
           FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_ID }}
           FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_SECRET }}
+          XERO_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_XERO_INTEGRATION_CLIENT_ID }}
+          XERO_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_XERO_INTEGRATION_CLIENT_SECRET }}
+          XERO_INTEGRATION_WEBHOOK_KEY: ${{ secrets.STAGING_XERO_INTEGRATION_WEBHOOK_KEY }}
           # Torqvoice Codes, the fault code service, run beside the app. The address
           # is the service's on the server's Docker network (http://torqvoice-codes:8090
           # when it joins the proxy network), and the key is one key with read:codes,
@@ -91,7 +94,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|XERO_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -148,6 +151,9 @@ jobs:
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
                 FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
                 FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
+                XERO_INTEGRATION_CLIENT_ID: ${XERO_INTEGRATION_CLIENT_ID}
+                XERO_INTEGRATION_CLIENT_SECRET: ${XERO_INTEGRATION_CLIENT_SECRET}
+                XERO_INTEGRATION_WEBHOOK_KEY: ${XERO_INTEGRATION_WEBHOOK_KEY}
                 TORQVOICE_CODES_URL: ${TORQVOICE_CODES_URL}
                 TORQVOICE_CODES_API_TOKEN: ${TORQVOICE_CODES_API_TOKEN}
               networks:

+ 89 - 0
messages/de/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Alle 30 Minuten. Eine in QuickBooks gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
       }
     },
+    "xero": {
+      "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an Xero und hole dort erfasste Zahlungen zurück.",
+      "tenantHelp": "Erstelle auf developer.xero.com unter My Apps eine Web app. Kopiere auf ihrer Seite Configuration die Client-ID, erzeuge ein Secret und trage die Redirect-URI unten ein. Damit in Xero erfasste Zahlungen sofort ankommen, öffne die Seite Webhooks der App, richte sie auf deine Torqvoice-Adresse gefolgt von /api/webhooks/xero, hake Invoices und Payments an und trage den Webhook-Schlüssel unten ein.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret",
+        "webhookKey": "Webhook-Schlüssel",
+        "webhookKeyHelp": "Optional. Der Schlüssel auf der Seite Webhooks deiner Xero-App. Damit kommen in Xero erfasste Zahlungen sofort hier an, ohne ihn innerhalb von 30 Minuten."
+      },
+      "settings": {
+        "tenantId": "Xero-Organisation",
+        "tenantIdHelp": "Die Organisation, in die die Rechnungen geschrieben werden. Es erscheinen nur die Organisationen, die du beim Verbinden freigegeben hast; verbinde dich erneut, um eine weitere freizugeben.",
+        "pushInvoices": "Ausgestellte Rechnungen an Xero senden",
+        "pushOnComplete": "Rechnungen auch senden, wenn der Auftrag als abgeschlossen markiert wird",
+        "pushOnCompleteHelp": "Ohne diese Option geht eine Rechnung erst über, wenn sie gesendet, geteilt oder bezahlt wurde. Mit ihr geht ein abgeschlossener Auftrag sofort über und wird bei Änderungen aktualisiert.",
+        "startDate": "Nur Rechnungen mit Datum ab",
+        "startDateHelp": "Leer lassen, um jede ausgestellte Rechnung zu senden. Nutze das Datum, an dem deine Bücher zu Xero gewechselt sind, damit ältere Rechnungen nicht doppelt erfasst werden.",
+        "invoiceStatus": "Status in Xero",
+        "invoiceStatusHelp": "Genehmigte Rechnungen zählen sofort als Umsatz und können Zahlungen erhalten. Wähle Entwurf oder Wartet auf Genehmigung, wenn jemand jede Rechnung zuerst in Xero prüft; Zahlungen werden dann erfasst, sobald sie dort genehmigt ist.",
+        "invoiceStatusAuthorised": "Genehmigt",
+        "invoiceStatusSubmitted": "Wartet auf Genehmigung",
+        "invoiceStatusDraft": "Entwurf",
+        "invoiceNumbers": "Rechnungsnummern",
+        "invoiceNumbersHelp": "Behalte die Torqvoice-Nummer, damit beide Systeme übereinstimmen, oder lass Xero die Rechnung in der eigenen Folge nummerieren. Bei Xero-Nummerierung wähle die Torqvoice-Rechnungsnummer als Referenz, um jede Rechnung wiederzufinden.",
+        "invoiceNumbersTorqvoice": "Torqvoice-Nummer verwenden",
+        "invoiceNumbersXero": "Xero nummerieren lassen",
+        "reference": "Referenz",
+        "referenceHelp": "Was in Xero im Feld Reference der Rechnung steht. Es erscheint dort auf der Rechnung sowie in Listen und Suchen.",
+        "referenceVehicle": "Fahrzeug und Kennzeichen",
+        "referenceTitle": "Auftragstitel",
+        "referenceInvoiceNumber": "Torqvoice-Rechnungsnummer",
+        "referenceNone": "Nichts",
+        "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.",
+        "includePartNumbers": "Teilenummern in Teilepositionen anzeigen",
+        "includePartNumbersHelp": "Die Beschreibung jeder Teileposition beginnt mit ihrer Teilenummer.",
+        "brandingThemeId": "Branding-Theme",
+        "brandingThemeIdHelp": "Das Xero-Branding-Theme der Rechnung. Es bestimmt ihr Layout, wenn sie in Xero angezeigt oder von dort gesendet wird. Leer lassen für den Standard der Organisation.",
+        "laborAccount": "Konto für Arbeit",
+        "laborAccountHelp": "Das Erlöskonto, auf das Arbeitspositionen gebucht werden. Leer lassen, um das Konto Sales der Organisation oder das Konto des unten gewählten Artikels zu verwenden.",
+        "partsAccount": "Konto für Teile",
+        "partsAccountHelp": "Das Erlöskonto, auf das Teilepositionen gebucht werden. Leer lassen, um das Konto Sales der Organisation oder das Konto des unten gewählten Artikels zu verwenden.",
+        "shopFeeAccount": "Konto für die Werkstattpauschale",
+        "shopFeeAccountHelp": "Hierhin geht die Position der Werkstattpauschale. Leer lassen, um sie mit der Arbeit zu buchen.",
+        "laborItem": "Artikel für Arbeitspositionen",
+        "laborItemHelp": "Optional. Ein Xero-Artikel, mit dem jede Arbeitsposition gekennzeichnet wird, für Artikelberichte in Xero. Das oben gewählte Konto hat weiterhin Vorrang.",
+        "partsItem": "Artikel für Teilepositionen",
+        "partsItemHelp": "Optional. Ein Xero-Artikel, mit dem jede Teileposition gekennzeichnet wird, für Artikelberichte in Xero. Das oben gewählte Konto hat weiterhin Vorrang.",
+        "matchItemsByPartNumber": "Teile über die Teilenummer Xero-Artikeln zuordnen",
+        "matchItemsByPartNumberHelp": "Ein Teil, dessen Nummer auch der Code eines Artikels in Xero ist, wird als dieser Artikel verkauft, sodass der in Xero geführte Bestand sinkt. Xero lehnt die Rechnung ab, wenn ein Lagerartikel nicht genug Bestand hat.",
+        "taxAmounts": "Steuerbeträge",
+        "taxAmountsHelp": "Wie berechnet sendet die Steuer jeder Position genau so, wie sie auf der Rechnung des Kunden steht, sodass beide Systeme auf den Cent übereinstimmen. Von Xero ermittelt lässt Xero den Prozentsatz des Steuersatzes jeder Position anwenden, und das Protokoll meldet abweichende Summen.",
+        "taxAmountsBilled": "Wie in Torqvoice berechnet",
+        "taxAmountsXero": "Von Xero ermittelt",
+        "taxType": "Steuersatz für besteuerte Positionen",
+        "taxTypeHelp": "Torqvoice wählt den Xero-Steuersatz, dessen Prozentsatz zur jeweiligen Rechnung passt, etwa GST on Income. Wähle hier einen, um stattdessen immer diesen zu verwenden.",
+        "zeroTaxType": "Steuersatz für unbesteuerte Positionen",
+        "zeroTaxTypeHelp": "Wird für Positionen ohne Steuer und für steuerbefreite Kunden verwendet. Leer lassen, um GST Free Income, Zero Rated oder No GST zu verwenden, je nachdem, was die Organisation hat.",
+        "tracking1": "Tracking-Option",
+        "tracking1Help": "Kennzeichne jede Position mit einer Tracking-Option, etwa der Filiale oder Abteilung, für Berichte in Xero.",
+        "tracking2": "Zweite Tracking-Option",
+        "tracking2Help": "Eine Option aus der anderen Tracking-Kategorie, falls die Organisation zwei hat.",
+        "sendFromXero": "Rechnung aus Xero per E-Mail an den Kunden senden",
+        "sendFromXeroHelp": "Xero sendet eine eigene Kopie der Rechnung an die E-Mail-Adresse des Kunden, mit den in Xero eingerichteten Online-Zahlungsoptionen. Schalte dies aus, wenn Torqvoice die Rechnung bereits sendet, damit der Kunde sie nicht doppelt erhält.",
+        "markAsSent": "Rechnungen in Xero als gesendet markieren",
+        "markAsSentHelp": "Torqvoice stellt die Rechnung zu, daher zeigt Xero sie als gesendet an, statt darauf zu warten, dass jemand sie dort sendet. Gilt für genehmigte Rechnungen.",
+        "attachPdf": "Rechnungs-PDF anhängen",
+        "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.",
+        "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",
+        "paymentAccount": "Zahlungen einzahlen auf",
+        "paymentAccountHelp": "Das Bankkonto in Xero, auf das in Torqvoice erfasste Zahlungen eingezahlt werden. Leer lassen, um das erste Bankkonto zu verwenden.",
+        "cashAccount": "Barzahlungen auf",
+        "cardAccount": "Kartenzahlungen auf",
+        "transferAccount": "Banküberweisungen auf",
+        "onlineAccount": "Online-Zahlungen auf",
+        "methodAccountHelp": "Leer lassen, um das Konto oben zu verwenden.",
+        "onlineAccountHelp": "Zahlungen über Stripe, PayPal, Vipps und die anderen Zahlungsintegrationen, oft ein Verrechnungskonto. Leer lassen, um das Konto oben zu verwenden.",
+        "manualPaidAsPayment": "Zahlung erfassen, wenn eine Rechnung von Hand als bezahlt markiert wird",
+        "manualPaidAsPaymentHelp": "Eine in Torqvoice ohne Zahlungseintrag als bezahlt markierte Rechnung erhält in Xero eine Zahlung über den dort noch offenen Betrag. Ausgeschaltet bleibt sie dort offen, bis eine Zahlung erfasst wird.",
+        "pullPayments": "In Xero erfasste Zahlungen zurückholen",
+        "pullPaymentsHelp": "Eine in Xero gegen eine Rechnung aus Torqvoice erfasste Zahlung wird hier auf der Rechnung erfasst, mit Webhook sofort, sonst innerhalb von 30 Minuten."
+      }
+    },
     "fiken": {
       "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an Fiken und hole dort erfasste Zahlungen zurück.",
       "tenantHelp": "Erstelle in Fiken unter Rediger konto, API eine App für deine eigenen Firmen. Trage die Redirect-URI unten in der App ein und kopiere Client-ID und Client-Secret hierher. Die Firma braucht das API-Modul von Fiken.",

+ 89 - 0
messages/en/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Every 30 minutes. A payment received in QuickBooks against an invoice from Torqvoice is recorded on the invoice here."
       }
     },
+    "xero": {
+      "description": "Send issued invoices, their customers and their payments to Xero, and bring back payments recorded there.",
+      "tenantHelp": "Create a Web app at developer.xero.com under My Apps. On its Configuration page copy the client id, generate a secret, and add the redirect URI below. For payments recorded in Xero to arrive straight away, open the app's Webhooks page, point it at your Torqvoice address followed by /api/webhooks/xero, tick Invoices and Payments, and enter the webhook key below.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret",
+        "webhookKey": "Webhook key",
+        "webhookKeyHelp": "Optional. The key on your Xero app's Webhooks page. With it, payments recorded in Xero arrive here straight away; without it, within 30 minutes."
+      },
+      "settings": {
+        "tenantId": "Xero organisation",
+        "tenantIdHelp": "The organisation invoices are written to. Only the organisations you allowed when connecting are listed; reconnect to allow another.",
+        "pushInvoices": "Send issued invoices to Xero",
+        "pushOnComplete": "Also send invoices when the job is marked completed",
+        "pushOnCompleteHelp": "Without this an invoice goes over once it is sent, shared or paid. With it, a completed job goes over straight away and is updated if it changes.",
+        "startDate": "Only invoices dated on or after",
+        "startDateHelp": "Leave empty to send every issued invoice. Use the date your books moved to Xero so older invoices are not entered twice.",
+        "invoiceStatus": "Status in Xero",
+        "invoiceStatusHelp": "Approved invoices count as sales straight away and can take payments. Choose Draft or Awaiting approval if someone checks each invoice in Xero first; payments are then recorded once it is approved there.",
+        "invoiceStatusAuthorised": "Approved",
+        "invoiceStatusSubmitted": "Awaiting approval",
+        "invoiceStatusDraft": "Draft",
+        "invoiceNumbers": "Invoice numbers",
+        "invoiceNumbersHelp": "Keep the Torqvoice number so both systems agree, or let Xero number the invoice in its own sequence. With Xero numbering, choose the Torqvoice invoice number as the reference to find each invoice again.",
+        "invoiceNumbersTorqvoice": "Use the Torqvoice number",
+        "invoiceNumbersXero": "Let Xero number them",
+        "reference": "Reference",
+        "referenceHelp": "What goes in the invoice's Reference field in Xero, which shows on the invoice and in lists and searches there.",
+        "referenceVehicle": "Vehicle and plate",
+        "referenceTitle": "Job title",
+        "referenceInvoiceNumber": "Torqvoice invoice number",
+        "referenceNone": "Nothing",
+        "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.",
+        "includePartNumbers": "Show part numbers on part lines",
+        "includePartNumbersHelp": "Each part line's description starts with its part number.",
+        "brandingThemeId": "Branding theme",
+        "brandingThemeIdHelp": "The Xero branding theme the invoice uses, which decides its layout when viewed or sent from Xero. Leave empty for the organisation's default.",
+        "laborAccount": "Account for labour",
+        "laborAccountHelp": "The sales account labour lines are booked to. Leave empty to use the organisation's Sales account, or the account of the item chosen below.",
+        "partsAccount": "Account for parts",
+        "partsAccountHelp": "The sales account part lines are booked to. Leave empty to use the organisation's Sales account, or the account of the item chosen below.",
+        "shopFeeAccount": "Account for the shop fee",
+        "shopFeeAccountHelp": "The shop fee line goes here. Leave empty to book it with labour.",
+        "laborItem": "Item for labour lines",
+        "laborItemHelp": "Optional. A Xero item every labour line is tagged with, for item reports in Xero. The account chosen above still wins.",
+        "partsItem": "Item for part lines",
+        "partsItemHelp": "Optional. A Xero item every part line is tagged with, for item reports in Xero. The account chosen above still wins.",
+        "matchItemsByPartNumber": "Match parts to Xero items by part number",
+        "matchItemsByPartNumberHelp": "A part whose number is also the code of an item in Xero is sold as that item, so stock tracked in Xero goes down. Xero refuses the invoice when a tracked item does not have enough on hand.",
+        "taxAmounts": "Tax amounts",
+        "taxAmountsHelp": "As billed sends the tax on each line exactly as it stands on the invoice the customer received, so both systems agree to the cent. Worked out by Xero lets Xero apply the rate of each line's tax rate, and the log says when the totals differ.",
+        "taxAmountsBilled": "As billed in Torqvoice",
+        "taxAmountsXero": "Worked out by Xero",
+        "taxType": "Tax rate for taxed lines",
+        "taxTypeHelp": "Torqvoice picks the Xero tax rate whose percentage matches each invoice, such as GST on Income. Choose one here to always use it instead.",
+        "zeroTaxType": "Tax rate for untaxed lines",
+        "zeroTaxTypeHelp": "Used for lines without tax and for tax-exempt customers. Leave empty to use GST Free Income, Zero Rated or No GST, whichever the organisation has.",
+        "tracking1": "Tracking option",
+        "tracking1Help": "Tag every line with a tracking option, such as the branch or department, for reports in Xero.",
+        "tracking2": "Second tracking option",
+        "tracking2Help": "An option from the other tracking category, if the organisation has two.",
+        "sendFromXero": "Email the invoice to the customer from Xero",
+        "sendFromXeroHelp": "Xero sends its own copy of the invoice to the customer's email, with any online payment options set up in Xero. Turn this off if Torqvoice already sends the invoice, so the customer does not get it twice.",
+        "markAsSent": "Mark invoices as sent in Xero",
+        "markAsSentHelp": "Torqvoice delivers the invoice, so Xero shows it as sent instead of waiting for someone to send it there. Applies to approved invoices.",
+        "attachPdf": "Attach the invoice PDF",
+        "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.",
+        "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",
+        "paymentAccount": "Pay into",
+        "paymentAccountHelp": "The bank account payments recorded in Torqvoice are put into in Xero. Leave empty to use the first bank account.",
+        "cashAccount": "Cash payments into",
+        "cardAccount": "Card payments into",
+        "transferAccount": "Bank transfers into",
+        "onlineAccount": "Online payments into",
+        "methodAccountHelp": "Leave empty to use the account above.",
+        "onlineAccountHelp": "Payments taken through Stripe, PayPal, Vipps and the other payment connectors, often a clearing account. Leave empty to use the account above.",
+        "manualPaidAsPayment": "Record a payment when an invoice is marked paid by hand",
+        "manualPaidAsPaymentHelp": "An invoice marked as paid in Torqvoice without a payment entry gets one payment in Xero for whatever it still shows as owed. Off, it stays open there until a payment is recorded.",
+        "pullPayments": "Bring back payments recorded in Xero",
+        "pullPaymentsHelp": "A payment recorded in Xero against an invoice from Torqvoice is recorded on the invoice here, straight away with a webhook, otherwise within 30 minutes."
+      }
+    },
     "fiken": {
       "description": "Send issued invoices, their customers and their payments to Fiken, and bring back payments recorded there.",
       "tenantHelp": "Create an app in Fiken under Rediger konto, API, for your own companies. Enter the redirect URI below on the app, then copy its client ID and client secret here. The company needs Fiken's API module.",

+ 89 - 0
messages/es/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en QuickBooks contra una factura de Torqvoice se registra aquí en la factura."
       }
     },
+    "xero": {
+      "description": "Envía las facturas emitidas, sus clientes y sus pagos a Xero y trae de vuelta los pagos registrados allí.",
+      "tenantHelp": "Crea una Web app en developer.xero.com, en My Apps. En su página Configuration copia el client id, genera un secret y añade la URI de redirección de abajo. Para que los pagos registrados en Xero lleguen al instante, abre la página Webhooks de la app, apúntala a tu dirección de Torqvoice seguida de /api/webhooks/xero, marca Invoices y Payments e introduce abajo la clave del webhook.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente",
+        "webhookKey": "Clave del webhook",
+        "webhookKeyHelp": "Opcional. La clave de la página Webhooks de tu app de Xero. Con ella, los pagos registrados en Xero llegan aquí al instante; sin ella, en un plazo de 30 minutos."
+      },
+      "settings": {
+        "tenantId": "Organización de Xero",
+        "tenantIdHelp": "La organización en la que se escriben las facturas. Solo aparecen las organizaciones que permitiste al conectar; vuelve a conectar para permitir otra.",
+        "pushInvoices": "Enviar facturas emitidas a Xero",
+        "pushOnComplete": "Enviar también las facturas cuando el trabajo se marque como completado",
+        "pushOnCompleteHelp": "Sin esto, una factura pasa cuando se envía, se comparte o se paga. Con esto, un trabajo completado pasa de inmediato y se actualiza si cambia.",
+        "startDate": "Solo facturas con fecha igual o posterior a",
+        "startDateHelp": "Déjalo vacío para enviar todas las facturas emitidas. Usa la fecha en que tu contabilidad pasó a Xero para no registrar dos veces las facturas antiguas.",
+        "invoiceStatus": "Estado en Xero",
+        "invoiceStatusHelp": "Las facturas aprobadas cuentan como ventas de inmediato y pueden recibir pagos. Elige Borrador o Pendiente de aprobación si alguien revisa antes cada factura en Xero; los pagos se registran entonces cuando se aprueba allí.",
+        "invoiceStatusAuthorised": "Aprobada",
+        "invoiceStatusSubmitted": "Pendiente de aprobación",
+        "invoiceStatusDraft": "Borrador",
+        "invoiceNumbers": "Números de factura",
+        "invoiceNumbersHelp": "Conserva el número de Torqvoice para que ambos sistemas coincidan, o deja que Xero numere la factura con su propia secuencia. Con la numeración de Xero, elige el número de factura de Torqvoice como referencia para volver a encontrar cada factura.",
+        "invoiceNumbersTorqvoice": "Usar el número de Torqvoice",
+        "invoiceNumbersXero": "Dejar que Xero las numere",
+        "reference": "Referencia",
+        "referenceHelp": "Lo que va en el campo Reference de la factura en Xero, que allí se muestra en la factura, en las listas y en las búsquedas.",
+        "referenceVehicle": "Vehículo y matrícula",
+        "referenceTitle": "Título del trabajo",
+        "referenceInvoiceNumber": "Número de factura de Torqvoice",
+        "referenceNone": "Nada",
+        "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.",
+        "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",
+        "brandingThemeIdHelp": "El tema de marca (Branding theme) de Xero que usa la factura, que decide su diseño al verla o enviarla desde Xero. Déjalo vacío para usar el predeterminado de la organización.",
+        "laborAccount": "Cuenta para mano de obra",
+        "laborAccountHelp": "La cuenta de ventas en la que se contabilizan las líneas de mano de obra. Déjalo vacío para usar la cuenta Sales de la organización, o la cuenta del artículo elegido abajo.",
+        "partsAccount": "Cuenta para piezas",
+        "partsAccountHelp": "La cuenta de ventas en la que se contabilizan las líneas de piezas. Déjalo vacío para usar la cuenta Sales de la organización, o la cuenta del artículo elegido abajo.",
+        "shopFeeAccount": "Cuenta para el cargo de taller",
+        "shopFeeAccountHelp": "Aquí va la línea del cargo de taller. Déjalo vacío para contabilizarla con la mano de obra.",
+        "laborItem": "Artículo para líneas de mano de obra",
+        "laborItemHelp": "Opcional. Un artículo de Xero con el que se etiqueta cada línea de mano de obra, para los informes de artículos en Xero. La cuenta elegida arriba sigue teniendo prioridad.",
+        "partsItem": "Artículo para líneas de piezas",
+        "partsItemHelp": "Opcional. Un artículo de Xero con el que se etiqueta cada línea de piezas, para los informes de artículos en Xero. La cuenta elegida arriba sigue teniendo prioridad.",
+        "matchItemsByPartNumber": "Emparejar piezas con artículos de Xero por número de pieza",
+        "matchItemsByPartNumberHelp": "Una pieza cuyo número es también el código de un artículo en Xero se vende como ese artículo, así baja el stock que se lleva en Xero. Xero rechaza la factura cuando un artículo con stock no tiene existencias suficientes.",
+        "taxAmounts": "Importes de impuestos",
+        "taxAmountsHelp": "Como se facturó envía el impuesto de cada línea tal como figura en la factura que recibió el cliente, así ambos sistemas coinciden al céntimo. Calculado por Xero deja que Xero aplique el porcentaje de la tasa de impuesto de cada línea, y el registro avisa cuando los totales difieren.",
+        "taxAmountsBilled": "Como se facturó en Torqvoice",
+        "taxAmountsXero": "Calculado por Xero",
+        "taxType": "Tasa de impuesto para líneas gravadas",
+        "taxTypeHelp": "Torqvoice elige la tasa de impuesto de Xero cuyo porcentaje coincide con cada factura, como GST on Income. Elige una aquí para usarla siempre en su lugar.",
+        "zeroTaxType": "Tasa de impuesto para líneas sin impuesto",
+        "zeroTaxTypeHelp": "Se usa para las líneas sin impuesto y para los clientes exentos. Déjalo vacío para usar GST Free Income, Zero Rated o No GST, la que tenga la organización.",
+        "tracking1": "Opción de seguimiento",
+        "tracking1Help": "Etiqueta cada línea con una opción de seguimiento (tracking), como la sucursal o el departamento, para los informes en Xero.",
+        "tracking2": "Segunda opción de seguimiento",
+        "tracking2Help": "Una opción de la otra categoría de seguimiento, si la organización tiene dos.",
+        "sendFromXero": "Enviar la factura al cliente por correo desde Xero",
+        "sendFromXeroHelp": "Xero envía su propia copia de la factura al correo del cliente, con las opciones de pago en línea configuradas en Xero. Desactívalo si Torqvoice ya envía la factura, para que el cliente no la reciba dos veces.",
+        "markAsSent": "Marcar las facturas como enviadas en Xero",
+        "markAsSentHelp": "Torqvoice entrega la factura, así que Xero la muestra como enviada en lugar de esperar a que alguien la envíe desde allí. Se aplica a las facturas aprobadas.",
+        "attachPdf": "Adjuntar el PDF de la factura",
+        "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.",
+        "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",
+        "paymentAccount": "Depositar pagos en",
+        "paymentAccountHelp": "La cuenta bancaria de Xero en la que se depositan los pagos registrados en Torqvoice. Déjalo vacío para usar la primera cuenta bancaria.",
+        "cashAccount": "Pagos en efectivo en",
+        "cardAccount": "Pagos con tarjeta en",
+        "transferAccount": "Transferencias bancarias en",
+        "onlineAccount": "Pagos en línea en",
+        "methodAccountHelp": "Déjalo vacío para usar la cuenta de arriba.",
+        "onlineAccountHelp": "Pagos cobrados con Stripe, PayPal, Vipps y las demás integraciones de pago, a menudo una cuenta puente. Déjalo vacío para usar la cuenta de arriba.",
+        "manualPaidAsPayment": "Registrar un pago cuando una factura se marca como pagada a mano",
+        "manualPaidAsPaymentHelp": "Una factura marcada como pagada en Torqvoice sin registro de pago recibe en Xero un pago por lo que aún figure pendiente. Desactivado, queda abierta allí hasta que se registre un pago.",
+        "pullPayments": "Traer los pagos registrados en Xero",
+        "pullPaymentsHelp": "Un pago registrado en Xero contra una factura de Torqvoice se registra aquí en la factura, al instante con un webhook y, si no, en un plazo de 30 minutos."
+      }
+    },
     "fiken": {
       "description": "Envía las facturas emitidas, sus clientes y sus pagos a Fiken y trae de vuelta los pagos registrados allí.",
       "tenantHelp": "Crea una app en Fiken en Rediger konto, API, para tus propias empresas. Introduce en la app la URI de redirección de abajo y copia aquí su ID de cliente y su secreto de cliente. La empresa necesita el módulo de API de Fiken.",

+ 89 - 0
messages/fr/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans QuickBooks sur une facture Torqvoice est enregistré ici sur la facture."
       }
     },
+    "xero": {
+      "description": "Envoyez les factures émises, leurs clients et leurs paiements vers Xero, et récupérez les paiements enregistrés là-bas.",
+      "tenantHelp": "Créez une Web app sur developer.xero.com, sous My Apps. Sur sa page Configuration, copiez le client id, générez un secret et ajoutez l'URI de redirection ci-dessous. Pour que les paiements enregistrés dans Xero arrivent tout de suite, ouvrez la page Webhooks de l'application, pointez-la vers votre adresse Torqvoice suivie de /api/webhooks/xero, cochez Invoices et Payments, puis saisissez la clé du webhook ci-dessous.",
+      "fields": {
+        "clientId": "Identifiant client",
+        "clientSecret": "Secret client",
+        "webhookKey": "Clé du webhook",
+        "webhookKeyHelp": "Facultatif. La clé affichée sur la page Webhooks de votre application Xero. Avec elle, les paiements enregistrés dans Xero arrivent ici tout de suite ; sans elle, sous 30 minutes."
+      },
+      "settings": {
+        "tenantId": "Organisation Xero",
+        "tenantIdHelp": "L'organisation dans laquelle les factures sont écrites. Seules les organisations autorisées lors de la connexion sont listées ; reconnectez-vous pour en autoriser une autre.",
+        "pushInvoices": "Envoyer les factures émises vers Xero",
+        "pushOnComplete": "Envoyer aussi les factures quand le travail est marqué terminé",
+        "pushOnCompleteHelp": "Sans cette option, une facture part une fois envoyée, partagée ou payée. Avec, un travail terminé part tout de suite et est mis à jour s'il change.",
+        "startDate": "Uniquement les factures datées à partir du",
+        "startDateHelp": "Laissez vide pour envoyer toutes les factures émises. Utilisez la date où votre comptabilité est passée sur Xero pour ne pas saisir deux fois les anciennes factures.",
+        "invoiceStatus": "Statut dans Xero",
+        "invoiceStatusHelp": "Les factures approuvées comptent tout de suite comme des ventes et peuvent recevoir des paiements. Choisissez Brouillon ou En attente d'approbation si quelqu'un vérifie d'abord chaque facture dans Xero ; les paiements sont alors enregistrés une fois qu'elle y est approuvée.",
+        "invoiceStatusAuthorised": "Approuvée",
+        "invoiceStatusSubmitted": "En attente d'approbation",
+        "invoiceStatusDraft": "Brouillon",
+        "invoiceNumbers": "Numéros de facture",
+        "invoiceNumbersHelp": "Gardez le numéro Torqvoice pour que les deux systèmes concordent, ou laissez Xero numéroter la facture dans sa propre séquence. Avec la numérotation Xero, choisissez le numéro de facture Torqvoice comme référence pour retrouver chaque facture.",
+        "invoiceNumbersTorqvoice": "Utiliser le numéro Torqvoice",
+        "invoiceNumbersXero": "Laisser Xero les numéroter",
+        "reference": "Référence",
+        "referenceHelp": "Ce qui figure dans le champ Reference de la facture dans Xero, visible là-bas sur la facture, dans les listes et dans les recherches.",
+        "referenceVehicle": "Véhicule et plaque",
+        "referenceTitle": "Titre du travail",
+        "referenceInvoiceNumber": "Numéro de facture Torqvoice",
+        "referenceNone": "Rien",
+        "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.",
+        "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",
+        "brandingThemeIdHelp": "Le thème de marque (Branding theme) Xero utilisé par la facture, qui décide de sa mise en page quand elle est consultée ou envoyée depuis Xero. Laissez vide pour celui par défaut de l'organisation.",
+        "laborAccount": "Compte pour la main-d'œuvre",
+        "laborAccountHelp": "Le compte de ventes sur lequel les lignes de main-d'œuvre sont comptabilisées. Laissez vide pour utiliser le compte Sales de l'organisation, ou le compte de l'article choisi ci-dessous.",
+        "partsAccount": "Compte pour les pièces",
+        "partsAccountHelp": "Le compte de ventes sur lequel les lignes de pièces sont comptabilisées. Laissez vide pour utiliser le compte Sales de l'organisation, ou le compte de l'article choisi ci-dessous.",
+        "shopFeeAccount": "Compte pour les frais d'atelier",
+        "shopFeeAccountHelp": "La ligne des frais d'atelier va ici. Laissez vide pour la comptabiliser avec la main-d'œuvre.",
+        "laborItem": "Article pour les lignes de main-d'œuvre",
+        "laborItemHelp": "Facultatif. Un article Xero attribué à chaque ligne de main-d'œuvre, pour les rapports par article dans Xero. Le compte choisi ci-dessus reste prioritaire.",
+        "partsItem": "Article pour les lignes de pièces",
+        "partsItemHelp": "Facultatif. Un article Xero attribué à chaque ligne de pièce, pour les rapports par article dans Xero. Le compte choisi ci-dessus reste prioritaire.",
+        "matchItemsByPartNumber": "Associer les pièces aux articles Xero par numéro de pièce",
+        "matchItemsByPartNumberHelp": "Une pièce dont le numéro est aussi le code d'un article dans Xero est vendue comme cet article, et le stock suivi dans Xero diminue. Xero refuse la facture quand un article suivi en stock n'a pas assez de quantité disponible.",
+        "taxAmounts": "Montants de taxe",
+        "taxAmountsHelp": "Tel que facturé envoie la taxe de chaque ligne exactement telle qu'elle figure sur la facture reçue par le client, pour que les deux systèmes concordent au centime. Calculé par Xero laisse Xero appliquer le pourcentage du taux de taxe de chaque ligne, et le journal signale les totaux différents.",
+        "taxAmountsBilled": "Tel que facturé dans Torqvoice",
+        "taxAmountsXero": "Calculé par Xero",
+        "taxType": "Taux de taxe pour les lignes taxées",
+        "taxTypeHelp": "Torqvoice choisit le taux de taxe Xero dont le pourcentage correspond à chaque facture, comme GST on Income. Choisissez-en un ici pour toujours l'utiliser à la place.",
+        "zeroTaxType": "Taux de taxe pour les lignes non taxées",
+        "zeroTaxTypeHelp": "Utilisé pour les lignes sans taxe et pour les clients exonérés. Laissez vide pour utiliser GST Free Income, Zero Rated ou No GST, selon ce que l'organisation possède.",
+        "tracking1": "Option de suivi",
+        "tracking1Help": "Associez chaque ligne à une option de suivi (tracking), comme l'agence ou le service, pour les rapports dans Xero.",
+        "tracking2": "Deuxième option de suivi",
+        "tracking2Help": "Une option de l'autre catégorie de suivi, si l'organisation en a deux.",
+        "sendFromXero": "Envoyer la facture au client par e-mail depuis Xero",
+        "sendFromXeroHelp": "Xero envoie sa propre copie de la facture à l'adresse e-mail du client, avec les options de paiement en ligne configurées dans Xero. Désactivez cette option si Torqvoice envoie déjà la facture, pour que le client ne la reçoive pas deux fois.",
+        "markAsSent": "Marquer les factures comme envoyées dans Xero",
+        "markAsSentHelp": "Torqvoice remet la facture, donc Xero l'affiche comme envoyée au lieu d'attendre que quelqu'un l'envoie depuis Xero. S'applique aux factures approuvées.",
+        "attachPdf": "Joindre le PDF de la facture",
+        "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.",
+        "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",
+        "paymentAccount": "Déposer les paiements sur",
+        "paymentAccountHelp": "Le compte bancaire Xero sur lequel sont déposés les paiements enregistrés dans Torqvoice. Laissez vide pour utiliser le premier compte bancaire.",
+        "cashAccount": "Paiements en espèces sur",
+        "cardAccount": "Paiements par carte sur",
+        "transferAccount": "Virements bancaires sur",
+        "onlineAccount": "Paiements en ligne sur",
+        "methodAccountHelp": "Laissez vide pour utiliser le compte ci-dessus.",
+        "onlineAccountHelp": "Les paiements encaissés via Stripe, PayPal, Vipps et les autres intégrations de paiement, souvent un compte d'attente. Laissez vide pour utiliser le compte ci-dessus.",
+        "manualPaidAsPayment": "Enregistrer un paiement quand une facture est marquée payée à la main",
+        "manualPaidAsPaymentHelp": "Une facture marquée payée dans Torqvoice sans écriture de paiement reçoit dans Xero un paiement du montant qu'elle y affiche encore comme dû. Désactivé, elle y reste ouverte jusqu'à l'enregistrement d'un paiement.",
+        "pullPayments": "Récupérer les paiements enregistrés dans Xero",
+        "pullPaymentsHelp": "Un paiement enregistré dans Xero sur une facture Torqvoice est enregistré ici sur la facture, tout de suite avec un webhook, sinon sous 30 minutes."
+      }
+    },
     "fiken": {
       "description": "Envoyez les factures émises, leurs clients et leurs paiements vers Fiken, et récupérez les paiements enregistrés là-bas.",
       "tenantHelp": "Créez une application dans Fiken sous Rediger konto, API, pour vos propres sociétés. Saisissez l'URI de redirection ci-dessous dans l'application, puis copiez ici son identifiant client et son secret client. La société doit avoir le module API de Fiken.",

+ 89 - 0
messages/it/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in QuickBooks su una fattura di Torqvoice viene registrato qui sulla fattura."
       }
     },
+    "xero": {
+      "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a Xero e riporta i pagamenti registrati lì.",
+      "tenantHelp": "Crea una Web app su developer.xero.com in My Apps. Nella sua pagina Configuration copia l'ID client, genera un segreto client e aggiungi l'URI di reindirizzamento qui sotto. Perché i pagamenti registrati in Xero arrivino subito, apri la pagina Webhooks dell'app, inserisci come URL il tuo indirizzo Torqvoice seguito da /api/webhooks/xero, spunta Invoices e Payments e inserisci la chiave del webhook qui sotto.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client",
+        "webhookKey": "Chiave del webhook",
+        "webhookKeyHelp": "Facoltativa. La chiave nella pagina Webhooks della tua app Xero. Con questa, i pagamenti registrati in Xero arrivano qui subito; senza, entro 30 minuti."
+      },
+      "settings": {
+        "tenantId": "Organizzazione Xero",
+        "tenantIdHelp": "L'organizzazione in cui vengono scritte le fatture. Sono elencate solo le organizzazioni che hai autorizzato durante il collegamento; ricollega per autorizzarne un'altra.",
+        "pushInvoices": "Invia le fatture emesse a Xero",
+        "pushOnComplete": "Invia le fatture anche quando il lavoro viene segnato come completato",
+        "pushOnCompleteHelp": "Senza questa opzione una fattura passa quando viene inviata, condivisa o pagata. Con questa, un lavoro completato passa subito e viene aggiornato se cambia.",
+        "startDate": "Solo fatture con data a partire dal",
+        "startDateHelp": "Lascia vuoto per inviare ogni fattura emessa. Usa la data in cui la contabilità è passata a Xero, così le fatture più vecchie non vengono registrate due volte.",
+        "invoiceStatus": "Stato in Xero",
+        "invoiceStatusHelp": "Le fatture approvate contano subito come vendite e possono ricevere pagamenti. Scegli Bozza o In attesa di approvazione se qualcuno controlla prima ogni fattura in Xero; i pagamenti vengono poi registrati una volta approvata lì.",
+        "invoiceStatusAuthorised": "Approvata",
+        "invoiceStatusSubmitted": "In attesa di approvazione",
+        "invoiceStatusDraft": "Bozza",
+        "invoiceNumbers": "Numeri di fattura",
+        "invoiceNumbersHelp": "Mantieni il numero Torqvoice così i due sistemi coincidono, oppure lascia che Xero numeri la fattura con la propria sequenza. Con la numerazione di Xero, scegli il numero di fattura Torqvoice come riferimento per ritrovare ogni fattura.",
+        "invoiceNumbersTorqvoice": "Usa il numero Torqvoice",
+        "invoiceNumbersXero": "Lascia la numerazione a Xero",
+        "reference": "Riferimento",
+        "referenceHelp": "Cosa va nel campo Riferimento (Reference) della fattura in Xero, che compare sulla fattura e negli elenchi e nelle ricerche di Xero.",
+        "referenceVehicle": "Veicolo e targa",
+        "referenceTitle": "Titolo del lavoro",
+        "referenceInvoiceNumber": "Numero di fattura Torqvoice",
+        "referenceNone": "Niente",
+        "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.",
+        "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",
+        "brandingThemeIdHelp": "Il tema grafico (Branding theme) di Xero usato dalla fattura, che ne decide l'impaginazione quando viene visualizzata o inviata da Xero. Lascia vuoto per usare quello predefinito dell'organizzazione.",
+        "laborAccount": "Conto per la manodopera",
+        "laborAccountHelp": "Il conto ricavi su cui vengono registrate le righe di manodopera. Lascia vuoto per usare il conto Sales dell'organizzazione, o il conto dell'articolo scelto qui sotto.",
+        "partsAccount": "Conto per i ricambi",
+        "partsAccountHelp": "Il conto ricavi su cui vengono registrate le righe di ricambi. Lascia vuoto per usare il conto Sales dell'organizzazione, o il conto dell'articolo scelto qui sotto.",
+        "shopFeeAccount": "Conto per il contributo officina",
+        "shopFeeAccountHelp": "La riga del contributo officina va qui. Lascia vuoto per registrarla con la manodopera.",
+        "laborItem": "Articolo per le righe di manodopera",
+        "laborItemHelp": "Facoltativo. Un articolo Xero assegnato a ogni riga di manodopera, per i report sugli articoli in Xero. Il conto scelto sopra ha comunque la precedenza.",
+        "partsItem": "Articolo per le righe di ricambi",
+        "partsItemHelp": "Facoltativo. Un articolo Xero assegnato a ogni riga di ricambi, per i report sugli articoli in Xero. Il conto scelto sopra ha comunque la precedenza.",
+        "matchItemsByPartNumber": "Abbina i ricambi agli articoli Xero tramite numero ricambio",
+        "matchItemsByPartNumberHelp": "Un ricambio il cui numero è anche il codice di un articolo in Xero viene venduto come quell'articolo, così la giacenza gestita in Xero diminuisce. Xero rifiuta la fattura quando un articolo a magazzino non ha giacenza sufficiente.",
+        "taxAmounts": "Importi delle imposte",
+        "taxAmountsHelp": "Come fatturato invia l'imposta di ogni riga esattamente come compare sulla fattura ricevuta dal cliente, così i due sistemi coincidono al centesimo. Calcolato da Xero lascia che Xero applichi la percentuale dell'aliquota di ogni riga, e il registro segnala i totali diversi.",
+        "taxAmountsBilled": "Come fatturato in Torqvoice",
+        "taxAmountsXero": "Calcolato da Xero",
+        "taxType": "Aliquota per le righe imponibili",
+        "taxTypeHelp": "Torqvoice sceglie l'aliquota Xero la cui percentuale corrisponde a ogni fattura, come GST on Income. Scegline una qui per usare sempre quella.",
+        "zeroTaxType": "Aliquota per le righe senza imposta",
+        "zeroTaxTypeHelp": "Usata per le righe senza imposta e per i clienti esenti. Lascia vuoto per usare GST Free Income, Zero Rated o No GST, a seconda di quale ha l'organizzazione.",
+        "tracking1": "Opzione di tracking",
+        "tracking1Help": "Assegna a ogni riga un'opzione di tracking, come la filiale o il reparto, per i report in Xero.",
+        "tracking2": "Seconda opzione di tracking",
+        "tracking2Help": "Un'opzione dell'altra categoria di tracking, se l'organizzazione ne ha due.",
+        "sendFromXero": "Invia la fattura al cliente via email da Xero",
+        "sendFromXeroHelp": "Xero invia la propria copia della fattura all'email del cliente, con le opzioni di pagamento online configurate in Xero. Disattiva questa opzione se Torqvoice invia già la fattura, così il cliente non la riceve due volte.",
+        "markAsSent": "Segna le fatture come inviate in Xero",
+        "markAsSentHelp": "Torqvoice consegna la fattura, quindi Xero la mostra come inviata invece di attendere che qualcuno la invii da lì. Vale per le fatture approvate.",
+        "attachPdf": "Allega il PDF della fattura",
+        "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.",
+        "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",
+        "paymentAccount": "Deposita i pagamenti su",
+        "paymentAccountHelp": "Il conto bancario in Xero su cui finiscono i pagamenti registrati in Torqvoice. Lascia vuoto per usare il primo conto bancario.",
+        "cashAccount": "Pagamenti in contanti su",
+        "cardAccount": "Pagamenti con carta su",
+        "transferAccount": "Bonifici su",
+        "onlineAccount": "Pagamenti online su",
+        "methodAccountHelp": "Lascia vuoto per usare il conto qui sopra.",
+        "onlineAccountHelp": "Pagamenti incassati tramite Stripe, PayPal, Vipps e le altre integrazioni di pagamento, spesso su un conto transitorio. Lascia vuoto per usare il conto qui sopra.",
+        "manualPaidAsPayment": "Registrare un pagamento quando una fattura viene segnata come pagata a mano",
+        "manualPaidAsPaymentHelp": "Una fattura segnata come pagata in Torqvoice senza registrazione di pagamento riceve in Xero un pagamento per quanto risulta ancora dovuto lì. Disattivato, resta aperta lì finché non viene registrato un pagamento.",
+        "pullPayments": "Riporta i pagamenti registrati in Xero",
+        "pullPaymentsHelp": "Un pagamento registrato in Xero su una fattura di Torqvoice viene registrato qui sulla fattura: subito se il webhook è configurato, altrimenti entro 30 minuti."
+      }
+    },
     "fiken": {
       "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a Fiken e riporta i pagamenti registrati lì.",
       "tenantHelp": "Crea un'app in Fiken in Rediger konto, API, per le tue aziende. Inserisci nell'app l'URI di reindirizzamento qui sotto, poi copia qui l'ID client e il segreto client. L'azienda deve avere il modulo API di Fiken.",

+ 89 - 0
messages/lt/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Kas 30 minučių. QuickBooks gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
       }
     },
+    "xero": {
+      "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į Xero ir parsisiųskite ten užregistruotus mokėjimus.",
+      "tenantHelp": "Sukurkite Web app tipo programą developer.xero.com skiltyje My Apps. Jos puslapyje Configuration nukopijuokite kliento ID, sugeneruokite kliento slaptažodį ir įveskite žemiau esantį peradresavimo URI. Kad Xero užregistruoti mokėjimai atkeliautų iš karto, atidarykite programos puslapį Webhooks, kaip URL nurodykite savo Torqvoice adresą su /api/webhooks/xero gale, pažymėkite Invoices ir Payments ir žemiau įveskite webhook raktą.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis",
+        "webhookKey": "Webhook raktas",
+        "webhookKeyHelp": "Neprivaloma. Raktas jūsų Xero programos puslapyje Webhooks. Su juo Xero užregistruoti mokėjimai čia atkeliauja iš karto; be jo per 30 minučių."
+      },
+      "settings": {
+        "tenantId": "Xero organizacija",
+        "tenantIdHelp": "Organizacija, į kurią rašomos sąskaitos. Rodomos tik organizacijos, kurias leidote jungdamiesi; norėdami leisti kitą, prijunkite iš naujo.",
+        "pushInvoices": "Siųsti išrašytas sąskaitas į Xero",
+        "pushOnComplete": "Siųsti sąskaitas ir tada, kai darbas pažymimas kaip užbaigtas",
+        "pushOnCompleteHelp": "Be šio nustatymo sąskaita perduodama, kai ji išsiunčiama, pasidalinama ar apmokama. Su juo užbaigtas darbas perduodamas iš karto ir atnaujinamas, jei pasikeičia.",
+        "startDate": "Tik sąskaitos, datuotos nuo",
+        "startDateHelp": "Palikite tuščią, kad būtų siunčiamos visos išrašytos sąskaitos. Naudokite datą, kada apskaita perkelta į Xero, kad senesnės sąskaitos nebūtų įvestos dukart.",
+        "invoiceStatus": "Būsena Xero sistemoje",
+        "invoiceStatusHelp": "Patvirtintos sąskaitos iš karto laikomos pardavimais ir gali priimti mokėjimus. Pasirinkite Juodraštis arba Laukia patvirtinimo, jei kas nors pirmiausia patikrina kiekvieną sąskaitą Xero sistemoje; tada mokėjimai užregistruojami, kai sąskaita ten patvirtinama.",
+        "invoiceStatusAuthorised": "Patvirtinta",
+        "invoiceStatusSubmitted": "Laukia patvirtinimo",
+        "invoiceStatusDraft": "Juodraštis",
+        "invoiceNumbers": "Sąskaitų numeriai",
+        "invoiceNumbersHelp": "Palikite Torqvoice numerį, kad abi sistemos sutaptų, arba leiskite Xero sunumeruoti sąskaitą pagal savo seką. Jei numeruoja Xero, kaip nuorodą pasirinkite Torqvoice sąskaitos numerį, kad kiekvieną sąskaitą galėtumėte vėl rasti.",
+        "invoiceNumbersTorqvoice": "Naudoti Torqvoice numerį",
+        "invoiceNumbersXero": "Leisti numeruoti Xero",
+        "reference": "Nuoroda",
+        "referenceHelp": "Kas įrašoma į sąskaitos lauką Nuoroda (Reference) Xero sistemoje. Jis rodomas sąskaitoje ir Xero sąrašuose bei paieškose.",
+        "referenceVehicle": "Transporto priemonė ir valstybinis numeris",
+        "referenceTitle": "Darbo pavadinimas",
+        "referenceInvoiceNumber": "Torqvoice sąskaitos numeris",
+        "referenceNone": "Nieko",
+        "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.",
+        "includePartNumbers": "Rodyti dalių numerius dalių eilutėse",
+        "includePartNumbersHelp": "Kiekvienos dalies eilutės aprašymas prasideda dalies numeriu.",
+        "brandingThemeId": "Firminio stiliaus tema",
+        "brandingThemeIdHelp": "Xero firminio stiliaus tema (Branding theme), kurią naudoja sąskaita. Ji lemia sąskaitos išdėstymą, kai ji peržiūrima ar siunčiama iš Xero. Palikite tuščią, kad būtų naudojama organizacijos numatytoji tema.",
+        "laborAccount": "Sąskaita darbui",
+        "laborAccountHelp": "Pajamų sąskaita, į kurią apskaitomos darbo eilutės. Palikite tuščią, kad būtų naudojama organizacijos sąskaita Sales arba žemiau pasirinktos prekės sąskaita.",
+        "partsAccount": "Sąskaita dalims",
+        "partsAccountHelp": "Pajamų sąskaita, į kurią apskaitomos dalių eilutės. Palikite tuščią, kad būtų naudojama organizacijos sąskaita Sales arba žemiau pasirinktos prekės sąskaita.",
+        "shopFeeAccount": "Sąskaita dirbtuvių mokesčiui",
+        "shopFeeAccountHelp": "Čia apskaitoma dirbtuvių mokesčio eilutė. Palikite tuščią, kad ji būtų apskaitoma kartu su darbu.",
+        "laborItem": "Prekė darbo eilutėms",
+        "laborItemHelp": "Neprivaloma. Xero prekė, kuria pažymima kiekviena darbo eilutė, prekių ataskaitoms Xero sistemoje. Aukščiau pasirinkta sąskaita vis tiek turi pirmenybę.",
+        "partsItem": "Prekė dalių eilutėms",
+        "partsItemHelp": "Neprivaloma. Xero prekė, kuria pažymima kiekviena dalių eilutė, prekių ataskaitoms Xero sistemoje. Aukščiau pasirinkta sąskaita vis tiek turi pirmenybę.",
+        "matchItemsByPartNumber": "Susieti dalis su Xero prekėmis pagal dalies numerį",
+        "matchItemsByPartNumberHelp": "Dalis, kurios numeris sutampa su Xero prekės kodu, parduodama kaip ta prekė, todėl Xero sekamas likutis sumažėja. Xero atmeta sąskaitą, kai sekamos prekės likučio nepakanka.",
+        "taxAmounts": "Mokesčių sumos",
+        "taxAmountsHelp": "Kaip sąskaitoje siunčia kiekvienos eilutės mokestį tiksliai tokį, koks nurodytas kliento gautoje sąskaitoje, todėl abi sistemos sutampa iki cento. Apskaičiuoja Xero leidžia Xero taikyti kiekvienos eilutės mokesčio tarifo procentą, o žurnalas praneša, kai sumos skiriasi.",
+        "taxAmountsBilled": "Kaip sąskaitoje Torqvoice",
+        "taxAmountsXero": "Apskaičiuoja Xero",
+        "taxType": "Mokesčio tarifas apmokestinamoms eilutėms",
+        "taxTypeHelp": "Torqvoice parenka Xero mokesčio tarifą, kurio procentas sutampa su kiekviena sąskaita, pavyzdžiui, GST on Income. Pasirinkite tarifą čia, kad visada būtų naudojamas jis.",
+        "zeroTaxType": "Mokesčio tarifas eilutėms be mokesčio",
+        "zeroTaxTypeHelp": "Naudojamas eilutėms be mokesčio ir nuo mokesčio atleistiems klientams. Palikite tuščią, kad būtų naudojamas GST Free Income, Zero Rated arba No GST, priklausomai nuo to, kurį turi organizacija.",
+        "tracking1": "Sekimo parinktis",
+        "tracking1Help": "Pažymėkite kiekvieną eilutę sekimo parinktimi, pavyzdžiui, filialu ar skyriumi, ataskaitoms Xero sistemoje.",
+        "tracking2": "Antra sekimo parinktis",
+        "tracking2Help": "Parinktis iš kitos sekimo kategorijos, jei organizacija turi dvi.",
+        "sendFromXero": "Siųsti sąskaitą klientui el. paštu iš Xero",
+        "sendFromXeroHelp": "Xero išsiunčia savo sąskaitos kopiją kliento el. pašto adresu su Xero nustatytomis internetinio mokėjimo galimybėmis. Išjunkite, jei sąskaitą jau siunčia Torqvoice, kad klientas negautų jos dukart.",
+        "markAsSent": "Pažymėti sąskaitas kaip išsiųstas Xero sistemoje",
+        "markAsSentHelp": "Sąskaitą pristato Torqvoice, todėl Xero rodo ją kaip išsiųstą ir nelaukia, kol kas nors ją išsiųs iš ten. Taikoma patvirtintoms sąskaitoms.",
+        "attachPdf": "Pridėti sąskaitos PDF",
+        "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.",
+        "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",
+        "paymentAccount": "Mokėjimus įnešti į",
+        "paymentAccountHelp": "Xero banko sąskaita, į kurią įnešami Torqvoice užregistruoti mokėjimai. Palikite tuščią, kad būtų naudojama pirmoji banko sąskaita.",
+        "cashAccount": "Grynųjų pinigų mokėjimus įnešti į",
+        "cardAccount": "Mokėjimus kortele įnešti į",
+        "transferAccount": "Banko pavedimus įnešti į",
+        "onlineAccount": "Internetinius mokėjimus įnešti į",
+        "methodAccountHelp": "Palikite tuščią, kad būtų naudojama aukščiau nurodyta sąskaita.",
+        "onlineAccountHelp": "Mokėjimai, gauti per Stripe, PayPal, Vipps ir kitas mokėjimų integracijas, dažnai į tarpinę sąskaitą. Palikite tuščią, kad būtų naudojama aukščiau nurodyta sąskaita.",
+        "manualPaidAsPayment": "Registruoti mokėjimą, kai sąskaita ranka pažymima kaip apmokėta",
+        "manualPaidAsPaymentHelp": "Torqvoice be mokėjimo įrašo kaip apmokėta pažymėta sąskaita Xero gauna vieną mokėjimą likusiai ten rodomai sumai. Išjungus ji ten lieka atvira, kol užregistruojamas mokėjimas.",
+        "pullPayments": "Parsisiųsti Xero užregistruotus mokėjimus",
+        "pullPaymentsHelp": "Xero užregistruotas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos: iš karto, jei nustatytas webhook, kitaip per 30 minučių."
+      }
+    },
     "fiken": {
       "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į Fiken ir parsisiųskite ten užregistruotus mokėjimus.",
       "tenantHelp": "Sukurkite programėlę Fiken skiltyje Rediger konto, API, savo įmonėms. Programėlėje įveskite žemiau pateiktą peradresavimo URI, tada čia nukopijuokite jos kliento ID ir kliento slaptažodį. Įmonei reikalingas Fiken API modulis.",

+ 89 - 0
messages/nb/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i QuickBooks mot en faktura fra Torqvoice registreres her på fakturaen."
       }
     },
+    "xero": {
+      "description": "Send utstedte fakturaer, kundene deres og betalingene deres til Xero, og hent tilbake betalinger registrert der.",
+      "tenantHelp": "Opprett en Web app på developer.xero.com under My Apps. På Configuration-siden kopierer du klient-ID-en, genererer en klienthemmelighet og legger inn omdirigerings-URI-en nedenfor. Skal betalinger registrert i Xero komme hit med en gang, åpner du Webhooks-siden til appen, legger inn Torqvoice-adressen din etterfulgt av /api/webhooks/xero som URL, krysser av for Invoices og Payments og legger inn webhook-nøkkelen nedenfor.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet",
+        "webhookKey": "Webhook-nøkkel",
+        "webhookKeyHelp": "Valgfri. Nøkkelen på Webhooks-siden til Xero-appen din. Med den kommer betalinger registrert i Xero hit med en gang; uten den innen 30 minutter."
+      },
+      "settings": {
+        "tenantId": "Xero-organisasjon",
+        "tenantIdHelp": "Organisasjonen fakturaene skal føres i. Bare organisasjonene du ga tilgang til da du koblet til, vises her; koble til på nytt for å gi tilgang til en annen.",
+        "pushInvoices": "Send utstedte fakturaer til Xero",
+        "pushOnComplete": "Send også fakturaer når jobben markeres som fullført",
+        "pushOnCompleteHelp": "Uten dette går en faktura over når den er sendt, delt eller betalt. Med det går en fullført jobb over med en gang og oppdateres om den endres.",
+        "startDate": "Bare fakturaer datert fra og med",
+        "startDateHelp": "La stå tomt for å sende alle utstedte fakturaer. Bruk datoen regnskapet flyttet til Xero, så eldre fakturaer ikke føres to ganger.",
+        "invoiceStatus": "Status i Xero",
+        "invoiceStatusHelp": "Godkjente fakturaer regnes som salg med en gang og kan ta imot betalinger. Velg Utkast eller Venter på godkjenning hvis noen kontrollerer hver faktura i Xero først; betalinger registreres da når fakturaen er godkjent der.",
+        "invoiceStatusAuthorised": "Godkjent",
+        "invoiceStatusSubmitted": "Venter på godkjenning",
+        "invoiceStatusDraft": "Utkast",
+        "invoiceNumbers": "Fakturanumre",
+        "invoiceNumbersHelp": "Behold nummeret fra Torqvoice så begge systemer stemmer, eller la Xero nummerere fakturaen i sin egen nummerserie. Med nummerering i Xero velger du fakturanummeret fra Torqvoice som referanse, så du finner igjen hver faktura.",
+        "invoiceNumbersTorqvoice": "Bruk nummeret fra Torqvoice",
+        "invoiceNumbersXero": "La Xero nummerere dem",
+        "reference": "Referanse",
+        "referenceHelp": "Det som legges i referansefeltet (Reference) på fakturaen i Xero. Det vises på fakturaen og i lister og søk der.",
+        "referenceVehicle": "Kjøretøy og registreringsnummer",
+        "referenceTitle": "Jobbtittel",
+        "referenceInvoiceNumber": "Fakturanummer fra Torqvoice",
+        "referenceNone": "Ingenting",
+        "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.",
+        "includePartNumbers": "Vis delenummer på delelinjer",
+        "includePartNumbersHelp": "Beskrivelsen på hver delelinje starter med delenummeret.",
+        "brandingThemeId": "Fakturamal",
+        "brandingThemeIdHelp": "Malen i Xero (Branding theme) som fakturaen bruker. Den bestemmer oppsettet når fakturaen vises eller sendes fra Xero. La stå tomt for å bruke organisasjonens standardmal.",
+        "laborAccount": "Konto for arbeid",
+        "laborAccountHelp": "Inntektskontoen arbeidslinjene bokføres på. La stå tom for å bruke organisasjonens Sales-konto, eller kontoen til varen som er valgt nedenfor.",
+        "partsAccount": "Konto for deler",
+        "partsAccountHelp": "Inntektskontoen delelinjene bokføres på. La stå tom for å bruke organisasjonens Sales-konto, eller kontoen til varen som er valgt nedenfor.",
+        "shopFeeAccount": "Konto for verkstedgebyret",
+        "shopFeeAccountHelp": "Linjen for verkstedgebyret bokføres her. La stå tom for å bokføre den sammen med arbeid.",
+        "laborItem": "Vare for arbeidslinjer",
+        "laborItemHelp": "Valgfri. En vare i Xero som hver arbeidslinje merkes med, for varerapporter i Xero. Kontoen som er valgt ovenfor, går likevel foran.",
+        "partsItem": "Vare for delelinjer",
+        "partsItemHelp": "Valgfri. En vare i Xero som hver delelinje merkes med, for varerapporter i Xero. Kontoen som er valgt ovenfor, går likevel foran.",
+        "matchItemsByPartNumber": "Koble deler til Xero-varer via delenummer",
+        "matchItemsByPartNumberHelp": "En del med samme nummer som varekoden til en vare i Xero selges som den varen, så lagerbeholdningen i Xero går ned. Xero avviser fakturaen når en lagerført vare ikke har nok på lager.",
+        "taxAmounts": "Avgiftsbeløp",
+        "taxAmountsHelp": "Som fakturert sender avgiften på hver linje nøyaktig slik den står på fakturaen kunden fikk, så begge systemer stemmer på øret. Beregnet av Xero lar Xero bruke prosenten i avgiftssatsen på hver linje, og loggen sier fra når totalene er ulike.",
+        "taxAmountsBilled": "Som fakturert i Torqvoice",
+        "taxAmountsXero": "Beregnet av Xero",
+        "taxType": "Avgiftssats for avgiftspliktige linjer",
+        "taxTypeHelp": "Torqvoice velger avgiftssatsen i Xero med samme prosent som hver faktura, for eksempel GST on Income. Velg en her for å alltid bruke den i stedet.",
+        "zeroTaxType": "Avgiftssats for avgiftsfrie linjer",
+        "zeroTaxTypeHelp": "Brukes for linjer uten avgift og for kunder som er fritatt. La stå tomt for å bruke GST Free Income, Zero Rated eller No GST, avhengig av hva organisasjonen har.",
+        "tracking1": "Sporingsvalg",
+        "tracking1Help": "Merk hver linje med et sporingsvalg, for eksempel avdeling eller filial, for rapporter i Xero.",
+        "tracking2": "Sporingsvalg 2",
+        "tracking2Help": "Et valg fra den andre sporingskategorien, hvis organisasjonen har to.",
+        "sendFromXero": "Send fakturaen til kunden på e-post fra Xero",
+        "sendFromXeroHelp": "Xero sender sin egen kopi av fakturaen til kundens e-post, med eventuelle nettbetalingsvalg som er satt opp i Xero. Slå dette av hvis Torqvoice allerede sender fakturaen, så kunden ikke får den to ganger.",
+        "markAsSent": "Merk fakturaer som sendt i Xero",
+        "markAsSentHelp": "Torqvoice leverer fakturaen, så Xero viser den som sendt i stedet for å vente på at noen sender den derfra. Gjelder godkjente fakturaer.",
+        "attachPdf": "Legg ved faktura-PDF-en",
+        "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.",
+        "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",
+        "paymentAccount": "Sett betalinger inn på",
+        "paymentAccountHelp": "Bankkontoen i Xero som betalinger registrert i Torqvoice settes inn på. La stå tom for å bruke den første bankkontoen.",
+        "cashAccount": "Kontantbetalinger inn på",
+        "cardAccount": "Kortbetalinger inn på",
+        "transferAccount": "Bankoverføringer inn på",
+        "onlineAccount": "Nettbetalinger inn på",
+        "methodAccountHelp": "La stå tom for å bruke kontoen ovenfor.",
+        "onlineAccountHelp": "Betalinger tatt imot gjennom Stripe, PayPal, Vipps og de andre betalingsintegrasjonene, ofte en mellomregningskonto. La stå tom for å bruke kontoen ovenfor.",
+        "manualPaidAsPayment": "Registrer en betaling når en faktura markeres som betalt for hånd",
+        "manualPaidAsPaymentHelp": "En faktura markert som betalt i Torqvoice uten betalingsføring får én betaling i Xero for det den fortsatt viser som skyldig der. Avslått blir den stående åpen der til en betaling registreres.",
+        "pullPayments": "Hent tilbake betalinger registrert i Xero",
+        "pullPaymentsHelp": "En betaling registrert i Xero mot en faktura fra Torqvoice registreres her på fakturaen: med en gang hvis webhooken er satt opp, ellers innen 30 minutter."
+      }
+    },
     "fiken": {
       "description": "Send utstedte fakturaer, kundene deres og betalingene deres til Fiken, og hent tilbake betalinger registrert der.",
       "tenantHelp": "Opprett en app i Fiken under Rediger konto, API, for dine egne foretak. Legg inn omdirigerings-URI-en nedenfor på appen, og kopier klient-ID og klienthemmelighet hit. Foretaket må ha Fikens API-modul.",

+ 89 - 0
messages/nl/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in QuickBooks op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
       }
     },
+    "xero": {
+      "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar Xero en haal daar geregistreerde betalingen terug.",
+      "tenantHelp": "Maak een Web app aan op developer.xero.com onder My Apps. Kopieer op de pagina Configuration de client-id, genereer een secret en voeg de redirect-URI hieronder toe. Wil je dat in Xero geregistreerde betalingen direct binnenkomen, open dan de pagina Webhooks van de app, laat die verwijzen naar je Torqvoice-adres gevolgd door /api/webhooks/xero, vink Invoices en Payments aan en vul hieronder de webhooksleutel in.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Clientgeheim",
+        "webhookKey": "Webhooksleutel",
+        "webhookKeyHelp": "Optioneel. De sleutel op de pagina Webhooks van je Xero-app. Hiermee komen in Xero geregistreerde betalingen hier direct binnen; zonder binnen 30 minuten."
+      },
+      "settings": {
+        "tenantId": "Xero-organisatie",
+        "tenantIdHelp": "De organisatie waarin de facturen worden aangemaakt. Alleen de organisaties die je bij het verbinden hebt toegestaan, staan in de lijst; verbind opnieuw om er nog een toe te staan.",
+        "pushInvoices": "Uitgegeven facturen naar Xero sturen",
+        "pushOnComplete": "Facturen ook sturen zodra de klus als voltooid is gemarkeerd",
+        "pushOnCompleteHelp": "Zonder deze optie gaat een factuur over zodra hij is verzonden, gedeeld of betaald. Met deze optie gaat een voltooide klus direct over en wordt hij bijgewerkt als er iets verandert.",
+        "startDate": "Alleen facturen gedateerd op of na",
+        "startDateHelp": "Laat leeg om elke uitgegeven factuur te sturen. Gebruik de datum waarop je boekhouding naar Xero is overgegaan, zodat oudere facturen niet dubbel worden ingevoerd.",
+        "invoiceStatus": "Status in Xero",
+        "invoiceStatusHelp": "Goedgekeurde facturen tellen direct als omzet en kunnen betalingen ontvangen. Kies Concept of Wacht op goedkeuring als iemand elke factuur eerst in Xero controleert; betalingen worden dan geregistreerd zodra de factuur daar is goedgekeurd.",
+        "invoiceStatusAuthorised": "Goedgekeurd",
+        "invoiceStatusSubmitted": "Wacht op goedkeuring",
+        "invoiceStatusDraft": "Concept",
+        "invoiceNumbers": "Factuurnummers",
+        "invoiceNumbersHelp": "Behoud het Torqvoice-nummer zodat beide systemen overeenkomen, of laat Xero de factuur in zijn eigen reeks nummeren. Kies bij nummering door Xero het Torqvoice-factuurnummer als referentie om elke factuur terug te vinden.",
+        "invoiceNumbersTorqvoice": "Het Torqvoice-nummer gebruiken",
+        "invoiceNumbersXero": "Xero laten nummeren",
+        "reference": "Referentie",
+        "referenceHelp": "Wat in het veld Reference van de factuur in Xero komt. Dat staat op de factuur en is daar te zien in lijsten en zoekresultaten.",
+        "referenceVehicle": "Voertuig en kenteken",
+        "referenceTitle": "Titel van de klus",
+        "referenceInvoiceNumber": "Torqvoice-factuurnummer",
+        "referenceNone": "Niets",
+        "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.",
+        "includePartNumbers": "Onderdeelnummers op onderdelenregels tonen",
+        "includePartNumbersHelp": "De omschrijving van elke onderdelenregel begint met het onderdeelnummer.",
+        "brandingThemeId": "Huisstijlthema",
+        "brandingThemeIdHelp": "Het huisstijlthema (Branding theme) in Xero dat de factuur gebruikt. Het bepaalt de opmaak als de factuur in Xero wordt bekeken of vanuit Xero wordt verzonden. Laat leeg voor de standaard van de organisatie.",
+        "laborAccount": "Rekening voor arbeid",
+        "laborAccountHelp": "De omzetrekening waarop arbeidsregels worden geboekt. Laat leeg om de rekening Sales van de organisatie te gebruiken, of de rekening van het artikel dat hieronder is gekozen.",
+        "partsAccount": "Rekening voor onderdelen",
+        "partsAccountHelp": "De omzetrekening waarop onderdelenregels worden geboekt. Laat leeg om de rekening Sales van de organisatie te gebruiken, of de rekening van het artikel dat hieronder is gekozen.",
+        "shopFeeAccount": "Rekening voor de werkplaatstoeslag",
+        "shopFeeAccountHelp": "Hier komt de regel voor de werkplaatstoeslag. Laat leeg om hem bij arbeid te boeken.",
+        "laborItem": "Artikel voor arbeidsregels",
+        "laborItemHelp": "Optioneel. Een Xero-artikel dat aan elke arbeidsregel wordt gekoppeld, voor artikelrapporten in Xero. De hierboven gekozen rekening gaat nog steeds voor.",
+        "partsItem": "Artikel voor onderdelenregels",
+        "partsItemHelp": "Optioneel. Een Xero-artikel dat aan elke onderdelenregel wordt gekoppeld, voor artikelrapporten in Xero. De hierboven gekozen rekening gaat nog steeds voor.",
+        "matchItemsByPartNumber": "Onderdelen op onderdeelnummer aan Xero-artikelen koppelen",
+        "matchItemsByPartNumberHelp": "Een onderdeel waarvan het nummer ook de code van een artikel in Xero is, wordt als dat artikel verkocht, zodat de voorraad die Xero bijhoudt daalt. Xero weigert de factuur als een voorraadartikel niet genoeg op voorraad heeft.",
+        "taxAmounts": "Belastingbedragen",
+        "taxAmountsHelp": "Zoals gefactureerd stuurt de belasting op elke regel precies zoals die op de factuur van de klant staat, zodat beide systemen tot op de cent overeenkomen. Berekend door Xero laat Xero het percentage van het btw-tarief van elke regel toepassen, en het logboek meldt afwijkende totalen.",
+        "taxAmountsBilled": "Zoals gefactureerd in Torqvoice",
+        "taxAmountsXero": "Berekend door Xero",
+        "taxType": "Btw-tarief voor belaste regels",
+        "taxTypeHelp": "Torqvoice kiest het btw-tarief in Xero waarvan het percentage bij elke factuur past, zoals GST on Income. Kies er hier een om altijd dat tarief te gebruiken.",
+        "zeroTaxType": "Btw-tarief voor regels zonder belasting",
+        "zeroTaxTypeHelp": "Gebruikt voor regels zonder belasting en voor vrijgestelde klanten. Laat leeg om GST Free Income, Zero Rated of No GST te gebruiken, afhankelijk van wat de organisatie heeft.",
+        "tracking1": "Trackingoptie",
+        "tracking1Help": "Label elke regel met een trackingoptie, zoals de vestiging of afdeling, voor rapporten in Xero.",
+        "tracking2": "Tweede trackingoptie",
+        "tracking2Help": "Een optie uit de andere trackingcategorie, als de organisatie er twee heeft.",
+        "sendFromXero": "De factuur vanuit Xero naar de klant e-mailen",
+        "sendFromXeroHelp": "Xero stuurt een eigen exemplaar van de factuur naar het e-mailadres van de klant, met eventuele online betaalopties die in Xero zijn ingesteld. Zet dit uit als Torqvoice de factuur al verstuurt, zodat de klant hem niet twee keer krijgt.",
+        "markAsSent": "Facturen in Xero als verzonden markeren",
+        "markAsSentHelp": "Torqvoice bezorgt de factuur, dus Xero toont hem als verzonden in plaats van te wachten tot iemand hem daar verstuurt. Geldt voor goedgekeurde facturen.",
+        "attachPdf": "Factuur-pdf bijvoegen",
+        "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.",
+        "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",
+        "paymentAccount": "Betalingen storten op",
+        "paymentAccountHelp": "De bankrekening in Xero waarop in Torqvoice geregistreerde betalingen worden gezet. Laat leeg om de eerste bankrekening te gebruiken.",
+        "cashAccount": "Contante betalingen storten op",
+        "cardAccount": "Kaartbetalingen storten op",
+        "transferAccount": "Bankoverschrijvingen storten op",
+        "onlineAccount": "Onlinebetalingen storten op",
+        "methodAccountHelp": "Laat leeg om de rekening hierboven te gebruiken.",
+        "onlineAccountHelp": "Betalingen via Stripe, PayPal, Vipps en de andere betaalintegraties, vaak een tussenrekening. Laat leeg om de rekening hierboven te gebruiken.",
+        "manualPaidAsPayment": "Een betaling registreren als een factuur handmatig als betaald wordt gemarkeerd",
+        "manualPaidAsPaymentHelp": "Een factuur die in Torqvoice zonder betalingsregistratie als betaald is gemarkeerd, krijgt in Xero één betaling voor wat daar nog openstaat. Uit blijft hij daar open tot er een betaling wordt geregistreerd.",
+        "pullPayments": "In Xero geregistreerde betalingen terughalen",
+        "pullPaymentsHelp": "Een betaling die in Xero op een factuur van Torqvoice wordt geregistreerd, wordt hier op de factuur vastgelegd, direct met een webhook en anders binnen 30 minuten."
+      }
+    },
     "fiken": {
       "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar Fiken en haal daar geregistreerde betalingen terug.",
       "tenantHelp": "Maak in Fiken onder Rediger konto, API een app aan voor je eigen bedrijven. Vul de redirect-URI hieronder in bij de app en kopieer de client-ID en het client-secret hierheen. Het bedrijf heeft de API-module van Fiken nodig.",

+ 89 - 0
messages/pl/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w QuickBooks za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
       }
     },
+    "xero": {
+      "description": "Wysyłaj wystawione faktury, ich klientów i płatności do Xero i pobieraj płatności zarejestrowane tam.",
+      "tenantHelp": "Utwórz aplikację typu Web app na developer.xero.com w sekcji My Apps. Na jej stronie Configuration skopiuj client id, wygeneruj secret i dodaj poniższy adres URI przekierowania. Aby płatności zarejestrowane w Xero docierały od razu, otwórz stronę Webhooks aplikacji, podaj adres swojego Torqvoice zakończony /api/webhooks/xero, zaznacz Invoices i Payments, a poniżej wpisz klucz webhooka.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta",
+        "webhookKey": "Klucz webhooka",
+        "webhookKeyHelp": "Opcjonalnie. Klucz ze strony Webhooks Twojej aplikacji Xero. Z nim płatności zarejestrowane w Xero docierają tu od razu; bez niego w ciągu 30 minut."
+      },
+      "settings": {
+        "tenantId": "Organizacja w Xero",
+        "tenantIdHelp": "Organizacja, w której zapisywane są faktury. Na liście są tylko organizacje, na które zezwolono przy łączeniu; połącz ponownie, aby zezwolić na kolejną.",
+        "pushInvoices": "Wysyłaj wystawione faktury do Xero",
+        "pushOnComplete": "Wysyłaj faktury także wtedy, gdy zlecenie zostanie oznaczone jako ukończone",
+        "pushOnCompleteHelp": "Bez tej opcji faktura trafia do księgowości po wysłaniu, udostępnieniu lub opłaceniu. Z nią ukończone zlecenie trafia od razu i jest aktualizowane, gdy się zmieni.",
+        "startDate": "Tylko faktury z datą od",
+        "startDateHelp": "Zostaw puste, aby wysyłać każdą wystawioną fakturę. Użyj daty przeniesienia księgowości do Xero, aby starsze faktury nie zostały wprowadzone dwa razy.",
+        "invoiceStatus": "Status w Xero",
+        "invoiceStatusHelp": "Zatwierdzone faktury od razu liczą się jako sprzedaż i mogą przyjmować płatności. Wybierz status Wersja robocza lub Oczekuje na zatwierdzenie, jeśli ktoś najpierw sprawdza każdą fakturę w Xero; płatności są wtedy rejestrowane po jej zatwierdzeniu tam.",
+        "invoiceStatusAuthorised": "Zatwierdzona",
+        "invoiceStatusSubmitted": "Oczekuje na zatwierdzenie",
+        "invoiceStatusDraft": "Wersja robocza",
+        "invoiceNumbers": "Numery faktur",
+        "invoiceNumbersHelp": "Zachowaj numer z Torqvoice, aby oba systemy się zgadzały, albo pozwól Xero numerować fakturę we własnej serii. Przy numeracji Xero wybierz numer faktury Torqvoice jako referencję, aby odnaleźć każdą fakturę.",
+        "invoiceNumbersTorqvoice": "Używaj numeru z Torqvoice",
+        "invoiceNumbersXero": "Pozwól Xero je numerować",
+        "reference": "Referencja",
+        "referenceHelp": "Co trafia do pola Reference faktury w Xero. Jest ono widoczne na fakturze oraz na listach i w wyszukiwaniu.",
+        "referenceVehicle": "Pojazd i numer rejestracyjny",
+        "referenceTitle": "Tytuł zlecenia",
+        "referenceInvoiceNumber": "Numer faktury Torqvoice",
+        "referenceNone": "Nic",
+        "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.",
+        "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",
+        "brandingThemeIdHelp": "Motyw firmowy (Branding theme) w Xero używany przez fakturę. Decyduje o jej układzie przy wyświetlaniu lub wysyłaniu z Xero. Zostaw puste, aby użyć domyślnego motywu organizacji.",
+        "laborAccount": "Konto dla robocizny",
+        "laborAccountHelp": "Konto sprzedaży, na które księgowane są wiersze robocizny. Zostaw puste, aby użyć konta Sales organizacji lub konta pozycji wybranej poniżej.",
+        "partsAccount": "Konto dla części",
+        "partsAccountHelp": "Konto sprzedaży, na które księgowane są wiersze części. Zostaw puste, aby użyć konta Sales organizacji lub konta pozycji wybranej poniżej.",
+        "shopFeeAccount": "Konto dla opłaty warsztatowej",
+        "shopFeeAccountHelp": "Tu trafia wiersz opłaty warsztatowej. Zostaw puste, aby księgować ją razem z robocizną.",
+        "laborItem": "Pozycja dla wierszy robocizny",
+        "laborItemHelp": "Opcjonalnie. Pozycja Xero przypisywana do każdego wiersza robocizny na potrzeby raportów pozycji w Xero. Konto wybrane powyżej i tak ma pierwszeństwo.",
+        "partsItem": "Pozycja dla wierszy części",
+        "partsItemHelp": "Opcjonalnie. Pozycja Xero przypisywana do każdego wiersza części na potrzeby raportów pozycji w Xero. Konto wybrane powyżej i tak ma pierwszeństwo.",
+        "matchItemsByPartNumber": "Dopasowuj części do pozycji Xero po numerze części",
+        "matchItemsByPartNumberHelp": "Część, której numer jest też kodem pozycji w Xero, jest sprzedawana jako ta pozycja, więc stan magazynowy w Xero się zmniejsza. Xero odrzuca fakturę, gdy pozycji magazynowej nie ma wystarczająco na stanie.",
+        "taxAmounts": "Kwoty podatku",
+        "taxAmountsHelp": "Jak na fakturze wysyła podatek każdego wiersza dokładnie taki, jaki widnieje na fakturze otrzymanej przez klienta, więc oba systemy zgadzają się co do grosza. Wyliczony przez Xero pozwala Xero zastosować stawkę podatku każdego wiersza, a dziennik informuje o różnicach w sumach.",
+        "taxAmountsBilled": "Jak na fakturze w Torqvoice",
+        "taxAmountsXero": "Wyliczony przez Xero",
+        "taxType": "Stawka podatku dla wierszy opodatkowanych",
+        "taxTypeHelp": "Torqvoice wybiera stawkę podatku Xero, której procent odpowiada każdej fakturze, np. GST on Income. Wybierz tu jedną, aby zawsze jej używać.",
+        "zeroTaxType": "Stawka podatku dla wierszy bez podatku",
+        "zeroTaxTypeHelp": "Używana dla wierszy bez podatku i klientów zwolnionych z podatku. Zostaw puste, aby użyć GST Free Income, Zero Rated lub No GST, zależnie od tego, co ma organizacja.",
+        "tracking1": "Opcja śledzenia",
+        "tracking1Help": "Oznacz każdy wiersz opcją śledzenia (Tracking w Xero), np. oddziałem lub działem, na potrzeby raportów w Xero.",
+        "tracking2": "Druga opcja śledzenia",
+        "tracking2Help": "Opcja z drugiej kategorii śledzenia, jeśli organizacja ma dwie.",
+        "sendFromXero": "Wysyłaj klientowi fakturę e-mailem z Xero",
+        "sendFromXeroHelp": "Xero wysyła własną kopię faktury na e-mail klienta, z opcjami płatności online skonfigurowanymi w Xero. Wyłącz to, jeśli Torqvoice już wysyła fakturę, aby klient nie dostał jej dwa razy.",
+        "markAsSent": "Oznaczaj faktury w Xero jako wysłane",
+        "markAsSentHelp": "Fakturę dostarcza Torqvoice, więc Xero pokazuje ją jako wysłaną, zamiast czekać, aż ktoś ją tam wyśle. Dotyczy zatwierdzonych faktur.",
+        "attachPdf": "Dołącz PDF faktury",
+        "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.",
+        "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",
+        "paymentAccount": "Wpłacaj płatności na",
+        "paymentAccountHelp": "Konto bankowe w Xero, na które trafiają płatności zarejestrowane w Torqvoice. Zostaw puste, aby użyć pierwszego konta bankowego.",
+        "cashAccount": "Wpłacaj płatności gotówką na",
+        "cardAccount": "Wpłacaj płatności kartą na",
+        "transferAccount": "Wpłacaj przelewy bankowe na",
+        "onlineAccount": "Wpłacaj płatności online na",
+        "methodAccountHelp": "Zostaw puste, aby użyć powyższego konta.",
+        "onlineAccountHelp": "Płatności przyjęte przez Stripe, PayPal, Vipps i inne integracje płatności, często na konto przejściowe. Zostaw puste, aby użyć powyższego konta.",
+        "manualPaidAsPayment": "Rejestruj płatność, gdy faktura zostanie ręcznie oznaczona jako opłacona",
+        "manualPaidAsPaymentHelp": "Faktura oznaczona w Torqvoice jako opłacona bez wpisu płatności otrzymuje w Xero jedną płatność na kwotę, która tam nadal figuruje jako należna. Wyłączone: pozostaje tam otwarta do czasu zarejestrowania płatności.",
+        "pullPayments": "Pobieraj płatności zarejestrowane w Xero",
+        "pullPaymentsHelp": "Płatność zarejestrowana w Xero za fakturę z Torqvoice zostaje zapisana tutaj na fakturze, od razu przez webhook, a bez niego w ciągu 30 minut."
+      }
+    },
     "fiken": {
       "description": "Wysyłaj wystawione faktury, ich klientów i płatności do Fiken i pobieraj płatności zarejestrowane tam.",
       "tenantHelp": "Utwórz aplikację w Fiken w sekcji Rediger konto, API, dla własnych firm. Wpisz w aplikacji poniższy adres URI przekierowania, a następnie skopiuj tutaj jej identyfikator klienta i sekret klienta. Firma musi mieć moduł API Fiken.",

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

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registrado aqui na fatura."
       }
     },
+    "xero": {
+      "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o Xero e traga de volta os pagamentos registrados lá.",
+      "tenantHelp": "Crie um Web app em developer.xero.com, em My Apps. Na página Configuration, copie o client id, gere um secret e adicione a URI de redirecionamento abaixo. Para que os pagamentos registrados no Xero cheguem na hora, abra a página Webhooks do app, aponte-a para o seu endereço do Torqvoice seguido de /api/webhooks/xero, marque Invoices e Payments e informe a chave do webhook abaixo.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente",
+        "webhookKey": "Chave do webhook",
+        "webhookKeyHelp": "Opcional. A chave na página Webhooks do seu app do Xero. Com ela, os pagamentos registrados no Xero chegam aqui na hora; sem ela, em até 30 minutos."
+      },
+      "settings": {
+        "tenantId": "Organização no Xero",
+        "tenantIdHelp": "A organização em que as faturas são lançadas. Só aparecem as organizações que você permitiu ao conectar; conecte de novo para permitir outra.",
+        "pushInvoices": "Enviar faturas emitidas para o Xero",
+        "pushOnComplete": "Enviar faturas também quando o serviço for marcado como concluído",
+        "pushOnCompleteHelp": "Sem isso, uma fatura vai quando é enviada, compartilhada ou paga. Com isso, um serviço concluído vai na hora e é atualizado se mudar.",
+        "startDate": "Somente faturas com data a partir de",
+        "startDateHelp": "Deixe vazio para enviar toda fatura emitida. Use a data em que sua contabilidade passou para o Xero para não lançar faturas antigas duas vezes.",
+        "invoiceStatus": "Status no Xero",
+        "invoiceStatusHelp": "Faturas aprovadas contam como vendas na hora e podem receber pagamentos. Escolha Rascunho ou Aguardando aprovação se alguém confere cada fatura no Xero primeiro; os pagamentos são registrados depois que ela for aprovada lá.",
+        "invoiceStatusAuthorised": "Aprovada",
+        "invoiceStatusSubmitted": "Aguardando aprovação",
+        "invoiceStatusDraft": "Rascunho",
+        "invoiceNumbers": "Números das faturas",
+        "invoiceNumbersHelp": "Mantenha o número do Torqvoice para que os dois sistemas batam, ou deixe o Xero numerar a fatura na própria sequência. Com a numeração do Xero, escolha o número da fatura do Torqvoice como referência para encontrar cada fatura de novo.",
+        "invoiceNumbersTorqvoice": "Usar o número do Torqvoice",
+        "invoiceNumbersXero": "Deixar o Xero numerar",
+        "reference": "Referência",
+        "referenceHelp": "O que vai no campo Reference da fatura no Xero, que aparece na fatura e nas listas e buscas de lá.",
+        "referenceVehicle": "Veículo e placa",
+        "referenceTitle": "Título do serviço",
+        "referenceInvoiceNumber": "Número da fatura do Torqvoice",
+        "referenceNone": "Nada",
+        "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.",
+        "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",
+        "brandingThemeIdHelp": "O tema de marca (Branding theme) do Xero que a fatura usa, que define o layout dela quando vista ou enviada pelo Xero. Deixe vazio para usar o padrão da organização.",
+        "laborAccount": "Conta para mão de obra",
+        "laborAccountHelp": "A conta de vendas em que as linhas de mão de obra são lançadas. Deixe vazio para usar a conta Sales da organização, ou a conta do item escolhido abaixo.",
+        "partsAccount": "Conta para peças",
+        "partsAccountHelp": "A conta de vendas em que as linhas de peças são lançadas. Deixe vazio para usar a conta Sales da organização, ou a conta do item escolhido abaixo.",
+        "shopFeeAccount": "Conta para a taxa de oficina",
+        "shopFeeAccountHelp": "A linha da taxa de oficina é lançada aqui. Deixe vazio para lançá-la junto com a mão de obra.",
+        "laborItem": "Item para linhas de mão de obra",
+        "laborItemHelp": "Opcional. Um item do Xero marcado em cada linha de mão de obra, para os relatórios de itens no Xero. A conta escolhida acima tem prioridade.",
+        "partsItem": "Item para linhas de peças",
+        "partsItemHelp": "Opcional. Um item do Xero marcado em cada linha de peça, para os relatórios de itens no Xero. A conta escolhida acima tem prioridade.",
+        "matchItemsByPartNumber": "Associar peças a itens do Xero pelo número da peça",
+        "matchItemsByPartNumberHelp": "Uma peça cujo número também é o código de um item no Xero é vendida como esse item, então o estoque controlado no Xero diminui. O Xero recusa a fatura quando um item controlado não tem quantidade suficiente em estoque.",
+        "taxAmounts": "Valores de imposto",
+        "taxAmountsHelp": "Como faturado envia o imposto de cada linha exatamente como consta na fatura que o cliente recebeu, então os dois sistemas batem ao centavo. Calculado pelo Xero deixa o Xero aplicar a alíquota de imposto de cada linha, e o registro avisa quando os totais diferem.",
+        "taxAmountsBilled": "Como faturado no Torqvoice",
+        "taxAmountsXero": "Calculado pelo Xero",
+        "taxType": "Alíquota de imposto para linhas tributadas",
+        "taxTypeHelp": "O Torqvoice escolhe a alíquota de imposto do Xero cujo percentual bate com cada fatura, como GST on Income. Escolha uma aqui para usá-la sempre.",
+        "zeroTaxType": "Alíquota de imposto para linhas sem imposto",
+        "zeroTaxTypeHelp": "Usada para linhas sem imposto e para clientes isentos. Deixe vazio para usar GST Free Income, Zero Rated ou No GST, a que a organização tiver.",
+        "tracking1": "Opção de rastreamento",
+        "tracking1Help": "Marque cada linha com uma opção de rastreamento (Tracking), como a filial ou o departamento, para relatórios no Xero.",
+        "tracking2": "Segunda opção de rastreamento",
+        "tracking2Help": "Uma opção da outra categoria de rastreamento, se a organização tiver duas.",
+        "sendFromXero": "Enviar a fatura por e-mail ao cliente pelo Xero",
+        "sendFromXeroHelp": "O Xero envia a própria cópia da fatura para o e-mail do cliente, com as opções de pagamento online configuradas no Xero. Desative isto se o Torqvoice já envia a fatura, para o cliente não recebê-la duas vezes.",
+        "markAsSent": "Marcar faturas como enviadas no Xero",
+        "markAsSentHelp": "O Torqvoice entrega a fatura, então o Xero a mostra como enviada em vez de esperar alguém enviá-la por lá. Vale para faturas aprovadas.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "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.",
+        "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",
+        "paymentAccount": "Depositar pagamentos em",
+        "paymentAccountHelp": "A conta bancária do Xero em que entram os pagamentos registrados no Torqvoice. Deixe vazio para usar a primeira conta bancária.",
+        "cashAccount": "Pagamentos em dinheiro em",
+        "cardAccount": "Pagamentos com cartão em",
+        "transferAccount": "Transferências bancárias em",
+        "onlineAccount": "Pagamentos online em",
+        "methodAccountHelp": "Deixe vazio para usar a conta acima.",
+        "onlineAccountHelp": "Pagamentos recebidos pelo Stripe, PayPal, Vipps e as outras integrações de pagamento, muitas vezes uma conta transitória. Deixe vazio para usar a conta acima.",
+        "manualPaidAsPayment": "Registrar um pagamento quando uma fatura for marcada como paga manualmente",
+        "manualPaidAsPaymentHelp": "Uma fatura marcada como paga no Torqvoice sem lançamento de pagamento recebe no Xero um pagamento pelo valor que ainda consta em aberto lá. Desligado, ela fica aberta lá até que um pagamento seja registrado.",
+        "pullPayments": "Trazer de volta pagamentos registrados no Xero",
+        "pullPaymentsHelp": "Um pagamento registrado no Xero para uma fatura do Torqvoice é registrado aqui na fatura, na hora com um webhook, ou então em até 30 minutos."
+      }
+    },
     "fiken": {
       "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o Fiken e traga de volta os pagamentos registrados lá.",
       "tenantHelp": "Crie um app no Fiken em Rediger konto, API, para as suas próprias empresas. Informe no app a URI de redirecionamento abaixo e copie para cá o ID do cliente e o segredo do cliente. A empresa precisa do módulo de API do Fiken.",

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

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registado aqui na fatura."
       }
     },
+    "xero": {
+      "description": "Envie as faturas emitidas, os respetivos clientes e pagamentos para o Xero e receba de volta os pagamentos lá registados.",
+      "tenantHelp": "Crie uma Web app em developer.xero.com, em My Apps. Na página Configuration, copie o client id, gere um secret e adicione o URI de redirecionamento abaixo. Para que os pagamentos registados no Xero cheguem de imediato, abra a página Webhooks da app, aponte-a para o seu endereço do Torqvoice seguido de /api/webhooks/xero, assinale Invoices e Payments e introduza a chave do webhook abaixo.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente",
+        "webhookKey": "Chave do webhook",
+        "webhookKeyHelp": "Opcional. A chave na página Webhooks da sua app do Xero. Com ela, os pagamentos registados no Xero chegam aqui de imediato; sem ela, no prazo de 30 minutos."
+      },
+      "settings": {
+        "tenantId": "Organização no Xero",
+        "tenantIdHelp": "A organização onde as faturas são lançadas. Só aparecem as organizações que permitiu ao ligar; volte a ligar para permitir outra.",
+        "pushInvoices": "Enviar as faturas emitidas para o Xero",
+        "pushOnComplete": "Enviar faturas também quando o serviço for marcado como concluído",
+        "pushOnCompleteHelp": "Sem isto, uma fatura é enviada quando é enviada ao cliente, partilhada ou paga. Com isto, um serviço concluído é enviado de imediato e atualizado se mudar.",
+        "startDate": "Só faturas com data a partir de",
+        "startDateHelp": "Deixe vazio para enviar todas as faturas emitidas. Utilize a data em que a sua contabilidade passou para o Xero, para não lançar faturas antigas duas vezes.",
+        "invoiceStatus": "Estado no Xero",
+        "invoiceStatusHelp": "As faturas aprovadas contam de imediato como vendas e podem receber pagamentos. Escolha Rascunho ou A aguardar aprovação se alguém verifica primeiro cada fatura no Xero; os pagamentos são então registados quando for lá aprovada.",
+        "invoiceStatusAuthorised": "Aprovada",
+        "invoiceStatusSubmitted": "A aguardar aprovação",
+        "invoiceStatusDraft": "Rascunho",
+        "invoiceNumbers": "Números das faturas",
+        "invoiceNumbersHelp": "Mantenha o número do Torqvoice para que os dois sistemas coincidam, ou deixe o Xero numerar a fatura na sua própria sequência. Com a numeração do Xero, escolha o número da fatura do Torqvoice como referência para voltar a encontrar cada fatura.",
+        "invoiceNumbersTorqvoice": "Usar o número do Torqvoice",
+        "invoiceNumbersXero": "Deixar o Xero numerar",
+        "reference": "Referência",
+        "referenceHelp": "O que vai no campo Reference da fatura no Xero, que aparece na fatura e nas listas e pesquisas de lá.",
+        "referenceVehicle": "Viatura e matrícula",
+        "referenceTitle": "Título do serviço",
+        "referenceInvoiceNumber": "Número da fatura do Torqvoice",
+        "referenceNone": "Nada",
+        "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.",
+        "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",
+        "brandingThemeIdHelp": "O tema de marca (Branding theme) do Xero que a fatura utiliza, que define o seu aspeto quando é vista ou enviada a partir do Xero. Deixe vazio para usar o predefinido da organização.",
+        "laborAccount": "Conta para mão de obra",
+        "laborAccountHelp": "A conta de vendas onde são lançadas as linhas de mão de obra. Deixe vazio para usar a conta Sales da organização, ou a conta do item escolhido abaixo.",
+        "partsAccount": "Conta para peças",
+        "partsAccountHelp": "A conta de vendas onde são lançadas as linhas de peças. Deixe vazio para usar a conta Sales da organização, ou a conta do item escolhido abaixo.",
+        "shopFeeAccount": "Conta para a taxa de oficina",
+        "shopFeeAccountHelp": "A linha da taxa de oficina é lançada aqui. Deixe vazio para a lançar com a mão de obra.",
+        "laborItem": "Item para linhas de mão de obra",
+        "laborItemHelp": "Opcional. Um item do Xero associado a cada linha de mão de obra, para os relatórios de itens no Xero. A conta escolhida acima tem prioridade.",
+        "partsItem": "Item para linhas de peças",
+        "partsItemHelp": "Opcional. Um item do Xero associado a cada linha de peça, para os relatórios de itens no Xero. A conta escolhida acima tem prioridade.",
+        "matchItemsByPartNumber": "Associar peças a itens do Xero pela referência da peça",
+        "matchItemsByPartNumberHelp": "Uma peça cuja referência é também o código de um item no Xero é vendida como esse item, pelo que o stock controlado no Xero diminui. O Xero recusa a fatura quando um item controlado não tem quantidade suficiente em stock.",
+        "taxAmounts": "Valores de imposto",
+        "taxAmountsHelp": "Como faturado envia o imposto de cada linha exatamente como consta na fatura que o cliente recebeu, pelo que os dois sistemas batem ao cêntimo. Calculado pelo Xero deixa o Xero aplicar a taxa de imposto de cada linha, e o registo indica quando os totais diferem.",
+        "taxAmountsBilled": "Como faturado no Torqvoice",
+        "taxAmountsXero": "Calculado pelo Xero",
+        "taxType": "Taxa de imposto para linhas tributadas",
+        "taxTypeHelp": "O Torqvoice escolhe a taxa de imposto do Xero cuja percentagem corresponde a cada fatura, como GST on Income. Escolha aqui uma para a usar sempre.",
+        "zeroTaxType": "Taxa de imposto para linhas sem imposto",
+        "zeroTaxTypeHelp": "Utilizada para linhas sem imposto e para clientes isentos. Deixe vazio para usar GST Free Income, Zero Rated ou No GST, a que a organização tiver.",
+        "tracking1": "Opção de rastreio",
+        "tracking1Help": "Associe a cada linha uma opção de rastreio (Tracking), como a filial ou o departamento, para os relatórios no Xero.",
+        "tracking2": "Segunda opção de rastreio",
+        "tracking2Help": "Uma opção da outra categoria de rastreio, se a organização tiver duas.",
+        "sendFromXero": "Enviar a fatura ao cliente por e-mail a partir do Xero",
+        "sendFromXeroHelp": "O Xero envia a sua própria cópia da fatura para o e-mail do cliente, com as opções de pagamento online configuradas no Xero. Desative esta opção se o Torqvoice já envia a fatura, para o cliente não a receber duas vezes.",
+        "markAsSent": "Marcar as faturas como enviadas no Xero",
+        "markAsSentHelp": "O Torqvoice entrega a fatura, por isso o Xero mostra-a como enviada em vez de esperar que alguém a envie a partir de lá. Aplica-se a faturas aprovadas.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "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.",
+        "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",
+        "paymentAccount": "Depositar pagamentos em",
+        "paymentAccountHelp": "A conta bancária do Xero onde entram os pagamentos registados no Torqvoice. Deixe vazio para usar a primeira conta bancária.",
+        "cashAccount": "Pagamentos em numerário em",
+        "cardAccount": "Pagamentos com cartão em",
+        "transferAccount": "Transferências bancárias em",
+        "onlineAccount": "Pagamentos online em",
+        "methodAccountHelp": "Deixe vazio para usar a conta acima.",
+        "onlineAccountHelp": "Pagamentos recebidos através do Stripe, PayPal, Vipps e das outras integrações de pagamento, muitas vezes numa conta transitória. Deixe vazio para usar a conta acima.",
+        "manualPaidAsPayment": "Registar um pagamento quando uma fatura for marcada manualmente como paga",
+        "manualPaidAsPaymentHelp": "Uma fatura marcada como paga no Torqvoice sem lançamento de pagamento recebe no Xero um pagamento pelo valor que lá ainda consta em aberto. Se estiver desativado, fica lá em aberto até ser registado um pagamento.",
+        "pullPayments": "Receber de volta os pagamentos registados no Xero",
+        "pullPaymentsHelp": "Um pagamento registado no Xero para uma fatura do Torqvoice é registado aqui na fatura, de imediato com um webhook, caso contrário no prazo de 30 minutos."
+      }
+    },
     "fiken": {
       "description": "Envie as faturas emitidas, os respetivos clientes e pagamentos para o Fiken e receba de volta os pagamentos lá registados.",
       "tenantHelp": "Crie uma aplicação no Fiken em Rediger konto, API, para as suas próprias empresas. Introduza na aplicação o URI de redirecionamento abaixo e copie para aqui o ID de cliente e o segredo de cliente. A empresa precisa do módulo de API do Fiken.",

+ 89 - 0
messages/ru/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Каждые 30 минут. Платёж, полученный в QuickBooks по счёту из Torqvoice, записывается здесь на счёт."
       }
     },
+    "xero": {
+      "description": "Отправляйте выставленные счета, их клиентов и платежи в Xero и получайте обратно платежи, записанные там.",
+      "tenantHelp": "Создайте приложение типа Web app на developer.xero.com в разделе My Apps. На его странице Configuration скопируйте client id, сгенерируйте secret и добавьте URI перенаправления ниже. Чтобы платежи, записанные в Xero, приходили сразу, откройте страницу Webhooks приложения, укажите адрес вашего Torqvoice с /api/webhooks/xero в конце, отметьте Invoices и Payments и введите ключ вебхука ниже.",
+      "fields": {
+        "clientId": "Идентификатор клиента",
+        "clientSecret": "Секрет клиента",
+        "webhookKey": "Ключ вебхука",
+        "webhookKeyHelp": "Необязательно. Ключ со страницы Webhooks вашего приложения Xero. С ним платежи, записанные в Xero, приходят сюда сразу; без него в течение 30 минут."
+      },
+      "settings": {
+        "tenantId": "Организация Xero",
+        "tenantIdHelp": "Организация, в которую записываются счета. В списке только организации, к которым вы разрешили доступ при подключении; переподключитесь, чтобы разрешить ещё одну.",
+        "pushInvoices": "Отправлять выставленные счета в Xero",
+        "pushOnComplete": "Также отправлять счета, когда заказ отмечен как выполненный",
+        "pushOnCompleteHelp": "Без этого счёт уходит, когда он отправлен, передан по ссылке или оплачен. С этим выполненный заказ уходит сразу и обновляется при изменениях.",
+        "startDate": "Только счета с датой не раньше",
+        "startDateHelp": "Оставьте пустым, чтобы отправлять все выставленные счета. Укажите дату перехода бухгалтерии на Xero, чтобы старые счета не вводились дважды.",
+        "invoiceStatus": "Статус в Xero",
+        "invoiceStatusHelp": "Утверждённые счета сразу учитываются как продажи и могут принимать платежи. Выберите «Черновик» или «Ожидает утверждения», если кто-то сначала проверяет каждый счёт в Xero; тогда платежи записываются, когда счёт там утвердят.",
+        "invoiceStatusAuthorised": "Утверждён",
+        "invoiceStatusSubmitted": "Ожидает утверждения",
+        "invoiceStatusDraft": "Черновик",
+        "invoiceNumbers": "Номера счетов",
+        "invoiceNumbersHelp": "Сохраните номер Torqvoice, чтобы обе системы совпадали, или пусть Xero нумерует счёт в своей последовательности. При нумерации Xero выберите номер счёта Torqvoice для поля Reference, чтобы находить каждый счёт.",
+        "invoiceNumbersTorqvoice": "Использовать номер Torqvoice",
+        "invoiceNumbersXero": "Пусть нумерует Xero",
+        "reference": "Поле Reference",
+        "referenceHelp": "Что попадает в поле Reference счёта в Xero. Оно видно на счёте, в списках и в поиске там.",
+        "referenceVehicle": "Автомобиль и номер",
+        "referenceTitle": "Название заказа",
+        "referenceInvoiceNumber": "Номер счёта Torqvoice",
+        "referenceNone": "Ничего",
+        "includeVehicle": "Указывать автомобиль в счёте",
+        "includeVehicleHelp": "Текстовая строка над позициями с годом, маркой, моделью, номером и пробегом.",
+        "includeNotes": "Добавлять заметки для клиента",
+        "includeNotesHelp": "Заметки для клиента из счёта, текстовой строкой под позициями.",
+        "includePartNumbers": "Показывать артикулы в строках запчастей",
+        "includePartNumbersHelp": "Описание каждой строки запчасти начинается с её артикула.",
+        "brandingThemeId": "Тема оформления",
+        "brandingThemeIdHelp": "Тема оформления (Branding theme) Xero, которую использует счёт. Она определяет его вид при просмотре или отправке из Xero. Оставьте пустым для темы организации по умолчанию.",
+        "laborAccount": "Счёт доходов для работ",
+        "laborAccountHelp": "Счёт продаж, на который проводятся строки работ. Оставьте пустым, чтобы использовать счёт Sales организации или счёт товара, выбранного ниже.",
+        "partsAccount": "Счёт доходов для запчастей",
+        "partsAccountHelp": "Счёт продаж, на который проводятся строки запчастей. Оставьте пустым, чтобы использовать счёт Sales организации или счёт товара, выбранного ниже.",
+        "shopFeeAccount": "Счёт доходов для сбора мастерской",
+        "shopFeeAccountHelp": "Сюда попадает строка сбора мастерской. Оставьте пустым, чтобы проводить его вместе с работами.",
+        "laborItem": "Товар для строк работ",
+        "laborItemHelp": "Необязательно. Товар Xero, которым помечается каждая строка работ, для отчётов по товарам в Xero. Выбранный выше счёт всё равно имеет приоритет.",
+        "partsItem": "Товар для строк запчастей",
+        "partsItemHelp": "Необязательно. Товар Xero, которым помечается каждая строка запчастей, для отчётов по товарам в Xero. Выбранный выше счёт всё равно имеет приоритет.",
+        "matchItemsByPartNumber": "Сопоставлять запчасти с товарами Xero по артикулу",
+        "matchItemsByPartNumberHelp": "Запчасть, артикул которой совпадает с кодом товара в Xero, продаётся как этот товар, и остаток, который ведёт Xero, уменьшается. Xero отклоняет счёт, если складского товара не хватает в наличии.",
+        "taxAmounts": "Суммы налога",
+        "taxAmountsHelp": "Как в счёте передаёт налог каждой строки ровно таким, каким он указан в счёте клиента, поэтому обе системы сходятся до копейки. Рассчитывает Xero позволяет Xero применить ставку налога каждой строки; журнал сообщает о расхождении итогов.",
+        "taxAmountsBilled": "Как в счёте Torqvoice",
+        "taxAmountsXero": "Рассчитывает Xero",
+        "taxType": "Налоговая ставка для облагаемых строк",
+        "taxTypeHelp": "Torqvoice выбирает налоговую ставку Xero, процент которой соответствует каждому счёту, например GST on Income. Выберите ставку здесь, чтобы всегда использовать её.",
+        "zeroTaxType": "Налоговая ставка для строк без налога",
+        "zeroTaxTypeHelp": "Используется для строк без налога и для клиентов, освобождённых от налога. Оставьте пустым, чтобы использовать GST Free Income, Zero Rated или No GST, смотря что есть в организации.",
+        "tracking1": "Опция отслеживания",
+        "tracking1Help": "Каждая строка помечается опцией отслеживания (Tracking в Xero), например филиалом или отделом, для отчётов в Xero.",
+        "tracking2": "Вторая опция отслеживания",
+        "tracking2Help": "Опция из другой категории отслеживания, если в организации их две.",
+        "sendFromXero": "Отправлять клиенту счёт по электронной почте из Xero",
+        "sendFromXeroHelp": "Xero отправляет свою копию счёта на электронную почту клиента со способами онлайн-оплаты, настроенными в Xero. Выключите это, если счёт уже отправляет Torqvoice, чтобы клиент не получил его дважды.",
+        "markAsSent": "Отмечать счета в Xero как отправленные",
+        "markAsSentHelp": "Счёт доставляет Torqvoice, поэтому Xero показывает его как отправленный, а не ждёт, пока его отправят оттуда. Относится к утверждённым счетам.",
+        "attachPdf": "Прикреплять PDF счёта",
+        "attachPdfHelp": "Счёт, который клиент получил от Torqvoice, прикрепляется к счёту в Xero.",
+        "updateContacts": "Обновлять данные клиентов в Xero",
+        "updateContactsHelp": "Изменение имени, электронной почты, телефона, адреса или ИНН клиента здесь копируется в его контакт в Xero. Выключено: контакты только создаются, а затем остаются такими, какими их ведёт бухгалтер.",
+        "voidOnDelete": "Аннулировать счёт в Xero, когда заказ удалён здесь",
+        "voidOnDeleteHelp": "Счёт с платежами в Xero никогда не аннулируется; журнал сообщает, что его оставили без изменений.",
+        "pushPayments": "Записывать платежи в Xero",
+        "paymentAccount": "Зачислять платежи на",
+        "paymentAccountHelp": "Банковский счёт в Xero, на который зачисляются платежи, записанные в Torqvoice. Оставьте пустым, чтобы использовать первый банковский счёт.",
+        "cashAccount": "Зачислять наличные на",
+        "cardAccount": "Зачислять оплату картой на",
+        "transferAccount": "Зачислять банковские переводы на",
+        "onlineAccount": "Зачислять онлайн-платежи на",
+        "methodAccountHelp": "Оставьте пустым, чтобы использовать счёт выше.",
+        "onlineAccountHelp": "Платежи через Stripe, PayPal, Vipps и другие платёжные интеграции, часто на транзитный счёт. Оставьте пустым, чтобы использовать счёт выше.",
+        "manualPaidAsPayment": "Записывать платёж, когда счёт отмечен оплаченным вручную",
+        "manualPaidAsPaymentHelp": "Счёт, отмеченный в Torqvoice как оплаченный без записи платежа, получает в Xero один платёж на сумму, которая там ещё числится к оплате. Выключено: он остаётся там открытым, пока не будет записан платёж.",
+        "pullPayments": "Забирать платежи, записанные в Xero",
+        "pullPaymentsHelp": "Платёж, записанный в Xero по счёту из Torqvoice, записывается здесь на счёт: сразу при наличии вебхука, иначе в течение 30 минут."
+      }
+    },
     "fiken": {
       "description": "Отправляйте выставленные счета, их клиентов и платежи в Fiken и получайте обратно платежи, записанные там.",
       "tenantHelp": "Создайте приложение в Fiken в разделе Rediger konto, API для своих компаний. Укажите в приложении URI перенаправления ниже и скопируйте сюда его идентификатор клиента и секрет клиента. Компании нужен модуль API Fiken.",

+ 89 - 0
messages/tr/integrations.json

@@ -740,6 +740,95 @@
         "pullPaymentsHelp": "Her 30 dakikada bir. QuickBooks'ta bir Torqvoice faturası için alınan ödeme burada faturaya kaydedilir."
       }
     },
+    "xero": {
+      "description": "Kesilen faturaları, müşterilerini ve ödemelerini Xero'ya gönderin ve orada kaydedilen ödemeleri geri alın.",
+      "tenantHelp": "developer.xero.com adresinde My Apps altında bir Web app oluşturun. Configuration sayfasında client id'yi kopyalayın, bir secret oluşturun ve aşağıdaki yönlendirme URI'sini ekleyin. Xero'da kaydedilen ödemelerin hemen gelmesi için uygulamanın Webhooks sayfasını açın, Torqvoice adresinizin sonuna /api/webhooks/xero ekleyip bunu adres olarak girin, Invoices ile Payments'ı işaretleyin ve webhook anahtarını aşağıya yazın.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci gizli anahtarı",
+        "webhookKey": "Webhook anahtarı",
+        "webhookKeyHelp": "İsteğe bağlı. Xero uygulamanızın Webhooks sayfasındaki anahtar. Bununla Xero'da kaydedilen ödemeler buraya hemen gelir; onsuz 30 dakika içinde gelir."
+      },
+      "settings": {
+        "tenantId": "Xero kuruluşu",
+        "tenantIdHelp": "Faturaların yazıldığı kuruluş. Yalnızca bağlanırken izin verdiğiniz kuruluşlar listelenir; başka birine izin vermek için yeniden bağlanın.",
+        "pushInvoices": "Kesilen faturaları Xero'ya gönder",
+        "pushOnComplete": "İş tamamlandı olarak işaretlendiğinde faturaları da gönder",
+        "pushOnCompleteHelp": "Bu olmadan fatura gönderildiğinde, paylaşıldığında veya ödendiğinde aktarılır. Bununla tamamlanan iş hemen aktarılır ve değişirse güncellenir.",
+        "startDate": "Yalnızca şu tarih ve sonrasındaki faturalar",
+        "startDateHelp": "Kesilen her faturayı göndermek için boş bırakın. Eski faturaların iki kez girilmemesi için defterlerinizin Xero'ya taşındığı tarihi kullanın.",
+        "invoiceStatus": "Xero'daki durum",
+        "invoiceStatusHelp": "Onaylanmış faturalar hemen satış sayılır ve ödeme alabilir. Her faturayı önce Xero'da biri kontrol ediyorsa Taslak veya Onay bekliyor seçin; ödemeler o zaman fatura orada onaylandıktan sonra kaydedilir.",
+        "invoiceStatusAuthorised": "Onaylandı",
+        "invoiceStatusSubmitted": "Onay bekliyor",
+        "invoiceStatusDraft": "Taslak",
+        "invoiceNumbers": "Fatura numaraları",
+        "invoiceNumbersHelp": "İki sistemin uyuşması için Torqvoice numarasını koruyun ya da Xero'nun faturayı kendi sırasıyla numaralandırmasına izin verin. Xero numaralandırmasında her faturayı yeniden bulmak için referans olarak Torqvoice fatura numarasını seçin.",
+        "invoiceNumbersTorqvoice": "Torqvoice numarasını kullan",
+        "invoiceNumbersXero": "Xero numaralandırsın",
+        "reference": "Referans",
+        "referenceHelp": "Faturanın Xero'daki Reference alanına yazılan bilgi; faturada ve oradaki listelerle aramalarda görünür.",
+        "referenceVehicle": "Araç ve plaka",
+        "referenceTitle": "İş başlığı",
+        "referenceInvoiceNumber": "Torqvoice fatura numarası",
+        "referenceNone": "Hiçbiri",
+        "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.",
+        "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ı",
+        "brandingThemeIdHelp": "Faturanın kullandığı Xero marka teması (Branding theme); fatura Xero'da görüntülendiğinde veya oradan gönderildiğinde düzenini belirler. Kuruluşun varsayılanı için boş bırakın.",
+        "laborAccount": "İşçilik hesabı",
+        "laborAccountHelp": "İşçilik satırlarının kaydedildiği satış hesabı. Kuruluşun Sales hesabını veya aşağıda seçilen kalemin hesabını kullanmak için boş bırakın.",
+        "partsAccount": "Parça hesabı",
+        "partsAccountHelp": "Parça satırlarının kaydedildiği satış hesabı. Kuruluşun Sales hesabını veya aşağıda seçilen kalemin hesabını kullanmak için boş bırakın.",
+        "shopFeeAccount": "Atölye ücreti hesabı",
+        "shopFeeAccountHelp": "Atölye ücreti satırı buraya kaydedilir. İşçilikle birlikte kaydetmek için boş bırakın.",
+        "laborItem": "İşçilik satırları için kalem",
+        "laborItemHelp": "İsteğe bağlı. Xero'daki kalem raporları için her işçilik satırına eklenen bir Xero kalemi. Yukarıda seçilen hesap yine önceliklidir.",
+        "partsItem": "Parça satırları için kalem",
+        "partsItemHelp": "İsteğe bağlı. Xero'daki kalem raporları için her parça satırına eklenen bir Xero kalemi. Yukarıda seçilen hesap yine önceliklidir.",
+        "matchItemsByPartNumber": "Parçaları parça numarasına göre Xero kalemleriyle eşleştir",
+        "matchItemsByPartNumberHelp": "Numarası Xero'daki bir kalemin koduyla aynı olan parça o kalem olarak satılır; böylece Xero'da takip edilen stok düşer. Takip edilen bir kalemin eldeki miktarı yetmezse Xero faturayı reddeder.",
+        "taxAmounts": "Vergi tutarları",
+        "taxAmountsHelp": "Faturadaki gibi, her satırın vergisini müşterinin aldığı faturadaki haliyle gönderir; böylece iki sistem kuruşuna kadar uyuşur. Xero hesaplasın, Xero'nun her satırın vergi oranını uygulamasına izin verir; toplamlar farklıysa günlük bildirir.",
+        "taxAmountsBilled": "Torqvoice faturasındaki gibi",
+        "taxAmountsXero": "Xero hesaplasın",
+        "taxType": "Vergili satırlar için vergi oranı",
+        "taxTypeHelp": "Torqvoice, yüzdesi her faturayla eşleşen Xero vergi oranını seçer; örneğin GST on Income. Her zaman birini kullanmak için burada seçin.",
+        "zeroTaxType": "Vergisiz satırlar için vergi oranı",
+        "zeroTaxTypeHelp": "Vergisiz satırlar ve vergiden muaf müşteriler için kullanılır. Kuruluşta hangisi varsa GST Free Income, Zero Rated veya No GST kullanmak için boş bırakın.",
+        "tracking1": "İzleme seçeneği",
+        "tracking1Help": "Xero'daki raporlar için her satırı şube veya departman gibi bir izleme (Tracking) seçeneğiyle etiketleyin.",
+        "tracking2": "İkinci izleme seçeneği",
+        "tracking2Help": "Kuruluşta iki izleme kategorisi varsa diğerinden bir seçenek.",
+        "sendFromXero": "Faturayı müşteriye Xero'dan e-postayla gönder",
+        "sendFromXeroHelp": "Xero, faturanın kendi kopyasını Xero'da ayarlanmış çevrimiçi ödeme seçenekleriyle birlikte müşterinin e-postasına gönderir. Torqvoice faturayı zaten gönderiyorsa müşteri iki kez almasın diye bunu kapatın.",
+        "markAsSent": "Faturaları Xero'da gönderildi olarak işaretle",
+        "markAsSentHelp": "Faturayı Torqvoice teslim eder; böylece Xero, birinin oradan göndermesini beklemek yerine onu gönderildi olarak gösterir. Onaylanmış faturalar için geçerlidir.",
+        "attachPdf": "Fatura PDF'ini ekle",
+        "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.",
+        "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",
+        "paymentAccount": "Ödemeleri şuraya yatır",
+        "paymentAccountHelp": "Torqvoice'ta kaydedilen ödemelerin Xero'da yatırıldığı banka hesabı. İlk banka hesabını kullanmak için boş bırakın.",
+        "cashAccount": "Nakit ödemeleri şuraya yatır",
+        "cardAccount": "Kart ödemelerini şuraya yatır",
+        "transferAccount": "Havaleleri şuraya yatır",
+        "onlineAccount": "Çevrimiçi ödemeleri şuraya yatır",
+        "methodAccountHelp": "Yukarıdaki hesabı kullanmak için boş bırakın.",
+        "onlineAccountHelp": "Stripe, PayPal, Vipps ve diğer ödeme entegrasyonları üzerinden alınan ödemeler; genellikle bir ara hesap. Yukarıdaki hesabı kullanmak için boş bırakın.",
+        "manualPaidAsPayment": "Fatura elle ödendi olarak işaretlendiğinde ödeme kaydet",
+        "manualPaidAsPaymentHelp": "Torqvoice'ta ödeme kaydı olmadan ödendi olarak işaretlenen bir fatura, Xero'da hâlâ alacak görünen tutar kadar tek bir ödeme alır. Kapalıysa bir ödeme kaydedilene kadar orada açık kalır.",
+        "pullPayments": "Xero'da kaydedilen ödemeleri geri al",
+        "pullPaymentsHelp": "Xero'da bir Torqvoice faturası için kaydedilen ödeme burada faturaya kaydedilir; webhook ile hemen, aksi halde 30 dakika içinde."
+      }
+    },
     "fiken": {
       "description": "Kesilen faturaları, müşterilerini ve ödemelerini Fiken'e gönderin ve orada kaydedilen ödemeleri geri alın.",
       "tenantHelp": "Fiken'de Rediger konto, API altında kendi şirketleriniz için bir uygulama oluşturun. Aşağıdaki yönlendirme URI'sini uygulamaya girin, ardından istemci kimliğini ve istemci gizli anahtarını buraya kopyalayın. Şirketin Fiken API modülüne ihtiyacı vardır.",

+ 13 - 0
public/images/integrations/xero.svg

@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Xero">
+  <circle cx="32" cy="32" r="32" fill="#13B5EA"/>
+  <g fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" transform="translate(1.25 0)">
+    <!-- x: two strokes crossing -->
+    <path d="M9.5 26.5l8 11M17.5 26.5l-8 11"/>
+    <!-- e: a ring cut by its bar, open at the lower right -->
+    <path d="M20 32h11a5.5 5.5 0 1 0-1.6 3.9"/>
+    <!-- r: stem and shoulder -->
+    <path d="M34 37.5V31a4.5 4.5 0 0 1 4.5-4.5"/>
+    <!-- o -->
+    <circle cx="46.5" cy="32" r="5.5"/>
+  </g>
+</svg>

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

@@ -0,0 +1,723 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import {
+  ConnectorHttpError,
+  type ConnectorContext,
+  type JobOutcome,
+  type LinkRecord,
+  type LogLevel,
+} from '@/features/integrations/Lib/types'
+import { salesRates } from '@/integrations/xero/tax'
+
+/**
+ * The connector against a scripted Xero: every request it makes is recorded
+ * and answered from here, so the URLs, headers and bodies that would reach
+ * Xero are asserted without an organisation. The loaders that read this
+ * app's database are replaced with fixtures.
+ */
+
+const loadInvoice = vi.fn<(orgId: string, id: string) => Promise<AccountingInvoice | null>>()
+const loadPayment = vi.fn<(orgId: string, id: string) => Promise<AccountingPayment | null>>()
+const loadCustomer = vi.fn<(orgId: string, id: string) => Promise<AccountingCustomer | null>>()
+const recordPulledPayment = vi.fn()
+const removePulledPayment = vi.fn()
+
+vi.mock('@/features/integrations/Lib/accounting-sync', () => ({
+  INVOICE_ENTITY: 'ServiceRecord',
+  CUSTOMER_ENTITY: 'Customer',
+  PAYMENT_ENTITY: 'Payment',
+  invoiceUrl: (appUrl: string, inv: { id: string; vehicleId: string | null }) =>
+    `${appUrl}/vehicles/${inv.vehicleId}/service/${inv.id}`,
+  loadInvoiceForAccounting: (orgId: string, id: string) => loadInvoice(orgId, id),
+  loadPaymentForAccounting: (orgId: string, id: string) => loadPayment(orgId, id),
+  loadCustomerForAccounting: (orgId: string, id: string) => loadCustomer(orgId, id),
+  loadInvoicePdfForAccounting: async () => ({
+    buffer: new Uint8Array([37, 80, 68, 70]),
+    filename: 'x.pdf',
+  }),
+  recordPulledPayment: (...args: unknown[]) => recordPulledPayment(...args),
+  removePulledPayment: (...args: unknown[]) => removePulledPayment(...args),
+  workshopCurrency: async () => 'AUD',
+  workshopDistanceUnit: async () => 'km',
+}))
+
+const { connector } = await import('@/integrations/xero/server')
+
+interface Call {
+  method: string
+  path: string
+  query: Record<string, string>
+  body: Record<string, unknown> | null
+  headers: Record<string, string>
+}
+
+type Answer = (call: Call) => unknown
+
+const API = '/api.xro/2.0'
+
+function notFound(): ConnectorHttpError {
+  return new ConnectorHttpError(404, '', 'https://api.xero.com/x')
+}
+
+function makeCtx(input: { settings?: Record<string, unknown>; answer: Answer }) {
+  const calls: Call[] = []
+  const uploads: { url: string; method: string; headers: Record<string, string> }[] = []
+  const links = new Map<string, LinkRecord & { entityId: string; entityType: string }>()
+  const logs: { level: LogLevel; message: string }[] = []
+  const now = new Date().toISOString()
+  const state: Record<string, unknown> = {
+    org: {
+      tenantId: 'tenant-1',
+      fetchedAt: now,
+      name: 'Demo Garage',
+      shortCode: '!abc',
+      baseCurrency: 'AUD',
+      countryCode: 'AU',
+      paysTax: true,
+      paymentTerms: null,
+      edition: 'BUSINESS',
+    },
+    catalog: {
+      tenantId: 'tenant-1',
+      fetchedAt: now,
+      rates: salesRates([
+        { Name: 'GST on Income', TaxType: 'OUTPUT', EffectiveRate: 10, Status: 'ACTIVE' },
+        { Name: 'GST Free Income', TaxType: 'EXEMPTOUTPUT', EffectiveRate: 0, Status: 'ACTIVE' },
+      ]),
+      defaultSalesAccount: '200',
+      bankAccountId: 'bank-1',
+      items: [],
+      tracking: [],
+    },
+  }
+  const key = (t: string, e: string) => `${t}:${e}`
+  const ctx: ConnectorContext = {
+    connection: {
+      id: 'conn1',
+      organizationId: 'org1',
+      connectorId: 'xero',
+      settings: { tenantId: 'tenant-1', ...input.settings },
+      state,
+      externalAccountId: 'xero-abc',
+    },
+    credentials: { accessToken: 'tok', refreshToken: 'ref' },
+    http: {
+      async fetch(url: string, init?: RequestInit) {
+        uploads.push({
+          url,
+          method: init?.method ?? 'GET',
+          headers: Object.fromEntries(new Headers(init?.headers).entries()),
+        })
+        return new Response('{}', { status: 200 })
+      },
+      async json<T>(url: string, init?: RequestInit): Promise<T> {
+        const u = new URL(url)
+        const call: Call = {
+          method: init?.method ?? 'GET',
+          path: u.pathname,
+          query: Object.fromEntries(u.searchParams.entries()),
+          body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
+          headers: Object.fromEntries(new Headers(init?.headers).entries()),
+        }
+        calls.push(call)
+        return input.answer(call) as T
+      },
+    },
+    links: {
+      async get(t, e) {
+        return links.get(key(t, e)) ?? null
+      },
+      async set(t, e, link) {
+        const prev = links.get(key(t, e))
+        links.set(key(t, e), {
+          entityType: t,
+          entityId: e,
+          remoteId: link.remoteId,
+          remoteUrl: link.remoteUrl ?? prev?.remoteUrl ?? null,
+          metadata: link.metadata === undefined ? (prev?.metadata ?? null) : link.metadata,
+          checksum: link.checksum ?? prev?.checksum ?? null,
+        })
+      },
+      async remove(t, e) {
+        links.delete(key(t, e))
+      },
+      async remoteIds(t) {
+        return new Set([...links.values()].filter((l) => l.entityType === t).map((l) => l.remoteId))
+      },
+      async byRemoteId(t, remoteId) {
+        return (
+          [...links.values()].find((l) => l.entityType === t && l.remoteId === remoteId) ?? null
+        )
+      },
+    },
+    async log(level, message) {
+      logs.push({ level, message })
+    },
+    async saveState(patch) {
+      Object.assign(state, patch)
+    },
+    timezone: 'Australia/Sydney',
+    appUrl: 'https://shop.example.com',
+  }
+  return { ctx, calls, uploads, links, logs, state }
+}
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Anna Berg',
+  email: 'anna@example.com',
+  phone: null,
+  address: null,
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  title: 'Brake service',
+  invoiceNumber: 'INV-1001',
+  status: 'completed',
+  issuedAt: new Date('2026-11-04T01:00:00Z'),
+  invoiceDate: new Date('2026-11-04T01:00:00Z'),
+  serviceDate: new Date('2026-11-03T01:00:00Z'),
+  dueDate: new Date('2026-11-18T01:00:00Z'),
+  mileage: null,
+  notes: null,
+  subtotal: 330,
+  taxableSubtotal: null,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 10,
+  taxAmount: 33,
+  taxInclusive: false,
+  totalAmount: 363,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'ABC123' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Brake service',
+      partNumber: null,
+      quantity: 2,
+      unitPrice: 100,
+      total: 200,
+      taxable: true,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 1,
+      unitPrice: 130,
+      total: 130,
+      taxable: true,
+    },
+  ],
+  payments: [],
+}
+
+const cardPayment: AccountingPayment = {
+  id: 'pay1',
+  serviceRecordId: 'svc1',
+  amount: 363,
+  date: new Date('2026-11-05T01:00:00Z'),
+  method: 'card',
+  note: null,
+  provider: null,
+  externalId: null,
+}
+
+/** A Xero organisation that has none of this workshop yet and accepts everything. */
+function emptyOrganisation(
+  overrides: Partial<Record<string, (call: Call) => unknown>> = {}
+): Answer {
+  let payments = 0
+  return (call) => {
+    const route = `${call.method} ${call.path.replace(API, '')}`
+    for (const [pattern, handler] of Object.entries(overrides)) {
+      if (new RegExp(`^${pattern}$`).test(route)) return handler?.(call)
+    }
+    if (route.startsWith('GET /Contacts/')) throw notFound()
+    if (route === 'GET /Contacts') return { Contacts: [] }
+    if (route === 'PUT /Contacts') {
+      const body = (call.body?.Contacts as Record<string, unknown>[])[0]
+      return { Contacts: [{ ContactID: 'contact-new', Name: body.Name }] }
+    }
+    if (route === 'GET /Invoices') return { Invoices: [] }
+    if (route === 'PUT /Invoices') {
+      const body = (call.body?.Invoices as Record<string, unknown>[])[0]
+      return {
+        Invoices: [
+          {
+            InvoiceID: 'inv-new',
+            InvoiceNumber: body.InvoiceNumber,
+            Status: body.Status,
+            Total: 363,
+          },
+        ],
+      }
+    }
+    if (route.startsWith('GET /Invoices/'))
+      return {
+        Invoices: [
+          { InvoiceID: 'inv-new', Status: 'AUTHORISED', Total: 363, AmountDue: 363, AmountPaid: 0 },
+        ],
+      }
+    if (route.startsWith('POST /Invoices/')) {
+      const body = (call.body?.Invoices as Record<string, unknown>[])[0]
+      return { Invoices: [{ ...body, Total: 363 }] }
+    }
+    if (route === 'PUT /Payments') return { Payments: [{ PaymentID: `pmt-${++payments}` }] }
+    if (route === 'GET /Payments') return { Payments: [] }
+    throw new Error(`unexpected ${route}`)
+  }
+}
+
+const api = (calls: Call[], method: string, path: string) =>
+  calls.filter((c) => c.method === method && c.path === `${API}${path}`)
+
+beforeEach(() => {
+  loadInvoice.mockReset().mockResolvedValue(invoice)
+  loadPayment.mockReset()
+  loadCustomer.mockReset()
+  recordPulledPayment.mockReset().mockResolvedValue({ id: 'local-pay', created: true })
+  removePulledPayment.mockReset().mockResolvedValue(true)
+})
+
+afterEach(() => vi.useRealTimers())
+
+describe('pushing an invoice', () => {
+  it('makes the customer and the invoice in the chosen organisation, and files the PDF', async () => {
+    const { ctx, calls, uploads, links } = makeCtx({ answer: emptyOrganisation() })
+    const outcome = (await connector.jobs['accounting.invoice'](ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    expect(outcome.summary).toBe('invoice INV-1001 created')
+
+    // Every call names the organisation.
+    expect(calls.every((c) => c.headers['xero-tenant-id'] === 'tenant-1')).toBe(true)
+    // The contact is looked for by our code, email and name before one is made.
+    expect(calls.slice(0, 4).map((c) => `${c.method} ${c.path.replace(API, '')}`)).toEqual([
+      'GET /Contacts/TV-C-0042',
+      'GET /Contacts',
+      'GET /Contacts',
+      'PUT /Contacts',
+    ])
+    expect(calls[1].query.where).toBe('EmailAddress=="anna@example.com"')
+    expect(calls[3].query.summarizeErrors).toBe('true')
+
+    const [put] = api(calls, 'PUT', '/Invoices')
+    expect(put.query).toEqual({ unitdp: '4', summarizeErrors: 'true' })
+    expect(put.headers['idempotency-key']).toMatch(/^tv-svc1-/)
+    expect((put.body?.Invoices as Record<string, unknown>[])[0]).toMatchObject({
+      Type: 'ACCREC',
+      Contact: { ContactID: 'contact-new' },
+      InvoiceNumber: 'INV-1001',
+      Reference: 'ABC123 Toyota Corolla',
+      Status: 'AUTHORISED',
+      LineAmountTypes: 'Exclusive',
+      SentToContact: true,
+    })
+
+    expect(uploads).toEqual([
+      {
+        url: 'https://api.xero.com/api.xro/2.0/Invoices/inv-new/Attachments/INV-1001.pdf?IncludeOnline=true',
+        method: 'PUT',
+        headers: expect.objectContaining({
+          'content-type': 'application/pdf',
+          'xero-tenant-id': 'tenant-1',
+        }),
+      },
+    ])
+    expect(links.get('ServiceRecord:svc1')).toMatchObject({
+      remoteId: 'inv-new',
+      remoteUrl:
+        'https://go.xero.com/organisationlogin/default.aspx?shortcode=!abc&redirecturl=/AccountsReceivable/View.aspx?InvoiceID=inv-new',
+      metadata: expect.objectContaining({ tenantId: 'tenant-1', attached: true }),
+    })
+  })
+
+  it('reuses the contact the bookkeeper already has under that email', async () => {
+    const { ctx, calls } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Contacts': (c) =>
+          c.query.where?.startsWith('EmailAddress')
+            ? { Contacts: [{ ContactID: 'contact-old' }] }
+            : { Contacts: [] },
+      }),
+    })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    expect(api(calls, 'PUT', '/Contacts')).toHaveLength(0)
+    expect(
+      (api(calls, 'PUT', '/Invoices')[0].body?.Invoices as Record<string, unknown>[])[0].Contact
+    ).toEqual({
+      ContactID: 'contact-old',
+    })
+  })
+
+  it('updates an unpaid invoice in place, never moving its approval back', async () => {
+    const { ctx, calls, links } = makeCtx({
+      settings: { invoiceStatus: 'DRAFT' },
+      answer: emptyOrganisation(),
+    })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    const link = links.get('ServiceRecord:svc1')
+    if (!link) throw new Error('no link')
+    links.set('ServiceRecord:svc1', { ...link, checksum: 'old' })
+    calls.length = 0
+
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    const [post] = api(calls, 'POST', '/Invoices/inv-new')
+    // Someone approved it in Xero; the workshop's Draft setting does not undo that.
+    expect((post.body?.Invoices as Record<string, unknown>[])[0]).toMatchObject({
+      InvoiceID: 'inv-new',
+      Status: 'AUTHORISED',
+    })
+    expect(api(calls, 'PUT', '/Invoices')).toHaveLength(0)
+  })
+
+  it('leaves an invoice alone once Xero holds a payment on it, and says so once', async () => {
+    const { ctx, calls, links, logs } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Invoices/inv-new': () => ({
+          Invoices: [
+            {
+              InvoiceID: 'inv-new',
+              Status: 'AUTHORISED',
+              Total: 300,
+              AmountPaid: 100,
+              AmountDue: 200,
+            },
+          ],
+        }),
+      }),
+    })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    const link = links.get('ServiceRecord:svc1')
+    if (!link) throw new Error('no link')
+    links.set('ServiceRecord:svc1', { ...link, checksum: 'old' })
+    calls.length = 0
+
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    expect(api(calls, 'POST', '/Invoices/inv-new')).toHaveLength(0)
+    expect(logs.filter((l) => l.message.includes('after a payment was recorded'))).toHaveLength(1)
+  })
+
+  it('starts afresh when the workshop switches to another organisation', async () => {
+    const { ctx, calls, links } = makeCtx({ answer: emptyOrganisation() })
+    links.set('ServiceRecord:svc1', {
+      entityType: 'ServiceRecord',
+      entityId: 'svc1',
+      remoteId: 'inv-elsewhere',
+      remoteUrl: null,
+      checksum: 'x',
+      metadata: { tenantId: 'tenant-old' },
+    })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    expect(api(calls, 'PUT', '/Invoices')).toHaveLength(1)
+    expect(calls.some((c) => c.path.includes('inv-elsewhere'))).toBe(false)
+  })
+
+  it('asks for an organisation before it calls Xero at all', async () => {
+    const { ctx, calls } = makeCtx({ settings: { tenantId: '' }, answer: emptyOrganisation() })
+    const outcome = (await connector.jobs['accounting.invoice'](ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    expect(outcome.summary).toMatch(/choose the Xero organisation/)
+    expect(calls).toHaveLength(0)
+  })
+})
+
+describe('payments', () => {
+  it('records a card payment into the card account, trimmed to what Xero says is owed', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [cardPayment] })
+    const { ctx, calls } = makeCtx({
+      settings: { cardAccount: 'card-acc', paymentAccount: 'bank-acc' },
+      answer: emptyOrganisation({
+        'GET /Invoices/inv-new': () => ({
+          Invoices: [
+            { InvoiceID: 'inv-new', Status: 'AUTHORISED', Total: 362.99, AmountDue: 362.99 },
+          ],
+        }),
+      }),
+    })
+    const outcome = (await connector.jobs['accounting.invoice'](ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    expect(outcome.summary).toContain('1 payments recorded')
+    const [put] = api(calls, 'PUT', '/Payments')
+    expect(put.headers['idempotency-key']).toBe('tv-payment-pay1')
+    expect((put.body?.Payments as Record<string, unknown>[])[0]).toEqual({
+      Invoice: { InvoiceID: 'inv-new' },
+      Account: { AccountID: 'card-acc' },
+      Date: '2026-11-05',
+      Amount: 362.99,
+      Reference: 'Torqvoice card',
+    })
+  })
+
+  it('waits for the invoice to be approved in Xero before recording a payment', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [cardPayment] })
+    loadPayment.mockResolvedValue({ ...cardPayment, date: new Date() })
+    const { ctx, calls } = makeCtx({
+      settings: { invoiceStatus: 'DRAFT' },
+      answer: emptyOrganisation({
+        'GET /Invoices/inv-new': () => ({
+          Invoices: [{ InvoiceID: 'inv-new', Status: 'DRAFT', AmountDue: 363 }],
+        }),
+      }),
+    })
+    const outcome = (await connector.jobs['accounting.payment'](ctx, {
+      entityId: 'pay1',
+    })) as JobOutcome
+    expect(outcome).toEqual({
+      summary: 'waiting for the invoice to be approved in Xero',
+      rescheduleInSeconds: 3600,
+    })
+    expect(api(calls, 'PUT', '/Payments')).toHaveLength(0)
+  })
+})
+
+describe('a job deleted here', () => {
+  async function pushedThenDeleted(
+    remote: Record<string, unknown>,
+    settings: Record<string, unknown> = {}
+  ) {
+    const harness = makeCtx({
+      settings,
+      answer: emptyOrganisation({
+        'GET /Invoices/inv-new': () => ({ Invoices: [{ InvoiceID: 'inv-new', ...remote }] }),
+      }),
+    })
+    await connector.jobs['accounting.invoice'](harness.ctx, { entityId: 'svc1' })
+    harness.calls.length = 0
+    loadInvoice.mockResolvedValue(null)
+    const outcome = (await connector.jobs['accounting.invoice'](harness.ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    return { ...harness, outcome }
+  }
+
+  it('voids an approved invoice and deletes a draft', async () => {
+    const approved = await pushedThenDeleted({ Status: 'AUTHORISED', InvoiceNumber: 'INV-1001' })
+    expect(api(approved.calls, 'POST', '/Invoices/inv-new')[0].body).toEqual({
+      Invoices: [{ InvoiceID: 'inv-new', Status: 'VOIDED' }],
+    })
+    loadInvoice.mockResolvedValue(invoice)
+    const draft = await pushedThenDeleted(
+      { Status: 'DRAFT', InvoiceNumber: 'INV-1001' },
+      { invoiceStatus: 'DRAFT' }
+    )
+    expect(api(draft.calls, 'POST', '/Invoices/inv-new')[0].body).toEqual({
+      Invoices: [{ InvoiceID: 'inv-new', Status: 'DELETED' }],
+    })
+  })
+
+  it('leaves an invoice with payments in Xero, and one the workshop wants kept', async () => {
+    const paid = await pushedThenDeleted({ Status: 'AUTHORISED', AmountPaid: 50, Total: 363 })
+    expect(api(paid.calls, 'POST', '/Invoices/inv-new')).toHaveLength(0)
+    expect(paid.outcome.summary).toBe('left in Xero, has payments')
+    loadInvoice.mockResolvedValue(invoice)
+    const kept = await pushedThenDeleted({ Status: 'AUTHORISED' }, { voidOnDelete: false })
+    expect(kept.calls).toHaveLength(0)
+  })
+})
+
+describe('pulling from Xero', () => {
+  it('records a payment taken in Xero, removes one deleted there, and keeps a voided invoice out', async () => {
+    vi.useFakeTimers({ toFake: ['Date'] })
+    vi.setSystemTime(new Date('2026-11-09T12:00:00.500Z'))
+    const { ctx, calls, links, logs } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Payments': () => ({
+          Payments: [
+            {
+              PaymentID: 'x1',
+              Status: 'AUTHORISED',
+              Amount: 100,
+              Date: '/Date(1762646400000+0000)/',
+              Reference: 'EFT',
+              Invoice: { InvoiceID: 'inv-1' },
+            },
+            { PaymentID: 'x2', Status: 'DELETED', Amount: 50, Invoice: { InvoiceID: 'inv-1' } },
+          ],
+        }),
+        'GET /Invoices': () => ({
+          Invoices: [{ InvoiceID: 'inv-2', InvoiceNumber: 'INV-2', Status: 'VOIDED' }],
+        }),
+      }),
+    })
+    const scoped = { tenantId: 'tenant-1' }
+    links.set('ServiceRecord:svc1', {
+      entityType: 'ServiceRecord',
+      entityId: 'svc1',
+      remoteId: 'inv-1',
+      remoteUrl: null,
+      checksum: null,
+      metadata: scoped,
+    })
+    links.set('ServiceRecord:svc2', {
+      entityType: 'ServiceRecord',
+      entityId: 'svc2',
+      remoteId: 'inv-2',
+      remoteUrl: null,
+      checksum: null,
+      metadata: scoped,
+    })
+    links.set('Payment:pulled', {
+      entityType: 'Payment',
+      entityId: 'pulled',
+      remoteId: 'x2',
+      remoteUrl: null,
+      checksum: null,
+      metadata: { ...scoped, createdByUs: false },
+    })
+
+    const outcome = (await connector.jobs['accounting.pull'](ctx, {})) as JobOutcome
+    expect(outcome.summary).toBe(
+      '1 payments recorded, 1 payments removed, 1 invoices voided or deleted in Xero'
+    )
+
+    const [payments] = api(calls, 'GET', '/Payments')
+    // A day back on a first pull, less a minute of overlap, to the second and without a zone.
+    expect(payments.headers['if-modified-since']).toBe('2026-11-08T11:59:00')
+    expect(payments.query.where).toBe('PaymentType=="ACCRECPAYMENT"')
+    expect(recordPulledPayment).toHaveBeenCalledWith('org1', {
+      serviceRecordId: 'svc1',
+      amount: 100,
+      // Noon in Sydney on the day Xero names, so it shows on that day there.
+      date: new Date('2025-11-09T01:00:00Z'),
+      method: 'other',
+      provider: 'xero',
+      externalId: 'x1',
+      note: 'Recorded in Xero. Ref EFT',
+    })
+    expect(removePulledPayment).toHaveBeenCalledWith('org1', 'pulled', 'xero')
+    // Xero never gives a voided invoice's number out again, so it is not resent under it.
+    expect(links.get('ServiceRecord:svc2')?.metadata).toMatchObject({ goneInXero: 'voided' })
+    expect(logs.some((l) => l.message.includes('INV-2 was voided in Xero'))).toBe(true)
+  })
+})
+
+describe('connecting', () => {
+  it('chooses the organisation by itself when only one was allowed', async () => {
+    const { ctx } = makeCtx({
+      settings: { tenantId: '' },
+      answer: (call) => {
+        if (call.path === '/connections')
+          return [
+            {
+              id: 'c1',
+              tenantId: 'tenant-9',
+              tenantType: 'ORGANISATION',
+              tenantName: 'Demo Garage',
+            },
+          ]
+        throw new Error('unexpected')
+      },
+    })
+    expect(await connector.onConnect?.(ctx)).toEqual({ settings: { tenantId: 'tenant-9' } })
+    expect(await connector.identify?.(ctx)).toMatchObject({ name: 'Demo Garage' })
+  })
+})
+
+describe('after a reconnect', () => {
+  it('finds the invoice already in Xero by its number and does not record its payment twice', async () => {
+    const appUrl = 'https://shop.example.com/vehicles/veh1/service/svc1'
+    const { ctx, calls, links } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Invoices': () => ({
+          Invoices: [{ InvoiceID: 'inv-old', Type: 'ACCREC', Status: 'AUTHORISED' }],
+        }),
+        'GET /Invoices/inv-old': () => ({
+          Invoices: [
+            {
+              InvoiceID: 'inv-old',
+              Status: 'AUTHORISED',
+              Url: appUrl,
+              Total: 363,
+              AmountPaid: 100,
+              AmountDue: 263,
+              Payments: [{ PaymentID: 'pmt-old', Amount: 100, Date: '/Date(1762300800000+0000)/' }],
+            },
+          ],
+        }),
+      }),
+    })
+    // The payment here is on the day Xero holds it (5 Nov 2025 in Xero's date above).
+    loadInvoice.mockResolvedValue({
+      ...invoice,
+      payments: [{ ...cardPayment, amount: 100, date: new Date('2025-11-05T01:00:00Z') }],
+    })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    expect(api(calls, 'PUT', '/Invoices')).toHaveLength(0)
+    expect(api(calls, 'PUT', '/Payments')).toHaveLength(0)
+    expect(links.get('ServiceRecord:svc1')?.remoteId).toBe('inv-old')
+    expect(links.get('Payment:pay1')).toMatchObject({
+      remoteId: 'pmt-old',
+      metadata: expect.objectContaining({ relinked: true }),
+    })
+  })
+})
+
+describe('an invoice voided in Xero', () => {
+  // Seen live on 9 Nov 2026: Xero answers "Invoice # must be unique." to a new
+  // invoice under the number of a voided one.
+  const voided = {
+    InvoiceID: 'inv-void',
+    InvoiceNumber: 'INV-1001',
+    Type: 'ACCREC',
+    Status: 'VOIDED',
+  }
+
+  it('is not sent again under its number, and the log says why once', async () => {
+    const { ctx, calls, links, logs } = makeCtx({
+      answer: emptyOrganisation({ 'GET /Invoices': () => ({ Invoices: [voided] }) }),
+    })
+    const first = (await connector.jobs['accounting.invoice'](ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    expect(first.summary).toBe('invoice INV-1001 voided in Xero; not sent again')
+    expect(api(calls, 'PUT', '/Invoices')).toHaveLength(0)
+    expect(links.get('ServiceRecord:svc1')?.metadata).toMatchObject({ goneInXero: 'voided' })
+    calls.length = 0
+    const again = (await connector.jobs['accounting.invoice'](ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    expect(again.summary).toBe('invoice INV-1001 voided in Xero; not sent again')
+    expect(calls.filter((c) => c.path.includes('/Invoices'))).toHaveLength(0)
+    expect(
+      logs.filter((l) => l.message.includes('does not give its number out again'))
+    ).toHaveLength(1)
+  })
+
+  it('is sent as a new invoice once it has a new number here', async () => {
+    const { ctx, calls, links } = makeCtx({
+      answer: emptyOrganisation({
+        'GET /Invoices': (c) =>
+          c.query.InvoiceNumbers === 'INV-1001' ? { Invoices: [voided] } : { Invoices: [] },
+      }),
+    })
+    await connector.jobs['accounting.invoice'](ctx, { entityId: 'svc1' })
+    loadInvoice.mockResolvedValue({ ...invoice, invoiceNumber: 'INV-1001B' })
+    const outcome = (await connector.jobs['accounting.invoice'](ctx, {
+      entityId: 'svc1',
+    })) as JobOutcome
+    expect(outcome.summary).toBe('invoice INV-1001B created')
+    expect(
+      (api(calls, 'PUT', '/Invoices')[0].body?.Invoices as Record<string, unknown>[])[0]
+        .InvoiceNumber
+    ).toBe('INV-1001B')
+    expect(links.get('ServiceRecord:svc1')?.metadata).not.toHaveProperty('goneInXero')
+  })
+})

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

@@ -0,0 +1,132 @@
+import { createHmac } from 'node:crypto'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Xero's webhook: one address for every connection on the same Xero app,
+ * signed with that app's key. A delivery is only believed when the key of
+ * the app that sent it signed it, and only that app's connections hear of
+ * it, so one workshop's own app can never wake another workshop's sync.
+ */
+
+const findMany = vi.fn()
+const enqueueJob = vi.fn()
+
+vi.mock('@/lib/db', () => ({
+  db: { integrationConnection: { findMany: (...a: unknown[]) => findMany(...a) } },
+}))
+vi.mock('@/features/integrations/Lib/jobs', () => ({
+  enqueueJob: (...a: unknown[]) => enqueueJob(...a),
+}))
+// Credentials are stored sealed; here the "sealed" value is just JSON.
+vi.mock('@/features/integrations/Lib/vault', () => ({
+  openCredentials: (sealed: string | null) => (sealed ? JSON.parse(sealed) : {}),
+}))
+
+const { receiveXeroWebhook, signatureMatches } = await import('@/integrations/xero/webhook')
+
+const PLATFORM_KEY = 'platform-webhook-key'
+const sign = (body: string, key: string) => createHmac('sha256', key).update(body).digest('base64')
+
+const delivery = (tenantId: string, category = 'PAYMENT') =>
+  JSON.stringify({
+    events: [
+      {
+        resourceId: 'r1',
+        tenantId,
+        tenantType: 'ORGANISATION',
+        eventCategory: category,
+        eventType: 'CREATE',
+      },
+    ],
+    firstEventSequence: 1,
+    lastEventSequence: 1,
+    entropy: 'abc',
+  })
+
+beforeEach(() => {
+  vi.stubEnv('XERO_INTEGRATION_WEBHOOK_KEY', PLATFORM_KEY)
+  enqueueJob.mockReset()
+  findMany.mockReset().mockResolvedValue([
+    // On the platform's app.
+    {
+      id: 'cloud1',
+      organizationId: 'org1',
+      settings: { tenantId: 't-1' },
+      credentials: JSON.stringify({ accessToken: 'a' }),
+    },
+    // A workshop with its own Xero app and webhook key.
+    {
+      id: 'own1',
+      organizationId: 'org2',
+      settings: { tenantId: 't-2' },
+      credentials: JSON.stringify({ clientId: 'own', webhookKey: 'own-key' }),
+    },
+  ])
+})
+
+describe('Xero webhook', () => {
+  it('checks the signature against the raw body', () => {
+    const body = delivery('t-1')
+    expect(signatureMatches(body, sign(body, PLATFORM_KEY), PLATFORM_KEY)).toBe(true)
+    expect(signatureMatches(`${body} `, sign(body, PLATFORM_KEY), PLATFORM_KEY)).toBe(false)
+    expect(signatureMatches(body, null, PLATFORM_KEY)).toBe(false)
+  })
+
+  it('answers Xero’s intent-to-receive check: 200 signed, 401 not', async () => {
+    const body = JSON.stringify({
+      events: [],
+      firstEventSequence: 0,
+      lastEventSequence: 0,
+      entropy: 'x',
+    })
+    expect(await receiveXeroWebhook(body, sign(body, PLATFORM_KEY))).toEqual({
+      status: 200,
+      queued: 0,
+    })
+    expect(await receiveXeroWebhook(body, sign(body, 'wrong'))).toEqual({ status: 401, queued: 0 })
+    expect(enqueueJob).not.toHaveBeenCalled()
+  })
+
+  it('queues a pull for the connection on the platform’s app that the event is about', async () => {
+    const body = delivery('t-1')
+    expect(await receiveXeroWebhook(body, sign(body, PLATFORM_KEY))).toEqual({
+      status: 200,
+      queued: 1,
+    })
+    expect(enqueueJob).toHaveBeenCalledWith({
+      connectionId: 'cloud1',
+      organizationId: 'org1',
+      kind: 'accounting.pull',
+      payload: { event: 'webhook' },
+      idempotencyKey: 'accounting.pull:webhook',
+    })
+  })
+
+  it('lets a workshop’s own app wake only that workshop', async () => {
+    const own = delivery('t-2')
+    expect(await receiveXeroWebhook(own, sign(own, 'own-key'))).toEqual({ status: 200, queued: 1 })
+    expect(enqueueJob.mock.calls[0][0].connectionId).toBe('own1')
+
+    // Its key cannot speak for the platform's connections...
+    enqueueJob.mockReset()
+    const forged = delivery('t-1')
+    expect(await receiveXeroWebhook(forged, sign(forged, 'own-key'))).toEqual({
+      status: 200,
+      queued: 0,
+    })
+    // ...nor the platform's key for the workshop's own app.
+    const other = delivery('t-2')
+    expect(await receiveXeroWebhook(other, sign(other, PLATFORM_KEY))).toEqual({
+      status: 200,
+      queued: 0,
+    })
+    expect(enqueueJob).not.toHaveBeenCalled()
+  })
+
+  it('takes invoice events in any case, and ignores categories it does not sync', async () => {
+    const invoice = delivery('t-1', 'Invoice')
+    expect((await receiveXeroWebhook(invoice, sign(invoice, PLATFORM_KEY))).queued).toBe(1)
+    const contact = delivery('t-1', 'CONTACT')
+    expect((await receiveXeroWebhook(contact, sign(contact, PLATFORM_KEY))).queued).toBe(0)
+  })
+})

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

@@ -0,0 +1,704 @@
+import { describe, expect, it } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+} from '@/features/integrations/Lib/accounting-sync'
+import {
+  type InvoiceOptions,
+  buildContact,
+  buildInvoice,
+  buildPayment,
+  defaultDueDate,
+  distribute,
+  invoiceUrl,
+  methodAccountKey,
+  modifiedSinceHeader,
+  parseXeroDate,
+  referenceFor,
+} from '@/integrations/xero/mapping'
+import { pickTaxedRate, pickZeroRate, salesRates } from '@/integrations/xero/tax'
+
+/**
+ * What a Torqvoice invoice looks like when it reaches Xero. Field names and
+ * codes are Xero's own (Accounting API v2, read from its OpenAPI spec and
+ * docs on 9 Nov 2026), so a wrong one here is a refused invoice there.
+ */
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Anna Berg',
+  email: 'anna@example.com',
+  phone: '0412 345 678',
+  address: '1 High Street\nSydney NSW 2000',
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  title: 'Brake service',
+  invoiceNumber: 'INV-1001',
+  status: 'completed',
+  issuedAt: new Date('2026-11-04T01:00:00Z'),
+  invoiceDate: new Date('2026-11-04T01:00:00Z'),
+  serviceDate: new Date('2026-11-03T01:00:00Z'),
+  dueDate: new Date('2026-11-18T01:00:00Z'),
+  mileage: 84200,
+  notes: 'Rear pads at 3 mm, replace next visit.',
+  subtotal: 330,
+  taxableSubtotal: null,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 10,
+  taxAmount: 33,
+  taxInclusive: false,
+  totalAmount: 363,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'ABC123' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Brake service',
+      partNumber: null,
+      quantity: 2,
+      unitPrice: 100,
+      total: 200,
+      taxable: true,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 1,
+      unitPrice: 100,
+      total: 100,
+      taxable: true,
+    },
+    {
+      kind: 'labor',
+      description: 'Shop supplies',
+      partNumber: null,
+      quantity: 1,
+      unitPrice: 30,
+      total: 30,
+      taxable: true,
+      shopFee: true,
+    },
+  ],
+  payments: [],
+}
+
+const options: InvoiceOptions = {
+  contactId: 'contact-1',
+  status: 'AUTHORISED',
+  invoiceNumber: 'INV-1001',
+  reference: 'ABC123 Toyota Corolla',
+  dueDate: '2026-11-18',
+  brandingThemeId: null,
+  currency: null,
+  timezone: 'Australia/Sydney',
+  url: 'https://shop.example.com/vehicles/veh1/service/svc1',
+  includeVehicle: true,
+  includeNotes: true,
+  includePartNumbers: true,
+  distanceUnit: 'km',
+  accounts: { labor: '210', parts: '200', shopFee: '220', fallback: '200' },
+  items: { labor: null, parts: null },
+  partItemCodes: new Set(),
+  itemAccounts: new Map(),
+  registeredForTax: true,
+  taxType: 'OUTPUT',
+  zeroTaxType: 'EXEMPTOUTPUT',
+  billedTax: true,
+  taxExempt: false,
+  tracking: [],
+  sentToContact: true,
+}
+
+type Line = Record<string, unknown>
+const linesOf = (body: Record<string, unknown>) => body.LineItems as Line[]
+const priced = (body: Record<string, unknown>) => linesOf(body).filter((l) => 'UnitAmount' in l)
+
+const AU_RATES = [
+  {
+    Name: 'GST on Income',
+    TaxType: 'OUTPUT',
+    EffectiveRate: 10,
+    Status: 'ACTIVE',
+    CanApplyToRevenue: true,
+  },
+  {
+    Name: 'GST Free Income',
+    TaxType: 'EXEMPTOUTPUT',
+    EffectiveRate: 0,
+    Status: 'ACTIVE',
+    CanApplyToRevenue: true,
+  },
+  {
+    Name: 'BAS Excluded',
+    TaxType: 'BASEXCLUDED',
+    EffectiveRate: 0,
+    Status: 'ACTIVE',
+    CanApplyToRevenue: true,
+  },
+  {
+    Name: 'GST on Expenses',
+    TaxType: 'INPUT',
+    EffectiveRate: 10,
+    Status: 'ACTIVE',
+    CanApplyToRevenue: false,
+  },
+]
+const NZ_RATES = [
+  {
+    Name: '15% GST on Income',
+    TaxType: 'OUTPUT2',
+    EffectiveRate: '15.0000',
+    Status: 'ACTIVE',
+    CanApplyToRevenue: true,
+  },
+  {
+    Name: 'Zero Rated',
+    TaxType: 'ZERORATED',
+    EffectiveRate: 0,
+    Status: 'ACTIVE',
+    CanApplyToRevenue: true,
+  },
+  { Name: 'No GST', TaxType: 'NONE', EffectiveRate: 0, Status: 'ACTIVE', CanApplyToRevenue: true },
+]
+
+describe('tax rates', () => {
+  it('books Australian GST under GST on Income and tax-free lines under GST Free Income', () => {
+    const rates = salesRates(AU_RATES)
+    expect(pickTaxedRate(rates, 10, null).entry?.taxType).toBe('OUTPUT')
+    expect(pickZeroRate(rates, null)?.taxType).toBe('EXEMPTOUTPUT')
+    // A purchase rate is never offered for a sale.
+    expect(rates.map((r) => r.taxType)).not.toContain('INPUT')
+  })
+
+  it('books New Zealand GST under OUTPUT2 and tax-free lines as Zero Rated', () => {
+    const rates = salesRates(NZ_RATES)
+    expect(pickTaxedRate(rates, 15, null)).toMatchObject({
+      entry: { taxType: 'OUTPUT2' },
+      matched: true,
+    })
+    expect(pickZeroRate(rates, null)?.taxType).toBe('ZERORATED')
+  })
+
+  it('uses a chosen rate whatever its percentage, and says when it does not match', () => {
+    const rates = salesRates(AU_RATES)
+    expect(pickTaxedRate(rates, 15, 'OUTPUT')).toMatchObject({
+      entry: { taxType: 'OUTPUT' },
+      matched: false,
+    })
+    expect(pickTaxedRate(rates, 15, null)).toEqual({ entry: null, matched: false })
+  })
+})
+
+describe('buildInvoice', () => {
+  it('sends an approved sales invoice with every line booked where the workshop chose', () => {
+    const body = buildInvoice(invoice, options)
+    expect(body).toMatchObject({
+      Type: 'ACCREC',
+      Contact: { ContactID: 'contact-1' },
+      Date: '2026-11-04',
+      DueDate: '2026-11-18',
+      InvoiceNumber: 'INV-1001',
+      Reference: 'ABC123 Toyota Corolla',
+      LineAmountTypes: 'Exclusive',
+      Status: 'AUTHORISED',
+      SentToContact: true,
+      Url: 'https://shop.example.com/vehicles/veh1/service/svc1',
+    })
+    // Only the contact's id: anything more would overwrite the contact in Xero.
+    expect(Object.keys(body.Contact as object)).toEqual(['ContactID'])
+    expect(linesOf(body)[0]).toEqual({
+      Description: 'Vehicle: 2018 Toyota Corolla, ABC123, 84,200 km',
+    })
+    expect(priced(body)).toEqual([
+      {
+        Description: 'Brake service',
+        Quantity: 2,
+        UnitAmount: 100,
+        AccountCode: '210',
+        TaxType: 'OUTPUT',
+        TaxAmount: 20,
+      },
+      {
+        Description: 'BP-100 Brake pads',
+        Quantity: 1,
+        UnitAmount: 100,
+        AccountCode: '200',
+        TaxType: 'OUTPUT',
+        TaxAmount: 10,
+      },
+      {
+        Description: 'Shop supplies',
+        Quantity: 1,
+        UnitAmount: 30,
+        AccountCode: '220',
+        TaxType: 'OUTPUT',
+        TaxAmount: 3,
+      },
+    ])
+    expect(linesOf(body).at(-1)).toEqual({ Description: 'Rear pads at 3 mm, replace next visit.' })
+  })
+
+  it('spreads a fixed discount over the lines so Xero taxes what is left, to the cent', () => {
+    const discounted = {
+      ...invoice,
+      discountType: 'fixed',
+      discountValue: 33,
+      discountAmount: 33,
+      taxAmount: 29.7,
+      totalAmount: 326.7,
+    }
+    const lines = priced(buildInvoice(discounted, options))
+    expect(lines.map((l) => l.DiscountAmount)).toEqual([20, 10, 3])
+    expect(lines.reduce((sum, l) => sum + (l.TaxAmount as number), 0)).toBeCloseTo(29.7, 10)
+  })
+
+  it('splits the billed tax in whole cents that add up to the invoice tax', () => {
+    const odd = { ...invoice, taxAmount: 33.01 }
+    const taxes = priced(buildInvoice(odd, options)).map((l) => l.TaxAmount as number)
+    expect(taxes.every((t) => Math.abs(Math.round(t * 100) - t * 100) < 1e-6)).toBe(true)
+    expect(taxes.reduce((a, b) => a + b, 0)).toBeCloseTo(33.01, 10)
+  })
+
+  it('lets Xero work the tax out when the workshop asks it to', () => {
+    const lines = priced(buildInvoice(invoice, { ...options, billedTax: false }))
+    expect(lines.every((l) => !('TaxAmount' in l) && l.TaxType === 'OUTPUT')).toBe(true)
+  })
+
+  it('books an untaxed line, and every line of a tax-exempt customer, at the zero rate', () => {
+    const partsOnly = {
+      ...invoice,
+      lines: invoice.lines.map((l) => (l.kind === 'labor' ? { ...l, taxable: false } : l)),
+      taxAmount: 10,
+    }
+    expect(priced(buildInvoice(partsOnly, options)).map((l) => [l.TaxType, l.TaxAmount])).toEqual([
+      ['EXEMPTOUTPUT', 0],
+      ['OUTPUT', 10],
+      ['EXEMPTOUTPUT', 0],
+    ])
+    const exempt = priced(buildInvoice(invoice, { ...options, taxExempt: true }))
+    expect(exempt.every((l) => l.TaxType === 'EXEMPTOUTPUT' && !('TaxAmount' in l))).toBe(true)
+  })
+
+  it('sends tax-inclusive prices as they were billed', () => {
+    const body = buildInvoice(
+      { ...invoice, taxInclusive: true, totalAmount: 330, taxAmount: 30 },
+      options
+    )
+    expect(body.LineAmountTypes).toBe('Inclusive')
+    expect(priced(body)[0]).toMatchObject({ UnitAmount: 100, TaxAmount: 18.18 })
+  })
+
+  it('leaves tax off entirely for an organisation that is not registered for it', () => {
+    const body = buildInvoice(invoice, { ...options, registeredForTax: false })
+    expect(body.LineAmountTypes).toBe('NoTax')
+    expect(priced(body).every((l) => !('TaxType' in l) && !('TaxAmount' in l))).toBe(true)
+  })
+
+  it('sends a line whose total was edited by hand as one unit at that total', () => {
+    const edited = {
+      ...invoice,
+      subtotal: 250,
+      taxAmount: 25,
+      totalAmount: 275,
+      lines: [{ ...invoice.lines[0], quantity: 3, unitPrice: 100, total: 250 }],
+    }
+    expect(priced(buildInvoice(edited, options))[0]).toMatchObject({
+      Description: 'Brake service (3 × 100)',
+      Quantity: 1,
+      UnitAmount: 250,
+    })
+  })
+
+  it('adds up to the printed subtotal, tax and total when lines carry fractions of a cent', () => {
+    // 1.5 h and 0.5 h at 33.33: 49.995 + 16.665 = 66.66 exactly, but each
+    // line on its own prints as 50.00 and 16.67, which Xero would add to 66.67.
+    const fractional: AccountingInvoice = {
+      ...invoice,
+      subtotal: 66.66,
+      taxAmount: 6.67,
+      totalAmount: 73.33,
+      lines: [
+        {
+          kind: 'labor',
+          description: 'Diagnosis',
+          partNumber: null,
+          quantity: 1.5,
+          unitPrice: 33.33,
+          total: 49.995,
+          taxable: true,
+        },
+        {
+          kind: 'labor',
+          description: 'Road test',
+          partNumber: null,
+          quantity: 0.5,
+          unitPrice: 33.33,
+          total: 16.665,
+          taxable: true,
+        },
+      ],
+    }
+    const lines = priced(buildInvoice(fractional, options))
+    const amounts = lines.map((l) => (l.Quantity as number) * (l.UnitAmount as number))
+    // Every line is a whole number of cents, so Xero has nothing to round.
+    expect(amounts.every((a) => Math.abs(Math.round(a * 100) - a * 100) < 1e-6)).toBe(true)
+    expect(amounts.reduce((a, b) => a + b, 0)).toBeCloseTo(66.66, 10)
+    expect(lines.reduce((a, l) => a + (l.TaxAmount as number), 0)).toBeCloseTo(6.67, 10)
+    // The quantity and price stay readable where the line had to go as one unit.
+    expect(lines[0].Description).toBe('Diagnosis (1.5 × 33.33)')
+  })
+
+  it('keeps quantity and price when they make the line exactly', () => {
+    const hourly = {
+      ...invoice,
+      subtotal: 1425,
+      taxAmount: 142.5,
+      totalAmount: 1567.5,
+      lines: [{ ...invoice.lines[0], quantity: 1.5, unitPrice: 950, total: 1425 }],
+    }
+    expect(priced(buildInvoice(hourly, options))[0]).toMatchObject({
+      Quantity: 1.5,
+      UnitAmount: 950,
+    })
+  })
+
+  it('rounds the discount and tax the way the printed invoice does', () => {
+    // A 10% discount on 333.33 is 33.333; the sheet prints 33.33 and taxes 300.00.
+    const discounted: AccountingInvoice = {
+      ...invoice,
+      subtotal: 333.33,
+      discountType: 'percentage',
+      discountValue: 10,
+      discountAmount: 33.333,
+      taxAmount: 30,
+      totalAmount: 330,
+      lines: [{ ...invoice.lines[0], quantity: 1, unitPrice: 333.33, total: 333.33 }],
+    }
+    const [line] = priced(buildInvoice(discounted, options))
+    expect(line).toMatchObject({ UnitAmount: 333.33, DiscountAmount: 33.33, TaxAmount: 30 })
+  })
+
+  it('lets Xero number the invoice, and only marks approved invoices as sent', () => {
+    const body = buildInvoice(invoice, { ...options, invoiceNumber: null, status: 'DRAFT' })
+    expect(body).not.toHaveProperty('InvoiceNumber')
+    expect(body).not.toHaveProperty('SentToContact')
+    expect(body.Status).toBe('DRAFT')
+  })
+
+  it('sells a part as the Xero item with its part number, and tags lines with tracking', () => {
+    const lines = priced(
+      buildInvoice(invoice, {
+        ...options,
+        accounts: { labor: null, parts: null, shopFee: null, fallback: '200' },
+        items: { labor: 'LAB', parts: null },
+        partItemCodes: new Set(['BP-100']),
+        itemAccounts: new Map([['LAB', '210']]),
+        tracking: [{ Name: 'Branch', Option: 'Parramatta' }],
+      })
+    )
+    // Every line names an account: the item's own, else the default sales
+    // account, since Xero refuses a line without one (seen live, 9 Nov 2026).
+    expect(lines[0]).toMatchObject({
+      ItemCode: 'LAB',
+      AccountCode: '210',
+      Tracking: [{ Name: 'Branch', Option: 'Parramatta' }],
+    })
+    expect(lines[1]).toMatchObject({ ItemCode: 'BP-100', AccountCode: '200' })
+    expect(lines[2]).toMatchObject({ ItemCode: 'LAB', AccountCode: '210' })
+  })
+
+  it('leaves out the vehicle, notes and part numbers when switched off', () => {
+    const body = buildInvoice(invoice, {
+      ...options,
+      includeVehicle: false,
+      includeNotes: false,
+      includePartNumbers: false,
+    })
+    expect(linesOf(body)).toHaveLength(3)
+    expect(priced(body)[1].Description).toBe('Brake pads')
+  })
+})
+
+describe('contacts, payments and references', () => {
+  it('bills a business customer in the company name, with the person beside it', () => {
+    const body = buildContact(
+      { ...customer, company: 'Berg Transport Pty Ltd', taxId: '51 824 753 556' },
+      'Berg Transport Pty Ltd'
+    )
+    expect(body).toEqual({
+      Name: 'Berg Transport Pty Ltd',
+      ContactNumber: 'TV-C-0042',
+      EmailAddress: 'anna@example.com',
+      FirstName: 'Anna',
+      LastName: 'Berg',
+      Phones: [{ PhoneType: 'DEFAULT', PhoneNumber: '0412 345 678' }],
+      Addresses: [
+        { AddressType: 'POBOX', AddressLine1: '1 High Street', AddressLine2: 'Sydney NSW 2000' },
+      ],
+      TaxNumber: '51 824 753 556',
+    })
+  })
+
+  it('records a payment against the invoice into the account for its method', () => {
+    const p = {
+      id: 'pay1',
+      serviceRecordId: 'svc1',
+      amount: 363,
+      date: new Date('2026-11-05T01:00:00Z'),
+      method: 'card',
+      note: null,
+      provider: 'stripe',
+      externalId: 'pi_123',
+    }
+    expect(
+      buildPayment(p, { invoiceId: 'inv-1', accountId: 'acc-1', timezone: 'Australia/Sydney' })
+    ).toEqual({
+      Invoice: { InvoiceID: 'inv-1' },
+      Account: { AccountID: 'acc-1' },
+      Date: '2026-11-05',
+      Amount: 363,
+      Reference: 'Torqvoice card, via stripe, pi_123',
+    })
+    expect(methodAccountKey(p)).toBe('onlineAccount')
+    expect(methodAccountKey({ method: 'cash', provider: null })).toBe('cashAccount')
+    expect(methodAccountKey({ method: 'other', provider: null })).toBeNull()
+  })
+
+  it('fills the reference from the workshop’s choice', () => {
+    expect(referenceFor(invoice, 'vehicle')).toBe('ABC123 Toyota Corolla')
+    expect(referenceFor(invoice, 'title')).toBe('Brake service')
+    expect(referenceFor(invoice, 'invoiceNumber')).toBe('INV-1001')
+    expect(referenceFor(invoice, 'none')).toBeNull()
+  })
+})
+
+describe('dates, money and links', () => {
+  it('works out a due date from the organisation’s sales terms', () => {
+    expect(defaultDueDate('2026-11-04', { Sales: { Day: 14, Type: 'DAYSAFTERBILLDATE' } })).toBe(
+      '2026-11-18'
+    )
+    expect(defaultDueDate('2026-11-04', { Sales: { Day: 20, Type: 'OFFOLLOWINGMONTH' } })).toBe(
+      '2026-12-20'
+    )
+    expect(defaultDueDate('2026-11-04', { Sales: { Day: 31, Type: 'OFCURRENTMONTH' } })).toBe(
+      '2026-11-30'
+    )
+    expect(defaultDueDate('2026-11-04', { Sales: { Day: 7, Type: 'DAYSAFTERBILLMONTH' } })).toBe(
+      '2026-12-07'
+    )
+    expect(defaultDueDate('2026-11-04', undefined)).toBe('2026-11-04')
+  })
+
+  it('splits an amount into cents that add up exactly', () => {
+    expect(distribute(10, [1, 1, 1])).toEqual([3.34, 3.33, 3.33])
+    expect(distribute(0, [1, 2])).toEqual([0, 0])
+  })
+
+  it('reads Xero’s dates and writes its If-Modified-Since', () => {
+    expect(parseXeroDate('/Date(1762300800000+0000)/')?.toISOString()).toBe(
+      '2025-11-05T00:00:00.000Z'
+    )
+    expect(modifiedSinceHeader(new Date('2026-11-09T12:34:56.789Z'))).toBe('2026-11-09T12:34:56')
+  })
+
+  it('links to the invoice in Xero the way Xero documents', () => {
+    expect(invoiceUrl('!NLglS', 'abc')).toBe(
+      'https://go.xero.com/organisationlogin/default.aspx?shortcode=!NLglS&redirecturl=/AccountsReceivable/View.aspx?InvoiceID=abc'
+    )
+  })
+})
+
+describe('rounding against the printed invoice', () => {
+  /** A small seeded generator, so a failure names a case that can be run again. */
+  function rng(seed: number) {
+    let s = seed >>> 0
+    return () => {
+      s = (s * 1664525 + 1013904223) >>> 0
+      return s / 2 ** 32
+    }
+  }
+
+  /** What Xero makes of the body: each line's amount less its discount, then the tax. */
+  function xeroTotals(body: Record<string, unknown>) {
+    const lines = priced(body)
+    let lineCents = 0
+    let taxCents = 0
+    for (const l of lines) {
+      const exact = Math.round((l.Quantity as number) * (l.UnitAmount as number) * 100)
+      // Xero would round this itself; the mapping must never leave it anything to round.
+      expect(
+        Math.abs(exact - (l.Quantity as number) * (l.UnitAmount as number) * 100)
+      ).toBeLessThan(1e-6)
+      lineCents += exact - Math.round(((l.DiscountAmount as number | undefined) ?? 0) * 100)
+      taxCents += Math.round(((l.TaxAmount as number | undefined) ?? 0) * 100)
+    }
+    const inclusive = body.LineAmountTypes === 'Inclusive'
+    return { totalCents: inclusive ? lineCents : lineCents + taxCents, taxCents }
+  }
+
+  it('lands on the printed total and tax for thousands of invoices', async () => {
+    const { calculateTotals, discountAmountFor, storedTaxableSubtotal } = await import('@/lib/tax')
+    const next = rng(20261109)
+    const pick = <T>(xs: T[]) => xs[Math.floor(next() * xs.length)]
+    for (let n = 0; n < 3000; n++) {
+      const lines = Array.from({ length: 1 + Math.floor(next() * 6) }, (_, i) => {
+        const quantity = pick([0.25, 0.3, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 3.3, 7])
+        const unitPrice = Math.round(next() * 50_000) / 100
+        return {
+          kind: (i % 2 ? 'part' : 'labor') as 'part' | 'labor',
+          description: `Line ${i}`,
+          partNumber: null,
+          quantity,
+          unitPrice,
+          // Stored as the form computes it: not rounded.
+          total: quantity * unitPrice,
+          taxable: next() > 0.2,
+        }
+      })
+      const subtotal = lines.reduce((a, l) => a + l.total, 0)
+      const taxableRaw = lines.filter((l) => l.taxable).reduce((a, l) => a + l.total, 0)
+      const discountType = pick([null, 'percentage', 'fixed'])
+      const discountValue =
+        discountType === 'percentage' ? pick([5, 7.5, 10, 12.5]) : Math.round(next() * 5000) / 100
+      const discountAmount = discountAmountFor(subtotal, discountType, discountValue)
+      const taxRate = pick([10, 15])
+      const taxInclusive = next() > 0.5
+      const taxableSubtotal = storedTaxableSubtotal(subtotal, taxableRaw)
+      const totals = calculateTotals({
+        subtotal,
+        discountAmount,
+        taxRate,
+        taxInclusive,
+        taxableSubtotal,
+      })
+      const inv: AccountingInvoice = {
+        ...invoice,
+        subtotal,
+        taxableSubtotal,
+        discountType,
+        discountValue,
+        discountAmount,
+        taxRate,
+        taxInclusive,
+        taxAmount: totals.taxAmount,
+        totalAmount: totals.totalAmount,
+        lines,
+      }
+      const seen = xeroTotals(buildInvoice(inv, options))
+      const label = `case ${n}: ${JSON.stringify({ lines: lines.map((l) => [l.quantity, l.unitPrice, l.taxable]), discountType, discountValue, taxRate, taxInclusive })}`
+      expect(seen.totalCents, label).toBe(Math.round(totals.totalAmount * 100))
+      expect(seen.taxCents, label).toBe(Math.round(totals.taxAmount * 100))
+    }
+  })
+})
+
+describe('review fixes', () => {
+  it('leaves purchase rates out of the sales lists, as a global organisation reports them', () => {
+    // A Norwegian organisation on Xero's global edition, as read on 9 Nov 2026.
+    const rates = salesRates([
+      {
+        Name: 'Tax Exempt',
+        TaxType: 'NONE',
+        EffectiveRate: 0,
+        Status: 'ACTIVE',
+        ReportTaxType: 'NONE',
+        CanApplyToRevenue: true,
+      },
+      {
+        Name: 'Tax on Purchases',
+        TaxType: 'INPUT',
+        EffectiveRate: 0,
+        Status: 'ACTIVE',
+        ReportTaxType: 'INPUT',
+        CanApplyToRevenue: true,
+      },
+      {
+        Name: 'Tax on Sales',
+        TaxType: 'OUTPUT',
+        EffectiveRate: 0,
+        Status: 'ACTIVE',
+        ReportTaxType: 'OUTPUT',
+        CanApplyToRevenue: true,
+      },
+      {
+        Name: 'MVA 25%',
+        TaxType: 'TAX001',
+        EffectiveRate: 25,
+        Status: 'ACTIVE',
+        ReportTaxType: 'OUTPUT',
+        CanApplyToRevenue: true,
+      },
+    ])
+    expect(rates.map((r) => r.taxType)).toEqual(['NONE', 'OUTPUT', 'TAX001'])
+    expect(pickTaxedRate(rates, 25, null).entry?.taxType).toBe('TAX001')
+  })
+
+  it('never books an untaxed line under a rate with a percentage, even when one was chosen', () => {
+    const rates = salesRates(AU_RATES)
+    expect(pickZeroRate(rates, 'OUTPUT')?.taxType).toBe('EXEMPTOUTPUT')
+    expect(pickZeroRate(rates, 'BASEXCLUDED')?.taxType).toBe('BASEXCLUDED')
+  })
+
+  it('keeps a payment on its day in New Zealand, where noon UTC is already tomorrow', async () => {
+    const { paymentDay } = await import('@/integrations/xero/mapping')
+    const { zonedDayKey } = await import('@/lib/timezone')
+    // Xero's 9 Nov 2026, midnight UTC.
+    const day = paymentDay('/Date(1762646400000+0000)/', 'Pacific/Auckland')
+    expect(zonedDayKey(day, 'Pacific/Auckland')).toBe('2025-11-09')
+  })
+
+  it('sends an old invoice that carries only its total as one line for that total', () => {
+    const costOnly: AccountingInvoice = {
+      ...invoice,
+      subtotal: 0,
+      taxAmount: 0,
+      totalAmount: 450,
+      taxRate: 0,
+      lines: [],
+    }
+    const body = buildInvoice(costOnly, options)
+    expect(priced(body)).toEqual([
+      expect.objectContaining({ Description: 'Brake service', Quantity: 1, UnitAmount: 450 }),
+    ])
+  })
+
+  it('carries the printed figures of an amount stored a hair off half a cent', () => {
+    // 0.18 plus 25%, stored before totals were held to the cent: it prints as 0.22.
+    const legacy: AccountingInvoice = {
+      ...invoice,
+      subtotal: 0.18,
+      taxRate: 25,
+      taxAmount: 0.04499999999999999,
+      totalAmount: 0.22499999999999998,
+      lines: [{ ...invoice.lines[0], quantity: 1, unitPrice: 0.18, total: 0.18 }],
+    }
+    const lines = priced(buildInvoice(legacy, options))
+    const sum = lines.reduce((a, l) => a + (l.UnitAmount as number) + (l.TaxAmount as number), 0)
+    expect(Number(sum.toFixed(2))).toBe(0.22)
+  })
+
+  it('keeps the quantity of a stock item, so Xero takes the right count off the shelf', () => {
+    const stock: AccountingInvoice = {
+      ...invoice,
+      subtotal: 49.975,
+      taxAmount: 5,
+      totalAmount: 54.98,
+      lines: [{ ...invoice.lines[1], quantity: 2.5, unitPrice: 19.99, total: 49.975 }],
+    }
+    const [line] = priced(buildInvoice(stock, { ...options, partItemCodes: new Set(['BP-100']) }))
+    expect(line).toMatchObject({ ItemCode: 'BP-100', Quantity: 2.5, UnitAmount: 19.992 })
+    // 2.5 x 19.992 is exactly the 49.98 the invoice printed.
+    expect((line.Quantity as number) * (line.UnitAmount as number)).toBeCloseTo(49.98, 10)
+  })
+})

+ 28 - 0
src/app/api/integrations/[connector]/oauth/callback/route.ts

@@ -7,6 +7,7 @@ import {
   appUrl,
   loadConnection,
   setConnectionStatus,
+  writeLog,
 } from '@/features/integrations/Lib/connections'
 import {
   exchangeCode,
@@ -125,6 +126,33 @@ export async function GET(
     return fail('identify')
   }
 
+  // Settings the vendor already knows, such as the one organisation that was
+  // allowed, so the form does not ask for them. A failure here leaves them
+  // for the workshop to choose; the connection itself is good.
+  try {
+    const { ctx: connectorCtx, server } = await loadConnection(connection.id)
+    const outcome = server.onConnect ? await server.onConnect(connectorCtx) : undefined
+    if (outcome?.settings) {
+      const row = await db.integrationConnection.findUnique({
+        where: { id: connection.id },
+        select: { settings: true },
+      })
+      await db.integrationConnection.update({
+        where: { id: connection.id },
+        data: {
+          settings: {
+            ...((row?.settings as Record<string, unknown> | null) ?? {}),
+            ...outcome.settings,
+          } as object,
+        },
+      })
+    }
+  } catch (err) {
+    await writeLog(connection.id, 'warn', 'Connection setup could not finish on its own', {
+      error: err instanceof Error ? err.message : String(err),
+    })
+  }
+
   await logAudit(
     { userId: ctx.userId, organizationId: ctx.organizationId },
     {

+ 13 - 0
src/app/api/webhooks/xero/route.ts

@@ -0,0 +1,13 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { receiveXeroWebhook } from '@/integrations/xero/webhook'
+
+/**
+ * Xero's webhook address, one for every connection on the same Xero app.
+ * Xero wants an answer within five seconds and an empty body, so this only
+ * checks the signature and queues work; the integration cron does the rest.
+ */
+export async function POST(request: NextRequest) {
+  const body = await request.text()
+  const { status } = await receiveXeroWebhook(body, request.headers.get('x-xero-signature'))
+  return new NextResponse(null, { status })
+}

+ 17 - 0
src/features/integrations/Lib/accounting-sync.ts

@@ -12,6 +12,7 @@ import { db } from '@/lib/db'
 import type { Prisma } from '@/generated/prisma/client'
 import { effectiveInvoiceDate } from '@/lib/invoice-utils'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { SHOP_FEE_PRICING } from '@/features/settings/Lib/shopFee'
 import { serviceUrl } from './calendar-sync'
 
 export const INVOICE_ENTITY = 'ServiceRecord'
@@ -39,6 +40,8 @@ export interface AccountingLine {
   total: number
   /** Whether the invoice's tax was charged on the line; a ledger books it under a code that says so. */
   taxable: boolean
+  /** The workshop's shop fee, a labor line of its own that a ledger may book to its own account. */
+  shopFee?: boolean
 }
 
 export interface AccountingPayment {
@@ -55,6 +58,8 @@ export interface AccountingPayment {
 export interface AccountingInvoice {
   id: string
   vehicleId: string | null
+  /** The job's own title, which a ledger can carry as the invoice's reference. */
+  title?: string
   invoiceNumber: string | null
   status: string
   issuedAt: Date | null
@@ -117,6 +122,7 @@ export async function loadInvoiceForAccounting(
     select: {
       id: true,
       vehicleId: true,
+      title: true,
       invoiceNumber: true,
       status: true,
       issuedAt: true,
@@ -195,6 +201,7 @@ export async function loadInvoiceForAccounting(
         unitPrice: hourly ? l.rate : l.total,
         total: l.total,
         taxable: l.taxable,
+        shopFee: l.pricingType === SHOP_FEE_PRICING,
       }
     }),
     ...r.partItems.map((p) => ({
@@ -210,6 +217,7 @@ export async function loadInvoiceForAccounting(
   return {
     id: r.id,
     vehicleId: r.vehicleId,
+    title: r.title,
     invoiceNumber: r.invoiceNumber,
     status: r.status,
     issuedAt: issuedAtOf(r),
@@ -294,6 +302,15 @@ export async function workshopCurrency(organizationId: string): Promise<string |
   return code && /^[A-Z]{3}$/.test(code) ? code : null
 }
 
+/** How the workshop writes mileage: km when it chose metric, miles otherwise (the default). */
+export async function workshopDistanceUnit(organizationId: string): Promise<'km' | 'mi'> {
+  const row = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key: SETTING_KEYS.UNIT_SYSTEM } },
+    select: { value: true },
+  })
+  return row?.value === 'metric' ? 'km' : 'mi'
+}
+
 export interface PulledPaymentInput {
   serviceRecordId: string
   amount: number

+ 2 - 0
src/integrations/registry.ts

@@ -25,6 +25,7 @@ import { manifest as openLaborProject } from './openlaborproject/manifest'
 import { manifest as paypal } from './paypal/manifest'
 import { manifest as postmark } from './postmark/manifest'
 import { manifest as quickbooks } from './quickbooks/manifest'
+import { manifest as xero } from './xero/manifest'
 import { manifest as rdw } from './rdw/manifest'
 import { manifest as regcheck } from './regcheck/manifest'
 import { manifest as resend } from './resend/manifest'
@@ -81,6 +82,7 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: vipps, load: () => import('./vipps/server') },
   { manifest: paypal, load: () => import('./paypal/server') },
   { manifest: quickbooks, load: () => import('./quickbooks/server') },
+  { manifest: xero, load: () => import('./xero/server') },
   { manifest: fiken, load: () => import('./fiken/server') },
   { manifest: s3, load: () => import('./s3/server') },
   { manifest: localFolder, load: () => import('./local-folder/server') },

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

@@ -0,0 +1,363 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Xero: issued invoices, their customers and their payments go to the books,
+ * and payments recorded in Xero come back.
+ *
+ * One consent can connect several Xero organisations, and every API call
+ * names the one it is for, so which organisation to write to is the first
+ * setting (picked by itself when only one was connected). Everything else a
+ * bookkeeper decides lives in the settings: the status and number an invoice
+ * gets, its reference, branding and tracking, the account and tax rate for
+ * each kind of line, whether Xero or Torqvoice sends it to the customer, and
+ * which bank or clearing account each kind of payment lands in.
+ *
+ * Apps created since 2 March 2026 get granular scopes only; offline_access
+ * is what yields a refresh token.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'xero',
+  name: 'Xero',
+  category: 'accounting',
+  countries: 'global',
+  logo: '/images/integrations/xero.svg',
+  docs: '/docs/integrations/xero',
+  auth: {
+    type: 'oauth2',
+    authorizeUrl: 'https://login.xero.com/identity/connect/authorize',
+    tokenUrl: 'https://identity.xero.com/connect/token',
+    scopes: [
+      'offline_access',
+      'accounting.invoices',
+      'accounting.payments',
+      'accounting.contacts',
+      'accounting.settings.read',
+      'accounting.attachments',
+    ],
+    tokenAuth: 'basic',
+    platformEnv: {
+      clientId: 'XERO_INTEGRATION_CLIENT_ID',
+      clientSecret: 'XERO_INTEGRATION_CLIENT_SECRET',
+    },
+    tenantFields: [
+      { key: 'clientId', label: 'clientId', type: 'text', required: true },
+      { key: 'clientSecret', label: 'clientSecret', type: 'password', required: true },
+      {
+        key: 'webhookKey',
+        label: 'webhookKey',
+        type: 'password',
+        help: 'webhookKeyHelp',
+        advanced: true,
+      },
+    ],
+    tenantHelp: 'tenantHelp',
+  },
+  capabilities: ['accounting.invoices', 'accounting.customers', 'accounting.payments'],
+  settings: [
+    {
+      key: 'tenantId',
+      type: 'remote-select',
+      label: 'tenantId',
+      help: 'tenantIdHelp',
+      source: 'organisations',
+      required: true,
+    },
+
+    /* ---------- invoices ---------- */
+    { key: 'pushInvoices', type: 'boolean', label: 'pushInvoices', default: true },
+    {
+      key: 'pushOnComplete',
+      type: 'boolean',
+      label: 'pushOnComplete',
+      help: 'pushOnCompleteHelp',
+      default: false,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'startDate',
+      type: 'date',
+      label: 'startDate',
+      help: 'startDateHelp',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'invoiceStatus',
+      type: 'select',
+      label: 'invoiceStatus',
+      help: 'invoiceStatusHelp',
+      default: 'AUTHORISED',
+      options: [
+        { value: 'AUTHORISED', label: 'invoiceStatusAuthorised' },
+        { value: 'SUBMITTED', label: 'invoiceStatusSubmitted' },
+        { value: 'DRAFT', label: 'invoiceStatusDraft' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'invoiceNumbers',
+      type: 'select',
+      label: 'invoiceNumbers',
+      help: 'invoiceNumbersHelp',
+      default: 'torqvoice',
+      options: [
+        { value: 'torqvoice', label: 'invoiceNumbersTorqvoice' },
+        { value: 'xero', label: 'invoiceNumbersXero' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'reference',
+      type: 'select',
+      label: 'reference',
+      help: 'referenceHelp',
+      default: 'vehicle',
+      options: [
+        { value: 'vehicle', label: 'referenceVehicle' },
+        { value: 'title', label: 'referenceTitle' },
+        { value: 'invoiceNumber', label: 'referenceInvoiceNumber' },
+        { value: 'none', label: 'referenceNone' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'includeVehicle',
+      type: 'boolean',
+      label: 'includeVehicle',
+      help: 'includeVehicleHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'includeNotes',
+      type: 'boolean',
+      label: 'includeNotes',
+      help: 'includeNotesHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'includePartNumbers',
+      type: 'boolean',
+      label: 'includePartNumbers',
+      help: 'includePartNumbersHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'brandingThemeId',
+      type: 'remote-select',
+      label: 'brandingThemeId',
+      help: 'brandingThemeIdHelp',
+      source: 'brandingThemes',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- accounts and items ---------- */
+    {
+      key: 'laborAccount',
+      type: 'remote-select',
+      label: 'laborAccount',
+      help: 'laborAccountHelp',
+      source: 'salesAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'partsAccount',
+      type: 'remote-select',
+      label: 'partsAccount',
+      help: 'partsAccountHelp',
+      source: 'salesAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'shopFeeAccount',
+      type: 'remote-select',
+      label: 'shopFeeAccount',
+      help: 'shopFeeAccountHelp',
+      source: 'salesAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'laborItem',
+      type: 'remote-select',
+      label: 'laborItem',
+      help: 'laborItemHelp',
+      source: 'items',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'partsItem',
+      type: 'remote-select',
+      label: 'partsItem',
+      help: 'partsItemHelp',
+      source: 'items',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'matchItemsByPartNumber',
+      type: 'boolean',
+      label: 'matchItemsByPartNumber',
+      help: 'matchItemsByPartNumberHelp',
+      default: false,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- tax ---------- */
+    {
+      key: 'taxAmounts',
+      type: 'select',
+      label: 'taxAmounts',
+      help: 'taxAmountsHelp',
+      default: 'billed',
+      options: [
+        { value: 'billed', label: 'taxAmountsBilled' },
+        { value: 'xero', label: 'taxAmountsXero' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'taxType',
+      type: 'remote-select',
+      label: 'taxType',
+      help: 'taxTypeHelp',
+      source: 'taxRates',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'zeroTaxType',
+      type: 'remote-select',
+      label: 'zeroTaxType',
+      help: 'zeroTaxTypeHelp',
+      source: 'zeroTaxRates',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- tracking ---------- */
+    {
+      key: 'tracking1',
+      type: 'remote-select',
+      label: 'tracking1',
+      help: 'tracking1Help',
+      source: 'trackingOptions',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'tracking2',
+      type: 'remote-select',
+      label: 'tracking2',
+      help: 'tracking2Help',
+      source: 'trackingOptions',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- delivery ---------- */
+    {
+      key: 'sendFromXero',
+      type: 'boolean',
+      label: 'sendFromXero',
+      help: 'sendFromXeroHelp',
+      default: false,
+      showWhen: { key: 'invoiceStatus', equals: 'AUTHORISED' },
+    },
+    {
+      key: 'markAsSent',
+      type: 'boolean',
+      label: 'markAsSent',
+      help: 'markAsSentHelp',
+      default: true,
+      showWhen: { key: 'sendFromXero', equals: false },
+    },
+    {
+      key: 'attachPdf',
+      type: 'boolean',
+      label: 'attachPdf',
+      help: 'attachPdfHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'updateContacts',
+      type: 'boolean',
+      label: 'updateContacts',
+      help: 'updateContactsHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'voidOnDelete',
+      type: 'boolean',
+      label: 'voidOnDelete',
+      help: 'voidOnDeleteHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- payments ---------- */
+    { key: 'pushPayments', type: 'boolean', label: 'pushPayments', default: true },
+    {
+      key: 'paymentAccount',
+      type: 'remote-select',
+      label: 'paymentAccount',
+      help: 'paymentAccountHelp',
+      source: 'paymentAccounts',
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'cashAccount',
+      type: 'remote-select',
+      label: 'cashAccount',
+      help: 'methodAccountHelp',
+      source: 'paymentAccounts',
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'cardAccount',
+      type: 'remote-select',
+      label: 'cardAccount',
+      help: 'methodAccountHelp',
+      source: 'paymentAccounts',
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'transferAccount',
+      type: 'remote-select',
+      label: 'transferAccount',
+      help: 'methodAccountHelp',
+      source: 'paymentAccounts',
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'onlineAccount',
+      type: 'remote-select',
+      label: 'onlineAccount',
+      help: 'onlineAccountHelp',
+      source: 'paymentAccounts',
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'manualPaidAsPayment',
+      type: 'boolean',
+      label: 'manualPaidAsPayment',
+      help: 'manualPaidAsPaymentHelp',
+      default: false,
+      showWhen: { key: 'pushPayments', equals: true },
+    },
+    {
+      key: 'pullPayments',
+      type: 'boolean',
+      label: 'pullPayments',
+      help: 'pullPaymentsHelp',
+      default: true,
+    },
+  ],
+  subscriptions: [
+    { event: 'service.create', job: 'accounting.invoice' },
+    { event: 'service.update', job: 'accounting.invoice' },
+    { event: 'service.status', job: 'accounting.invoice' },
+    { event: 'service.delete', job: 'accounting.invoice' },
+    { event: 'customer.update', job: 'accounting.customer' },
+    { event: 'payment.create', job: 'accounting.payment' },
+    { event: 'payment.delete', job: 'accounting.payment' },
+  ],
+  schedules: [{ job: 'accounting.pull', everyMinutes: 30 }],
+}

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

@@ -0,0 +1,618 @@
+/**
+ * Torqvoice records in the shapes Xero's Accounting API takes, and Xero's
+ * answers read back. Pure functions only: the server decides what to send
+ * and when, this decides what it looks like.
+ */
+
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingLine,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import { roundAsPrinted } from '@/lib/money'
+import { zonedDate, zonedDayKey } from '@/lib/timezone'
+
+export const API_BASE = 'https://api.xero.com/api.xro/2.0'
+export const CONNECTIONS_URL = 'https://api.xero.com/connections'
+export const REVOKE_URL = 'https://identity.xero.com/connect/revocation'
+/** The contact a counter sale with no customer is billed to. */
+export const WALK_IN_NAME = 'Walk-in customer'
+/** Contacts made here carry this in ContactNumber, so they are found again without a name search. */
+export const CONTACT_NUMBER_PREFIX = 'TV-'
+
+/* ---------- Xero's shapes ---------- */
+
+export interface XeroConnection {
+  id: string
+  tenantId: string
+  tenantType?: string
+  tenantName?: string | null
+}
+
+export interface XeroOrganisation {
+  OrganisationID?: string
+  Name?: string
+  LegalName?: string
+  BaseCurrency?: string
+  CountryCode?: string
+  ShortCode?: string
+  IsDemoCompany?: boolean
+  PaysTax?: boolean
+  PaymentTerms?: { Sales?: { Day?: number; Type?: string } }
+}
+
+export interface XeroContact {
+  ContactID: string
+  Name?: string
+  ContactNumber?: string
+  EmailAddress?: string
+  ContactStatus?: string
+}
+
+export interface XeroAccount {
+  AccountID: string
+  Code?: string
+  Name?: string
+  Type?: string
+  Class?: string
+  Status?: string
+  EnablePaymentsToAccount?: boolean
+}
+
+export interface XeroItem {
+  ItemID: string
+  Code: string
+  Name?: string
+  IsSold?: boolean
+  IsTrackedAsInventory?: boolean
+  SalesDetails?: { AccountCode?: string }
+}
+
+export interface XeroTrackingCategory {
+  TrackingCategoryID: string
+  Name: string
+  Status?: string
+  Options?: { TrackingOptionID: string; Name: string; Status?: string }[]
+}
+
+export interface XeroBrandingTheme {
+  BrandingThemeID: string
+  Name: string
+  SortOrder?: number
+}
+
+export interface XeroPayment {
+  PaymentID: string
+  Date?: string
+  Amount?: number
+  Reference?: string
+  Status?: string
+  PaymentType?: string
+  Invoice?: { InvoiceID?: string; InvoiceNumber?: string }
+  UpdatedDateUTC?: string
+}
+
+export interface XeroInvoice {
+  InvoiceID: string
+  InvoiceNumber?: string
+  Reference?: string
+  Status?: string
+  Type?: string
+  Date?: string
+  Contact?: { ContactID?: string; Name?: string }
+  SubTotal?: number
+  TotalTax?: number
+  Total?: number
+  AmountDue?: number
+  AmountPaid?: number
+  Payments?: XeroPayment[]
+  HasAttachments?: boolean
+  UpdatedDateUTC?: string
+}
+
+export interface XeroLine {
+  Description?: string
+  Quantity?: number
+  UnitAmount?: number
+  AccountCode?: string
+  ItemCode?: string
+  TaxType?: string
+  TaxAmount?: number
+  DiscountRate?: number
+  DiscountAmount?: number
+  Tracking?: { Name: string; Option: string }[]
+}
+
+/* ---------- small helpers ---------- */
+
+/** Money as the printed invoice rounds it, so both systems agree to the cent. */
+export function round2(n: number): number {
+  return roundAsPrinted(n)
+}
+
+/** Four places, which Xero keeps for unit prices when asked with unitdp=4. */
+export function round4(n: number): number {
+  return Math.round((n + Number.EPSILON) * 10_000) / 10_000
+}
+
+/**
+ * Xero's JSON dates come as `/Date(1539993600000+0000)/`, the milliseconds
+ * at midnight UTC of the day; a few fields are ISO strings instead.
+ */
+export function parseXeroDate(value: string | undefined | null): Date | null {
+  if (!value) return null
+  const ms = /\/Date\((-?\d+)(?:[+-]\d{4})?\)\//.exec(value)
+  if (ms) return new Date(Number(ms[1]))
+  const parsed = Date.parse(value)
+  return Number.isFinite(parsed) ? new Date(parsed) : null
+}
+
+/** The day a Xero date names. Xero sends it as midnight UTC of that day. */
+export function xeroDayKey(value: string | undefined | null): string | null {
+  const d = parseXeroDate(value)
+  return d ? d.toISOString().slice(0, 10) : null
+}
+
+/**
+ * A payment's day as an instant here: noon on that day in the workshop's
+ * time zone, so it shows on the same day in every zone, New Zealand's +13
+ * included, where noon UTC would already be the next day.
+ */
+export function paymentDay(value: string | undefined, timeZone: string): Date {
+  const day = xeroDayKey(value)
+  if (!day) return new Date()
+  const [y, m, d] = day.split('-').map(Number)
+  return zonedDate(y, m, d, 12, 0, timeZone)
+}
+
+/**
+ * Splits an amount over weights in whole cents, the remainder going to the
+ * largest shares first, so the parts add up to exactly the whole.
+ */
+export function distribute(total: number, weights: number[]): number[] {
+  const cents = Math.round(roundAsPrinted(total) * 100)
+  const sum = weights.reduce((a, b) => a + Math.max(0, b), 0)
+  if (sum <= 0 || cents === 0) return weights.map(() => 0)
+  const raw = weights.map((w) => (Math.max(0, w) / sum) * cents)
+  const floors = raw.map((r) => Math.floor(r))
+  let left = cents - floors.reduce((a, b) => a + b, 0)
+  const order = raw
+    .map((r, i) => ({ i, frac: r - Math.floor(r) }))
+    .sort((a, b) => b.frac - a.frac || weights[b.i] - weights[a.i])
+  for (const { i } of order) {
+    if (left <= 0) break
+    floors[i]++
+    left--
+  }
+  return floors.map((c) => c / 100)
+}
+
+/** Text a Xero field takes, cut to its length. */
+function clip(value: string | null | undefined, max: number): string | undefined {
+  const v = value?.trim()
+  return v ? v.slice(0, max) : undefined
+}
+
+/* ---------- contacts ---------- */
+
+/**
+ * The contact's name. A business customer is billed in the company's name,
+ * with the person as first and last name; Xero keeps names unique, so this
+ * is also how an existing contact is recognised.
+ */
+export function contactName(c: AccountingCustomer): string {
+  return xeroName(c.company) ?? xeroName(c.name) ?? 'Customer'
+}
+
+/** Xero refuses angle brackets, spaces at either end and runs of spaces in a contact name. */
+function xeroName(value: string | null | undefined): string | undefined {
+  const v = value?.replace(/[<>]/g, '').replace(/\s+/g, ' ').trim()
+  return v ? v.slice(0, 255) : undefined
+}
+
+export function contactNumber(c: AccountingCustomer): string {
+  return `${CONTACT_NUMBER_PREFIX}${c.customerNumber ?? c.id}`.slice(0, 50)
+}
+
+/**
+ * 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
+ * guessed into city and postcode.
+ */
+export function buildContact(c: AccountingCustomer, name: string): Record<string, unknown> {
+  const person = c.company?.trim() ? c.name.trim() : ''
+  const [first, ...rest] = person.split(/\s+/).filter(Boolean)
+  const lines = (c.address ?? '')
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .filter(Boolean)
+  const address =
+    lines.length > 4 ? [...lines.slice(0, 3), lines.slice(3).join(', ')] : lines.slice(0, 4)
+  const body: Record<string, unknown> = {
+    Name: name,
+    ContactNumber: contactNumber(c),
+  }
+  // Xero refuses an address with letters outside plain ASCII; better none than no contact.
+  const email = clip(c.email, 255)
+  if (email && /^[\x21-\x7e]+$/.test(email)) body.EmailAddress = email
+  if (first) body.FirstName = first.slice(0, 255)
+  if (rest.length) body.LastName = rest.join(' ').slice(0, 255)
+  const phone = clip(c.phone, 50)
+  if (phone) body.Phones = [{ PhoneType: 'DEFAULT', PhoneNumber: phone }]
+  if (address.length) {
+    const postal: Record<string, unknown> = { AddressType: 'POBOX' }
+    address.forEach((line, i) => {
+      postal[`AddressLine${i + 1}`] = line.slice(0, 500)
+    })
+    body.Addresses = [postal]
+  }
+  const taxNumber = clip(c.taxId, 50)
+  if (taxNumber) body.TaxNumber = taxNumber
+  return body
+}
+
+/* ---------- invoices ---------- */
+
+export type InvoiceStatus = 'DRAFT' | 'SUBMITTED' | 'AUTHORISED'
+
+/** How far along Xero's approval an invoice is; an update never moves it back. */
+export const STATUS_RANK: Record<string, number> = { DRAFT: 0, SUBMITTED: 1, AUTHORISED: 2 }
+
+export interface InvoiceOptions {
+  contactId: string
+  status: InvoiceStatus
+  /** The number to send, or null to let Xero number it. */
+  invoiceNumber: string | null
+  reference: string | null
+  dueDate: string
+  brandingThemeId: string | null
+  currency: string | null
+  timezone: string
+  /** Back to the job here; Xero shows it as a link on the invoice. */
+  url: string
+  includeVehicle: boolean
+  includeNotes: boolean
+  includePartNumbers: boolean
+  distanceUnit: string
+  accounts: { labor: string | null; parts: string | null; shopFee: string | null; fallback: string }
+  items: { labor: string | null; parts: string | null }
+  /** Codes of Xero items sold by part number; empty when that is off. */
+  partItemCodes: ReadonlySet<string>
+  /** Each Xero item's own sales account, used when no account is chosen for the line. */
+  itemAccounts: ReadonlyMap<string, string>
+  /** 'NoTax' when the organisation is not registered for tax. */
+  registeredForTax: boolean
+  taxType: string | null
+  zeroTaxType: string | null
+  /** Send the billed tax per line rather than let Xero work it out. */
+  billedTax: boolean
+  taxExempt: boolean
+  tracking: { Name: string; Option: string }[]
+  sentToContact: boolean
+}
+
+export function vehicleDescription(inv: AccountingInvoice, distanceUnit: string): string | null {
+  if (!inv.vehicle) return null
+  const name = [inv.vehicle.year, inv.vehicle.make, inv.vehicle.model].filter(Boolean).join(' ')
+  const parts = [
+    name,
+    inv.vehicle.licensePlate,
+    inv.mileage ? `${inv.mileage.toLocaleString('en')} ${distanceUnit}` : null,
+  ]
+  return parts.filter(Boolean).join(', ') || null
+}
+
+/** The Reference field, from the workshop's choice. */
+export function referenceFor(
+  inv: AccountingInvoice,
+  choice: 'vehicle' | 'title' | 'invoiceNumber' | 'none'
+): string | null {
+  if (choice === 'none') return null
+  if (choice === 'invoiceNumber') return clip(inv.invoiceNumber, 255) ?? null
+  if (choice === 'title') return clip(inv.title, 255) ?? null
+  if (!inv.vehicle) return clip(inv.title, 255) ?? null
+  const name = [inv.vehicle.make, inv.vehicle.model].filter(Boolean).join(' ')
+  return clip([inv.vehicle.licensePlate, name].filter(Boolean).join(' '), 255) ?? null
+}
+
+function accountFor(line: AccountingLine, o: InvoiceOptions): string | null {
+  if (line.kind === 'part') return o.accounts.parts
+  if (line.shopFee) return o.accounts.shopFee ?? o.accounts.labor
+  return o.accounts.labor
+}
+
+function itemFor(line: AccountingLine, o: InvoiceOptions): string | null {
+  if (line.kind === 'part') {
+    const code = line.partNumber?.trim()
+    if (code && o.partItemCodes.has(code)) return code
+    return o.items.parts
+  }
+  return o.items.labor
+}
+
+function lineDescription(line: AccountingLine, includePartNumbers: boolean): string {
+  const pn = includePartNumbers && line.kind === 'part' ? line.partNumber?.trim() : null
+  const text = line.description.trim() || (line.kind === 'part' ? 'Part' : 'Labour')
+  return (pn ? `${pn} ${text}` : text).slice(0, 4000)
+}
+
+/**
+ * A quantity times a unit price in whole cents, when Xero would get exactly
+ * that: both fit the four places Xero keeps, and the product has no part of
+ * a cent for Xero to round its own way. Null otherwise.
+ */
+export function exactCents(quantity: number, unitPrice: number): number | null {
+  const q = Math.round(quantity * 10_000)
+  const u = Math.round(unitPrice * 10_000)
+  if (Math.abs(q / 10_000 - quantity) > 1e-9 || Math.abs(u / 10_000 - unitPrice) > 1e-9) return null
+  // Scaled by 10^8: whole cents are the multiples of 10^6.
+  const product = BigInt(q) * BigInt(u)
+  const centScale = BigInt(1_000_000)
+  if (product % centScale !== BigInt(0)) return null
+  return Number(product / centScale)
+}
+
+function plainNumber(n: number): string {
+  return String(Number(n.toFixed(4)))
+}
+
+/**
+ * The invoice as Xero takes it, adding up to the printed invoice to the cent.
+ *
+ * Torqvoice keeps each line's exact total and rounds the subtotal from their
+ * sum, the discount and the tax on their own (calculateTotals). Xero instead
+ * rounds every line and adds the results, which can land a cent away. So the
+ * printed subtotal is split over the lines in whole cents first (each line
+ * stays within a cent of its printed total), the printed discount and tax are
+ * split over those in whole cents too, and a line goes as quantity times unit
+ * price only when that product is exactly its amount. Otherwise it goes as
+ * one unit at its amount with the quantity and price kept in the description,
+ * so Xero has nothing left to round. Prices go over as billed, tax-inclusive
+ * or not, with the line amount type saying which.
+ */
+export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<string, unknown> {
+  const taxed = o.registeredForTax && inv.taxRate > 0 && !o.taxExempt
+  // What the customer was asked to pay, as printed: that is the figure a
+  // ledger must carry (see roundAsPrinted). The lines add up to the printed
+  // total less its tax (the gross itself when prices include tax), plus the
+  // discount that comes off them.
+  const total = round2(inv.totalAmount)
+  const tax = round2(inv.taxAmount)
+  const discount = round2(inv.discountAmount)
+  const linesSum = Math.max(0, round2((inv.taxInclusive ? total : total - tax) + discount))
+  let priced = inv.lines.filter((l) => Math.abs(l.total) > 0 || l.quantity > 0)
+  // Records from before itemised lines carry only their total.
+  if (priced.reduce((a, l) => a + Math.max(0, l.total), 0) <= 0 && linesSum > 0) {
+    priced = [
+      {
+        kind: 'labor',
+        description: inv.title?.trim() || 'Service',
+        partNumber: null,
+        quantity: 1,
+        unitPrice: linesSum,
+        total: linesSum,
+        taxable: true,
+      },
+    ]
+  }
+  const amounts = distribute(
+    linesSum,
+    priced.map((l) => l.total)
+  )
+  const discounts =
+    discount > 0 ? distribute(Math.min(discount, linesSum), amounts) : priced.map(() => 0)
+  const taxedLine = (l: AccountingLine) => taxed && l.taxable
+  const taxes =
+    taxed && o.billedTax
+      ? distribute(
+          tax,
+          priced.map((l, i) => (taxedLine(l) ? amounts[i] - discounts[i] : 0))
+        )
+      : priced.map(() => 0)
+
+  const lines: XeroLine[] = []
+  const vehicle = o.includeVehicle ? vehicleDescription(inv, o.distanceUnit) : null
+  if (vehicle) lines.push({ Description: `Vehicle: ${vehicle}`.slice(0, 4000) })
+
+  priced.forEach((line, i) => {
+    const cents = Math.round(amounts[i] * 100)
+    const description = lineDescription(line, o.includePartNumbers)
+    const account = accountFor(line, o)
+    const item = itemFor(line, o)
+    let xl: XeroLine
+    if (exactCents(line.quantity, line.unitPrice) === cents) {
+      xl = { Description: description, Quantity: line.quantity, UnitAmount: line.unitPrice }
+    } else if (item && line.quantity > 0) {
+      // An item keeps its quantity, or Xero takes the wrong count off the
+      // shelf. The unit price moves by a fraction of a cent to make the line
+      // exact where four places allow it; otherwise Xero rounds this line.
+      const unit = round4(amounts[i] / line.quantity)
+      xl = {
+        Description: description,
+        Quantity: line.quantity,
+        UnitAmount: exactCents(line.quantity, unit) === cents ? unit : line.unitPrice,
+      }
+    } else {
+      xl = {
+        Description:
+          line.quantity === 1 && Math.abs(line.unitPrice - amounts[i]) < 0.005
+            ? description
+            : `${description} (${plainNumber(line.quantity)} × ${plainNumber(line.unitPrice)})`.slice(
+                0,
+                4000
+              ),
+        Quantity: 1,
+        UnitAmount: amounts[i],
+      }
+    }
+    if (item) xl.ItemCode = item
+    // Always an account, since Xero refuses a line without one: the chosen
+    // one, else the item's own sales account, else the default sales account.
+    xl.AccountCode = account ?? (item ? o.itemAccounts.get(item) : undefined) ?? o.accounts.fallback
+    if (o.registeredForTax) {
+      const type = taxedLine(line) ? o.taxType : o.zeroTaxType
+      if (type) xl.TaxType = type
+      if (taxed && o.billedTax) xl.TaxAmount = taxedLine(line) ? taxes[i] : 0
+    }
+    if (discounts[i] > 0) xl.DiscountAmount = discounts[i]
+    if (o.tracking.length) xl.Tracking = o.tracking
+    lines.push(xl)
+  })
+
+  const notes = o.includeNotes ? inv.notes?.trim() : null
+  if (notes) lines.push({ Description: notes.slice(0, 4000) })
+
+  const body: Record<string, unknown> = {
+    Type: 'ACCREC',
+    Contact: { ContactID: o.contactId },
+    Date: zonedDayKey(inv.invoiceDate, o.timezone),
+    DueDate: o.dueDate,
+    LineAmountTypes: !o.registeredForTax ? 'NoTax' : inv.taxInclusive ? 'Inclusive' : 'Exclusive',
+    LineItems: lines,
+    Status: o.status,
+  }
+  // Shown in Xero as a link back to the job. Only a secure address: Xero's
+  // rules for this field are not published, and a local http one may be refused.
+  if (o.url.startsWith('https://')) body.Url = o.url.slice(0, 2000)
+  if (o.invoiceNumber) body.InvoiceNumber = o.invoiceNumber.slice(0, 255)
+  // Always sent: on an update a missing field keeps Xero's old value, so
+  // "Nothing" has to be an empty reference to clear one set before.
+  body.Reference = o.reference ?? ''
+  if (o.brandingThemeId) body.BrandingThemeID = o.brandingThemeId
+  if (o.currency) body.CurrencyCode = o.currency
+  if (o.sentToContact && o.status === 'AUTHORISED') body.SentToContact = true
+  return body
+}
+
+/**
+ * The due date when the invoice carries none: the organisation's default
+ * sales terms in Xero, else the invoice date (due on receipt).
+ */
+export function defaultDueDate(
+  invoiceDay: string,
+  terms: XeroOrganisation['PaymentTerms'] | undefined
+): string {
+  const sales = terms?.Sales
+  const day = sales?.Day
+  if (!sales || typeof day !== 'number' || day < 0) return invoiceDay
+  const [y, m, d] = invoiceDay.split('-').map(Number)
+  const at = (year: number, month: number, dom: number) => {
+    // A day past the month's end lands on its last day, as Xero does.
+    const last = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
+    return new Date(Date.UTC(year, month, Math.min(Math.max(dom, 1), last)))
+      .toISOString()
+      .slice(0, 10)
+  }
+  switch (sales.Type) {
+    case 'DAYSAFTERBILLDATE':
+      return new Date(Date.UTC(y, m - 1, d + day)).toISOString().slice(0, 10)
+    case 'DAYSAFTERBILLMONTH':
+      return new Date(Date.UTC(y, m, day)).toISOString().slice(0, 10)
+    case 'OFCURRENTMONTH':
+      return at(y, m - 1, day)
+    case 'OFFOLLOWINGMONTH':
+      return at(y, m, day)
+    default:
+      return invoiceDay
+  }
+}
+
+/* ---------- payments ---------- */
+
+export function buildPayment(
+  p: AccountingPayment,
+  o: { invoiceId: string; accountId: string; timezone: string; currencyRate?: number }
+): Record<string, unknown> {
+  const reference = [
+    `Torqvoice ${p.method}`,
+    p.provider ? `via ${p.provider}` : null,
+    p.externalId,
+    p.note,
+  ]
+    .filter(Boolean)
+    .join(', ')
+  return {
+    Invoice: { InvoiceID: o.invoiceId },
+    Account: { AccountID: o.accountId },
+    Date: zonedDayKey(p.date, o.timezone),
+    Amount: round2(p.amount),
+    Reference: reference.slice(0, 255),
+  }
+}
+
+/** Which payment-method setting a payment from here goes under. */
+export function methodAccountKey(
+  p: Pick<AccountingPayment, 'method' | 'provider'>
+): 'onlineAccount' | 'cashAccount' | 'cardAccount' | 'transferAccount' | null {
+  if (p.provider) return 'onlineAccount'
+  if (p.method === 'cash') return 'cashAccount'
+  if (p.method === 'card') return 'cardAccount'
+  if (p.method === 'transfer') return 'transferAccount'
+  return null
+}
+
+/* ---------- reading Xero's answers ---------- */
+
+interface ValidationBody {
+  Message?: string
+  Detail?: string
+  Title?: string
+  /** A 403 comes as a problem document, in lower case. */
+  detail?: string
+  title?: string
+  Elements?: { ValidationErrors?: { Message?: string }[] }[]
+}
+
+/** The sentence Xero's error body says, for the log. */
+export function errorMessage(body: string): string {
+  try {
+    const parsed = JSON.parse(body) as ValidationBody
+    const validation = (parsed.Elements ?? [])
+      .flatMap((e) => e.ValidationErrors ?? [])
+      .map((v) => v.Message)
+      .filter(Boolean)
+    if (validation.length) return [...new Set(validation)].join(' ')
+    return (
+      parsed.Detail ??
+      parsed.Message ??
+      parsed.detail ??
+      parsed.Title ??
+      parsed.title ??
+      body.slice(0, 300)
+    )
+  } catch {
+    return body.slice(0, 300) || 'no details'
+  }
+}
+
+/**
+ * A deep link into Xero's web app, in the form Xero documents: the
+ * organisation's short code and the page, both as they are, unencoded.
+ */
+function deepLink(shortCode: string | null, path: string): string {
+  return shortCode
+    ? `https://go.xero.com/organisationlogin/default.aspx?shortcode=${shortCode}&redirecturl=${path}`
+    : `https://go.xero.com${path}`
+}
+
+/** Where an invoice opens in Xero. */
+export function invoiceUrl(shortCode: string | null, invoiceId: string): string {
+  return deepLink(shortCode, `/AccountsReceivable/View.aspx?InvoiceID=${invoiceId}`)
+}
+
+export function contactUrl(shortCode: string | null, contactId: string): string {
+  return deepLink(shortCode, `/Contacts/View/${contactId}`)
+}
+
+/** Xero's If-Modified-Since: UTC to the second, without a zone suffix. */
+export function modifiedSinceHeader(at: Date): string {
+  return at.toISOString().slice(0, 19)
+}
+
+/** Escapes a string for Xero's where-clause, which quotes with double quotes. */
+export function whereString(value: string): string {
+  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
+}
+
+export { checksumOf } from '@/features/integrations/Lib/checksum'

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

@@ -0,0 +1,1585 @@
+import { createHash } from 'node:crypto'
+import {
+  type AccountingCustomer,
+  type AccountingInvoice,
+  type AccountingPayment,
+  CUSTOMER_ENTITY,
+  INVOICE_ENTITY,
+  PAYMENT_ENTITY,
+  invoiceUrl as appInvoiceUrl,
+  loadCustomerForAccounting,
+  loadInvoiceForAccounting,
+  loadInvoicePdfForAccounting,
+  loadPaymentForAccounting,
+  recordPulledPayment,
+  removePulledPayment,
+  workshopCurrency,
+  workshopDistanceUnit,
+} from '@/features/integrations/Lib/accounting-sync'
+import { oauthSpec, resolveClient } from '@/features/integrations/Lib/oauth'
+import {
+  ConnectorHttpError,
+  type ConnectorContext,
+  type ConnectorServer,
+  type JobOutcome,
+  type LinkRecord,
+} from '@/features/integrations/Lib/types'
+import { zonedDayKey } from '@/lib/timezone'
+import { manifest } from './manifest'
+import {
+  API_BASE,
+  CONNECTIONS_URL,
+  type InvoiceStatus,
+  REVOKE_URL,
+  STATUS_RANK,
+  WALK_IN_NAME,
+  type XeroAccount,
+  type XeroBrandingTheme,
+  type XeroConnection,
+  type XeroContact,
+  type XeroInvoice,
+  type XeroItem,
+  type XeroOrganisation,
+  type XeroPayment,
+  type XeroTrackingCategory,
+  buildContact,
+  buildInvoice,
+  buildPayment,
+  checksumOf,
+  contactName,
+  contactNumber,
+  contactUrl,
+  defaultDueDate,
+  errorMessage,
+  invoiceUrl,
+  methodAccountKey,
+  modifiedSinceHeader,
+  parseXeroDate,
+  paymentDay,
+  referenceFor,
+  xeroDayKey,
+  round2,
+  whereString,
+} from './mapping'
+import {
+  type TaxRateEntry,
+  type XeroTaxRate,
+  pickTaxedRate,
+  pickZeroRate,
+  rateLabel,
+  salesRates,
+} from './tax'
+
+const PROVIDER = 'xero'
+/**
+ * The lines are built to add up to the printed invoice to the cent, so any
+ * difference means Xero changed something: a rate, an amount, its own tax.
+ */
+const TOTAL_TOLERANCE = 0.005
+/** How long the organisation's lists (rates, accounts, items, tracking) are trusted. */
+const CATALOG_TTL_MS = 6 * 60 * 60 * 1000
+/** A payment waiting for its invoice to be approved in Xero is retried this often, for this long. */
+const APPROVAL_RETRY_SECONDS = 60 * 60
+const APPROVAL_WAIT_DAYS = 30
+const NO_TENANT = 'choose the Xero organisation in the settings'
+/** How far a payment may exceed Xero's amount due and still be the same payment, rounded differently. */
+const ROUNDING_SLACK = 0.05
+
+class XeroError extends Error {
+  status: number
+  /** Xero's correlation id for the failed call; their support asks for it. */
+  requestId: string | null
+  constructor(status: number, body: string, requestId: string | null = null) {
+    super(`Xero: ${errorMessage(body)}${requestId ? ` (correlation id ${requestId})` : ''}`)
+    this.status = status
+    this.requestId = requestId
+  }
+}
+
+/* ---------- settings and state ---------- */
+
+type ReferenceChoice = 'vehicle' | 'title' | 'invoiceNumber' | 'none'
+
+function settingsOf(ctx: ConnectorContext) {
+  const s = ctx.connection.settings
+  const str = (k: string) => (typeof s[k] === 'string' && (s[k] as string).trim()) || null
+  const startDate = str('startDate')
+  const status = str('invoiceStatus')
+  const reference = str('reference')
+  return {
+    tenantId: str('tenantId'),
+    pushInvoices: s.pushInvoices !== false,
+    pushOnComplete: s.pushOnComplete === true,
+    startDate: startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? startDate : null,
+    invoiceStatus: (status === 'DRAFT' || status === 'SUBMITTED'
+      ? status
+      : 'AUTHORISED') as InvoiceStatus,
+    xeroNumbers: s.invoiceNumbers === 'xero',
+    reference: (reference === 'title' || reference === 'invoiceNumber' || reference === 'none'
+      ? reference
+      : 'vehicle') as ReferenceChoice,
+    includeVehicle: s.includeVehicle !== false,
+    includeNotes: s.includeNotes !== false,
+    includePartNumbers: s.includePartNumbers !== false,
+    brandingThemeId: str('brandingThemeId'),
+    laborAccount: str('laborAccount'),
+    partsAccount: str('partsAccount'),
+    shopFeeAccount: str('shopFeeAccount'),
+    laborItem: str('laborItem'),
+    partsItem: str('partsItem'),
+    matchItemsByPartNumber: s.matchItemsByPartNumber === true,
+    billedTax: s.taxAmounts !== 'xero',
+    taxType: str('taxType'),
+    zeroTaxType: str('zeroTaxType'),
+    tracking: [str('tracking1'), str('tracking2')].filter((v): v is string => Boolean(v)),
+    sendFromXero: s.sendFromXero === true,
+    markAsSent: s.markAsSent !== false,
+    attachPdf: s.attachPdf !== false,
+    updateContacts: s.updateContacts !== false,
+    voidOnDelete: s.voidOnDelete !== false,
+    pushPayments: s.pushPayments !== false,
+    paymentAccount: str('paymentAccount'),
+    methodAccounts: {
+      cashAccount: str('cashAccount'),
+      cardAccount: str('cardAccount'),
+      transferAccount: str('transferAccount'),
+      onlineAccount: str('onlineAccount'),
+    },
+    manualPaidAsPayment: s.manualPaidAsPayment === true,
+    pullPayments: s.pullPayments !== false,
+  }
+}
+
+type Settings = ReturnType<typeof settingsOf>
+
+interface OrgInfo {
+  tenantId: string
+  fetchedAt: string
+  name: string
+  shortCode: string | null
+  baseCurrency: string | null
+  countryCode: string | null
+  paysTax: boolean
+  paymentTerms: XeroOrganisation['PaymentTerms'] | null
+  /** PARTNER organisations cannot invoice at all. */
+  edition: string | null
+}
+
+/** The organisation's lists, read at most every few hours, for one tenant. */
+interface Catalog {
+  tenantId: string
+  fetchedAt: string
+  rates: TaxRateEntry[]
+  defaultSalesAccount: string | null
+  bankAccountId: string | null
+  /** Items that can be sold, with their own sales account when they have one. */
+  items: { code: string; salesAccount: string | null }[]
+  tracking: { id: string; name: string; categoryId: string; categoryName: string }[]
+}
+
+interface State {
+  org: OrgInfo | null
+  catalog: Catalog | null
+  walkIn: { tenantId: string; contactId: string } | null
+  lastPull: { tenantId: string; at: string } | null
+}
+
+function stateOf(ctx: ConnectorContext): State {
+  const s = ctx.connection.state
+  const obj = (v: unknown) => (typeof v === 'object' && v !== null ? v : null)
+  return {
+    org: obj(s.org) as OrgInfo | null,
+    catalog: obj(s.catalog) as Catalog | null,
+    walkIn: obj(s.walkIn) as State['walkIn'],
+    lastPull: obj(s.lastPull) as State['lastPull'],
+  }
+}
+
+function fresh(at: string | undefined): boolean {
+  const t = at ? Date.parse(at) : Number.NaN
+  return Number.isFinite(t) && Date.now() - t < CATALOG_TTL_MS
+}
+
+/* ---------- calls ---------- */
+
+interface CallInit {
+  method?: string
+  body?: unknown
+  query?: Record<string, string>
+  headers?: Record<string, string>
+}
+
+/**
+ * One call to the Accounting API for the chosen organisation. Every call
+ * names the tenant in a header; Xero answers JSON when asked, and puts its
+ * reasons in a validation body that the error carries to the log.
+ */
+async function api<T>(ctx: ConnectorContext, path: string, init: CallInit = {}): Promise<T> {
+  const tenantId = settingsOf(ctx).tenantId
+  if (!tenantId) throw new Error(`No Xero organisation chosen; ${NO_TENANT}`)
+  const url = new URL(`${API_BASE}/${path}`)
+  for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v)
+  try {
+    return await ctx.http.json<T>(url.toString(), {
+      method: init.method ?? (init.body !== undefined ? 'POST' : 'GET'),
+      headers: { Accept: 'application/json', 'xero-tenant-id': tenantId, ...init.headers },
+      ...(init.body !== undefined && { body: JSON.stringify(init.body) }),
+    })
+  } catch (err) {
+    if (err instanceof ConnectorHttpError) throw new XeroError(err.status, err.body, err.requestId)
+    throw err
+  }
+}
+
+function isNotFound(err: unknown): boolean {
+  return err instanceof XeroError && err.status === 404
+}
+
+async function connectionsOf(ctx: ConnectorContext): Promise<XeroConnection[]> {
+  try {
+    const list = await ctx.http.json<XeroConnection[]>(CONNECTIONS_URL, {
+      headers: { Accept: 'application/json' },
+    })
+    return (Array.isArray(list) ? list : []).filter(
+      (c) => !c.tenantType || c.tenantType === 'ORGANISATION'
+    )
+  } catch (err) {
+    if (err instanceof ConnectorHttpError) throw new XeroError(err.status, err.body, err.requestId)
+    throw err
+  }
+}
+
+/** Every page of a list endpoint. Xero pages by number and returns fewer than a page at the end. */
+async function listAll<T>(
+  ctx: ConnectorContext,
+  path: string,
+  key: string,
+  init: CallInit = {},
+  pageSize = 100
+): Promise<T[]> {
+  const out: T[] = []
+  for (let page = 1; page <= 100; page++) {
+    const res = await api<Record<string, T[] | undefined>>(ctx, path, {
+      ...init,
+      query: { ...init.query, page: String(page), pageSize: String(pageSize) },
+    })
+    const rows = res[key] ?? []
+    out.push(...rows)
+    if (rows.length < pageSize) break
+  }
+  return out
+}
+
+/* ---------- organisation and lists ---------- */
+
+async function loadOrg(ctx: ConnectorContext, opts: { refresh?: boolean } = {}): Promise<OrgInfo> {
+  const tenantId = settingsOf(ctx).tenantId
+  if (!tenantId) throw new Error(`No Xero organisation chosen; ${NO_TENANT}`)
+  const cached = stateOf(ctx).org
+  if (!opts.refresh && cached?.tenantId === tenantId && fresh(cached.fetchedAt)) return cached
+  const res = await api<{ Organisations?: XeroOrganisation[] }>(ctx, 'Organisation')
+  const o = res.Organisations?.[0] ?? {}
+  const org: OrgInfo = {
+    tenantId,
+    fetchedAt: new Date().toISOString(),
+    name: o.Name ?? o.LegalName ?? 'Xero',
+    shortCode: o.ShortCode ?? null,
+    baseCurrency: o.BaseCurrency ?? null,
+    countryCode: o.CountryCode ?? null,
+    // Absent on older answers: assume registered, which only adds tax codes.
+    paysTax: o.PaysTax !== false,
+    paymentTerms: o.PaymentTerms ?? null,
+    edition: (o as { Edition?: string }).Edition ?? null,
+  }
+  await ctx.saveState({ org })
+  return org
+}
+
+async function loadCatalog(
+  ctx: ConnectorContext,
+  opts: { refresh?: boolean } = {}
+): Promise<Catalog> {
+  const tenantId = settingsOf(ctx).tenantId
+  if (!tenantId) throw new Error(`No Xero organisation chosen; ${NO_TENANT}`)
+  const cached = stateOf(ctx).catalog
+  if (
+    !opts.refresh &&
+    cached?.tenantId === tenantId &&
+    fresh(cached.fetchedAt) &&
+    Array.isArray(cached.items)
+  ) {
+    return cached
+  }
+  const [rates, accounts, items, tracking] = await Promise.all([
+    api<{ TaxRates?: XeroTaxRate[] }>(ctx, 'TaxRates'),
+    api<{ Accounts?: XeroAccount[] }>(ctx, 'Accounts'),
+    api<{ Items?: XeroItem[] }>(ctx, 'Items'),
+    api<{ TrackingCategories?: XeroTrackingCategory[] }>(ctx, 'TrackingCategories'),
+  ])
+  const active = (accounts.Accounts ?? []).filter((a) => !a.Status || a.Status === 'ACTIVE')
+  const revenue = active.filter((a) => a.Class === 'REVENUE' && a.Code)
+  // Xero's own charts call it Sales, code 200 in most; any revenue account will do after that.
+  const sales =
+    revenue.find((a) => a.Code === '200') ??
+    revenue.find((a) => a.Type === 'SALES' || /^sales$/i.test(a.Name ?? '')) ??
+    revenue[0] ??
+    null
+  const bank = active.find((a) => a.Type === 'BANK') ?? null
+  const catalog: Catalog = {
+    tenantId,
+    fetchedAt: new Date().toISOString(),
+    rates: salesRates(rates.TaxRates ?? []),
+    defaultSalesAccount: sales?.Code ?? null,
+    bankAccountId: bank?.AccountID ?? null,
+    items: (items.Items ?? [])
+      .filter((i) => i.IsSold !== false)
+      .map((i) => ({ code: i.Code, salesAccount: i.SalesDetails?.AccountCode ?? null })),
+    tracking: (tracking.TrackingCategories ?? [])
+      .filter((c) => !c.Status || c.Status === 'ACTIVE')
+      .flatMap((c) =>
+        (c.Options ?? [])
+          .filter((o) => !o.Status || o.Status === 'ACTIVE')
+          .map((o) => ({
+            id: o.TrackingOptionID,
+            name: o.Name,
+            categoryId: c.TrackingCategoryID,
+            categoryName: c.Name,
+          }))
+      ),
+  }
+  await ctx.saveState({ catalog })
+  return catalog
+}
+
+/* ---------- links, scoped to the organisation ---------- */
+
+/** A link counts only for the organisation it was made in; switching organisation starts afresh. */
+async function linkFor(
+  ctx: ConnectorContext,
+  entityType: string,
+  entityId: string
+): Promise<LinkRecord | null> {
+  const link = await ctx.links.get(entityType, entityId)
+  if (!link) return null
+  return link.metadata?.tenantId === settingsOf(ctx).tenantId ? link : null
+}
+
+async function setLink(
+  ctx: ConnectorContext,
+  entityType: string,
+  entityId: string,
+  link: { remoteId: string; remoteUrl?: string | null; checksum?: string | null },
+  metadata: Record<string, unknown> = {}
+): Promise<void> {
+  await ctx.links.set(entityType, entityId, {
+    remoteId: link.remoteId,
+    remoteUrl: link.remoteUrl ?? null,
+    checksum: link.checksum ?? null,
+    metadata: { ...metadata, tenantId: settingsOf(ctx).tenantId },
+  })
+}
+
+async function patchLink(
+  ctx: ConnectorContext,
+  entityType: string,
+  entityId: string,
+  link: LinkRecord,
+  patch: Record<string, unknown>
+): Promise<void> {
+  await ctx.links.set(entityType, entityId, {
+    remoteId: link.remoteId,
+    remoteUrl: link.remoteUrl,
+    checksum: link.checksum,
+    metadata: { ...(link.metadata ?? {}), ...patch },
+  })
+}
+
+/* ---------- contacts ---------- */
+
+async function findContact(ctx: ConnectorContext, where: string): Promise<XeroContact | null> {
+  const res = await api<{ Contacts?: XeroContact[] }>(ctx, 'Contacts', {
+    query: { where, summaryOnly: 'true' },
+  })
+  return (res.Contacts ?? []).find((c) => c.ContactStatus !== 'ARCHIVED') ?? null
+}
+
+/** 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 {
+    const res = await api<{ Contacts?: XeroContact[] }>(
+      ctx,
+      `Contacts/${encodeURIComponent(number)}`
+    )
+    const found = res.Contacts?.[0]
+    return found && found.ContactStatus !== 'ARCHIVED' ? found : null
+  } catch (err) {
+    if (isNotFound(err)) return null
+    throw err
+  }
+}
+
+async function walkInContact(ctx: ConnectorContext): Promise<string> {
+  const tenantId = settingsOf(ctx).tenantId
+  const cached = stateOf(ctx).walkIn
+  if (cached && cached.tenantId === tenantId) return cached.contactId
+  let contact = await findContact(ctx, `Name==${whereString(WALK_IN_NAME)}`)
+  if (!contact) {
+    const res = await api<{ Contacts?: XeroContact[] }>(ctx, 'Contacts', {
+      method: 'PUT',
+      query: { summarizeErrors: 'true' },
+      body: { Contacts: [{ Name: WALK_IN_NAME }] },
+    })
+    contact = res.Contacts?.[0] ?? null
+  }
+  if (!contact) throw new Error('Xero did not return the walk-in contact')
+  await ctx.saveState({ walkIn: { tenantId, contactId: contact.ContactID } })
+  return contact.ContactID
+}
+
+function isDuplicateName(err: unknown): boolean {
+  return (
+    err instanceof XeroError && err.status === 400 && /already (exists|assigned)/i.test(err.message)
+  )
+}
+
+async function createContact(ctx: ConnectorContext, c: AccountingCustomer): Promise<XeroContact> {
+  const name = contactName(c)
+  const put = async (body: Record<string, unknown>) => {
+    const res = await api<{ Contacts?: XeroContact[] }>(ctx, 'Contacts', {
+      method: 'PUT',
+      query: { summarizeErrors: 'true' },
+      body: { Contacts: [body] },
+    })
+    const made = res.Contacts?.[0]
+    if (!made) throw new Error('Xero did not return the new contact')
+    return made
+  }
+  try {
+    return await put(buildContact(c, name))
+  } catch (err) {
+    if (!isDuplicateName(err)) throw err
+    // Xero keeps names unique; the customer number tells two of the same name apart.
+    const suffix = c.customerNumber ?? c.id.slice(-4)
+    return put(buildContact(c, `${name} (${suffix})`.slice(0, 255)))
+  }
+}
+
+/**
+ * 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.
+ */
+async function ensureContact(
+  ctx: ConnectorContext,
+  c: AccountingCustomer | null
+): Promise<{ id: string; email: string | null; taxExempt: boolean }> {
+  if (!c) return { id: await walkInContact(ctx), email: null, taxExempt: false }
+  const settings = settingsOf(ctx)
+  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 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,
+      })
+      return result(link.remoteId)
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+      await ctx.links.remove(CUSTOMER_ENTITY, c.id)
+    }
+  }
+  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)}`))
+  const remote = existing ?? (await createContact(ctx, c))
+  await setLink(
+    ctx,
+    CUSTOMER_ENTITY,
+    c.id,
+    {
+      remoteId: remote.ContactID,
+      remoteUrl: contactUrl(org.shortCode, remote.ContactID),
+      checksum,
+    },
+    { reused: Boolean(existing) }
+  )
+  return result(remote.ContactID)
+}
+
+/** Copies the customer's details over. A name another contact already holds stays as it was. */
+async function updateContact(
+  ctx: ConnectorContext,
+  contactId: string,
+  c: AccountingCustomer,
+  name: string
+): Promise<void> {
+  const body: Record<string, unknown> = { ...buildContact(c, name), ContactID: contactId }
+  try {
+    await api(ctx, `Contacts/${contactId}`, { body: { Contacts: [body] } })
+  } catch (err) {
+    if (!isDuplicateName(err)) throw err
+    const { Name: _name, ...rest } = body
+    await api(ctx, `Contacts/${contactId}`, { body: { Contacts: [rest] } })
+  }
+}
+
+/* ---------- tax ---------- */
+
+interface ResolvedTax {
+  registered: boolean
+  taxType: string | null
+  zeroTaxType: string | null
+  note: string | null
+}
+
+async function resolveTax(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  org: OrgInfo,
+  taxExempt: boolean
+): Promise<ResolvedTax> {
+  const settings = settingsOf(ctx)
+  if (!org.paysTax) {
+    return {
+      registered: false,
+      taxType: null,
+      zeroTaxType: null,
+      note:
+        inv.taxAmount > 0
+          ? `The Xero organisation is not registered for tax, so invoice ${inv.invoiceNumber} went over without its ${round2(inv.taxAmount)} tax. Turn on tax in Xero's financial settings if it should carry it.`
+          : null,
+    }
+  }
+  let catalog = await loadCatalog(ctx)
+  const zero = pickZeroRate(catalog.rates, settings.zeroTaxType)
+  const taxed = inv.taxRate > 0 && !taxExempt
+  if (!taxed)
+    return { registered: true, taxType: null, zeroTaxType: zero?.taxType ?? null, note: null }
+  let pick = pickTaxedRate(catalog.rates, inv.taxRate, settings.taxType)
+  if (!pick.matched && !settings.taxType) {
+    // The rate may be new in Xero since the list was read.
+    catalog = await loadCatalog(ctx, { refresh: true })
+    pick = pickTaxedRate(catalog.rates, inv.taxRate, settings.taxType)
+  }
+  let note: string | null = null
+  if (!pick.matched) {
+    note = pick.entry
+      ? `Invoice ${inv.invoiceNumber} charges ${inv.taxRate}% but was booked under "${pick.entry.name}" (${pick.entry.percent}%), the tax rate chosen in the integration settings.`
+      : `No Xero tax rate carries ${inv.taxRate}%, so invoice ${inv.invoiceNumber} went over without one and Xero used each account's default. Add a ${inv.taxRate}% rate in Xero or choose one in the integration settings.`
+  }
+  return {
+    registered: true,
+    taxType: pick.entry?.taxType ?? null,
+    zeroTaxType: zero?.taxType ?? null,
+    note,
+  }
+}
+
+/* ---------- invoices ---------- */
+
+/** Why the invoice stays out of Xero, or null when it goes. */
+function whyNotEligible(inv: AccountingInvoice, s: Settings, tz: string): string | null {
+  if (!s.pushInvoices) return 'invoice push switched off'
+  if (!inv.invoiceNumber) return 'no invoice number'
+  const issued = Boolean(inv.issuedAt) || (s.pushOnComplete && inv.status === 'completed')
+  if (!issued) return 'not issued yet'
+  if (s.startDate && zonedDayKey(inv.invoiceDate, tz) < s.startDate) {
+    return 'dated before the start date'
+  }
+  return null
+}
+
+/**
+ * The currency to put on the invoice: none when the workshop bills in the
+ * organisation's own currency, which is Xero's default. Another currency is
+ * sent as it is; Xero accepts it when the organisation has multi-currency
+ * and that currency added, and says so when it has not.
+ */
+async function currencyFor(ctx: ConnectorContext, org: OrgInfo): Promise<string | null> {
+  const ours = await workshopCurrency(ctx.connection.organizationId)
+  if (!ours || !org.baseCurrency || ours === org.baseCurrency) return null
+  return ours
+}
+
+async function readInvoice(ctx: ConnectorContext, invoiceId: string): Promise<XeroInvoice> {
+  const res = await api<{ Invoices?: XeroInvoice[] }>(ctx, `Invoices/${invoiceId}`)
+  const inv = res.Invoices?.[0]
+  if (!inv) throw new XeroError(404, '{"Message":"Invoice not found"}')
+  return inv
+}
+
+function isGone(inv: XeroInvoice): boolean {
+  return inv.Status === 'VOIDED' || inv.Status === 'DELETED'
+}
+
+/** The invoice's number belongs to one voided or deleted in Xero, which Xero never gives out again. */
+class NumberTaken extends Error {
+  invoice: XeroInvoice
+  constructor(invoice: XeroInvoice) {
+    super(
+      `Invoice number ${invoice.InvoiceNumber} is taken by a ${invoice.Status?.toLowerCase()} invoice in Xero`
+    )
+    this.invoice = invoice
+  }
+}
+
+/**
+ * An invoice voided or deleted in Xero cannot come back under its number.
+ * With Torqvoice numbers it is left out, and the link remembers that so later
+ * edits are skipped without failing; given a new number here, it is sent as
+ * a new invoice. With Xero numbering it simply goes over again as new.
+ */
+async function markGone(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  remote: XeroInvoice
+): Promise<JobOutcome> {
+  const status = (remote.Status ?? 'voided').toLowerCase()
+  await setLink(
+    ctx,
+    INVOICE_ENTITY,
+    inv.id,
+    { remoteId: remote.InvoiceID, remoteUrl: null, checksum: null },
+    { goneInXero: status, invoiceNumber: inv.invoiceNumber }
+  )
+  await ctx.log(
+    'warn',
+    `Invoice ${inv.invoiceNumber} was ${status} in Xero, and Xero does not give its number out again, so edits here are not sent. Give the invoice a new number in Torqvoice to send it as a new invoice.`
+  )
+  return { summary: `invoice ${inv.invoiceNumber} ${status} in Xero; not sent again` }
+}
+
+function hasMoney(inv: XeroInvoice): boolean {
+  return (inv.AmountPaid ?? 0) > 0 || (inv.Payments?.length ?? 0) > 0 || inv.Status === 'PAID'
+}
+
+/** The record is gone here: void or delete Xero's copy unless money was taken against it. */
+async function retireInvoice(ctx: ConnectorContext, serviceRecordId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  const link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+  if (!link) return { summary: 'nothing to do' }
+  if (!settings.voidOnDelete) {
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: 'left in Xero, voiding switched off' }
+  }
+  try {
+    const current = await readInvoice(ctx, link.remoteId)
+    const label = current.InvoiceNumber ?? current.InvoiceID
+    if (current.Status === 'VOIDED' || current.Status === 'DELETED') {
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: `invoice ${label} already ${current.Status.toLowerCase()} in Xero` }
+    }
+    if (hasMoney(current)) {
+      await ctx.log(
+        'warn',
+        `Invoice ${label} was deleted here but has payments in Xero; left as is`
+      )
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: 'left in Xero, has payments' }
+    }
+    // A draft or one awaiting approval is deleted; an approved one can only be voided.
+    const status = current.Status === 'AUTHORISED' ? 'VOIDED' : 'DELETED'
+    await api(ctx, `Invoices/${current.InvoiceID}`, {
+      body: { Invoices: [{ InvoiceID: current.InvoiceID, Status: status }] },
+    })
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: `invoice ${label} ${status.toLowerCase()} in Xero` }
+  } catch (err) {
+    if (!isNotFound(err)) throw err
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: 'already gone from Xero' }
+  }
+}
+
+/** The tracking options chosen, as Xero names them on a line; at most one per category. */
+function trackingFor(catalog: Catalog, chosen: string[]): { Name: string; Option: string }[] {
+  const out: { Name: string; Option: string }[] = []
+  const seen = new Set<string>()
+  for (const value of chosen) {
+    const optionId = value.includes(':') ? value.split(':')[1] : value
+    const option = catalog.tracking.find((t) => t.id === optionId)
+    if (!option || seen.has(option.categoryId)) continue
+    seen.add(option.categoryId)
+    out.push({ Name: option.categoryName, Option: option.name })
+  }
+  return out
+}
+
+async function buildBody(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  contact: { id: string; taxExempt: boolean },
+  org: OrgInfo
+): Promise<{ body: Record<string, unknown>; note: string | null }> {
+  const settings = settingsOf(ctx)
+  const tax = await resolveTax(ctx, inv, org, contact.taxExempt)
+  const catalog = await loadCatalog(ctx)
+  const fallback = catalog.defaultSalesAccount
+  if (!fallback && !(settings.laborAccount && settings.partsAccount)) {
+    throw new Error(
+      'The Xero organisation has no sales account to book to; choose the accounts in the integration settings'
+    )
+  }
+  const invoiceDay = zonedDayKey(inv.invoiceDate, ctx.timezone)
+  const body = buildInvoice(inv, {
+    contactId: contact.id,
+    status: settings.invoiceStatus,
+    invoiceNumber: settings.xeroNumbers ? null : inv.invoiceNumber,
+    reference: referenceFor(inv, settings.reference),
+    dueDate: inv.dueDate
+      ? zonedDayKey(inv.dueDate, ctx.timezone)
+      : defaultDueDate(invoiceDay, org.paymentTerms ?? undefined),
+    brandingThemeId: settings.brandingThemeId,
+    currency: await currencyFor(ctx, org),
+    timezone: ctx.timezone,
+    url: appInvoiceUrl(ctx.appUrl, inv),
+    includeVehicle: settings.includeVehicle,
+    includeNotes: settings.includeNotes,
+    includePartNumbers: settings.includePartNumbers,
+    distanceUnit: await workshopDistanceUnit(ctx.connection.organizationId),
+    accounts: {
+      labor: settings.laborAccount,
+      parts: settings.partsAccount,
+      shopFee: settings.shopFeeAccount,
+      fallback: fallback ?? settings.laborAccount ?? '',
+    },
+    items: { labor: settings.laborItem, parts: settings.partsItem },
+    partItemCodes: new Set(settings.matchItemsByPartNumber ? catalog.items.map((i) => i.code) : []),
+    itemAccounts: new Map(
+      catalog.items.flatMap((i) => (i.salesAccount ? [[i.code, i.salesAccount] as const] : []))
+    ),
+    registeredForTax: tax.registered,
+    taxType: tax.taxType,
+    zeroTaxType: tax.zeroTaxType,
+    billedTax: settings.billedTax,
+    taxExempt: contact.taxExempt,
+    tracking: trackingFor(catalog, settings.tracking),
+    sentToContact: settings.markAsSent && !settings.sendFromXero,
+  })
+  return { body, note: tax.note }
+}
+
+/**
+ * A new invoice in Xero. When the Torqvoice number is used and Xero already
+ * has an invoice of that number that links back to this job, an earlier
+ * push lost its link and that invoice is adopted instead of doubled.
+ */
+async function createInvoice(
+  ctx: ConnectorContext,
+  body: Record<string, unknown>,
+  inv: AccountingInvoice,
+  checksum: string
+): Promise<{ invoice: XeroInvoice; adopted: boolean }> {
+  const number = typeof body.InvoiceNumber === 'string' ? body.InvoiceNumber : null
+  if (number) {
+    const found = await api<{ Invoices?: XeroInvoice[] }>(ctx, 'Invoices', {
+      query: { InvoiceNumbers: number, summaryOnly: 'true' },
+    })
+    const sales = (found.Invoices ?? []).filter((i) => i.Type === 'ACCREC')
+    const live = sales.filter((i) => !isGone(i))
+    // Xero keeps a voided or deleted invoice's number taken for good.
+    const gone = sales.find(isGone)
+    if (!live.length && gone) throw new NumberTaken(gone)
+    if (live.length) {
+      const full = (await readInvoice(ctx, live[0].InvoiceID)) as XeroInvoice & { Url?: string }
+      // Ours when it links back to this job, or, without a link to compare,
+      // when it is for the same customer on the same day.
+      const ours = body.Url
+        ? full.Url === body.Url
+        : full.Contact?.ContactID === (body.Contact as { ContactID?: string }).ContactID &&
+          zonedDayKey(parseXeroDate(full.Date) ?? new Date(0), 'UTC') === body.Date
+      if (ours) {
+        await ctx.log('info', `Invoice ${number} was already in Xero; linked to it`)
+        await relinkPayments(ctx, inv, full)
+        return {
+          invoice: hasMoney(full) ? full : await saveInvoice(ctx, full, body),
+          adopted: true,
+        }
+      }
+      // Xero keeps sales invoice numbers unique and would refuse a second one.
+      throw new Error(
+        `Xero already has an invoice numbered ${number} that did not come from Torqvoice, so invoice ${inv.invoiceNumber} cannot be added under that number. Renumber one of them, or choose "Let Xero number them" in the integration settings.`
+      )
+    }
+  }
+  const res = await api<{ Invoices?: XeroInvoice[] }>(ctx, 'Invoices', {
+    method: 'PUT',
+    // Summarised, a refused invoice is a 400 with Xero's reasons, never a 200.
+    query: { unitdp: '4', summarizeErrors: 'true' },
+    // A retry after a lost answer must not make a second invoice.
+    headers: { 'Idempotency-Key': `tv-${inv.id}-${checksum.slice(0, 24)}` },
+    body: { Invoices: [body] },
+  })
+  const made = res.Invoices?.[0]
+  if (!made) throw new Error('Xero did not return the new invoice')
+  return { invoice: made, adopted: false }
+}
+
+/**
+ * After a lost link (a reconnect forgets what was sent), the payments Xero
+ * already holds for an invoice found again are linked to the ones here by
+ * amount and day, so they are not recorded a second time. A payment that
+ * came from Xero is known by its own id.
+ */
+async function relinkPayments(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  remote: XeroInvoice
+): Promise<void> {
+  const theirs = (remote.Payments ?? []).filter((x) => x.Status !== 'DELETED')
+  const taken = new Set<string>()
+  for (const p of inv.payments) {
+    if (await linkFor(ctx, PAYMENT_ENTITY, p.id)) continue
+    const fromXero = p.provider === PROVIDER && p.externalId
+    const day = zonedDayKey(p.date, ctx.timezone)
+    const match = fromXero
+      ? theirs.find((x) => x.PaymentID === p.externalId)
+      : theirs.find(
+          (x) =>
+            !taken.has(x.PaymentID) &&
+            Math.abs((x.Amount ?? 0) - round2(p.amount)) <= ROUNDING_SLACK &&
+            xeroDayKey(x.Date) === day
+        )
+    if (!match) continue
+    taken.add(match.PaymentID)
+    await setLink(
+      ctx,
+      PAYMENT_ENTITY,
+      p.id,
+      { remoteId: match.PaymentID },
+      { createdByUs: !fromXero, serviceRecordId: inv.id, relinked: true }
+    )
+  }
+}
+
+/**
+ * Writes our body over an invoice Xero still lets us change. An update never
+ * moves it back in approval: a draft someone approved in Xero stays approved.
+ */
+async function saveInvoice(
+  ctx: ConnectorContext,
+  current: XeroInvoice,
+  body: Record<string, unknown>
+): Promise<XeroInvoice> {
+  const ours = STATUS_RANK[String(body.Status)] ?? 0
+  const theirs = STATUS_RANK[current.Status ?? ''] ?? 0
+  const next: Record<string, unknown> = { ...body, InvoiceID: current.InvoiceID }
+  if (theirs > ours) next.Status = current.Status
+  // An invoice Xero numbered keeps its number.
+  if (!body.InvoiceNumber) delete next.InvoiceNumber
+  const res = await api<{ Invoices?: XeroInvoice[] }>(ctx, `Invoices/${current.InvoiceID}`, {
+    query: { unitdp: '4' },
+    body: { Invoices: [next] },
+  })
+  return res.Invoices?.[0] ?? current
+}
+
+async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.tenantId) return { summary: NO_TENANT }
+  const inv = await loadInvoiceForAccounting(ctx.connection.organizationId, serviceRecordId)
+  if (!inv) return retireInvoice(ctx, serviceRecordId)
+  const skip = whyNotEligible(inv, settings, ctx.timezone)
+  if (skip) return { summary: skip }
+
+  const org = await loadOrg(ctx)
+  const contact = await ensureContact(ctx, inv.customer)
+  const { body, note } = await buildBody(ctx, inv, contact, org)
+  const checksum = checksumOf(body)
+  let link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+  if (link?.metadata?.goneInXero) {
+    if (!settings.xeroNumbers && link.metadata.invoiceNumber === inv.invoiceNumber) {
+      return {
+        summary: `invoice ${inv.invoiceNumber} ${link.metadata.goneInXero} in Xero; not sent again`,
+      }
+    }
+    // Renumbered here, or numbered by Xero: it goes over as a new invoice.
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    link = null
+  }
+
+  let saved: XeroInvoice | null = null
+  let action = 'unchanged'
+  if (!link || link.checksum !== checksum) {
+    if (note) await ctx.log('warn', note)
+    if (link) {
+      try {
+        const current = await readInvoice(ctx, link.remoteId)
+        if (isGone(current)) {
+          // Gone in Xero: with Xero numbering a new one is made below; under
+          // the same Torqvoice number Xero would refuse it.
+          if (!settings.xeroNumbers) return markGone(ctx, inv, current)
+          await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+          link = null
+        } else if (hasMoney(current)) {
+          saved = current
+          action = await settledDifference(ctx, inv, current, link, checksum)
+        } else {
+          saved = await saveInvoice(ctx, current, body)
+          action = 'updated'
+        }
+      } catch (err) {
+        if (!isNotFound(err)) throw err
+        await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+        link = null
+      }
+    }
+    if (!link) {
+      try {
+        const made = await createInvoice(ctx, body, inv, checksum)
+        saved = made.invoice
+        action = made.adopted ? 'found in Xero and linked again' : 'created'
+      } catch (err) {
+        if (err instanceof NumberTaken) return markGone(ctx, inv, err.invoice)
+        throw err
+      }
+    }
+    if (saved && action !== 'left as paid') {
+      await setLink(
+        ctx,
+        INVOICE_ENTITY,
+        serviceRecordId,
+        {
+          remoteId: saved.InvoiceID,
+          remoteUrl: invoiceUrl(org.shortCode, saved.InvoiceID),
+          checksum,
+        },
+        {
+          invoiceNumber: saved.InvoiceNumber ?? null,
+          contactId: contact.id,
+          // The PDF changed with the invoice; the same file name overwrites the old one in Xero.
+          attached: false,
+          emailed: link?.metadata?.emailed ?? false,
+        }
+      )
+      checkTotals(ctx, inv, saved, settings, org)
+    }
+  }
+
+  link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+  if (link) {
+    if (settings.attachPdf && link.metadata?.attached !== true) {
+      if (await attachPdf(ctx, link.remoteId, inv)) {
+        await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, { attached: true })
+        link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+      }
+    }
+    if (link && settings.sendFromXero && link.metadata?.emailed !== true) {
+      if (await emailFromXero(ctx, link.remoteId, inv, contact.email)) {
+        await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, { emailed: true })
+      }
+    }
+  }
+
+  let paymentsPushed = 0
+  let waiting = false
+  if (settings.pushPayments && link) {
+    const pushed = await pushPendingPayments(ctx, inv, link.remoteId)
+    paymentsPushed = pushed.count
+    waiting = pushed.waitingForApproval
+  }
+  const summary = `invoice ${inv.invoiceNumber} ${action}`
+  const extra = [
+    paymentsPushed ? `${paymentsPushed} payments recorded` : null,
+    waiting ? 'payments wait for approval in Xero' : null,
+  ].filter(Boolean)
+  return { summary: extra.length ? `${summary}, ${extra.join(', ')}` : summary }
+}
+
+/**
+ * The invoice changed here after money was taken against it in Xero, which
+ * Xero no longer lets anyone edit. When Xero already holds the same total
+ * and customer, only the checksum moved (a setting such as an account
+ * changed) and it is refreshed quietly; otherwise the workshop hears once.
+ */
+async function settledDifference(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  current: XeroInvoice,
+  link: LinkRecord,
+  checksum: string
+): Promise<string> {
+  const sameTotal = Math.abs((current.Total ?? 0) - inv.totalAmount) <= TOTAL_TOLERANCE
+  if (sameTotal) {
+    await ctx.links.set(INVOICE_ENTITY, inv.id, {
+      remoteId: link.remoteId,
+      remoteUrl: link.remoteUrl,
+      checksum,
+      metadata: link.metadata,
+    })
+    return 'left as paid'
+  }
+  if (link.metadata?.warnedChecksum !== checksum) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} changed here (total ${round2(inv.totalAmount)}) after a payment was recorded against it in Xero (total ${round2(current.Total ?? 0)}); Xero does not allow editing it, so it was left as is. Remove the payment in Xero to let the change through.`
+    )
+    await patchLink(ctx, INVOICE_ENTITY, inv.id, link, { warnedChecksum: checksum })
+  }
+  return 'left as paid'
+}
+
+function checkTotals(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  saved: XeroInvoice,
+  settings: Settings,
+  org: OrgInfo
+): void {
+  if (saved.Total === undefined) return
+  if (Math.abs(saved.Total - inv.totalAmount) <= TOTAL_TOLERANCE) return
+  const hint = !org.paysTax
+    ? 'the organisation is not registered for tax in Xero'
+    : !settings.billedTax
+      ? 'Xero worked the tax out itself; set Tax amounts to "as billed" to send yours'
+      : 'check the tax rates in the integration settings'
+  void ctx.log(
+    'warn',
+    `Invoice ${inv.invoiceNumber}: Xero total ${round2(saved.Total)} differs from ${round2(inv.totalAmount)} here; ${hint}`,
+    { xero: saved.Total, torqvoice: inv.totalAmount }
+  )
+}
+
+/** The invoice the customer received, attached to Xero's copy. */
+async function attachPdf(
+  ctx: ConnectorContext,
+  invoiceId: string,
+  inv: AccountingInvoice
+): Promise<boolean> {
+  try {
+    const pdf = await loadInvoicePdfForAccounting(ctx.connection.organizationId, inv.id)
+    if (!pdf) return false
+    const filename = `${String(inv.invoiceNumber).replace(/[^\w.-]+/g, '_')}.pdf`
+    const tenantId = settingsOf(ctx).tenantId ?? ''
+    const url = `${API_BASE}/Invoices/${invoiceId}/Attachments/${encodeURIComponent(filename)}?IncludeOnline=true`
+    const res = await ctx.http.fetch(url, {
+      method: 'PUT',
+      headers: {
+        Accept: 'application/json',
+        'Content-Type': 'application/pdf',
+        'xero-tenant-id': tenantId,
+      },
+      body: pdf.buffer as BodyInit,
+    })
+    if (!res.ok) throw new XeroError(res.status, await res.text())
+    return true
+  } catch (err) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} is in Xero but its PDF could not be attached: ${err instanceof Error ? err.message : String(err)}`
+    )
+    return false
+  }
+}
+
+/** Xero sends its own copy to the contact. Only an approved invoice with an email can go. */
+async function emailFromXero(
+  ctx: ConnectorContext,
+  invoiceId: string,
+  inv: AccountingInvoice,
+  email: string | null
+): Promise<boolean> {
+  if (!email) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} was not emailed from Xero: the customer has no email address`
+    )
+    return true
+  }
+  try {
+    const current = await readInvoice(ctx, invoiceId)
+    if (current.Status !== 'AUTHORISED' && current.Status !== 'PAID') return false
+    await api(ctx, `Invoices/${invoiceId}/Email`, { body: {} })
+    return true
+  } catch (err) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} could not be emailed from Xero: ${err instanceof Error ? err.message : String(err)}`
+    )
+    return true
+  }
+}
+
+/* ---------- payments ---------- */
+
+/** The bank or clearing account a payment from here goes into. */
+async function paymentAccountFor(
+  ctx: ConnectorContext,
+  p: Pick<AccountingPayment, 'method' | 'provider'>
+): Promise<string> {
+  const settings = settingsOf(ctx)
+  const key = methodAccountKey(p)
+  const chosen = (key && settings.methodAccounts[key]) || settings.paymentAccount
+  if (chosen) return chosen
+  const catalog = await loadCatalog(ctx)
+  if (catalog.bankAccountId) return catalog.bankAccountId
+  throw new Error(
+    'The Xero organisation has no bank account to record payments in; add one in Xero or choose an account in the integration settings'
+  )
+}
+
+/**
+ * Records the payments here that Xero does not have yet. Xero takes payments
+ * only on an approved invoice, so on a draft they wait and go over once it is
+ * approved there (the next edit, pull or retry sends them).
+ */
+async function pushPendingPayments(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  invoiceId: string
+): Promise<{ count: number; waitingForApproval: boolean }> {
+  const settings = settingsOf(ctx)
+  const pending: AccountingPayment[] = []
+  for (const p of inv.payments) {
+    if (!(await linkFor(ctx, PAYMENT_ENTITY, p.id))) pending.push(p)
+  }
+  const manual =
+    settings.manualPaidAsPayment &&
+    inv.manuallyPaid &&
+    !(await linkFor(ctx, PAYMENT_ENTITY, `manual:${inv.id}`))
+  if (!pending.length && !manual) return { count: 0, waitingForApproval: false }
+  const current = await readInvoice(ctx, invoiceId)
+  if (current.Status !== 'AUTHORISED' && current.Status !== 'PAID') {
+    return {
+      count: 0,
+      waitingForApproval: current.Status === 'DRAFT' || current.Status === 'SUBMITTED',
+    }
+  }
+  // Xero refuses a payment above what it shows as owed. A few cents over is
+  // its per-line tax rounding against ours, and the payment is trimmed to fit;
+  // more than that means the invoices disagree, which the log says.
+  let remaining = current.AmountDue ?? Number.POSITIVE_INFINITY
+  let count = 0
+  for (const p of pending) {
+    let amount = round2(p.amount)
+    if (amount > remaining + 0.005) {
+      if (amount - remaining <= ROUNDING_SLACK && remaining > 0) {
+        amount = round2(remaining)
+      } else {
+        await ctx.log(
+          'warn',
+          `A payment of ${amount} on invoice ${inv.invoiceNumber} is more than Xero shows as owed (${round2(Math.max(0, remaining))}); it was not recorded in Xero`
+        )
+        continue
+      }
+    }
+    await pushPaymentRow(ctx, { ...p, amount }, invoiceId)
+    remaining -= amount
+    count++
+  }
+  if (manual) {
+    const due = round2(Math.max(0, remaining))
+    if (due > 0 && Number.isFinite(due)) {
+      await pushPaymentRow(
+        ctx,
+        {
+          id: `manual:${inv.id}`,
+          serviceRecordId: inv.id,
+          amount: due,
+          date: inv.issuedAt ?? new Date(),
+          method: 'other',
+          note: 'Marked as paid by hand in Torqvoice',
+          provider: null,
+          externalId: null,
+        },
+        invoiceId,
+        { manual: true }
+      )
+      count++
+    }
+  }
+  return { count, waitingForApproval: false }
+}
+
+async function pushPaymentRow(
+  ctx: ConnectorContext,
+  p: AccountingPayment,
+  invoiceId: string,
+  extra: Record<string, unknown> = {}
+): Promise<void> {
+  const body = buildPayment(p, {
+    invoiceId,
+    accountId: await paymentAccountFor(ctx, p),
+    timezone: ctx.timezone,
+  })
+  const res = await api<{ Payments?: XeroPayment[] }>(ctx, 'Payments', {
+    method: 'PUT',
+    query: { summarizeErrors: 'true' },
+    headers: { 'Idempotency-Key': `tv-payment-${p.id}` },
+    body: { Payments: [body] },
+  })
+  const made = res.Payments?.[0]
+  if (!made) throw new Error('Xero did not return the payment')
+  await setLink(
+    ctx,
+    PAYMENT_ENTITY,
+    p.id,
+    { remoteId: made.PaymentID },
+    { createdByUs: true, serviceRecordId: p.serviceRecordId, ...extra }
+  )
+}
+
+async function pushPayment(ctx: ConnectorContext, paymentId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.tenantId) return { summary: NO_TENANT }
+  const p = await loadPaymentForAccounting(ctx.connection.organizationId, paymentId)
+  const link = await linkFor(ctx, PAYMENT_ENTITY, paymentId)
+  if (!p) {
+    if (!link) return { summary: 'nothing to do' }
+    if (link.metadata?.createdByUs !== true) {
+      // A payment Xero made and this app then deleted stays in Xero.
+      await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+      return { summary: 'payment came from Xero; left there' }
+    }
+    try {
+      await api(ctx, `Payments/${link.remoteId}`, {
+        body: { Payments: [{ PaymentID: link.remoteId, Status: 'DELETED' }] },
+      })
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+    }
+    await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+    return { summary: 'payment removed from Xero' }
+  }
+  if (link) return { summary: 'unchanged' }
+  if (!settings.pushPayments) return { summary: 'payments not sent' }
+
+  // The payment needs its invoice in Xero first; pushing the invoice also
+  // records every payment on it, this one included.
+  const outcome = await pushInvoice(ctx, p.serviceRecordId)
+  if (await linkFor(ctx, PAYMENT_ENTITY, paymentId)) return { summary: 'payment recorded' }
+  const age = Date.now() - p.date.getTime()
+  if (outcome.summary?.includes('wait for approval') && age < APPROVAL_WAIT_DAYS * 86_400_000) {
+    return {
+      summary: 'waiting for the invoice to be approved in Xero',
+      rescheduleInSeconds: APPROVAL_RETRY_SECONDS,
+    }
+  }
+  return { summary: `payment not recorded: ${outcome.summary ?? 'invoice not in Xero'}` }
+}
+
+/* ---------- customers on edit ---------- */
+
+async function pushCustomer(ctx: ConnectorContext, customerId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.tenantId) return { summary: NO_TENANT }
+  if (!settings.updateContacts) return { summary: 'contact updates switched off' }
+  const link = await linkFor(ctx, CUSTOMER_ENTITY, customerId)
+  // Customers reach Xero with their first invoice; an edit before that is nothing yet.
+  if (!link) return { summary: 'not in Xero yet' }
+  const c = await loadCustomerForAccounting(ctx.connection.organizationId, customerId)
+  if (!c) return { summary: 'customer gone' }
+  await ensureContact(ctx, c)
+  return { summary: 'contact updated' }
+}
+
+/* ---------- pull ---------- */
+
+function pullSince(ctx: ConnectorContext): Date {
+  const tenantId = settingsOf(ctx).tenantId
+  const last = stateOf(ctx).lastPull
+  const at = last && last.tenantId === tenantId ? Date.parse(last.at) : Number.NaN
+  const start = Number.isFinite(at) ? at : Date.now() - 86_400_000
+  // A minute of overlap so a change landing during the last pull is not missed.
+  return new Date(start - 60_000)
+}
+
+/**
+ * What changed in Xero since the last pull: payments recorded there against
+ * invoices from here are recorded here too, and ones deleted there removed
+ * again; invoices voided or deleted there lose their link, so the next edit
+ * here makes a new one; and invoices approved there get any payments that
+ * were waiting for the approval.
+ */
+async function pullChanges(ctx: ConnectorContext): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.tenantId) return { summary: NO_TENANT }
+  const startedAt = new Date()
+  const since = pullSince(ctx)
+  const modified = { 'If-Modified-Since': modifiedSinceHeader(since) }
+  const orgId = ctx.connection.organizationId
+
+  let recorded = 0
+  let removed = 0
+  if (settings.pullPayments) {
+    const payments = await listAll<XeroPayment>(ctx, 'Payments', 'Payments', {
+      headers: modified,
+      query: { where: 'PaymentType=="ACCRECPAYMENT"' },
+    })
+    const known = await ctx.links.remoteIds(PAYMENT_ENTITY)
+    for (const p of payments) {
+      if (p.Status === 'DELETED') {
+        const link = await ctx.links.byRemoteId(PAYMENT_ENTITY, p.PaymentID)
+        if (!link) continue
+        if (link.metadata?.createdByUs === true) {
+          await ctx.log(
+            'warn',
+            `A payment sent from here was removed in Xero (${p.Reference ?? p.PaymentID}); the payment here is kept`
+          )
+          await ctx.links.remove(PAYMENT_ENTITY, link.entityId)
+          continue
+        }
+        if (await removePulledPayment(orgId, link.entityId, PROVIDER)) removed++
+        await ctx.links.remove(PAYMENT_ENTITY, link.entityId)
+        continue
+      }
+      if (known.has(p.PaymentID)) continue
+      const invoiceId = p.Invoice?.InvoiceID
+      const amount = p.Amount ?? 0
+      if (!invoiceId || !(amount > 0)) continue
+      const invoiceLink = await ctx.links.byRemoteId(INVOICE_ENTITY, invoiceId)
+      if (!invoiceLink || invoiceLink.metadata?.tenantId !== settings.tenantId) continue
+      const made = await recordPulledPayment(orgId, {
+        serviceRecordId: invoiceLink.entityId,
+        amount: round2(amount),
+        date: paymentDay(p.Date, ctx.timezone),
+        method: 'other',
+        provider: PROVIDER,
+        externalId: p.PaymentID,
+        note: ['Recorded in Xero', p.Reference ? `Ref ${p.Reference}` : null]
+          .filter(Boolean)
+          .join('. '),
+      })
+      if (!made) continue
+      await setLink(
+        ctx,
+        PAYMENT_ENTITY,
+        made.id,
+        { remoteId: p.PaymentID },
+        { createdByUs: false, serviceRecordId: invoiceLink.entityId }
+      )
+      known.add(p.PaymentID)
+      if (made.created) recorded++
+    }
+  }
+
+  let unlinked = 0
+  let released = 0
+  const invoices = await listAll<XeroInvoice>(ctx, 'Invoices', 'Invoices', {
+    headers: modified,
+    query: { Statuses: 'VOIDED,DELETED,AUTHORISED', summaryOnly: 'true' },
+  })
+  for (const remote of invoices) {
+    const link = await ctx.links.byRemoteId(INVOICE_ENTITY, remote.InvoiceID)
+    if (!link || link.metadata?.tenantId !== settings.tenantId) continue
+    if (isGone(remote)) {
+      if (link.metadata?.goneInXero) continue
+      const status = (remote.Status ?? 'voided').toLowerCase()
+      if (settings.xeroNumbers) {
+        await ctx.links.remove(INVOICE_ENTITY, link.entityId)
+        await ctx.log(
+          'warn',
+          `Invoice ${remote.InvoiceNumber ?? remote.InvoiceID} was ${status} in Xero; it will be sent again as a new invoice if edited here`
+        )
+      } else {
+        await ctx.links.set(INVOICE_ENTITY, link.entityId, {
+          remoteId: link.remoteId,
+          remoteUrl: link.remoteUrl,
+          checksum: link.checksum,
+          metadata: {
+            ...(link.metadata ?? {}),
+            goneInXero: status,
+            invoiceNumber: remote.InvoiceNumber ?? link.metadata?.invoiceNumber ?? null,
+          },
+        })
+        await ctx.log(
+          'warn',
+          `Invoice ${remote.InvoiceNumber ?? remote.InvoiceID} was ${status} in Xero, and Xero does not give its number out again, so edits here are not sent. Give the invoice a new number in Torqvoice to send it as a new invoice.`
+        )
+      }
+      unlinked++
+      continue
+    }
+    if (!settings.pushPayments) continue
+    // Approved in Xero since the last pull: payments waiting for that go now.
+    const inv = await loadInvoiceForAccounting(orgId, link.entityId)
+    if (!inv) continue
+    const pushed = await pushPendingPayments(ctx, inv, remote.InvoiceID)
+    released += pushed.count
+  }
+
+  await ctx.saveState({ lastPull: { tenantId: settings.tenantId, at: startedAt.toISOString() } })
+  const parts = [
+    recorded ? `${recorded} payments recorded` : null,
+    removed ? `${removed} payments removed` : null,
+    unlinked ? `${unlinked} invoices voided or deleted in Xero` : null,
+    released ? `${released} waiting payments sent` : null,
+  ].filter(Boolean)
+  return { summary: parts.length ? parts.join(', ') : 'no changes' }
+}
+
+/* ---------- connector ---------- */
+
+function entityId(payload: Record<string, unknown>): string | null {
+  return typeof payload.entityId === 'string' ? payload.entityId : null
+}
+
+/** Settings lists, read for the organisation chosen, or empty before one is. */
+function forTenant<T>(fn: (ctx: ConnectorContext) => Promise<T[]>) {
+  return async (ctx: ConnectorContext) => (settingsOf(ctx).tenantId ? fn(ctx) : [])
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+  /**
+   * Who connected: the organisations they allowed. The tenant ids are kept
+   * off the row; a hash of them is enough to tell two consents apart.
+   */
+  async identify(ctx) {
+    const orgs = await connectionsOf(ctx)
+    if (!orgs.length)
+      throw new Error('No Xero organisation was allowed; connect again and choose one')
+    const ids = orgs.map((o) => o.tenantId).sort()
+    const name =
+      orgs.length === 1
+        ? (orgs[0].tenantName ?? 'Xero')
+        : `${orgs[0].tenantName ?? 'Xero'} and ${orgs.length - 1} more`
+    return {
+      id: `xero-${createHash('sha256').update(ids.join(',')).digest('hex').slice(0, 16)}`,
+      name,
+    }
+  },
+  /** One organisation allowed: it is the one, and the form need not ask. */
+  async onConnect(ctx) {
+    if (settingsOf(ctx).tenantId) return
+    const orgs = await connectionsOf(ctx)
+    if (orgs.length === 1) return { settings: { tenantId: orgs[0].tenantId } }
+  },
+  async test(ctx) {
+    try {
+      const orgs = await connectionsOf(ctx)
+      const tenantId = settingsOf(ctx).tenantId
+      if (!tenantId) return { ok: true }
+      if (!orgs.some((o) => o.tenantId === tenantId)) {
+        return {
+          ok: false,
+          message:
+            'The chosen Xero organisation is no longer connected to Torqvoice. Reconnect and allow it, or choose another.',
+        }
+      }
+      const org = await loadOrg(ctx, { refresh: true })
+      if (org.edition === 'PARTNER') {
+        return {
+          ok: false,
+          message: `${org.name} is a Xero Partner Edition organisation, which cannot hold invoices. Choose another organisation.`,
+        }
+      }
+      return { ok: true, message: org.name }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : String(err) }
+    }
+  },
+  remoteOptions: {
+    async organisations(ctx) {
+      const orgs = await connectionsOf(ctx)
+      return orgs
+        .map((o) => ({ value: o.tenantId, label: o.tenantName ?? o.tenantId }))
+        .sort((a, b) => a.label.localeCompare(b.label))
+    },
+    salesAccounts: forTenant(async (ctx) => {
+      const res = await api<{ Accounts?: XeroAccount[] }>(ctx, 'Accounts')
+      return (res.Accounts ?? [])
+        .filter((a) => a.Class === 'REVENUE' && a.Code && (!a.Status || a.Status === 'ACTIVE'))
+        .sort((a, b) => String(a.Code).localeCompare(String(b.Code), undefined, { numeric: true }))
+        .map((a) => ({ value: String(a.Code), label: `${a.Code} ${a.Name ?? ''}`.trim() }))
+    }),
+    paymentAccounts: forTenant(async (ctx) => {
+      const res = await api<{ Accounts?: XeroAccount[] }>(ctx, 'Accounts')
+      return (res.Accounts ?? [])
+        .filter(
+          (a) =>
+            (!a.Status || a.Status === 'ACTIVE') &&
+            (a.Type === 'BANK' || a.EnablePaymentsToAccount === true)
+        )
+        .sort((a, b) => (a.Name ?? '').localeCompare(b.Name ?? ''))
+        .map((a) => ({
+          value: a.AccountID,
+          label: a.Code ? `${a.Name ?? a.Code} (${a.Code})` : (a.Name ?? a.AccountID),
+        }))
+    }),
+    items: forTenant(async (ctx) => {
+      const res = await api<{ Items?: XeroItem[] }>(ctx, 'Items')
+      return (res.Items ?? [])
+        .filter((i) => i.IsSold !== false)
+        .sort((a, b) => a.Code.localeCompare(b.Code))
+        .map((i) => ({
+          value: i.Code,
+          label: `${i.Code}${i.Name ? ` ${i.Name}` : ''}${i.IsTrackedAsInventory ? ' (tracked)' : ''}`,
+        }))
+    }),
+    taxRates: forTenant(async (ctx) => {
+      const catalog = await loadCatalog(ctx, { refresh: true })
+      return catalog.rates
+        .filter((r) => r.percent > 0)
+        .map((r) => ({ value: r.taxType, label: rateLabel(r) }))
+    }),
+    /** Only zero rates: a rate with a percentage on an untaxed line would add tax nobody billed. */
+    zeroTaxRates: forTenant(async (ctx) => {
+      const catalog = await loadCatalog(ctx, { refresh: true })
+      return catalog.rates
+        .filter((r) => r.percent === 0)
+        .map((r) => ({ value: r.taxType, label: rateLabel(r) }))
+    }),
+    trackingOptions: forTenant(async (ctx) => {
+      const catalog = await loadCatalog(ctx, { refresh: true })
+      return catalog.tracking.map((t) => ({
+        value: `${t.categoryId}:${t.id}`,
+        label: `${t.categoryName}: ${t.name}`,
+      }))
+    }),
+    brandingThemes: forTenant(async (ctx) => {
+      const res = await api<{ BrandingThemes?: XeroBrandingTheme[] }>(ctx, 'BrandingThemes')
+      return (res.BrandingThemes ?? [])
+        .sort((a, b) => (a.SortOrder ?? 0) - (b.SortOrder ?? 0))
+        .map((t) => ({ value: t.BrandingThemeID, label: t.Name }))
+    }),
+  },
+  jobs: {
+    'accounting.invoice': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no record id' }
+      return pushInvoice(ctx, id)
+    },
+    'accounting.payment': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no payment id' }
+      return pushPayment(ctx, id)
+    },
+    'accounting.customer': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no customer id' }
+      return pushCustomer(ctx, id)
+    },
+    'accounting.pull': pullChanges,
+  },
+  /** Hand the refresh token back, which removes Torqvoice from the organisations it reached. */
+  async onDisconnect(ctx) {
+    const spec = oauthSpec(manifest)
+    const client = spec ? resolveClient(spec, ctx.credentials) : null
+    const token =
+      typeof ctx.credentials.refreshToken === 'string' ? ctx.credentials.refreshToken : null
+    if (!client || !token) return
+    try {
+      await fetch(REVOKE_URL, {
+        method: 'POST',
+        headers: {
+          Authorization: `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`).toString('base64')}`,
+          'Content-Type': 'application/x-www-form-urlencoded',
+        },
+        body: new URLSearchParams({ token }).toString(),
+      })
+    } catch (err) {
+      await ctx.log('warn', `Could not revoke the Xero token: ${String(err)}`)
+    }
+  },
+}

+ 104 - 0
src/integrations/xero/tax.ts

@@ -0,0 +1,104 @@
+/**
+ * Which Xero tax rate a line is booked under.
+ *
+ * Every Xero organisation has its own list of tax rates, each known by a
+ * TaxType code: GST on Income is OUTPUT in Australia and OUTPUT2 in New
+ * Zealand, a custom rate is TAX001 and up. So nothing is hard-coded: the
+ * organisation's sales rates are read, and the one whose percentage matches
+ * the invoice is used, unless the workshop chose one. Lines without tax go
+ * under a zero rate, preferring the codes that report a tax-free sale as one.
+ */
+
+export interface XeroTaxRate {
+  Name?: string
+  TaxType?: string
+  Status?: string
+  /** The rate as a number of percent, such as 10 or 15. */
+  EffectiveRate?: number | string
+  DisplayTaxRate?: number | string
+  CanApplyToRevenue?: boolean
+  ReportTaxType?: string
+}
+
+export interface TaxRateEntry {
+  taxType: string
+  name: string
+  percent: number
+  reportTaxType: string | null
+}
+
+/**
+ * Zero-rated sales codes, best first: GST Free Income (AU), Zero Rated (NZ is
+ * ZERORATED, the UK ZERORATEDOUTPUT), then No GST / Tax Exempt (NONE).
+ */
+const ZERO_PREFERENCE = ['EXEMPTOUTPUT', 'ZERORATED', 'ZERORATEDOUTPUT', 'NONE']
+
+function percentOf(r: XeroTaxRate): number | null {
+  const raw = r.EffectiveRate ?? r.DisplayTaxRate
+  const n = typeof raw === 'number' ? raw : Number.parseFloat(String(raw ?? ''))
+  return Number.isFinite(n) ? n : null
+}
+
+/** The organisation's active rates that a sale can carry. */
+export function salesRates(rates: XeroTaxRate[]): TaxRateEntry[] {
+  const out: TaxRateEntry[] = []
+  for (const r of rates) {
+    if (!r.TaxType || (r.Status && r.Status !== 'ACTIVE')) continue
+    if (r.CanApplyToRevenue === false) continue
+    // Xero's global edition lets its purchase rate onto sales too; it is still a purchase rate.
+    if (r.ReportTaxType === 'INPUT' || r.ReportTaxType === 'GSTONIMPORTS') continue
+    const percent = percentOf(r)
+    if (percent === null) continue
+    out.push({
+      taxType: r.TaxType,
+      name: r.Name ?? r.TaxType,
+      percent,
+      reportTaxType: r.ReportTaxType ?? null,
+    })
+  }
+  return out
+}
+
+const samePercent = (a: number, b: number) => Math.abs(a - b) < 0.0005
+
+/**
+ * The rate for taxed lines. A rate the workshop chose is used whatever its
+ * percentage, and `matched` says whether it agrees with the invoice. With
+ * none chosen, the first rate carrying the invoice's percentage wins, the
+ * standard output codes before custom ones.
+ */
+export function pickTaxedRate(
+  rates: TaxRateEntry[],
+  percent: number,
+  chosen: string | null
+): { entry: TaxRateEntry | null; matched: boolean } {
+  if (chosen) {
+    const entry = rates.find((r) => r.taxType === chosen) ?? null
+    return { entry, matched: Boolean(entry && samePercent(entry.percent, percent)) }
+  }
+  const candidates = rates.filter((r) => r.percent > 0 && samePercent(r.percent, percent))
+  const standard = candidates.find((r) => /^OUTPUT\d*$/.test(r.taxType))
+  const entry = standard ?? candidates[0] ?? null
+  return { entry, matched: Boolean(entry) }
+}
+
+/**
+ * The rate for lines without tax: the workshop's choice when it is a zero
+ * rate, else the best zero rate there is. A rate with a percentage is never
+ * used here, since Xero would then add tax that was never billed.
+ */
+export function pickZeroRate(rates: TaxRateEntry[], chosen: string | null): TaxRateEntry | null {
+  const zero = rates.filter((r) => samePercent(r.percent, 0))
+  const picked = chosen ? zero.find((r) => r.taxType === chosen) : null
+  if (picked) return picked
+  for (const code of ZERO_PREFERENCE) {
+    const hit = zero.find((r) => r.taxType === code)
+    if (hit) return hit
+  }
+  return zero[0] ?? null
+}
+
+/** How a rate shows in the settings list. */
+export function rateLabel(r: TaxRateEntry): string {
+  return `${r.name} (${r.percent}%)`
+}

+ 118 - 0
src/integrations/xero/webhook.ts

@@ -0,0 +1,118 @@
+import 'server-only'
+
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import { db } from '@/lib/db'
+import { enqueueJob } from '@/features/integrations/Lib/jobs'
+import { openCredentials } from '@/features/integrations/Lib/vault'
+
+/**
+ * Xero's webhooks: one address and one signing key per Xero app, not per
+ * connection. The platform's app (cloud) signs with the key in this
+ * environment variable; a workshop that registered its own app enters that
+ * app's key on its connection. Every delivery is checked against the key of
+ * the app that sent it, and only the connections that app serves hear of it.
+ *
+ * A delivery says which record changed and nothing else, so it queues the
+ * same pull the half-hourly schedule runs, which reads what changed since
+ * the last one. A burst of deliveries collapses into one pull.
+ */
+export const PLATFORM_WEBHOOK_KEY_ENV = 'XERO_INTEGRATION_WEBHOOK_KEY'
+const PULL_JOB = 'accounting.pull'
+
+/** Xero signs the raw body: base64 of HMAC-SHA256 under the app's webhook key. */
+export function signatureMatches(body: string, signature: string | null, key: string): boolean {
+  if (!signature || !key) return false
+  const expected = Buffer.from(createHmac('sha256', key).update(body, 'utf8').digest('base64'))
+  const given = Buffer.from(signature)
+  return expected.length === given.length && timingSafeEqual(expected, given)
+}
+
+interface XeroEvent {
+  tenantId?: unknown
+  eventCategory?: unknown
+}
+
+function credentialString(credentials: Record<string, unknown>, key: string): string | null {
+  const v = credentials[key]
+  return typeof v === 'string' && v.trim() ? v.trim() : null
+}
+
+/**
+ * Checks one delivery and queues a pull for each connection it concerns.
+ * 401 for a signature no known key produced: that is also how Xero's
+ * intent-to-receive check expects a wrong signature to be answered. A
+ * delivery with no events (that same check, signed correctly) gets a 200.
+ */
+export async function receiveXeroWebhook(
+  body: string,
+  signature: string | null
+): Promise<{ status: 200 | 401; queued: number }> {
+  const connections = await db.integrationConnection.findMany({
+    where: { connectorId: 'xero', status: { in: ['active', 'error'] } },
+    select: { id: true, organizationId: true, settings: true, credentials: true },
+  })
+  const opened = connections.map((c) => {
+    let credentials: Record<string, unknown> = {}
+    try {
+      credentials = openCredentials(c.credentials)
+    } catch {
+      // A row the vault cannot open serves no deliveries.
+    }
+    return { ...c, credentials }
+  })
+
+  // Which app signed this: the platform's, or one workshop's own.
+  const platformKey = process.env[PLATFORM_WEBHOOK_KEY_ENV]?.trim() || null
+  let signer: 'platform' | string | null = null
+  if (platformKey && signatureMatches(body, signature, platformKey)) {
+    signer = 'platform'
+  } else {
+    for (const c of opened) {
+      const key = credentialString(c.credentials, 'webhookKey')
+      if (key && signatureMatches(body, signature, key)) {
+        signer = c.id
+        break
+      }
+    }
+  }
+  if (!signer) return { status: 401, queued: 0 }
+
+  let events: XeroEvent[] = []
+  try {
+    const parsed = JSON.parse(body) as { events?: unknown }
+    events = Array.isArray(parsed.events) ? (parsed.events as XeroEvent[]) : []
+  } catch {
+    return { status: 200, queued: 0 }
+  }
+  // A payment recorded or removed is a PAYMENT event; an invoice voided or
+  // approved is an INVOICE one. Xero's own examples vary the case.
+  const tenants = new Set(
+    events
+      .filter((e) => {
+        const category = String(e.eventCategory ?? '').toUpperCase()
+        return category === 'PAYMENT' || category === 'INVOICE'
+      })
+      .map((e) => e.tenantId)
+      .filter((t): t is string => typeof t === 'string')
+  )
+  if (tenants.size === 0) return { status: 200, queued: 0 }
+
+  let queued = 0
+  for (const c of opened) {
+    const tenantId = (c.settings as Record<string, unknown> | null)?.tenantId
+    if (typeof tenantId !== 'string' || !tenants.has(tenantId)) continue
+    const ownApp = Boolean(credentialString(c.credentials, 'clientId'))
+    // The platform's key speaks for connections on the platform's app only;
+    // a workshop's key speaks for that workshop's connection only.
+    if (signer === 'platform' ? ownApp : signer !== c.id) continue
+    await enqueueJob({
+      connectionId: c.id,
+      organizationId: c.organizationId,
+      kind: PULL_JOB,
+      payload: { event: 'webhook' },
+      idempotencyKey: `${PULL_JOB}:webhook`,
+    })
+    queued++
+  }
+  return { status: 200, queued }
+}