Przeglądaj źródła

Send invoices, customers and payments to Zoho Books (#597)

* Send invoices, customers and payments to Zoho Books

* Keep item quantities, show where the vehicle goes, and name the vehicle in the workshop's language
Bernt Christian Egeland 6 godzin temu
rodzic
commit
ba28416ea7

+ 2 - 0
.env.example

@@ -78,6 +78,8 @@ FIKEN_INTEGRATION_CLIENT_SECRET=""
 XERO_INTEGRATION_CLIENT_ID=""
 XERO_INTEGRATION_CLIENT_SECRET=""
 XERO_INTEGRATION_WEBHOOK_KEY=""
+ZOHO_BOOKS_INTEGRATION_CLIENT_ID=""
+ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET=""
 # 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

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

@@ -99,6 +99,8 @@ jobs:
           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 }}
+          ZOHO_BOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_ZOHO_BOOKS_INTEGRATION_CLIENT_ID }}
+          ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET }}
           # 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,
@@ -112,7 +114,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_|XERO_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_|ZOHO_BOOKS_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -175,6 +177,8 @@ jobs:
                 XERO_INTEGRATION_CLIENT_ID: ${XERO_INTEGRATION_CLIENT_ID}
                 XERO_INTEGRATION_CLIENT_SECRET: ${XERO_INTEGRATION_CLIENT_SECRET}
                 XERO_INTEGRATION_WEBHOOK_KEY: ${XERO_INTEGRATION_WEBHOOK_KEY}
+                ZOHO_BOOKS_INTEGRATION_CLIENT_ID: ${ZOHO_BOOKS_INTEGRATION_CLIENT_ID}
+                ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET: ${ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET}
                 TORQVOICE_CODES_URL: ${TORQVOICE_CODES_URL}
                 TORQVOICE_CODES_API_TOKEN: ${TORQVOICE_CODES_API_TOKEN}
               networks:

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

@@ -81,6 +81,8 @@ jobs:
           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 }}
+          ZOHO_BOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_ZOHO_BOOKS_INTEGRATION_CLIENT_ID }}
+          ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET }}
           # 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,
@@ -94,7 +96,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_|XERO_INTEGRATION_|XERO_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_|XERO_INTEGRATION_|ZOHO_BOOKS_INTEGRATION_|INBOUND_MAIL_|PLATFORM_BACKUP_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -154,6 +156,8 @@ jobs:
                 XERO_INTEGRATION_CLIENT_ID: ${XERO_INTEGRATION_CLIENT_ID}
                 XERO_INTEGRATION_CLIENT_SECRET: ${XERO_INTEGRATION_CLIENT_SECRET}
                 XERO_INTEGRATION_WEBHOOK_KEY: ${XERO_INTEGRATION_WEBHOOK_KEY}
+                ZOHO_BOOKS_INTEGRATION_CLIENT_ID: ${ZOHO_BOOKS_INTEGRATION_CLIENT_ID}
+                ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET: ${ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET}
                 TORQVOICE_CODES_URL: ${TORQVOICE_CODES_URL}
                 TORQVOICE_CODES_API_TOKEN: ${TORQVOICE_CODES_API_TOKEN}
               networks:

+ 89 - 0
messages/de/integrations.json

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an Zoho Books und hole dort erfasste Zahlungen zurück.",
+      "tenantHelp": "Erstelle in der Zoho API Console (api-console.zoho.com oder die Console deiner eigenen Region, etwa api-console.zoho.eu) eine Server-based Application. Trage die Redirect-URI unten ein, kopiere Client-ID und Secret und hake auf dem Tab Settings des Clients \"Use the same OAuth credentials for all data centers\" an, wenn sich Werkstätten aus anderen Regionen anmelden.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "organizationId": "Zoho-Books-Organisation",
+        "organizationIdHelp": "Die Organisation, in die die Rechnungen geschrieben werden. Es erscheint jede Organisation, auf die der Zoho-Benutzer, der die Verbindung hergestellt hat, Zugriff hat.",
+        "pushInvoices": "Ausgestellte Rechnungen an Zoho Books 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 Zoho Books gewechselt sind, damit ältere Rechnungen nicht doppelt erfasst werden.",
+        "invoiceStatus": "Status in Zoho Books",
+        "invoiceStatusHelp": "Gesendete Rechnungen zählen sofort als Umsatz und können Zahlungen erhalten. Wähle Entwurf, wenn jemand jede Rechnung zuerst in Zoho Books prüft; Zahlungen werden dann erfasst, sobald sie dort gesendet ist.",
+        "invoiceStatusSent": "Gesendet",
+        "invoiceStatusDraft": "Entwurf",
+        "invoiceNumbers": "Rechnungsnummern",
+        "invoiceNumbersHelp": "Behalte die Torqvoice-Nummer, damit beide Systeme übereinstimmen, oder lass Zoho Books die Rechnung in der eigenen Folge nummerieren. Bei Zoho-Nummerierung wähle die Torqvoice-Rechnungsnummer als Referenz, um jede Rechnung wiederzufinden.",
+        "invoiceNumbersTorqvoice": "Torqvoice-Nummer verwenden",
+        "invoiceNumbersZoho": "Zoho Books nummerieren lassen",
+        "reference": "Referenz",
+        "referenceHelp": "Was in Zoho Books 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": "Baujahr, Marke, Modell, Kennzeichen und Kilometerstand, zusammen mit den Hinweisen an den Kunden.",
+        "includeNotes": "Hinweise an den Kunden übernehmen",
+        "includeNotesHelp": "Die Hinweise der Rechnung an den Kunden, als reiner Text.",
+        "notesPosition": "Wo Fahrzeug und Hinweise stehen",
+        "notesPositionHelp": "Customer Notes ist Zohos eigenes Feld und wird auf der Zoho-Rechnung unter den Positionen gedruckt. Über oder unter den Positionen setzt sie stattdessen in eine Textzeile zwischen den Rechnungspositionen.",
+        "notesPositionCustomerNotes": "Zohos Feld Customer Notes",
+        "notesPositionBelow": "Eine Zeile unter den Positionen",
+        "notesPositionAbove": "Eine Zeile über den Positionen",
+        "includePartNumbers": "Teilenummern in Teilepositionen anzeigen",
+        "includePartNumbersHelp": "Die Beschreibung jeder Teileposition beginnt mit ihrer Teilenummer.",
+        "templateId": "Rechnungsvorlage",
+        "templateIdHelp": "Die Zoho-Books-Vorlage, die die Rechnung nutzt, wenn sie in Zoho angezeigt oder von dort gesendet wird. Leer lassen für den Standard der Organisation.",
+        "locationId": "Standort",
+        "locationIdHelp": "Nur für Organisationen, bei denen Standorte in Zoho Books eingeschaltet sind und jede Rechnung einen Standort braucht. Sonst leer lassen.",
+        "laborAccount": "Konto für Arbeit",
+        "laborAccountHelp": "Das Erlöskonto, auf das Arbeitspositionen gebucht werden. Leer lassen, um das Konto des unten gewählten Artikels oder das Konto Sales von Zoho zu verwenden.",
+        "partsAccount": "Konto für Teile",
+        "partsAccountHelp": "Das Erlöskonto, auf das Teilepositionen gebucht werden. Leer lassen, um das Konto des unten gewählten Artikels oder das Konto Sales von Zoho 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 Zoho-Books-Artikel, mit dem jede Arbeitsposition gekennzeichnet wird, für Artikelberichte in Zoho. Das oben gewählte Konto hat weiterhin Vorrang.",
+        "partsItem": "Artikel für Teilepositionen",
+        "partsItemHelp": "Optional. Ein Zoho-Books-Artikel, mit dem jede Teileposition gekennzeichnet wird, für Artikelberichte in Zoho. Das oben gewählte Konto hat weiterhin Vorrang.",
+        "matchItemsByPartNumber": "Teile über die Teilenummer Zoho-Artikeln zuordnen",
+        "matchItemsByPartNumberHelp": "Ein Teil, dessen Nummer auch die SKU eines Artikels in Zoho Books ist, wird als dieser Artikel verkauft, sodass der in Zoho geführte Bestand sinkt. Damit etwas gefunden wird, schalte in Zoho Books unter Settings, Items die SKUs ein.",
+        "taxId": "Steuer für besteuerte Positionen",
+        "taxIdHelp": "Torqvoice wählt die Zoho-Books-Steuer, deren Prozentsatz zur jeweiligen Rechnung passt. Wähle hier eine für den Fall, dass mehrere denselben Prozentsatz haben oder keine passt.",
+        "zeroTaxId": "Steuer für unbesteuerte Positionen",
+        "zeroTaxIdHelp": "Wird für Positionen ohne Steuer und für steuerbefreite Kunden verwendet, wenn die Organisation dafür eine Steuer mit 0 % hat. Leer lassen, um diese Positionen ohne Steuer zu senden.",
+        "roundingAdjustment": "Summe auf den Cent abgleichen",
+        "roundingAdjustmentHelp": "Zoho Books berechnet die Steuer selbst. Weicht seine Summe um ein oder zwei Cent von der Rechnung ab, die der Kunde erhalten hat, wird die Differenz als Rundungsausgleich von Zoho eingetragen, sodass beide Systeme übereinstimmen. Ausgeschaltet meldet das Protokoll, wenn sie abweichen.",
+        "sendFromZoho": "Rechnung aus Zoho Books per E-Mail an den Kunden senden",
+        "sendFromZohoHelp": "Zoho Books sendet eine eigene Kopie der Rechnung an die E-Mail-Adresse des Kunden, mit den in Zoho eingerichteten Online-Zahlungsoptionen. Schalte dies aus, wenn Torqvoice die Rechnung bereits sendet, damit der Kunde sie nicht doppelt erhält.",
+        "attachPdf": "Rechnungs-PDF anhängen",
+        "attachPdfHelp": "Die Rechnung, die der Kunde von Torqvoice erhalten hat, wird an die Rechnung in Zoho Books angehängt und bei Änderungen ersetzt.",
+        "updateContacts": "Kundendaten in Zoho Books aktuell halten",
+        "updateContactsHelp": "Eine Änderung an Name, E-Mail, Telefon oder Adresse eines Kunden hier wird in seinen Kontakt in Zoho Books übernommen. Ausgeschaltet werden Kontakte nur angelegt und danach so gelassen, wie der Buchhalter sie führt.",
+        "sameEmail": "Kunden mit derselben E-Mail",
+        "sameEmailHelp": "Mehrere Kunden können sich eine E-Mail-Adresse teilen, etwa eine Familie oder das Buchhaltungspostfach einer Firma. Bei der ersten Wahl wird ein per E-Mail gefundener Zoho-Kontakt nur verwendet, wenn auch sein Name zum Kunden passt; sonst bekommt der Kunde einen neuen Kontakt, und das Protokoll nennt den anderen, damit du beide in Zoho zusammenführen kannst, falls es derselbe Kunde ist.",
+        "sameEmailCreate": "Neuen Kontakt anlegen, wenn der Name abweicht",
+        "sameEmailReuse": "Vorhandenen Kontakt verwenden, auch wenn der Name abweicht",
+        "voidOnDelete": "Zoho-Rechnung stornieren, wenn der Auftrag hier gelöscht wird",
+        "voidOnDeleteHelp": "Ein Entwurf wird gelöscht und eine gesendete Rechnung storniert. Eine Rechnung mit Zahlungen in Zoho Books wird nie angefasst; das Protokoll vermerkt, dass sie unverändert blieb.",
+        "pushPayments": "Zahlungen in Zoho Books erfassen",
+        "paymentAccount": "Einzahlen auf",
+        "paymentAccountHelp": "Das Konto in Zoho Books, auf das in Torqvoice erfasste Zahlungen eingezahlt werden. Leer lassen, um Undeposited Funds 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, Paystack 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 Zoho Books eine Zahlung über den dort noch offenen Betrag. Ausgeschaltet bleibt sie dort offen, bis eine Zahlung erfasst wird.",
+        "pullPayments": "In Zoho Books erfasste Zahlungen zurückholen",
+        "pullPaymentsHelp": "Eine in Zoho Books gegen eine Rechnung aus Torqvoice erfasste Zahlung wird hier innerhalb von 30 Minuten auf der Rechnung erfasst, und eine dort gelöschte Zahlung wird hier entfernt."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Send issued invoices, their customers and their payments to Zoho Books, and bring back payments recorded there.",
+      "tenantHelp": "Create a Server-based Application in the Zoho API Console (api-console.zoho.com, or the console of your own region, such as api-console.zoho.eu). Add the redirect URI below, copy the client id and secret, and on the client's Settings tab tick \"Use the same OAuth credentials for all data centers\" if workshops sign in from other regions.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "organizationId": "Zoho Books organisation",
+        "organizationIdHelp": "The organisation invoices are written to. Every organisation the Zoho user who connected can reach is listed.",
+        "pushInvoices": "Send issued invoices to Zoho Books",
+        "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 Zoho Books so older invoices are not entered twice.",
+        "invoiceStatus": "Status in Zoho Books",
+        "invoiceStatusHelp": "Sent invoices count as sales straight away and can take payments. Choose Draft if someone checks each invoice in Zoho Books first; payments are then recorded once it is sent there.",
+        "invoiceStatusSent": "Sent",
+        "invoiceStatusDraft": "Draft",
+        "invoiceNumbers": "Invoice numbers",
+        "invoiceNumbersHelp": "Keep the Torqvoice number so both systems agree, or let Zoho Books number the invoice in its own sequence. With Zoho numbering, choose the Torqvoice invoice number as the reference to find each invoice again.",
+        "invoiceNumbersTorqvoice": "Use the Torqvoice number",
+        "invoiceNumbersZoho": "Let Zoho Books number them",
+        "reference": "Reference",
+        "referenceHelp": "What goes in the invoice's Reference# field in Zoho Books, 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": "The year, make, model, plate and mileage, placed with the customer notes.",
+        "includeNotes": "Include the customer notes",
+        "includeNotesHelp": "The invoice's notes to the customer, as plain text.",
+        "notesPosition": "Where the vehicle and notes go",
+        "notesPositionHelp": "Customer Notes is Zoho's own field, printed under the charges on Zoho's invoice. Above or below the charges puts them in a text line among the invoice lines instead.",
+        "notesPositionCustomerNotes": "Zoho's Customer Notes field",
+        "notesPositionBelow": "A line below the charges",
+        "notesPositionAbove": "A line above the charges",
+        "includePartNumbers": "Show part numbers on part lines",
+        "includePartNumbersHelp": "Each part line's description starts with its part number.",
+        "templateId": "Invoice template",
+        "templateIdHelp": "The Zoho Books template the invoice uses when viewed or sent from Zoho. Leave empty for the organisation's default.",
+        "locationId": "Location",
+        "locationIdHelp": "Only for organisations with locations switched on in Zoho Books, where every invoice needs one. Leave empty otherwise.",
+        "laborAccount": "Account for labour",
+        "laborAccountHelp": "The income account labour lines are booked to. Leave empty to use the account of the item chosen below, or Zoho's Sales account.",
+        "partsAccount": "Account for parts",
+        "partsAccountHelp": "The income account part lines are booked to. Leave empty to use the account of the item chosen below, or Zoho's Sales account.",
+        "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 Zoho Books item every labour line is tagged with, for item reports in Zoho. The account chosen above still wins.",
+        "partsItem": "Item for part lines",
+        "partsItemHelp": "Optional. A Zoho Books item every part line is tagged with, for item reports in Zoho. The account chosen above still wins.",
+        "matchItemsByPartNumber": "Match parts to Zoho items by part number",
+        "matchItemsByPartNumberHelp": "A part whose number is also the SKU of an item in Zoho Books is sold as that item, so stock tracked in Zoho goes down. Switch on SKUs under Settings, Items in Zoho Books for this to find anything.",
+        "taxId": "Tax for taxed lines",
+        "taxIdHelp": "Torqvoice picks the Zoho Books tax whose percentage matches each invoice. Choose one here for when several have the same percentage, or none does.",
+        "zeroTaxId": "Tax for untaxed lines",
+        "zeroTaxIdHelp": "Used for lines without tax and for tax-exempt customers, when the organisation has a 0% tax for them. Leave empty to send those lines without a tax.",
+        "roundingAdjustment": "Match the total to the cent",
+        "roundingAdjustmentHelp": "Zoho Books works out the tax itself. When its total lands a cent or two from the invoice the customer received, the difference goes in as Zoho's rounding adjustment, so both systems agree. Off, the log says when they differ.",
+        "sendFromZoho": "Email the invoice to the customer from Zoho Books",
+        "sendFromZohoHelp": "Zoho Books sends its own copy of the invoice to the customer's email, with any online payment options set up in Zoho. Turn this off if Torqvoice already sends the invoice, so the customer does not get it twice.",
+        "attachPdf": "Attach the invoice PDF",
+        "attachPdfHelp": "The invoice the customer received from Torqvoice is attached to the invoice in Zoho Books, and replaced when it changes.",
+        "updateContacts": "Keep customer details up to date in Zoho Books",
+        "updateContactsHelp": "A change to a customer's name, email, phone or address here is copied to their contact in Zoho Books. Off, contacts are only created and then left as the bookkeeper has them.",
+        "sameEmail": "Customers with the same email",
+        "sameEmailHelp": "Several customers can share an email address, such as a family or a company's accounts inbox. With the first choice, a Zoho contact found by email is used only when its name matches the customer too; otherwise the customer gets a new contact, and the log names the other one so they can be merged in Zoho if they are the same customer.",
+        "sameEmailCreate": "Create a new contact when the name is different",
+        "sameEmailReuse": "Use the existing contact, even if the name is different",
+        "voidOnDelete": "Void the Zoho invoice when the job is deleted here",
+        "voidOnDeleteHelp": "A draft is deleted and a sent invoice is voided. An invoice with payments in Zoho Books is never touched; the log says it was left alone.",
+        "pushPayments": "Record payments in Zoho Books",
+        "paymentAccount": "Deposit to",
+        "paymentAccountHelp": "The account payments recorded in Torqvoice are deposited to in Zoho Books. Leave empty to use Undeposited Funds.",
+        "cashAccount": "Cash payments to",
+        "cardAccount": "Card payments to",
+        "transferAccount": "Bank transfers to",
+        "onlineAccount": "Online payments to",
+        "methodAccountHelp": "Leave empty to use the account above.",
+        "onlineAccountHelp": "Payments taken through Stripe, PayPal, Vipps, Paystack 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 Zoho Books for whatever it still shows as owed. Off, it stays open there until a payment is recorded.",
+        "pullPayments": "Bring back payments recorded in Zoho Books",
+        "pullPaymentsHelp": "A payment recorded in Zoho Books against an invoice from Torqvoice is recorded on the invoice here within 30 minutes, and one deleted there is removed here."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Envía las facturas emitidas, sus clientes y sus pagos a Zoho Books y trae de vuelta los pagos registrados allí.",
+      "tenantHelp": "Crea una Server-based Application en la Zoho API Console (api-console.zoho.com, o la consola de tu propia región, como api-console.zoho.eu). Añade la URI de redirección de abajo, copia el client id y el secret y, en la pestaña Settings del cliente, marca \"Use the same OAuth credentials for all data centers\" si hay talleres que inician sesión desde otras regiones.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "organizationId": "Organización de Zoho Books",
+        "organizationIdHelp": "La organización en la que se escriben las facturas. Aparece cada organización a la que tiene acceso el usuario de Zoho que hizo la conexión.",
+        "pushInvoices": "Enviar facturas emitidas a Zoho Books",
+        "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 Zoho Books para no registrar dos veces las facturas antiguas.",
+        "invoiceStatus": "Estado en Zoho Books",
+        "invoiceStatusHelp": "Las facturas enviadas cuentan como ventas de inmediato y pueden recibir pagos. Elige Borrador si alguien revisa antes cada factura en Zoho Books; los pagos se registran entonces cuando se envía desde allí.",
+        "invoiceStatusSent": "Enviada",
+        "invoiceStatusDraft": "Borrador",
+        "invoiceNumbers": "Números de factura",
+        "invoiceNumbersHelp": "Conserva el número de Torqvoice para que ambos sistemas coincidan, o deja que Zoho Books numere la factura con su propia secuencia. Con la numeración de Zoho, elige el número de factura de Torqvoice como referencia para volver a encontrar cada factura.",
+        "invoiceNumbersTorqvoice": "Usar el número de Torqvoice",
+        "invoiceNumbersZoho": "Dejar que Zoho Books las numere",
+        "reference": "Referencia",
+        "referenceHelp": "Lo que va en el campo Reference# de la factura en Zoho Books, 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": "El año, la marca, el modelo, la matrícula y el kilometraje, junto a las notas para el cliente.",
+        "includeNotes": "Incluir las notas para el cliente",
+        "includeNotesHelp": "Las notas de la factura para el cliente, como texto sin formato.",
+        "notesPosition": "Dónde van el vehículo y las notas",
+        "notesPositionHelp": "Customer Notes es el campo propio de Zoho, que se imprime debajo de los cargos en la factura de Zoho. Encima o debajo de los cargos los coloca en cambio en una línea de texto entre las líneas de la factura.",
+        "notesPositionCustomerNotes": "El campo Customer Notes de Zoho",
+        "notesPositionBelow": "Una línea debajo de los cargos",
+        "notesPositionAbove": "Una línea encima de los cargos",
+        "includePartNumbers": "Mostrar los números de pieza en las líneas de piezas",
+        "includePartNumbersHelp": "La descripción de cada línea de pieza empieza por su número de pieza.",
+        "templateId": "Plantilla de factura",
+        "templateIdHelp": "La plantilla de Zoho Books que usa la factura al verla o enviarla desde Zoho. Déjalo vacío para usar la predeterminada de la organización.",
+        "locationId": "Ubicación",
+        "locationIdHelp": "Solo para organizaciones con las ubicaciones activadas en Zoho Books, donde cada factura necesita una. Si no, déjalo vacío.",
+        "laborAccount": "Cuenta para mano de obra",
+        "laborAccountHelp": "La cuenta de ingresos en la que se contabilizan las líneas de mano de obra. Déjalo vacío para usar la cuenta del artículo elegido abajo, o la cuenta Sales de Zoho.",
+        "partsAccount": "Cuenta para piezas",
+        "partsAccountHelp": "La cuenta de ingresos en la que se contabilizan las líneas de piezas. Déjalo vacío para usar la cuenta del artículo elegido abajo, o la cuenta Sales de Zoho.",
+        "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 Zoho Books con el que se etiqueta cada línea de mano de obra, para los informes de artículos en Zoho. La cuenta elegida arriba sigue teniendo prioridad.",
+        "partsItem": "Artículo para líneas de piezas",
+        "partsItemHelp": "Opcional. Un artículo de Zoho Books con el que se etiqueta cada línea de piezas, para los informes de artículos en Zoho. La cuenta elegida arriba sigue teniendo prioridad.",
+        "matchItemsByPartNumber": "Emparejar piezas con artículos de Zoho por número de pieza",
+        "matchItemsByPartNumberHelp": "Una pieza cuyo número es también el SKU de un artículo en Zoho Books se vende como ese artículo, así baja el stock que se lleva en Zoho. Para que encuentre algo, activa los SKU en Settings, Items en Zoho Books.",
+        "taxId": "Impuesto para líneas gravadas",
+        "taxIdHelp": "Torqvoice elige el impuesto de Zoho Books cuyo porcentaje coincide con cada factura. Elige uno aquí para cuando varios tengan el mismo porcentaje o ninguno coincida.",
+        "zeroTaxId": "Impuesto para líneas sin impuesto",
+        "zeroTaxIdHelp": "Se usa para las líneas sin impuesto y para los clientes exentos, cuando la organización tiene un impuesto del 0 % para ellos. Déjalo vacío para enviar esas líneas sin impuesto.",
+        "roundingAdjustment": "Cuadrar el total al céntimo",
+        "roundingAdjustmentHelp": "Zoho Books calcula el impuesto por su cuenta. Cuando su total se queda a uno o dos céntimos de la factura que recibió el cliente, la diferencia entra como ajuste de redondeo de Zoho, así ambos sistemas coinciden. Desactivado, el registro avisa cuando difieren.",
+        "sendFromZoho": "Enviar la factura al cliente por correo desde Zoho Books",
+        "sendFromZohoHelp": "Zoho Books envía su propia copia de la factura al correo del cliente, con las opciones de pago en línea configuradas en Zoho. Desactívalo si Torqvoice ya envía la factura, para que el cliente no la reciba dos veces.",
+        "attachPdf": "Adjuntar el PDF de la factura",
+        "attachPdfHelp": "La factura que el cliente recibió de Torqvoice se adjunta a la factura en Zoho Books y se sustituye cuando cambia.",
+        "updateContacts": "Mantener al día los datos de los clientes en Zoho Books",
+        "updateContactsHelp": "Un cambio aquí en el nombre, correo, teléfono o dirección de un cliente se copia a su contacto en Zoho Books. Desactivado, los contactos solo se crean y luego se dejan como los tenga el contable.",
+        "sameEmail": "Clientes con el mismo correo",
+        "sameEmailHelp": "Varios clientes pueden compartir un correo, como una familia o el buzón de contabilidad de una empresa. Con la primera opción, un contacto de Zoho encontrado por correo solo se usa si su nombre también coincide con el cliente; si no, el cliente recibe un contacto nuevo y el registro indica el otro, para que puedas combinarlos en Zoho si son el mismo cliente.",
+        "sameEmailCreate": "Crear un contacto nuevo si el nombre es distinto",
+        "sameEmailReuse": "Usar el contacto existente aunque el nombre sea distinto",
+        "voidOnDelete": "Anular la factura de Zoho cuando el trabajo se elimine aquí",
+        "voidOnDeleteHelp": "Un borrador se elimina y una factura enviada se anula. Una factura con pagos en Zoho Books nunca se toca; el registro indica que se dejó como estaba.",
+        "pushPayments": "Registrar pagos en Zoho Books",
+        "paymentAccount": "Depositar en",
+        "paymentAccountHelp": "La cuenta de Zoho Books en la que se depositan los pagos registrados en Torqvoice. Déjalo vacío para usar Undeposited Funds.",
+        "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, Paystack 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 Zoho Books 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 Zoho Books",
+        "pullPaymentsHelp": "Un pago registrado en Zoho Books contra una factura de Torqvoice se registra aquí en la factura en un plazo de 30 minutos, y uno eliminado allí se quita aquí."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Envoyez les factures émises, leurs clients et leurs paiements vers Zoho Books, et récupérez les paiements enregistrés là-bas.",
+      "tenantHelp": "Créez une Server-based Application dans la Zoho API Console (api-console.zoho.com, ou la console de votre région, comme api-console.zoho.eu). Ajoutez l'URI de redirection ci-dessous, copiez le client id et le secret, puis, sur l'onglet Settings du client, cochez \"Use the same OAuth credentials for all data centers\" si des ateliers se connectent depuis d'autres régions.",
+      "fields": {
+        "clientId": "Identifiant client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "organizationId": "Organisation Zoho Books",
+        "organizationIdHelp": "L'organisation dans laquelle les factures sont écrites. Toutes les organisations accessibles à l'utilisateur Zoho qui a fait la connexion sont listées.",
+        "pushInvoices": "Envoyer les factures émises vers Zoho Books",
+        "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 Zoho Books pour ne pas saisir deux fois les anciennes factures.",
+        "invoiceStatus": "Statut dans Zoho Books",
+        "invoiceStatusHelp": "Les factures envoyées comptent tout de suite comme des ventes et peuvent recevoir des paiements. Choisissez Brouillon si quelqu'un vérifie d'abord chaque facture dans Zoho Books ; les paiements sont alors enregistrés une fois qu'elle y est envoyée.",
+        "invoiceStatusSent": "Envoyée",
+        "invoiceStatusDraft": "Brouillon",
+        "invoiceNumbers": "Numéros de facture",
+        "invoiceNumbersHelp": "Gardez le numéro Torqvoice pour que les deux systèmes concordent, ou laissez Zoho Books numéroter la facture dans sa propre séquence. Avec la numérotation Zoho, choisissez le numéro de facture Torqvoice comme référence pour retrouver chaque facture.",
+        "invoiceNumbersTorqvoice": "Utiliser le numéro Torqvoice",
+        "invoiceNumbersZoho": "Laisser Zoho Books les numéroter",
+        "reference": "Référence",
+        "referenceHelp": "Ce qui figure dans le champ Reference# de la facture dans Zoho Books, 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": "L'année, la marque, le modèle, la plaque et le kilométrage, placés avec les notes au client.",
+        "includeNotes": "Inclure les notes au client",
+        "includeNotesHelp": "Les notes de la facture destinées au client, en texte brut.",
+        "notesPosition": "Emplacement du véhicule et des notes",
+        "notesPositionHelp": "Customer Notes est le champ propre à Zoho, imprimé sous les lignes facturées sur la facture Zoho. Au-dessus ou sous les lignes facturées les place plutôt dans une ligne de texte parmi les lignes de la facture.",
+        "notesPositionCustomerNotes": "Le champ Customer Notes de Zoho",
+        "notesPositionBelow": "Une ligne sous les lignes facturées",
+        "notesPositionAbove": "Une ligne au-dessus des lignes facturées",
+        "includePartNumbers": "Afficher les numéros de pièce sur les lignes de pièces",
+        "includePartNumbersHelp": "La description de chaque ligne de pièce commence par son numéro de pièce.",
+        "templateId": "Modèle de facture",
+        "templateIdHelp": "Le modèle Zoho Books utilisé par la facture quand elle est consultée ou envoyée depuis Zoho. Laissez vide pour celui par défaut de l'organisation.",
+        "locationId": "Emplacement",
+        "locationIdHelp": "Uniquement pour les organisations où les emplacements sont activés dans Zoho Books, et où chaque facture en exige un. Sinon, laissez vide.",
+        "laborAccount": "Compte pour la main-d'œuvre",
+        "laborAccountHelp": "Le compte de produits sur lequel les lignes de main-d'œuvre sont comptabilisées. Laissez vide pour utiliser le compte de l'article choisi ci-dessous, ou le compte Sales de Zoho.",
+        "partsAccount": "Compte pour les pièces",
+        "partsAccountHelp": "Le compte de produits sur lequel les lignes de pièces sont comptabilisées. Laissez vide pour utiliser le compte de l'article choisi ci-dessous, ou le compte Sales de Zoho.",
+        "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 Zoho Books attribué à chaque ligne de main-d'œuvre, pour les rapports par article dans Zoho. Le compte choisi ci-dessus reste prioritaire.",
+        "partsItem": "Article pour les lignes de pièces",
+        "partsItemHelp": "Facultatif. Un article Zoho Books attribué à chaque ligne de pièce, pour les rapports par article dans Zoho. Le compte choisi ci-dessus reste prioritaire.",
+        "matchItemsByPartNumber": "Associer les pièces aux articles Zoho par numéro de pièce",
+        "matchItemsByPartNumberHelp": "Une pièce dont le numéro est aussi le SKU d'un article dans Zoho Books est vendue comme cet article, et le stock suivi dans Zoho diminue. Pour que cela trouve quelque chose, activez les SKU sous Settings, Items dans Zoho Books.",
+        "taxId": "Taxe pour les lignes taxées",
+        "taxIdHelp": "Torqvoice choisit la taxe Zoho Books dont le pourcentage correspond à chaque facture. Choisissez-en une ici pour les cas où plusieurs ont le même pourcentage, ou où aucune ne correspond.",
+        "zeroTaxId": "Taxe pour les lignes non taxées",
+        "zeroTaxIdHelp": "Utilisée pour les lignes sans taxe et pour les clients exonérés, quand l'organisation a une taxe à 0 % pour eux. Laissez vide pour envoyer ces lignes sans taxe.",
+        "roundingAdjustment": "Faire concorder le total au centime",
+        "roundingAdjustmentHelp": "Zoho Books calcule lui-même la taxe. Quand son total s'écarte d'un ou deux centimes de la facture reçue par le client, la différence est saisie comme ajustement d'arrondi de Zoho, pour que les deux systèmes concordent. Désactivé, le journal signale les écarts.",
+        "sendFromZoho": "Envoyer la facture au client par e-mail depuis Zoho Books",
+        "sendFromZohoHelp": "Zoho Books envoie sa propre copie de la facture à l'adresse e-mail du client, avec les options de paiement en ligne configurées dans Zoho. Désactivez cette option si Torqvoice envoie déjà la facture, pour que le client ne la reçoive pas deux fois.",
+        "attachPdf": "Joindre le PDF de la facture",
+        "attachPdfHelp": "La facture reçue par le client depuis Torqvoice est jointe à la facture dans Zoho Books, et remplacée quand elle change.",
+        "updateContacts": "Garder à jour les coordonnées des clients dans Zoho Books",
+        "updateContactsHelp": "Une modification ici du nom, de l'e-mail, du téléphone ou de l'adresse d'un client est copiée sur son contact dans Zoho Books. Désactivé, les contacts sont seulement créés, puis laissés tels que le comptable les tient.",
+        "sameEmail": "Clients avec le même e-mail",
+        "sameEmailHelp": "Plusieurs clients peuvent partager une adresse e-mail, comme une famille ou la boîte comptable d'une entreprise. Avec le premier choix, un contact Zoho trouvé par e-mail n'est utilisé que si son nom correspond aussi au client ; sinon, le client reçoit un nouveau contact et le journal indique l'autre, pour que vous puissiez les fusionner dans Zoho s'il s'agit du même client.",
+        "sameEmailCreate": "Créer un nouveau contact si le nom est différent",
+        "sameEmailReuse": "Utiliser le contact existant, même si le nom est différent",
+        "voidOnDelete": "Annuler la facture Zoho quand le travail est supprimé ici",
+        "voidOnDeleteHelp": "Un brouillon est supprimé et une facture envoyée est annulée. Une facture avec des paiements dans Zoho Books n'est jamais modifiée ; le journal indique qu'elle a été laissée telle quelle.",
+        "pushPayments": "Enregistrer les paiements dans Zoho Books",
+        "paymentAccount": "Déposer sur",
+        "paymentAccountHelp": "Le compte Zoho Books sur lequel sont déposés les paiements enregistrés dans Torqvoice. Laissez vide pour utiliser Undeposited Funds.",
+        "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, Paystack 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 Zoho Books 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 Zoho Books",
+        "pullPaymentsHelp": "Un paiement enregistré dans Zoho Books sur une facture Torqvoice est enregistré ici sur la facture sous 30 minutes, et un paiement supprimé là-bas est retiré ici."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a Zoho Books e riporta i pagamenti registrati lì.",
+      "tenantHelp": "Crea una Server-based Application nella Zoho API Console (api-console.zoho.com, o la console della tua regione, come api-console.zoho.eu). Aggiungi l'URI di reindirizzamento qui sotto, copia l'ID client e il segreto client e, nella scheda Settings del client, spunta \"Use the same OAuth credentials for all data centers\" se ci sono officine che accedono da altre regioni.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "organizationId": "Organizzazione Zoho Books",
+        "organizationIdHelp": "L'organizzazione in cui vengono scritte le fatture. Sono elencate tutte le organizzazioni a cui ha accesso l'utente Zoho che ha effettuato il collegamento.",
+        "pushInvoices": "Invia le fatture emesse a Zoho Books",
+        "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 Zoho Books, così le fatture più vecchie non vengono registrate due volte.",
+        "invoiceStatus": "Stato in Zoho Books",
+        "invoiceStatusHelp": "Le fatture inviate contano subito come vendite e possono ricevere pagamenti. Scegli Bozza se qualcuno controlla prima ogni fattura in Zoho Books; i pagamenti vengono poi registrati una volta inviata da lì.",
+        "invoiceStatusSent": "Inviata",
+        "invoiceStatusDraft": "Bozza",
+        "invoiceNumbers": "Numeri di fattura",
+        "invoiceNumbersHelp": "Mantieni il numero Torqvoice così i due sistemi coincidono, oppure lascia che Zoho Books numeri la fattura con la propria sequenza. Con la numerazione di Zoho, scegli il numero di fattura Torqvoice come riferimento per ritrovare ogni fattura.",
+        "invoiceNumbersTorqvoice": "Usa il numero Torqvoice",
+        "invoiceNumbersZoho": "Lascia la numerazione a Zoho Books",
+        "reference": "Riferimento",
+        "referenceHelp": "Cosa va nel campo Reference# della fattura in Zoho Books, che compare sulla fattura e negli elenchi e nelle ricerche di Zoho.",
+        "referenceVehicle": "Veicolo e targa",
+        "referenceTitle": "Titolo del lavoro",
+        "referenceInvoiceNumber": "Numero di fattura Torqvoice",
+        "referenceNone": "Niente",
+        "includeVehicle": "Indicare il veicolo sulla fattura",
+        "includeVehicleHelp": "Anno, marca, modello, targa e chilometraggio, insieme alle note per il cliente.",
+        "includeNotes": "Includi le note per il cliente",
+        "includeNotesHelp": "Le note per il cliente della fattura, come testo semplice.",
+        "notesPosition": "Dove vanno il veicolo e le note",
+        "notesPositionHelp": "Customer Notes è il campo di Zoho, stampato sotto le voci sulla fattura di Zoho. Sopra o sotto le voci li mette invece in una riga di testo tra le righe della fattura.",
+        "notesPositionCustomerNotes": "Il campo Customer Notes di Zoho",
+        "notesPositionBelow": "Una riga sotto le voci",
+        "notesPositionAbove": "Una riga sopra le voci",
+        "includePartNumbers": "Mostra i numeri ricambio sulle righe dei ricambi",
+        "includePartNumbersHelp": "La descrizione di ogni riga di ricambio inizia con il suo numero ricambio.",
+        "templateId": "Modello di fattura",
+        "templateIdHelp": "Il modello di Zoho Books usato dalla fattura quando viene visualizzata o inviata da Zoho. Lascia vuoto per usare quello predefinito dell'organizzazione.",
+        "locationId": "Sede",
+        "locationIdHelp": "Solo per le organizzazioni con le sedi attivate in Zoho Books, dove ogni fattura ne richiede una. Altrimenti lascia vuoto.",
+        "laborAccount": "Conto per la manodopera",
+        "laborAccountHelp": "Il conto ricavi su cui vengono registrate le righe di manodopera. Lascia vuoto per usare il conto dell'articolo scelto qui sotto, o il conto Sales di Zoho.",
+        "partsAccount": "Conto per i ricambi",
+        "partsAccountHelp": "Il conto ricavi su cui vengono registrate le righe di ricambi. Lascia vuoto per usare il conto dell'articolo scelto qui sotto, o il conto Sales di Zoho.",
+        "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 Zoho Books assegnato a ogni riga di manodopera, per i report sugli articoli in Zoho. Il conto scelto sopra ha comunque la precedenza.",
+        "partsItem": "Articolo per le righe di ricambi",
+        "partsItemHelp": "Facoltativo. Un articolo Zoho Books assegnato a ogni riga di ricambi, per i report sugli articoli in Zoho. Il conto scelto sopra ha comunque la precedenza.",
+        "matchItemsByPartNumber": "Abbina i ricambi agli articoli Zoho tramite numero ricambio",
+        "matchItemsByPartNumberHelp": "Un ricambio il cui numero è anche lo SKU di un articolo in Zoho Books viene venduto come quell'articolo, così la giacenza gestita in Zoho diminuisce. Perché trovi qualcosa, attiva gli SKU in Settings, Items in Zoho Books.",
+        "taxId": "Imposta per le righe imponibili",
+        "taxIdHelp": "Torqvoice sceglie l'imposta di Zoho Books la cui percentuale corrisponde a ogni fattura. Scegline una qui per quando più imposte hanno la stessa percentuale, o nessuna corrisponde.",
+        "zeroTaxId": "Imposta per le righe senza imposta",
+        "zeroTaxIdHelp": "Usata per le righe senza imposta e per i clienti esenti, quando l'organizzazione ha un'imposta allo 0% per loro. Lascia vuoto per inviare quelle righe senza imposta.",
+        "roundingAdjustment": "Fai coincidere il totale al centesimo",
+        "roundingAdjustmentHelp": "Zoho Books calcola l'imposta da solo. Quando il suo totale si scosta di un centesimo o due dalla fattura ricevuta dal cliente, la differenza viene inserita come arrotondamento di Zoho, così i due sistemi coincidono. Disattivato, il registro segnala quando differiscono.",
+        "sendFromZoho": "Invia la fattura al cliente via email da Zoho Books",
+        "sendFromZohoHelp": "Zoho Books invia la propria copia della fattura all'email del cliente, con le opzioni di pagamento online configurate in Zoho. Disattiva questa opzione se Torqvoice invia già la fattura, così il cliente non la riceve due volte.",
+        "attachPdf": "Allega il PDF della fattura",
+        "attachPdfHelp": "La fattura che il cliente ha ricevuto da Torqvoice viene allegata alla fattura in Zoho Books e sostituita quando cambia.",
+        "updateContacts": "Mantieni aggiornati i dati dei clienti in Zoho Books",
+        "updateContactsHelp": "Una modifica qui a nome, email, telefono o indirizzo di un cliente viene copiata nel suo contatto in Zoho Books. Disattivato, i contatti vengono solo creati e poi lasciati come li tiene il contabile.",
+        "sameEmail": "Clienti con la stessa email",
+        "sameEmailHelp": "Più clienti possono condividere un indirizzo email, come una famiglia o la casella amministrativa di un'azienda. Con la prima scelta, un contatto Zoho trovato tramite email viene usato solo se anche il suo nome corrisponde al cliente; altrimenti il cliente riceve un nuovo contatto e il registro indica l'altro, così puoi unirli in Zoho se sono lo stesso cliente.",
+        "sameEmailCreate": "Crea un nuovo contatto se il nome è diverso",
+        "sameEmailReuse": "Usa il contatto esistente anche se il nome è diverso",
+        "voidOnDelete": "Annulla la fattura Zoho quando il lavoro viene eliminato qui",
+        "voidOnDeleteHelp": "Una bozza viene eliminata e una fattura inviata viene annullata. Una fattura con pagamenti in Zoho Books non viene mai toccata; il registro indica che è stata lasciata invariata.",
+        "pushPayments": "Registra i pagamenti in Zoho Books",
+        "paymentAccount": "Deposita su",
+        "paymentAccountHelp": "Il conto in Zoho Books su cui vengono depositati i pagamenti registrati in Torqvoice. Lascia vuoto per usare Undeposited Funds.",
+        "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, Paystack 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 Zoho Books un pagamento per quanto risulta ancora dovuto lì. Disattivato, resta aperta lì finché non viene registrato un pagamento.",
+        "pullPayments": "Riporta i pagamenti registrati in Zoho Books",
+        "pullPaymentsHelp": "Un pagamento registrato in Zoho Books su una fattura di Torqvoice viene registrato qui sulla fattura entro 30 minuti, e uno eliminato lì viene rimosso qui."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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ų."
       }
     },
+    "zoho-books": {
+      "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į Zoho Books ir parsisiųskite ten užregistruotus mokėjimus.",
+      "tenantHelp": "Sukurkite Server-based Application tipo programą Zoho API Console (api-console.zoho.com arba jūsų regiono konsolėje, pavyzdžiui, api-console.zoho.eu). Įveskite žemiau esantį peradresavimo URI, nukopijuokite kliento ID ir slaptažodį, o kliento skirtuke Settings pažymėkite \"Use the same OAuth credentials for all data centers\", jei dirbtuvės jungiasi iš kitų regionų.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "organizationId": "Zoho Books organizacija",
+        "organizationIdHelp": "Organizacija, į kurią rašomos sąskaitos. Rodomos visos organizacijos, kurias gali pasiekti prisijungęs Zoho naudotojas.",
+        "pushInvoices": "Siųsti išrašytas sąskaitas į Zoho Books",
+        "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 į Zoho Books, kad senesnės sąskaitos nebūtų įvestos dukart.",
+        "invoiceStatus": "Būsena Zoho Books sistemoje",
+        "invoiceStatusHelp": "Išsiųstos sąskaitos iš karto laikomos pardavimais ir gali priimti mokėjimus. Pasirinkite Juodraštis, jei kas nors pirmiausia patikrina kiekvieną sąskaitą Zoho Books sistemoje; tada mokėjimai užregistruojami, kai sąskaita iš ten išsiunčiama.",
+        "invoiceStatusSent": "Išsiųsta",
+        "invoiceStatusDraft": "Juodraštis",
+        "invoiceNumbers": "Sąskaitų numeriai",
+        "invoiceNumbersHelp": "Palikite Torqvoice numerį, kad abi sistemos sutaptų, arba leiskite Zoho Books sunumeruoti sąskaitą pagal savo seką. Jei numeruoja Zoho, kaip nuorodą pasirinkite Torqvoice sąskaitos numerį, kad kiekvieną sąskaitą galėtumėte vėl rasti.",
+        "invoiceNumbersTorqvoice": "Naudoti Torqvoice numerį",
+        "invoiceNumbersZoho": "Leisti numeruoti Zoho Books",
+        "reference": "Nuoroda",
+        "referenceHelp": "Kas įrašoma į sąskaitos lauką Reference# Zoho Books sistemoje. Jis rodomas sąskaitoje ir Zoho 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": "Metai, markė, modelis, numeris ir rida, pateikiami kartu su pastabomis klientui.",
+        "includeNotes": "Įtraukti pastabas klientui",
+        "includeNotesHelp": "Sąskaitos pastabos klientui kaip paprastas tekstas.",
+        "notesPosition": "Kur dedama transporto priemonė ir pastabos",
+        "notesPositionHelp": "Customer Notes yra paties Zoho laukas, spausdinamas po pozicijomis Zoho sąskaitoje. Pasirinkus Virš pozicijų arba Po pozicijomis, jie dedami į teksto eilutę tarp sąskaitos eilučių.",
+        "notesPositionCustomerNotes": "Zoho laukas Customer Notes",
+        "notesPositionBelow": "Eilutė po pozicijomis",
+        "notesPositionAbove": "Eilutė virš pozicijų",
+        "includePartNumbers": "Rodyti dalių numerius dalių eilutėse",
+        "includePartNumbersHelp": "Kiekvienos dalies eilutės aprašymas prasideda dalies numeriu.",
+        "templateId": "Sąskaitos šablonas",
+        "templateIdHelp": "Zoho Books šablonas, kurį sąskaita naudoja, kai ji peržiūrima ar siunčiama iš Zoho. Palikite tuščią, kad būtų naudojamas organizacijos numatytasis šablonas.",
+        "locationId": "Vieta",
+        "locationIdHelp": "Tik organizacijoms, kurių Zoho Books įjungtos vietos ir kiekvienai sąskaitai jos reikia. Kitu atveju palikite tuščią.",
+        "laborAccount": "Sąskaita darbui",
+        "laborAccountHelp": "Pajamų sąskaita, į kurią apskaitomos darbo eilutės. Palikite tuščią, kad būtų naudojama žemiau pasirinktos prekės sąskaita arba Zoho sąskaita Sales.",
+        "partsAccount": "Sąskaita dalims",
+        "partsAccountHelp": "Pajamų sąskaita, į kurią apskaitomos dalių eilutės. Palikite tuščią, kad būtų naudojama žemiau pasirinktos prekės sąskaita arba Zoho sąskaita Sales.",
+        "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. Zoho Books prekė, kuria pažymima kiekviena darbo eilutė, prekių ataskaitoms Zoho sistemoje. Aukščiau pasirinkta sąskaita vis tiek turi pirmenybę.",
+        "partsItem": "Prekė dalių eilutėms",
+        "partsItemHelp": "Neprivaloma. Zoho Books prekė, kuria pažymima kiekviena dalių eilutė, prekių ataskaitoms Zoho sistemoje. Aukščiau pasirinkta sąskaita vis tiek turi pirmenybę.",
+        "matchItemsByPartNumber": "Susieti dalis su Zoho prekėmis pagal dalies numerį",
+        "matchItemsByPartNumberHelp": "Dalis, kurios numeris sutampa su Zoho Books prekės SKU, parduodama kaip ta prekė, todėl Zoho sekamas likutis sumažėja. Kad būtų kas nors rasta, Zoho Books skiltyje Settings, Items įjunkite SKU.",
+        "taxId": "Mokestis apmokestinamoms eilutėms",
+        "taxIdHelp": "Torqvoice parenka Zoho Books mokestį, kurio procentas sutampa su kiekviena sąskaita. Pasirinkite mokestį čia tam atvejui, kai keli turi tą patį procentą arba nė vienas nesutampa.",
+        "zeroTaxId": "Mokestis eilutėms be mokesčio",
+        "zeroTaxIdHelp": "Naudojamas eilutėms be mokesčio ir nuo mokesčio atleistiems klientams, kai organizacija turi tam 0 % mokestį. Palikite tuščią, kad šios eilutės būtų siunčiamos be mokesčio.",
+        "roundingAdjustment": "Suderinti sumą iki cento",
+        "roundingAdjustmentHelp": "Zoho Books mokestį apskaičiuoja pats. Kai jo suma vienu ar dviem centais skiriasi nuo kliento gautos sąskaitos, skirtumas įrašomas kaip Zoho apvalinimo korekcija, todėl abi sistemos sutampa. Išjungus žurnalas praneša, kai sumos skiriasi.",
+        "sendFromZoho": "Siųsti sąskaitą klientui el. paštu iš Zoho Books",
+        "sendFromZohoHelp": "Zoho Books išsiunčia savo sąskaitos kopiją kliento el. pašto adresu su Zoho nustatytomis internetinio mokėjimo galimybėmis. Išjunkite, jei sąskaitą jau siunčia Torqvoice, kad klientas negautų jos dukart.",
+        "attachPdf": "Pridėti sąskaitos PDF",
+        "attachPdfHelp": "Kliento iš Torqvoice gauta sąskaita pridedama prie sąskaitos Zoho Books sistemoje ir pakeičiama, kai ji pasikeičia.",
+        "updateContacts": "Atnaujinti klientų duomenis Zoho Books sistemoje",
+        "updateContactsHelp": "Čia pakeisti kliento vardas, el. paštas, telefonas ar adresas nukopijuojami į jo kontaktą Zoho Books sistemoje. Išjungus kontaktai tik sukuriami ir paliekami tokie, kokius juos tvarko buhalteris.",
+        "sameEmail": "Klientai su tuo pačiu el. paštu",
+        "sameEmailHelp": "Keli klientai gali naudoti tą patį el. pašto adresą, pavyzdžiui, šeima ar įmonės buhalterijos pašto dėžutė. Pasirinkus pirmą variantą, pagal el. paštą rastas Zoho kontaktas naudojamas tik tada, kai ir jo vardas sutampa su kliento; kitu atveju klientui sukuriamas naujas kontaktas, o žurnale nurodomas kitas, kad galėtumėte juos sujungti Zoho sistemoje, jei tai tas pats klientas.",
+        "sameEmailCreate": "Sukurti naują kontaktą, jei vardas skiriasi",
+        "sameEmailReuse": "Naudoti esamą kontaktą, net jei vardas skiriasi",
+        "voidOnDelete": "Anuliuoti Zoho sąskaitą, kai darbas čia ištrinamas",
+        "voidOnDeleteHelp": "Juodraštis ištrinamas, o išsiųsta sąskaita anuliuojama. Sąskaita, kuriai Zoho Books sistemoje yra mokėjimų, niekada neliečiama; žurnale nurodoma, kad ji palikta nepakeista.",
+        "pushPayments": "Registruoti mokėjimus Zoho Books",
+        "paymentAccount": "Įnešti į",
+        "paymentAccountHelp": "Zoho Books sąskaita, į kurią įnešami Torqvoice užregistruoti mokėjimai. Palikite tuščią, kad būtų naudojama Undeposited Funds.",
+        "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, Paystack 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 Zoho Books gauna vieną mokėjimą likusiai ten rodomai sumai. Išjungus ji ten lieka atvira, kol užregistruojamas mokėjimas.",
+        "pullPayments": "Parsisiųsti Zoho Books užregistruotus mokėjimus",
+        "pullPaymentsHelp": "Zoho Books užregistruotas mokėjimas už Torqvoice sąskaitą per 30 minučių užregistruojamas čia prie sąskaitos, o ten ištrintas mokėjimas čia pašalinamas."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Send utstedte fakturaer, kundene deres og betalingene deres til Zoho Books, og hent tilbake betalinger registrert der.",
+      "tenantHelp": "Opprett en Server-based Application i Zoho API Console (api-console.zoho.com, eller konsollen for din egen region, for eksempel api-console.zoho.eu). Legg inn omdirigerings-URI-en nedenfor, kopier klient-ID-en og klienthemmeligheten, og kryss av for \"Use the same OAuth credentials for all data centers\" på Settings-fanen til klienten hvis verksteder logger inn fra andre regioner.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "organizationId": "Zoho Books-organisasjon",
+        "organizationIdHelp": "Organisasjonen fakturaene skal føres i. Alle organisasjoner som Zoho-brukeren som koblet til har tilgang til, vises her.",
+        "pushInvoices": "Send utstedte fakturaer til Zoho Books",
+        "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 Zoho Books, så eldre fakturaer ikke føres to ganger.",
+        "invoiceStatus": "Status i Zoho Books",
+        "invoiceStatusHelp": "Sendte fakturaer regnes som salg med en gang og kan ta imot betalinger. Velg Utkast hvis noen kontrollerer hver faktura i Zoho Books først; betalinger registreres da når fakturaen er sendt derfra.",
+        "invoiceStatusSent": "Sendt",
+        "invoiceStatusDraft": "Utkast",
+        "invoiceNumbers": "Fakturanumre",
+        "invoiceNumbersHelp": "Behold nummeret fra Torqvoice så begge systemer stemmer, eller la Zoho Books nummerere fakturaen i sin egen nummerserie. Med nummerering i Zoho velger du fakturanummeret fra Torqvoice som referanse, så du finner igjen hver faktura.",
+        "invoiceNumbersTorqvoice": "Bruk nummeret fra Torqvoice",
+        "invoiceNumbersZoho": "La Zoho Books nummerere dem",
+        "reference": "Referanse",
+        "referenceHelp": "Det som legges i feltet Reference# på fakturaen i Zoho Books. 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": "Årsmodell, merke, modell, skilt og kilometerstand, plassert sammen med notatene til kunden.",
+        "includeNotes": "Ta med notatene til kunden",
+        "includeNotesHelp": "Fakturaens notater til kunden, som ren tekst.",
+        "notesPosition": "Hvor kjøretøyet og notatene står",
+        "notesPositionHelp": "Customer Notes er Zohos eget felt, som skrives ut under postene på fakturaen fra Zoho. Over eller under postene legger dem i stedet i en tekstlinje blant fakturalinjene.",
+        "notesPositionCustomerNotes": "Zohos felt Customer Notes",
+        "notesPositionBelow": "En linje under postene",
+        "notesPositionAbove": "En linje over postene",
+        "includePartNumbers": "Vis delenummer på delelinjer",
+        "includePartNumbersHelp": "Beskrivelsen på hver delelinje starter med delenummeret.",
+        "templateId": "Fakturamal",
+        "templateIdHelp": "Malen i Zoho Books som fakturaen bruker når den vises eller sendes fra Zoho. La stå tomt for å bruke organisasjonens standardmal.",
+        "locationId": "Lokasjon",
+        "locationIdHelp": "Bare for organisasjoner som har slått på lokasjoner i Zoho Books, der hver faktura må ha en. La stå tomt ellers.",
+        "laborAccount": "Konto for arbeid",
+        "laborAccountHelp": "Inntektskontoen arbeidslinjene bokføres på. La stå tom for å bruke kontoen til varen som er valgt nedenfor, eller Sales-kontoen i Zoho.",
+        "partsAccount": "Konto for deler",
+        "partsAccountHelp": "Inntektskontoen delelinjene bokføres på. La stå tom for å bruke kontoen til varen som er valgt nedenfor, eller Sales-kontoen i Zoho.",
+        "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 Zoho Books som hver arbeidslinje merkes med, for varerapporter i Zoho. Kontoen som er valgt ovenfor, går likevel foran.",
+        "partsItem": "Vare for delelinjer",
+        "partsItemHelp": "Valgfri. En vare i Zoho Books som hver delelinje merkes med, for varerapporter i Zoho. Kontoen som er valgt ovenfor, går likevel foran.",
+        "matchItemsByPartNumber": "Koble deler til Zoho-varer via delenummer",
+        "matchItemsByPartNumberHelp": "En del med samme nummer som SKU-en til en vare i Zoho Books selges som den varen, så lagerbeholdningen i Zoho går ned. Slå på SKU under Settings, Items i Zoho Books, ellers finner den ingenting.",
+        "taxId": "Avgift for avgiftspliktige linjer",
+        "taxIdHelp": "Torqvoice velger avgiften i Zoho Books med samme prosent som hver faktura. Velg en her for når flere har samme prosent, eller ingen passer.",
+        "zeroTaxId": "Avgift for avgiftsfrie linjer",
+        "zeroTaxIdHelp": "Brukes for linjer uten avgift og for kunder som er fritatt, når organisasjonen har en avgift på 0 % for dem. La stå tomt for å sende de linjene uten avgift.",
+        "roundingAdjustment": "La totalen stemme på øret",
+        "roundingAdjustmentHelp": "Zoho Books regner ut avgiften selv. Når totalen der havner et øre eller to unna fakturaen kunden fikk, føres differansen som avrundingsjustering i Zoho, så begge systemer stemmer. Avslått sier loggen fra når de er ulike.",
+        "sendFromZoho": "Send fakturaen til kunden på e-post fra Zoho Books",
+        "sendFromZohoHelp": "Zoho Books sender sin egen kopi av fakturaen til kundens e-post, med eventuelle nettbetalingsvalg som er satt opp i Zoho. Slå dette av hvis Torqvoice allerede sender fakturaen, så kunden ikke får den to ganger.",
+        "attachPdf": "Legg ved faktura-PDF-en",
+        "attachPdfHelp": "Fakturaen kunden fikk fra Torqvoice, legges ved fakturaen i Zoho Books og byttes ut når den endres.",
+        "updateContacts": "Hold kundeopplysningene oppdatert i Zoho Books",
+        "updateContactsHelp": "Endrer du navn, e-post, telefon eller adresse på en kunde her, kopieres det til kontakten i Zoho Books. Avslått blir kontakter bare opprettet, og deretter står de slik regnskapsføreren har dem.",
+        "sameEmail": "Kunder med samme e-post",
+        "sameEmailHelp": "Flere kunder kan dele en e-postadresse, for eksempel en familie eller regnskapsadressen til et firma. Med det første valget brukes en kontakt i Zoho funnet via e-post bare når også navnet stemmer med kunden; ellers får kunden en ny kontakt, og loggen nevner den andre, så du kan slå dem sammen i Zoho hvis det er samme kunde.",
+        "sameEmailCreate": "Opprett ny kontakt når navnet er forskjellig",
+        "sameEmailReuse": "Bruk den eksisterende kontakten selv om navnet er forskjellig",
+        "voidOnDelete": "Annuller Zoho-fakturaen når jobben slettes her",
+        "voidOnDeleteHelp": "Et utkast slettes og en sendt faktura annulleres. En faktura med betalinger i Zoho Books røres aldri; loggen viser at den ble stående urørt.",
+        "pushPayments": "Registrer betalinger i Zoho Books",
+        "paymentAccount": "Sett inn på",
+        "paymentAccountHelp": "Kontoen i Zoho Books som betalinger registrert i Torqvoice settes inn på. La stå tom for å bruke Undeposited Funds.",
+        "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, Paystack 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 Zoho Books 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 Zoho Books",
+        "pullPaymentsHelp": "En betaling registrert i Zoho Books mot en faktura fra Torqvoice registreres her på fakturaen innen 30 minutter, og en betaling som slettes der, fjernes her."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar Zoho Books en haal daar geregistreerde betalingen terug.",
+      "tenantHelp": "Maak een Server-based Application aan in de Zoho API Console (api-console.zoho.com, of de console van je eigen regio, zoals api-console.zoho.eu). Voeg de redirect-URI hieronder toe, kopieer de client-id en het secret, en vink op het tabblad Settings van de client \"Use the same OAuth credentials for all data centers\" aan als werkplaatsen vanuit andere regio's inloggen.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Clientgeheim"
+      },
+      "settings": {
+        "organizationId": "Zoho Books-organisatie",
+        "organizationIdHelp": "De organisatie waarin de facturen worden geschreven. Elke organisatie waar de Zoho-gebruiker die de koppeling maakte bij kan, staat in de lijst.",
+        "pushInvoices": "Uitgegeven facturen naar Zoho Books 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 Zoho Books is overgegaan, zodat oudere facturen niet dubbel worden ingevoerd.",
+        "invoiceStatus": "Status in Zoho Books",
+        "invoiceStatusHelp": "Verzonden facturen tellen direct als omzet en kunnen betalingen ontvangen. Kies Concept als iemand elke factuur eerst in Zoho Books controleert; betalingen worden dan geregistreerd zodra de factuur daar is verzonden.",
+        "invoiceStatusSent": "Verzonden",
+        "invoiceStatusDraft": "Concept",
+        "invoiceNumbers": "Factuurnummers",
+        "invoiceNumbersHelp": "Behoud het Torqvoice-nummer zodat beide systemen overeenkomen, of laat Zoho Books de factuur in zijn eigen reeks nummeren. Kies bij nummering door Zoho het Torqvoice-factuurnummer als referentie om elke factuur terug te vinden.",
+        "invoiceNumbersTorqvoice": "Het Torqvoice-nummer gebruiken",
+        "invoiceNumbersZoho": "Zoho Books laten nummeren",
+        "reference": "Referentie",
+        "referenceHelp": "Wat in het veld Reference# van de factuur in Zoho Books 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": "Bouwjaar, merk, model, kenteken en kilometerstand, bij de opmerkingen voor de klant.",
+        "includeNotes": "Opmerkingen voor de klant meenemen",
+        "includeNotesHelp": "De opmerkingen voor de klant, als platte tekst.",
+        "notesPosition": "Waar het voertuig en de opmerkingen komen",
+        "notesPositionHelp": "Customer Notes is het eigen veld van Zoho, dat onder de posten op de factuur van Zoho wordt afgedrukt. Boven of onder de posten zet ze in plaats daarvan in een tekstregel tussen de factuurregels.",
+        "notesPositionCustomerNotes": "Het veld Customer Notes van Zoho",
+        "notesPositionBelow": "Een regel onder de posten",
+        "notesPositionAbove": "Een regel boven de posten",
+        "includePartNumbers": "Onderdeelnummers op onderdelenregels tonen",
+        "includePartNumbersHelp": "De omschrijving van elke onderdelenregel begint met het onderdeelnummer.",
+        "templateId": "Factuursjabloon",
+        "templateIdHelp": "Het sjabloon in Zoho Books dat de factuur gebruikt als hij in Zoho wordt bekeken of vanuit Zoho wordt verzonden. Laat leeg voor de standaard van de organisatie.",
+        "locationId": "Locatie",
+        "locationIdHelp": "Alleen voor organisaties waarbij locaties in Zoho Books zijn ingeschakeld, waar elke factuur er een nodig heeft. Laat anders leeg.",
+        "laborAccount": "Rekening voor arbeid",
+        "laborAccountHelp": "De omzetrekening waarop arbeidsregels worden geboekt. Laat leeg om de rekening van het artikel dat hieronder is gekozen te gebruiken, of de rekening Sales van Zoho.",
+        "partsAccount": "Rekening voor onderdelen",
+        "partsAccountHelp": "De omzetrekening waarop onderdelenregels worden geboekt. Laat leeg om de rekening van het artikel dat hieronder is gekozen te gebruiken, of de rekening Sales van Zoho.",
+        "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 Zoho Books-artikel dat aan elke arbeidsregel wordt gekoppeld, voor artikelrapporten in Zoho. De hierboven gekozen rekening gaat nog steeds voor.",
+        "partsItem": "Artikel voor onderdelenregels",
+        "partsItemHelp": "Optioneel. Een Zoho Books-artikel dat aan elke onderdelenregel wordt gekoppeld, voor artikelrapporten in Zoho. De hierboven gekozen rekening gaat nog steeds voor.",
+        "matchItemsByPartNumber": "Onderdelen op onderdeelnummer aan Zoho-artikelen koppelen",
+        "matchItemsByPartNumberHelp": "Een onderdeel waarvan het nummer ook de SKU van een artikel in Zoho Books is, wordt als dat artikel verkocht, zodat de voorraad die Zoho bijhoudt daalt. Zet SKU's aan onder Settings, Items in Zoho Books, anders wordt er niets gevonden.",
+        "taxId": "Btw voor belaste regels",
+        "taxIdHelp": "Torqvoice kiest de btw in Zoho Books waarvan het percentage bij elke factuur past. Kies er hier een voor als meerdere hetzelfde percentage hebben, of geen enkele past.",
+        "zeroTaxId": "Btw voor regels zonder belasting",
+        "zeroTaxIdHelp": "Gebruikt voor regels zonder belasting en voor vrijgestelde klanten, als de organisatie daarvoor een btw van 0% heeft. Laat leeg om die regels zonder btw te sturen.",
+        "roundingAdjustment": "Het totaal op de cent laten kloppen",
+        "roundingAdjustmentHelp": "Zoho Books berekent de btw zelf. Komt het totaal daar een cent of twee anders uit dan de factuur die de klant kreeg, dan gaat het verschil erin als afrondingscorrectie van Zoho, zodat beide systemen overeenkomen. Uit meldt het logboek wanneer ze verschillen.",
+        "sendFromZoho": "De factuur vanuit Zoho Books naar de klant e-mailen",
+        "sendFromZohoHelp": "Zoho Books stuurt een eigen exemplaar van de factuur naar het e-mailadres van de klant, met eventuele online betaalopties die in Zoho zijn ingesteld. Zet dit uit als Torqvoice de factuur al verstuurt, zodat de klant hem niet twee keer krijgt.",
+        "attachPdf": "Factuur-pdf bijvoegen",
+        "attachPdfHelp": "De factuur die de klant van Torqvoice ontving, wordt bij de factuur in Zoho Books gevoegd en vervangen als hij verandert.",
+        "updateContacts": "Klantgegevens in Zoho Books actueel houden",
+        "updateContactsHelp": "Wijzigingen die je hier aanbrengt in de naam, het e-mailadres, het telefoonnummer of het adres van een klant, worden naar het contact in Zoho Books gekopieerd. Uit worden contacten alleen aangemaakt en daarna gelaten zoals de boekhouder ze heeft.",
+        "sameEmail": "Klanten met hetzelfde e-mailadres",
+        "sameEmailHelp": "Meerdere klanten kunnen een e-mailadres delen, zoals een gezin of de administratiemailbox van een bedrijf. Bij de eerste keuze wordt een contact in Zoho dat op e-mail is gevonden alleen gebruikt als ook de naam bij de klant past; anders krijgt de klant een nieuw contact en noemt het logboek het andere, zodat je ze in Zoho kunt samenvoegen als het dezelfde klant is.",
+        "sameEmailCreate": "Nieuw contact aanmaken als de naam afwijkt",
+        "sameEmailReuse": "Bestaand contact gebruiken, ook als de naam afwijkt",
+        "voidOnDelete": "De Zoho-factuur ongeldig maken als de klus hier wordt verwijderd",
+        "voidOnDeleteHelp": "Een concept wordt verwijderd en een verzonden factuur ongeldig gemaakt. Een factuur met betalingen in Zoho Books wordt nooit aangeraakt; het logboek meldt dat hij ongemoeid is gelaten.",
+        "pushPayments": "Betalingen in Zoho Books registreren",
+        "paymentAccount": "Storten op",
+        "paymentAccountHelp": "De rekening in Zoho Books waarop in Torqvoice geregistreerde betalingen worden gestort. Laat leeg om Undeposited Funds 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, Paystack 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 Zoho Books één betaling voor wat daar nog openstaat. Uit blijft hij daar open tot er een betaling wordt geregistreerd.",
+        "pullPayments": "In Zoho Books geregistreerde betalingen terughalen",
+        "pullPaymentsHelp": "Een betaling die in Zoho Books op een factuur van Torqvoice wordt geregistreerd, wordt hier binnen 30 minuten op de factuur vastgelegd, en een betaling die daar wordt verwijderd, wordt hier weggehaald."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Wysyłaj wystawione faktury, ich klientów i płatności do Zoho Books i pobieraj płatności zarejestrowane tam.",
+      "tenantHelp": "Utwórz aplikację typu Server-based Application w Zoho API Console (api-console.zoho.com lub konsola Twojego regionu, np. api-console.zoho.eu). Dodaj poniższy adres URI przekierowania, skopiuj client id i secret, a na karcie Settings klienta zaznacz \"Use the same OAuth credentials for all data centers\", jeśli warsztaty logują się z innych regionów.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "organizationId": "Organizacja w Zoho Books",
+        "organizationIdHelp": "Organizacja, do której zapisywane są faktury. Na liście jest każda organizacja, do której ma dostęp użytkownik Zoho, który nawiązał połączenie.",
+        "pushInvoices": "Wysyłaj wystawione faktury do Zoho Books",
+        "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 Zoho Books, aby starsze faktury nie zostały wprowadzone dwa razy.",
+        "invoiceStatus": "Status w Zoho Books",
+        "invoiceStatusHelp": "Wysłane faktury od razu liczą się jako sprzedaż i mogą przyjmować płatności. Wybierz status Wersja robocza, jeśli ktoś najpierw sprawdza każdą fakturę w Zoho Books; płatności są wtedy rejestrowane po jej wysłaniu stamtąd.",
+        "invoiceStatusSent": "Wysłana",
+        "invoiceStatusDraft": "Wersja robocza",
+        "invoiceNumbers": "Numery faktur",
+        "invoiceNumbersHelp": "Zachowaj numer z Torqvoice, aby oba systemy się zgadzały, albo pozwól Zoho Books numerować fakturę we własnej serii. Przy numeracji Zoho wybierz numer faktury Torqvoice jako referencję, aby odnaleźć każdą fakturę.",
+        "invoiceNumbersTorqvoice": "Używaj numeru z Torqvoice",
+        "invoiceNumbersZoho": "Pozwól Zoho Books je numerować",
+        "reference": "Referencja",
+        "referenceHelp": "Co trafia do pola Reference# faktury w Zoho Books. 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": "Rocznik, marka, model, rejestracja i przebieg, umieszczane razem z notatkami dla klienta.",
+        "includeNotes": "Dołącz notatki dla klienta",
+        "includeNotesHelp": "Notatki dla klienta z faktury jako zwykły tekst.",
+        "notesPosition": "Gdzie trafiają pojazd i notatki",
+        "notesPositionHelp": "Customer Notes to własne pole Zoho, drukowane pod pozycjami na fakturze Zoho. Nad lub pod pozycjami umieszcza je zamiast tego w wierszu tekstu między wierszami faktury.",
+        "notesPositionCustomerNotes": "Pole Customer Notes w Zoho",
+        "notesPositionBelow": "Wiersz pod pozycjami",
+        "notesPositionAbove": "Wiersz nad pozycjami",
+        "includePartNumbers": "Pokazuj numery części w wierszach części",
+        "includePartNumbersHelp": "Opis każdego wiersza części zaczyna się od numeru części.",
+        "templateId": "Szablon faktury",
+        "templateIdHelp": "Szablon Zoho Books używany przez fakturę przy wyświetlaniu lub wysyłaniu z Zoho. Zostaw puste, aby użyć domyślnego szablonu organizacji.",
+        "locationId": "Lokalizacja",
+        "locationIdHelp": "Tylko dla organizacji z włączonymi lokalizacjami w Zoho Books, gdzie każda faktura musi ją mieć. W innym wypadku zostaw puste.",
+        "laborAccount": "Konto dla robocizny",
+        "laborAccountHelp": "Konto przychodów, na które księgowane są wiersze robocizny. Zostaw puste, aby użyć konta pozycji wybranej poniżej lub konta Sales w Zoho.",
+        "partsAccount": "Konto dla części",
+        "partsAccountHelp": "Konto przychodów, na które księgowane są wiersze części. Zostaw puste, aby użyć konta pozycji wybranej poniżej lub konta Sales w Zoho.",
+        "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 Zoho Books przypisywana do każdego wiersza robocizny na potrzeby raportów pozycji w Zoho. Konto wybrane powyżej i tak ma pierwszeństwo.",
+        "partsItem": "Pozycja dla wierszy części",
+        "partsItemHelp": "Opcjonalnie. Pozycja Zoho Books przypisywana do każdego wiersza części na potrzeby raportów pozycji w Zoho. Konto wybrane powyżej i tak ma pierwszeństwo.",
+        "matchItemsByPartNumber": "Dopasowuj części do pozycji Zoho po numerze części",
+        "matchItemsByPartNumberHelp": "Część, której numer jest też SKU pozycji w Zoho Books, jest sprzedawana jako ta pozycja, więc stan magazynowy w Zoho się zmniejsza. Aby coś zostało znalezione, włącz SKU w Settings, Items w Zoho Books.",
+        "taxId": "Podatek dla wierszy opodatkowanych",
+        "taxIdHelp": "Torqvoice wybiera podatek Zoho Books, którego procent odpowiada każdej fakturze. Wybierz tu jeden na wypadek, gdy kilka ma ten sam procent albo żaden nie pasuje.",
+        "zeroTaxId": "Podatek dla wierszy bez podatku",
+        "zeroTaxIdHelp": "Używany dla wierszy bez podatku i klientów zwolnionych z podatku, gdy organizacja ma dla nich podatek 0%. Zostaw puste, aby wysyłać te wiersze bez podatku.",
+        "roundingAdjustment": "Uzgadniaj sumę co do grosza",
+        "roundingAdjustmentHelp": "Zoho Books sam oblicza podatek. Gdy jego suma różni się o grosz lub dwa od faktury, którą otrzymał klient, różnica trafia jako korekta zaokrąglenia w Zoho, więc oba systemy się zgadzają. Wyłączone: dziennik informuje, gdy sumy się różnią.",
+        "sendFromZoho": "Wysyłaj klientowi fakturę e-mailem z Zoho Books",
+        "sendFromZohoHelp": "Zoho Books wysyła własną kopię faktury na e-mail klienta, z opcjami płatności online skonfigurowanymi w Zoho. Wyłącz to, jeśli Torqvoice już wysyła fakturę, aby klient nie dostał jej dwa razy.",
+        "attachPdf": "Dołącz PDF faktury",
+        "attachPdfHelp": "Faktura, którą klient otrzymał z Torqvoice, zostaje dołączona do faktury w Zoho Books i zastąpiona, gdy się zmieni.",
+        "updateContacts": "Aktualizuj dane klientów w Zoho Books",
+        "updateContactsHelp": "Zmiana nazwy, e-maila, telefonu lub adresu klienta wprowadzona tutaj jest kopiowana do jego kontaktu w Zoho Books. Wyłączone: kontakty są tylko tworzone, a potem zostają takie, jakie prowadzi księgowy.",
+        "sameEmail": "Klienci z tym samym e-mailem",
+        "sameEmailHelp": "Kilku klientów może mieć ten sam adres e-mail, na przykład rodzina albo skrzynka księgowości firmy. Przy pierwszym wyborze kontakt w Zoho znaleziony po e-mailu jest używany tylko wtedy, gdy także jego nazwa pasuje do klienta; w przeciwnym razie klient dostaje nowy kontakt, a dziennik wskazuje ten drugi, żebyś mógł je scalić w Zoho, jeśli to ten sam klient.",
+        "sameEmailCreate": "Utwórz nowy kontakt, gdy nazwa się różni",
+        "sameEmailReuse": "Użyj istniejącego kontaktu, nawet gdy nazwa się różni",
+        "voidOnDelete": "Unieważniaj fakturę w Zoho, gdy zlecenie zostanie tu usunięte",
+        "voidOnDeleteHelp": "Wersja robocza jest usuwana, a wysłana faktura unieważniana. Faktura z płatnościami w Zoho Books nigdy nie jest ruszana; dziennik informuje, że została pominięta.",
+        "pushPayments": "Rejestruj płatności w Zoho Books",
+        "paymentAccount": "Wpłacaj na",
+        "paymentAccountHelp": "Konto w Zoho Books, na które trafiają płatności zarejestrowane w Torqvoice. Zostaw puste, aby użyć Undeposited Funds.",
+        "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, Paystack 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 Zoho Books 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 Zoho Books",
+        "pullPaymentsHelp": "Płatność zarejestrowana w Zoho Books za fakturę z Torqvoice zostaje zapisana tutaj na fakturze w ciągu 30 minut, a płatność usunięta tam jest usuwana tutaj."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o Zoho Books e traga de volta os pagamentos registrados lá.",
+      "tenantHelp": "Crie uma Server-based Application na Zoho API Console (api-console.zoho.com, ou o console da sua região, como api-console.zoho.eu). Adicione a URI de redirecionamento abaixo, copie o client id e o secret e, na aba Settings do cliente, marque \"Use the same OAuth credentials for all data centers\" se houver oficinas que entram de outras regiões.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "organizationId": "Organização no Zoho Books",
+        "organizationIdHelp": "A organização em que as faturas são lançadas. Aparece toda organização a que o usuário do Zoho que fez a conexão tem acesso.",
+        "pushInvoices": "Enviar faturas emitidas para o Zoho Books",
+        "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 Zoho Books para não lançar faturas antigas duas vezes.",
+        "invoiceStatus": "Status no Zoho Books",
+        "invoiceStatusHelp": "Faturas enviadas contam como vendas na hora e podem receber pagamentos. Escolha Rascunho se alguém confere cada fatura no Zoho Books primeiro; os pagamentos são registrados depois que ela for enviada de lá.",
+        "invoiceStatusSent": "Enviada",
+        "invoiceStatusDraft": "Rascunho",
+        "invoiceNumbers": "Números das faturas",
+        "invoiceNumbersHelp": "Mantenha o número do Torqvoice para que os dois sistemas batam, ou deixe o Zoho Books numerar a fatura na própria sequência. Com a numeração do Zoho, escolha o número da fatura do Torqvoice como referência para encontrar cada fatura de novo.",
+        "invoiceNumbersTorqvoice": "Usar o número do Torqvoice",
+        "invoiceNumbersZoho": "Deixar o Zoho Books numerar",
+        "reference": "Referência",
+        "referenceHelp": "O que vai no campo Reference# da fatura no Zoho Books, 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": "Ano, marca, modelo, placa e quilometragem, colocados junto com as notas para o cliente.",
+        "includeNotes": "Incluir as notas para o cliente",
+        "includeNotesHelp": "As notas da fatura para o cliente, como texto simples.",
+        "notesPosition": "Onde ficam o veículo e as notas",
+        "notesPositionHelp": "Customer Notes é o campo do próprio Zoho, impresso abaixo dos itens na fatura do Zoho. Acima ou abaixo dos itens os coloca, em vez disso, numa linha de texto entre as linhas da fatura.",
+        "notesPositionCustomerNotes": "O campo Customer Notes do Zoho",
+        "notesPositionBelow": "Uma linha abaixo dos itens",
+        "notesPositionAbove": "Uma linha acima dos itens",
+        "includePartNumbers": "Mostrar números de peça nas linhas de peças",
+        "includePartNumbersHelp": "A descrição de cada linha de peça começa com o número da peça.",
+        "templateId": "Modelo de fatura",
+        "templateIdHelp": "O modelo do Zoho Books que a fatura usa quando vista ou enviada pelo Zoho. Deixe vazio para usar o padrão da organização.",
+        "locationId": "Local",
+        "locationIdHelp": "Só para organizações com locais ativados no Zoho Books, em que toda fatura precisa de um. Caso contrário, deixe vazio.",
+        "laborAccount": "Conta para mão de obra",
+        "laborAccountHelp": "A conta de receita em que as linhas de mão de obra são lançadas. Deixe vazio para usar a conta do item escolhido abaixo, ou a conta Sales do Zoho.",
+        "partsAccount": "Conta para peças",
+        "partsAccountHelp": "A conta de receita em que as linhas de peças são lançadas. Deixe vazio para usar a conta do item escolhido abaixo, ou a conta Sales do Zoho.",
+        "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 Zoho Books marcado em cada linha de mão de obra, para os relatórios de itens no Zoho. A conta escolhida acima tem prioridade.",
+        "partsItem": "Item para linhas de peças",
+        "partsItemHelp": "Opcional. Um item do Zoho Books marcado em cada linha de peça, para os relatórios de itens no Zoho. A conta escolhida acima tem prioridade.",
+        "matchItemsByPartNumber": "Associar peças a itens do Zoho pelo número da peça",
+        "matchItemsByPartNumberHelp": "Uma peça cujo número também é o SKU de um item no Zoho Books é vendida como esse item, então o estoque controlado no Zoho diminui. Para encontrar algo, ative o SKU em Settings, Items no Zoho Books.",
+        "taxId": "Imposto para linhas tributadas",
+        "taxIdHelp": "O Torqvoice escolhe o imposto do Zoho Books cujo percentual bate com cada fatura. Escolha um aqui para quando vários tiverem o mesmo percentual, ou nenhum bater.",
+        "zeroTaxId": "Imposto para linhas sem imposto",
+        "zeroTaxIdHelp": "Usado para linhas sem imposto e para clientes isentos, quando a organização tem um imposto de 0% para eles. Deixe vazio para enviar essas linhas sem imposto.",
+        "roundingAdjustment": "Fazer o total bater no centavo",
+        "roundingAdjustmentHelp": "O Zoho Books calcula o imposto por conta própria. Quando o total dele fica um ou dois centavos diferente da fatura que o cliente recebeu, a diferença entra como ajuste de arredondamento do Zoho, para que os dois sistemas batam. Desligado, o registro avisa quando eles diferem.",
+        "sendFromZoho": "Enviar a fatura por e-mail ao cliente pelo Zoho Books",
+        "sendFromZohoHelp": "O Zoho Books envia a própria cópia da fatura para o e-mail do cliente, com as opções de pagamento online configuradas no Zoho. Desative isto se o Torqvoice já envia a fatura, para o cliente não recebê-la duas vezes.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "attachPdfHelp": "A fatura que o cliente recebeu do Torqvoice é anexada à fatura no Zoho Books e substituída quando muda.",
+        "updateContacts": "Manter os dados dos clientes atualizados no Zoho Books",
+        "updateContactsHelp": "Uma mudança no nome, e-mail, telefone ou endereço de um cliente aqui é copiada para o contato dele no Zoho Books. Desligado, os contatos só são criados e depois ficam como o contador os deixou.",
+        "sameEmail": "Clientes com o mesmo e-mail",
+        "sameEmailHelp": "Vários clientes podem compartilhar um endereço de e-mail, como uma família ou a caixa de contabilidade de uma empresa. Com a primeira opção, um contato do Zoho encontrado pelo e-mail só é usado quando o nome também corresponde ao cliente; caso contrário, o cliente recebe um contato novo e o registro informa o outro, para que você possa mesclá-los no Zoho se forem o mesmo cliente.",
+        "sameEmailCreate": "Criar um contato novo quando o nome for diferente",
+        "sameEmailReuse": "Usar o contato existente, mesmo com nome diferente",
+        "voidOnDelete": "Anular a fatura no Zoho quando o serviço for excluído aqui",
+        "voidOnDeleteHelp": "Um rascunho é excluído e uma fatura enviada é anulada. Uma fatura com pagamentos no Zoho Books nunca é alterada; o registro informa que ela foi deixada como estava.",
+        "pushPayments": "Registrar pagamentos no Zoho Books",
+        "paymentAccount": "Depositar em",
+        "paymentAccountHelp": "A conta do Zoho Books em que entram os pagamentos registrados no Torqvoice. Deixe vazio para usar Undeposited Funds.",
+        "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, Paystack 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 Zoho Books 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 Zoho Books",
+        "pullPaymentsHelp": "Um pagamento registrado no Zoho Books para uma fatura do Torqvoice é registrado aqui na fatura em até 30 minutos, e um excluído lá é removido aqui."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "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."
       }
     },
+    "zoho-books": {
+      "description": "Envie as faturas emitidas, os respetivos clientes e pagamentos para o Zoho Books e receba de volta os pagamentos lá registados.",
+      "tenantHelp": "Crie uma Server-based Application na Zoho API Console (api-console.zoho.com, ou a consola da sua região, como api-console.zoho.eu). Adicione o URI de redirecionamento abaixo, copie o client id e o secret e, no separador Settings do cliente, assinale \"Use the same OAuth credentials for all data centers\" se houver oficinas que iniciam sessão a partir de outras regiões.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "organizationId": "Organização no Zoho Books",
+        "organizationIdHelp": "A organização onde as faturas são lançadas. Aparecem todas as organizações a que o utilizador do Zoho que fez a ligação tem acesso.",
+        "pushInvoices": "Enviar as faturas emitidas para o Zoho Books",
+        "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 Zoho Books, para não lançar faturas antigas duas vezes.",
+        "invoiceStatus": "Estado no Zoho Books",
+        "invoiceStatusHelp": "As faturas enviadas contam de imediato como vendas e podem receber pagamentos. Escolha Rascunho se alguém verifica primeiro cada fatura no Zoho Books; os pagamentos são então registados quando for lá enviada.",
+        "invoiceStatusSent": "Enviada",
+        "invoiceStatusDraft": "Rascunho",
+        "invoiceNumbers": "Números das faturas",
+        "invoiceNumbersHelp": "Mantenha o número do Torqvoice para que os dois sistemas coincidam, ou deixe o Zoho Books numerar a fatura na sua própria sequência. Com a numeração do Zoho, escolha o número da fatura do Torqvoice como referência para voltar a encontrar cada fatura.",
+        "invoiceNumbersTorqvoice": "Usar o número do Torqvoice",
+        "invoiceNumbersZoho": "Deixar o Zoho Books numerar",
+        "reference": "Referência",
+        "referenceHelp": "O que vai no campo Reference# da fatura no Zoho Books, 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": "Ano, marca, modelo, matrícula e quilometragem, colocados junto das notas para o cliente.",
+        "includeNotes": "Incluir as notas para o cliente",
+        "includeNotesHelp": "As notas da fatura para o cliente, em texto simples.",
+        "notesPosition": "Onde ficam a viatura e as notas",
+        "notesPositionHelp": "Customer Notes é o campo do próprio Zoho, impresso abaixo dos itens na fatura do Zoho. Acima ou abaixo dos itens coloca-os antes numa linha de texto entre as linhas da fatura.",
+        "notesPositionCustomerNotes": "O campo Customer Notes do Zoho",
+        "notesPositionBelow": "Uma linha abaixo dos itens",
+        "notesPositionAbove": "Uma linha acima dos itens",
+        "includePartNumbers": "Mostrar a referência da peça nas linhas de peças",
+        "includePartNumbersHelp": "A descrição de cada linha de peça começa pela referência da peça.",
+        "templateId": "Modelo de fatura",
+        "templateIdHelp": "O modelo do Zoho Books que a fatura utiliza quando é vista ou enviada a partir do Zoho. Deixe vazio para usar o predefinido da organização.",
+        "locationId": "Localização",
+        "locationIdHelp": "Só para organizações com as localizações ativadas no Zoho Books, em que cada fatura precisa de uma. Caso contrário, deixe vazio.",
+        "laborAccount": "Conta para mão de obra",
+        "laborAccountHelp": "A conta de rendimentos onde são lançadas as linhas de mão de obra. Deixe vazio para usar a conta do item escolhido abaixo, ou a conta Sales do Zoho.",
+        "partsAccount": "Conta para peças",
+        "partsAccountHelp": "A conta de rendimentos onde são lançadas as linhas de peças. Deixe vazio para usar a conta do item escolhido abaixo, ou a conta Sales do Zoho.",
+        "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 Zoho Books associado a cada linha de mão de obra, para os relatórios de itens no Zoho. A conta escolhida acima tem prioridade.",
+        "partsItem": "Item para linhas de peças",
+        "partsItemHelp": "Opcional. Um item do Zoho Books associado a cada linha de peça, para os relatórios de itens no Zoho. A conta escolhida acima tem prioridade.",
+        "matchItemsByPartNumber": "Associar peças a itens do Zoho pela referência da peça",
+        "matchItemsByPartNumberHelp": "Uma peça cuja referência é também o SKU de um item no Zoho Books é vendida como esse item, pelo que o stock controlado no Zoho diminui. Para encontrar alguma coisa, ative o SKU em Settings, Items no Zoho Books.",
+        "taxId": "Imposto para linhas tributadas",
+        "taxIdHelp": "O Torqvoice escolhe o imposto do Zoho Books cuja percentagem corresponde a cada fatura. Escolha aqui um para quando vários tiverem a mesma percentagem, ou nenhum corresponder.",
+        "zeroTaxId": "Imposto para linhas sem imposto",
+        "zeroTaxIdHelp": "Utilizado para linhas sem imposto e para clientes isentos, quando a organização tem um imposto de 0% para eles. Deixe vazio para enviar essas linhas sem imposto.",
+        "roundingAdjustment": "Acertar o total ao cêntimo",
+        "roundingAdjustmentHelp": "O Zoho Books calcula o imposto por si. Quando o total dele fica a um ou dois cêntimos da fatura que o cliente recebeu, a diferença entra como ajuste de arredondamento do Zoho, para que os dois sistemas coincidam. Se estiver desativado, o registo indica quando diferem.",
+        "sendFromZoho": "Enviar a fatura ao cliente por e-mail a partir do Zoho Books",
+        "sendFromZohoHelp": "O Zoho Books envia a sua própria cópia da fatura para o e-mail do cliente, com as opções de pagamento online configuradas no Zoho. Desative esta opção se o Torqvoice já envia a fatura, para o cliente não a receber duas vezes.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "attachPdfHelp": "A fatura que o cliente recebeu do Torqvoice é anexada à fatura no Zoho Books e substituída quando muda.",
+        "updateContacts": "Manter os dados dos clientes atualizados no Zoho Books",
+        "updateContactsHelp": "Uma alteração ao nome, e-mail, telefone ou morada de um cliente aqui é copiada para o respetivo contacto no Zoho Books. Se estiver desativado, os contactos só são criados e depois ficam como o contabilista os deixou.",
+        "sameEmail": "Clientes com o mesmo e-mail",
+        "sameEmailHelp": "Vários clientes podem partilhar um endereço de e-mail, como uma família ou a caixa de contabilidade de uma empresa. Com a primeira opção, um contacto do Zoho encontrado pelo e-mail só é usado quando o nome também corresponde ao cliente; caso contrário, o cliente recebe um contacto novo e o registo indica o outro, para que os possa juntar no Zoho se forem o mesmo cliente.",
+        "sameEmailCreate": "Criar um contacto novo quando o nome for diferente",
+        "sameEmailReuse": "Usar o contacto existente, mesmo com nome diferente",
+        "voidOnDelete": "Anular a fatura no Zoho quando o serviço for eliminado aqui",
+        "voidOnDeleteHelp": "Um rascunho é eliminado e uma fatura enviada é anulada. Uma fatura com pagamentos no Zoho Books nunca é alterada; o registo indica que foi deixada como estava.",
+        "pushPayments": "Registar pagamentos no Zoho Books",
+        "paymentAccount": "Depositar em",
+        "paymentAccountHelp": "A conta do Zoho Books onde são depositados os pagamentos registados no Torqvoice. Deixe vazio para usar Undeposited Funds.",
+        "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, Paystack 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 Zoho Books 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 Zoho Books",
+        "pullPaymentsHelp": "Um pagamento registado no Zoho Books para uma fatura do Torqvoice é registado aqui na fatura no prazo de 30 minutos, e um pagamento eliminado lá é removido aqui."
+      }
+    },
     "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

@@ -849,6 +849,95 @@
         "pullPaymentsHelp": "Платёж, записанный в Xero по счёту из Torqvoice, записывается здесь на счёт: сразу при наличии вебхука, иначе в течение 30 минут."
       }
     },
+    "zoho-books": {
+      "description": "Отправляйте выставленные счета, их клиентов и платежи в Zoho Books и получайте обратно платежи, записанные там.",
+      "tenantHelp": "Создайте приложение типа Server-based Application в Zoho API Console (api-console.zoho.com или консоль вашего региона, например api-console.zoho.eu). Добавьте URI перенаправления ниже, скопируйте client id и secret, а на вкладке Settings клиента отметьте \"Use the same OAuth credentials for all data centers\", если мастерские входят из других регионов.",
+      "fields": {
+        "clientId": "Идентификатор клиента",
+        "clientSecret": "Секрет клиента"
+      },
+      "settings": {
+        "organizationId": "Организация Zoho Books",
+        "organizationIdHelp": "Организация, в которую записываются счета. В списке все организации, к которым есть доступ у пользователя Zoho, выполнившего подключение.",
+        "pushInvoices": "Отправлять выставленные счета в Zoho Books",
+        "pushOnComplete": "Также отправлять счета, когда заказ отмечен как выполненный",
+        "pushOnCompleteHelp": "Без этого счёт уходит, когда он отправлен, передан по ссылке или оплачен. С этим выполненный заказ уходит сразу и обновляется при изменениях.",
+        "startDate": "Только счета с датой не раньше",
+        "startDateHelp": "Оставьте пустым, чтобы отправлять все выставленные счета. Укажите дату перехода бухгалтерии на Zoho Books, чтобы старые счета не вводились дважды.",
+        "invoiceStatus": "Статус в Zoho Books",
+        "invoiceStatusHelp": "Отправленные счета сразу учитываются как продажи и могут принимать платежи. Выберите «Черновик», если кто-то сначала проверяет каждый счёт в Zoho Books; тогда платежи записываются, когда счёт оттуда отправят.",
+        "invoiceStatusSent": "Отправлен",
+        "invoiceStatusDraft": "Черновик",
+        "invoiceNumbers": "Номера счетов",
+        "invoiceNumbersHelp": "Сохраните номер Torqvoice, чтобы обе системы совпадали, или пусть Zoho Books нумерует счёт в своей последовательности. При нумерации Zoho выберите номер счёта Torqvoice для поля Reference#, чтобы находить каждый счёт.",
+        "invoiceNumbersTorqvoice": "Использовать номер Torqvoice",
+        "invoiceNumbersZoho": "Пусть нумерует Zoho Books",
+        "reference": "Поле Reference#",
+        "referenceHelp": "Что попадает в поле Reference# счёта в Zoho Books. Оно видно на счёте, в списках и в поиске там.",
+        "referenceVehicle": "Автомобиль и номер",
+        "referenceTitle": "Название заказа",
+        "referenceInvoiceNumber": "Номер счёта Torqvoice",
+        "referenceNone": "Ничего",
+        "includeVehicle": "Указывать автомобиль в счёте",
+        "includeVehicleHelp": "Год, марка, модель, номер и пробег, вместе с заметками для клиента.",
+        "includeNotes": "Добавлять заметки для клиента",
+        "includeNotesHelp": "Заметки для клиента из счёта, простым текстом.",
+        "notesPosition": "Где размещать автомобиль и заметки",
+        "notesPositionHelp": "Customer Notes это собственное поле Zoho, которое печатается под позициями в счёте Zoho. Над или под позициями вместо этого помещает их в текстовую строку среди строк счёта.",
+        "notesPositionCustomerNotes": "Поле Customer Notes в Zoho",
+        "notesPositionBelow": "Строка под позициями",
+        "notesPositionAbove": "Строка над позициями",
+        "includePartNumbers": "Показывать артикулы в строках запчастей",
+        "includePartNumbersHelp": "Описание каждой строки запчасти начинается с её артикула.",
+        "templateId": "Шаблон счёта",
+        "templateIdHelp": "Шаблон Zoho Books, который использует счёт при просмотре или отправке из Zoho. Оставьте пустым для шаблона организации по умолчанию.",
+        "locationId": "Локация",
+        "locationIdHelp": "Только для организаций, где в Zoho Books включены локации и каждому счёту она нужна. Иначе оставьте пустым.",
+        "laborAccount": "Счёт доходов для работ",
+        "laborAccountHelp": "Счёт доходов, на который проводятся строки работ. Оставьте пустым, чтобы использовать счёт товара, выбранного ниже, или счёт Sales в Zoho.",
+        "partsAccount": "Счёт доходов для запчастей",
+        "partsAccountHelp": "Счёт доходов, на который проводятся строки запчастей. Оставьте пустым, чтобы использовать счёт товара, выбранного ниже, или счёт Sales в Zoho.",
+        "shopFeeAccount": "Счёт доходов для сбора мастерской",
+        "shopFeeAccountHelp": "Сюда попадает строка сбора мастерской. Оставьте пустым, чтобы проводить его вместе с работами.",
+        "laborItem": "Товар для строк работ",
+        "laborItemHelp": "Необязательно. Товар Zoho Books, которым помечается каждая строка работ, для отчётов по товарам в Zoho. Выбранный выше счёт всё равно имеет приоритет.",
+        "partsItem": "Товар для строк запчастей",
+        "partsItemHelp": "Необязательно. Товар Zoho Books, которым помечается каждая строка запчастей, для отчётов по товарам в Zoho. Выбранный выше счёт всё равно имеет приоритет.",
+        "matchItemsByPartNumber": "Сопоставлять запчасти с товарами Zoho по артикулу",
+        "matchItemsByPartNumberHelp": "Запчасть, артикул которой совпадает с SKU товара в Zoho Books, продаётся как этот товар, и остаток, который ведёт Zoho, уменьшается. Чтобы что-то находилось, включите SKU в разделе Settings, Items в Zoho Books.",
+        "taxId": "Налог для облагаемых строк",
+        "taxIdHelp": "Torqvoice выбирает налог Zoho Books, процент которого соответствует каждому счёту. Выберите налог здесь на случай, если у нескольких одинаковый процент или ни один не подходит.",
+        "zeroTaxId": "Налог для строк без налога",
+        "zeroTaxIdHelp": "Используется для строк без налога и для клиентов, освобождённых от налога, если в организации есть для них налог 0%. Оставьте пустым, чтобы отправлять такие строки без налога.",
+        "roundingAdjustment": "Сводить итог до копейки",
+        "roundingAdjustmentHelp": "Zoho Books сам рассчитывает налог. Если его итог расходится на копейку или две со счётом, который получил клиент, разница вносится как корректировка округления в Zoho, и обе системы совпадают. Выключено: журнал сообщает о расхождении.",
+        "sendFromZoho": "Отправлять клиенту счёт по электронной почте из Zoho Books",
+        "sendFromZohoHelp": "Zoho Books отправляет свою копию счёта на электронную почту клиента со способами онлайн-оплаты, настроенными в Zoho. Выключите это, если счёт уже отправляет Torqvoice, чтобы клиент не получил его дважды.",
+        "attachPdf": "Прикреплять PDF счёта",
+        "attachPdfHelp": "Счёт, который клиент получил от Torqvoice, прикрепляется к счёту в Zoho Books и заменяется при изменении.",
+        "updateContacts": "Обновлять данные клиентов в Zoho Books",
+        "updateContactsHelp": "Изменение имени, электронной почты, телефона или адреса клиента здесь копируется в его контакт в Zoho Books. Выключено: контакты только создаются, а затем остаются такими, какими их ведёт бухгалтер.",
+        "sameEmail": "Клиенты с одинаковым e-mail",
+        "sameEmailHelp": "Несколько клиентов могут использовать один адрес электронной почты, например семья или бухгалтерский ящик компании. При первом варианте контакт в Zoho, найденный по почте, используется, только если его имя тоже совпадает с клиентом; иначе клиенту создаётся новый контакт, а журнал называет другой, чтобы вы могли объединить их в Zoho, если это один и тот же клиент.",
+        "sameEmailCreate": "Создавать новый контакт, если имя отличается",
+        "sameEmailReuse": "Использовать существующий контакт, даже если имя отличается",
+        "voidOnDelete": "Аннулировать счёт в Zoho, когда заказ удалён здесь",
+        "voidOnDeleteHelp": "Черновик удаляется, а отправленный счёт аннулируется. Счёт с платежами в Zoho Books никогда не затрагивается; журнал сообщает, что его оставили без изменений.",
+        "pushPayments": "Записывать платежи в Zoho Books",
+        "paymentAccount": "Зачислять на",
+        "paymentAccountHelp": "Счёт в Zoho Books, на который зачисляются платежи, записанные в Torqvoice. Оставьте пустым, чтобы использовать Undeposited Funds.",
+        "cashAccount": "Зачислять наличные на",
+        "cardAccount": "Зачислять оплату картой на",
+        "transferAccount": "Зачислять банковские переводы на",
+        "onlineAccount": "Зачислять онлайн-платежи на",
+        "methodAccountHelp": "Оставьте пустым, чтобы использовать счёт выше.",
+        "onlineAccountHelp": "Платежи через Stripe, PayPal, Vipps, Paystack и другие платёжные интеграции, часто на транзитный счёт. Оставьте пустым, чтобы использовать счёт выше.",
+        "manualPaidAsPayment": "Записывать платёж, когда счёт отмечен оплаченным вручную",
+        "manualPaidAsPaymentHelp": "Счёт, отмеченный в Torqvoice как оплаченный без записи платежа, получает в Zoho Books один платёж на сумму, которая там ещё числится к оплате. Выключено: он остаётся там открытым, пока не будет записан платёж.",
+        "pullPayments": "Забирать платежи, записанные в Zoho Books",
+        "pullPaymentsHelp": "Платёж, записанный в Zoho Books по счёту из Torqvoice, в течение 30 минут записывается здесь на счёт, а платёж, удалённый там, удаляется здесь."
+      }
+    },
     "fiken": {
       "description": "Отправляйте выставленные счета, их клиентов и платежи в Fiken и получайте обратно платежи, записанные там.",
       "tenantHelp": "Создайте приложение в Fiken в разделе Rediger konto, API для своих компаний. Укажите в приложении URI перенаправления ниже и скопируйте сюда его идентификатор клиента и секрет клиента. Компании нужен модуль API Fiken.",

+ 89 - 0
messages/tr/integrations.json

@@ -849,6 +849,95 @@
         "pullPaymentsHelp": "Xero'da bir Torqvoice faturası için kaydedilen ödeme burada faturaya kaydedilir; webhook ile hemen, aksi halde 30 dakika içinde."
       }
     },
+    "zoho-books": {
+      "description": "Kesilen faturaları, müşterilerini ve ödemelerini Zoho Books'a gönderin ve orada kaydedilen ödemeleri geri alın.",
+      "tenantHelp": "Zoho API Console'da (api-console.zoho.com ya da kendi bölgenizin konsolu, örneğin api-console.zoho.eu) bir Server-based Application oluşturun. Aşağıdaki yönlendirme URI'sini ekleyin, client id ile secret'ı kopyalayın ve atölyeler başka bölgelerden giriş yapıyorsa istemcinin Settings sekmesinde \"Use the same OAuth credentials for all data centers\" seçeneğini işaretleyin.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci gizli anahtarı"
+      },
+      "settings": {
+        "organizationId": "Zoho Books kuruluşu",
+        "organizationIdHelp": "Faturaların yazıldığı kuruluş. Bağlantıyı kuran Zoho kullanıcısının erişebildiği her kuruluş listelenir.",
+        "pushInvoices": "Kesilen faturaları Zoho Books'a 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 Zoho Books'a taşındığı tarihi kullanın.",
+        "invoiceStatus": "Zoho Books'taki durum",
+        "invoiceStatusHelp": "Gönderilmiş faturalar hemen satış sayılır ve ödeme alabilir. Her faturayı önce Zoho Books'ta biri kontrol ediyorsa Taslak seçin; ödemeler o zaman fatura oradan gönderildikten sonra kaydedilir.",
+        "invoiceStatusSent": "Gönderildi",
+        "invoiceStatusDraft": "Taslak",
+        "invoiceNumbers": "Fatura numaraları",
+        "invoiceNumbersHelp": "İki sistemin uyuşması için Torqvoice numarasını koruyun ya da Zoho Books'un faturayı kendi sırasıyla numaralandırmasına izin verin. Zoho numaralandırmasında her faturayı yeniden bulmak için referans olarak Torqvoice fatura numarasını seçin.",
+        "invoiceNumbersTorqvoice": "Torqvoice numarasını kullan",
+        "invoiceNumbersZoho": "Zoho Books numaralandırsın",
+        "reference": "Referans",
+        "referenceHelp": "Faturanın Zoho Books'taki 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": "Yıl, marka, model, plaka ve kilometre; müşteri notlarıyla birlikte yer alır.",
+        "includeNotes": "Müşteri notlarını ekle",
+        "includeNotesHelp": "Faturanın müşteriye yönelik notları, düz metin olarak.",
+        "notesPosition": "Araç ve notların yeri",
+        "notesPositionHelp": "Customer Notes, Zoho'nun kendi alanıdır ve Zoho faturasında kalemlerin altına basılır. Kalemlerin üzerinde veya altında seçenekleri ise bunları fatura satırları arasında bir metin satırına koyar.",
+        "notesPositionCustomerNotes": "Zoho'nun Customer Notes alanı",
+        "notesPositionBelow": "Kalemlerin altında bir satır",
+        "notesPositionAbove": "Kalemlerin üzerinde bir satır",
+        "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.",
+        "templateId": "Fatura şablonu",
+        "templateIdHelp": "Fatura Zoho'da görüntülendiğinde veya oradan gönderildiğinde kullanılan Zoho Books şablonu. Kuruluşun varsayılanı için boş bırakın.",
+        "locationId": "Konum",
+        "locationIdHelp": "Yalnızca Zoho Books'ta konumları açık olan ve her faturanın bir konum gerektirdiği kuruluşlar için. Aksi halde boş bırakın.",
+        "laborAccount": "İşçilik hesabı",
+        "laborAccountHelp": "İşçilik satırlarının kaydedildiği gelir hesabı. Aşağıda seçilen kalemin hesabını veya Zoho'nun Sales hesabını kullanmak için boş bırakın.",
+        "partsAccount": "Parça hesabı",
+        "partsAccountHelp": "Parça satırlarının kaydedildiği gelir hesabı. Aşağıda seçilen kalemin hesabını veya Zoho'nun Sales 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ı. Zoho'daki kalem raporları için her işçilik satırına eklenen bir Zoho Books kalemi. Yukarıda seçilen hesap yine önceliklidir.",
+        "partsItem": "Parça satırları için kalem",
+        "partsItemHelp": "İsteğe bağlı. Zoho'daki kalem raporları için her parça satırına eklenen bir Zoho Books kalemi. Yukarıda seçilen hesap yine önceliklidir.",
+        "matchItemsByPartNumber": "Parçaları parça numarasına göre Zoho kalemleriyle eşleştir",
+        "matchItemsByPartNumberHelp": "Numarası Zoho Books'taki bir kalemin SKU'suyla aynı olan parça o kalem olarak satılır; böylece Zoho'da takip edilen stok düşer. Bir şey bulunabilmesi için Zoho Books'ta Settings, Items altında SKU'ları açın.",
+        "taxId": "Vergili satırlar için vergi",
+        "taxIdHelp": "Torqvoice, yüzdesi her faturayla eşleşen Zoho Books vergisini seçer. Birden fazla verginin yüzdesi aynıysa ya da hiçbiri eşleşmiyorsa kullanılacak olanı burada seçin.",
+        "zeroTaxId": "Vergisiz satırlar için vergi",
+        "zeroTaxIdHelp": "Kuruluşta bunlar için %0 bir vergi varsa, vergisiz satırlar ve vergiden muaf müşteriler için kullanılır. Bu satırları vergisiz göndermek için boş bırakın.",
+        "roundingAdjustment": "Toplamı kuruşu kuruşuna eşitle",
+        "roundingAdjustmentHelp": "Zoho Books vergiyi kendisi hesaplar. Toplamı müşterinin aldığı faturadan bir iki kuruş farklı çıkarsa fark Zoho'nun yuvarlama düzeltmesi olarak girilir ve iki sistem uyuşur. Kapalıysa farklılık olduğunda günlük bunu bildirir.",
+        "sendFromZoho": "Faturayı müşteriye Zoho Books'tan e-postayla gönder",
+        "sendFromZohoHelp": "Zoho Books, faturanın kendi kopyasını Zoho'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.",
+        "attachPdf": "Fatura PDF'ini ekle",
+        "attachPdfHelp": "Müşterinin Torqvoice'tan aldığı fatura, Zoho Books'taki faturaya eklenir ve değiştiğinde yenisiyle değiştirilir.",
+        "updateContacts": "Müşteri bilgilerini Zoho Books'ta güncel tut",
+        "updateContactsHelp": "Burada bir müşterinin adında, e-postasında, telefonunda veya adresinde yapılan değişiklik Zoho Books'taki kişisine kopyalanır. Kapalıysa kişiler yalnızca oluşturulur, sonra muhasebecinin bıraktığı gibi kalır.",
+        "sameEmail": "Aynı e-postaya sahip müşteriler",
+        "sameEmailHelp": "Birden fazla müşteri aynı e-posta adresini paylaşabilir, örneğin bir aile veya bir şirketin muhasebe kutusu. İlk seçenekte e-postayla bulunan bir Zoho kişisi yalnızca adı da müşteriyle eşleştiğinde kullanılır; aksi halde müşteri için yeni bir kişi oluşturulur ve günlük diğerini belirtir, böylece aynı müşteriyse ikisini Zoho'da birleştirebilirsiniz.",
+        "sameEmailCreate": "Ad farklıysa yeni kişi oluştur",
+        "sameEmailReuse": "Ad farklı olsa da mevcut kişiyi kullan",
+        "voidOnDelete": "İş burada silindiğinde Zoho faturasını iptal et",
+        "voidOnDeleteHelp": "Taslak silinir, gönderilmiş fatura iptal edilir. Zoho Books'ta ödemesi olan bir faturaya asla dokunulmaz; günlük, faturanın olduğu gibi bırakıldığını bildirir.",
+        "pushPayments": "Ödemeleri Zoho Books'a kaydet",
+        "paymentAccount": "Şuraya yatır",
+        "paymentAccountHelp": "Torqvoice'ta kaydedilen ödemelerin Zoho Books'ta yatırıldığı hesap. Undeposited Funds 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, Paystack 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, Zoho Books'ta 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": "Zoho Books'ta kaydedilen ödemeleri geri al",
+        "pullPaymentsHelp": "Zoho Books'ta bir Torqvoice faturası için kaydedilen ödeme 30 dakika içinde burada faturaya kaydedilir, orada silinen bir ödeme de buradan kaldırılır."
+      }
+    },
     "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.",

+ 1 - 0
public/images/integrations/zoho-books.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Zoho Books"><title>Zoho Books</title><rect width="48" height="48" rx="11" fill="#21263C"/><g transform="translate(9.5 9.5) scale(0.0566)" fill="#FFFFFF"><path d="M361 502.5H24.5c-8 0-14.4-6.5-14.4-14.4V365.8c0-26.7 17-50.5 42.2-59.2l373-128c29-10 48.6-37.3 48.6-68 0-39.6-32.3-71.9-71.9-71.9H39v238.1c0 8-6.5 14.4-14.4 14.4S10 284.9 10 276.9V24.3c0-8 6.5-14.4 14.4-14.4h377.5c55.6 0 100.8 45.2 100.8 100.8 0 43-27.3 81.3-68.1 95.3l-373 128a33.72 33.72 0 00-22.7 31.9v107.8H361c30.1 0 58.5-11.7 79.8-33a112 112 0 0033-79.8c0-21.3-6-42.1-17.4-60.1a113.42 113.42 0 00-46.1-41.3 14.41 14.41 0 01-6.7-19.3c3.5-7.2 12.1-10.2 19.3-6.7 23.8 11.5 43.8 29.5 58 51.9 14.3 22.6 21.8 48.8 21.8 75.6 0 37.8-14.7 73.4-41.5 100.2A141.24 141.24 0 01361 502.5z"/><path d="M172.4 211.9c-31.8 0-57.7-25.9-57.7-57.7s25.9-57.7 57.7-57.7 57.7 25.9 57.7 57.7-25.9 57.7-57.7 57.7zm0-86.6c-15.9 0-28.9 13-28.9 28.9s13 28.9 28.9 28.9 28.9-13 28.9-28.9-13-28.9-28.9-28.9zm144.7 290.4c-31.8 0-57.7-25.9-57.7-57.7s25.9-57.7 57.7-57.7 57.7 25.9 57.7 57.7-25.8 57.7-57.7 57.7zm0-86.6c-15.9 0-28.9 13-28.9 28.9s13 28.9 28.9 28.9 28.9-13 28.9-28.9-12.9-28.9-28.9-28.9z"/></g></svg>

+ 561 - 0
src/__tests__/features/integrations/zoho-books-server.test.ts

@@ -0,0 +1,561 @@
+import { 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'
+
+/**
+ * The connector against a scripted Zoho Books: every call it makes is
+ * recorded and answered from here, so the paths and bodies that would reach
+ * Zoho are asserted without an organisation. The answers copy what the
+ * live organisation sent on 14 Nov 2026.
+ */
+
+const loadInvoice = vi.fn<(orgId: string, id: string) => Promise<AccountingInvoice | null>>()
+const loadPayment = vi.fn<(orgId: string, id: string) => Promise<AccountingPayment | 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',
+  loadInvoiceForAccounting: (orgId: string, id: string) => loadInvoice(orgId, id),
+  loadPaymentForAccounting: (orgId: string, id: string) => loadPayment(orgId, id),
+  loadCustomerForAccounting: async () => null,
+  loadInvoicePdfForAccounting: async () => ({
+    buffer: new Uint8Array([37, 80, 68, 70]),
+    filename: 'x.pdf',
+  }),
+  recordPulledPayment: (...args: unknown[]) => recordPulledPayment(...args),
+  removePulledPayment: (...args: unknown[]) => removePulledPayment(...args),
+  workshopCurrency: async () => 'NOK',
+  workshopDistanceUnit: async () => 'km',
+  workshopVehicleLabel: async () => 'Vehicle',
+}))
+
+const { connector } = await import('@/integrations/zoho-books/server')
+
+interface Call {
+  method: string
+  path: string
+  query: Record<string, string>
+  body: Record<string, unknown> | null
+}
+
+type Answer = (call: Call) => unknown
+
+const BASE = '/books/v3'
+
+function zohoError(status: number, code: number, message: string): ConnectorHttpError {
+  return new ConnectorHttpError(
+    status,
+    JSON.stringify({ code, message }),
+    'https://www.zohoapis.eu/books/v3/x'
+  )
+}
+
+function makeCtx(input: { settings?: Record<string, unknown>; answer: Answer }) {
+  const calls: Call[] = []
+  const uploads: { url: string; method: 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: {
+      organizationId: '201',
+      fetchedAt: now,
+      name: 'Torqvoice',
+      currencyCode: 'NOK',
+      locationsEnabled: false,
+    },
+    catalog: {
+      organizationId: '201',
+      fetchedAt: now,
+      taxes: [
+        { id: 'tax25', name: 'MVA 25%', percent: 25 },
+        { id: 'tax15', name: 'MVA 15%', percent: 15 },
+      ],
+      skus: {},
+      currencies: { NOK: 'cur-nok' },
+    },
+    walkIn: null,
+    lastPull: null,
+    deposit: { organizationId: '201', fetchedAt: now, accountId: 'acc-undeposited' },
+  }
+  const key = (t: string, e: string) => `${t}:${e}`
+  const ctx: ConnectorContext = {
+    connection: {
+      id: 'conn1',
+      organizationId: 'org1',
+      connectorId: 'zoho-books',
+      settings: { organizationId: '201', ...input.settings },
+      state,
+      externalAccountId: 'zoho-abc',
+    },
+    credentials: { accessToken: 'tok', refreshToken: 'ref', apiDomain: 'https://www.zohoapis.eu' },
+    http: {
+      async fetch(url: string, init?: RequestInit) {
+        uploads.push({ url, method: init?.method ?? 'GET' })
+        return new Response(
+          JSON.stringify({
+            code: 0,
+            documents: [{ document_id: 'doc-new', file_name: '2026-1054.pdf' }],
+          }),
+          { 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.replace(BASE, ''),
+          query: Object.fromEntries(u.searchParams.entries()),
+          body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
+        }
+        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: 'Europe/Oslo',
+    appUrl: 'https://shop.example.com',
+  }
+  return { ctx, calls, uploads, links, logs, state }
+}
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Kari Nordmann',
+  email: 'kari@example.com',
+  phone: null,
+  address: null,
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  customerNumber: null,
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  title: 'Oil service',
+  invoiceNumber: '2026-1054',
+  status: 'completed',
+  issuedAt: new Date('2026-11-04T09:00:00Z'),
+  invoiceDate: new Date('2026-11-04T09:00:00Z'),
+  serviceDate: new Date('2026-11-03T09:00:00Z'),
+  dueDate: new Date('2026-11-18T09:00:00Z'),
+  mileage: null,
+  notes: null,
+  subtotal: 1000,
+  taxableSubtotal: null,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxAmount: 250,
+  taxInclusive: false,
+  totalAmount: 1250,
+  manuallyPaid: false,
+  customer,
+  vehicle: null,
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Service',
+      partNumber: null,
+      quantity: 1,
+      unitPrice: 1000,
+      total: 1000,
+      taxable: true,
+    },
+  ],
+  payments: [],
+}
+
+const cashPayment: AccountingPayment = {
+  id: 'pay1',
+  serviceRecordId: 'svc1',
+  amount: 1250,
+  date: new Date('2026-11-05T09:00:00Z'),
+  method: 'cash',
+  note: null,
+  provider: null,
+  externalId: null,
+}
+
+interface Remote {
+  status: string
+  total: number
+  balance: number
+  adjustment?: number
+}
+
+/** A Zoho that holds one invoice and answers the calls this connector makes. */
+function zoho(remote: Remote, overrides: Partial<Record<string, Answer>> = {}): Answer {
+  return (call) => {
+    const id = `${call.method} ${call.path}`
+    for (const [pattern, answer] of Object.entries(overrides)) {
+      if (new RegExp(`^${pattern}$`).test(id) && answer) return answer(call)
+    }
+    if (id === 'GET /contacts') return { code: 0, contacts: [] }
+    if (id === 'POST /contacts') return { code: 0, contact: { contact_id: 'zc1' } }
+    if (id === 'GET /invoices') return { code: 0, invoices: [] }
+    if (id === 'POST /invoices' || id === 'PUT /invoices/zi1') {
+      return {
+        code: 0,
+        invoice: {
+          invoice_id: 'zi1',
+          invoice_number: '2026-1054',
+          status: remote.status,
+          total: remote.total,
+          balance: remote.balance,
+          adjustment: remote.adjustment ?? 0,
+          line_items: [{ line_item_id: 'li1' }],
+        },
+      }
+    }
+    if (id === 'GET /invoices/zi1') {
+      return {
+        code: 0,
+        invoice: {
+          invoice_id: 'zi1',
+          invoice_number: '2026-1054',
+          customer_id: 'zc1',
+          status: remote.status,
+          total: remote.total,
+          balance: remote.balance,
+          adjustment: remote.adjustment ?? 0,
+          line_items: [{ line_item_id: 'li1' }],
+        },
+      }
+    }
+    if (id === 'POST /invoices/zi1/status/sent') {
+      remote.status = 'sent'
+      return { code: 0 }
+    }
+    if (id === 'GET /invoices/zi1/payments') return { code: 0, payments: [] }
+    if (id === 'POST /customerpayments') return { code: 0, payment: { payment_id: 'zp1' } }
+    throw new Error(`unexpected ${id}`)
+  }
+}
+
+beforeEach(() => {
+  loadInvoice.mockReset()
+  loadPayment.mockReset()
+  recordPulledPayment.mockReset()
+  removePulledPayment.mockReset()
+})
+
+async function run(ctx: ConnectorContext, job: string, entityId?: string): Promise<JobOutcome> {
+  return (await connector.jobs[job](ctx, entityId ? { entityId } : {})) ?? {}
+}
+
+describe('sending an invoice', () => {
+  it('creates the contact and the invoice under the Torqvoice number, marks it sent and attaches the PDF', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'draft', total: 1250, balance: 1250 }
+    const { ctx, calls, uploads } = makeCtx({ answer: zoho(remote) })
+    const out = await run(ctx, 'accounting.invoice', 'svc1')
+    expect(out.summary).toBe('invoice 2026-1054 created')
+    const create = calls.find((c) => c.method === 'POST' && c.path === '/invoices')
+    expect(create?.query).toMatchObject({
+      organization_id: '201',
+      ignore_auto_number_generation: 'true',
+    })
+    expect(create?.body).toMatchObject({
+      customer_id: 'zc1',
+      invoice_number: '2026-1054',
+      notes: '',
+      adjustment: 0,
+      line_items: [
+        { description: 'Service', rate: 1000, quantity: 1, tax_id: 'tax25', discount: 0 },
+      ],
+    })
+    expect(create?.body).not.toHaveProperty('reason')
+    expect(calls.some((c) => c.path === '/invoices/zi1/status/sent')).toBe(true)
+    expect(uploads.map((u) => u.url)).toEqual([
+      expect.stringContaining('/books/v3/invoices/zi1/attachment?organization_id=201'),
+    ])
+  })
+
+  it('closes a cent of Zoho’s tax rounding with its rounding adjustment, keeping the line ids', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'draft', total: 1249.99, balance: 1249.99 }
+    const { ctx, calls } = makeCtx({
+      settings: { attachPdf: false },
+      answer: zoho(remote, {
+        'PUT /invoices/zi1': (call) => {
+          remote.total = 1249.99 + Number(call.body?.adjustment ?? 0)
+          return {
+            code: 0,
+            invoice: {
+              invoice_id: 'zi1',
+              status: 'draft',
+              total: remote.total,
+              balance: remote.total,
+              line_items: [{ line_item_id: 'li1' }],
+            },
+          }
+        },
+      }),
+    })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    const fix = calls.find((c) => c.method === 'PUT' && c.path === '/invoices/zi1')
+    expect(fix?.body).toMatchObject({
+      adjustment: 0.01,
+      adjustment_description: 'Rounding',
+      line_items: [{ line_item_id: 'li1' }],
+    })
+    expect(remote.total).toBe(1250)
+  })
+
+  it('leaves a bigger gap alone and says so, rather than hiding a real difference', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'sent', total: 1240, balance: 1240 }
+    const { ctx, calls, logs } = makeCtx({ settings: { attachPdf: false }, answer: zoho(remote) })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(0)
+    expect(logs.some((l) => /Zoho Books total 1240 differs from 1250/.test(l.message))).toBe(true)
+  })
+
+  it('sends nothing when nothing changed, and gives a reason when a sent invoice changes', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'draft', total: 1250, balance: 1250 }
+    const { ctx, calls } = makeCtx({ settings: { attachPdf: false }, answer: zoho(remote) })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    calls.length = 0
+    expect((await run(ctx, 'accounting.invoice', 'svc1')).summary).toBe(
+      'invoice 2026-1054 unchanged'
+    )
+    expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0)
+
+    loadInvoice.mockResolvedValue({ ...invoice, title: 'Oil and filters', notes: 'Changed <here>' })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    const update = calls.find((c) => c.method === 'PUT')
+    expect(update?.body).toMatchObject({ reason: 'Changed in Torqvoice', notes: 'Changed ‹here›' })
+  })
+
+  it('keeps a voided invoice out, since Zoho keeps its number, and sends it again once renumbered', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'draft', total: 1250, balance: 1250 }
+    const { ctx, logs } = makeCtx({ settings: { attachPdf: false }, answer: zoho(remote) })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    remote.status = 'void'
+    loadInvoice.mockResolvedValue({ ...invoice, notes: 'edited' })
+    expect((await run(ctx, 'accounting.invoice', 'svc1')).summary).toContain('voided in Zoho Books')
+    expect(logs.some((l) => l.level === 'warn' && /voided in Zoho Books/.test(l.message))).toBe(
+      true
+    )
+    expect((await run(ctx, 'accounting.invoice', 'svc1')).summary).toContain('not sent again')
+  })
+
+  it('says plainly when the organisation has used its calls for the day', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const { ctx } = makeCtx({
+      answer: zoho(
+        { status: 'draft', total: 1250, balance: 1250 },
+        {
+          'GET /contacts': () => {
+            throw zohoError(429, 45, 'You have exceeded the maximum call rate limit of 1000.')
+          },
+        }
+      ),
+    })
+    await expect(run(ctx, 'accounting.invoice', 'svc1')).rejects.toThrow(
+      /used its API calls for today/
+    )
+  })
+})
+
+describe('deleting a job here', () => {
+  async function linked(status: string, balance = 1250) {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'draft', total: 1250, balance: 1250 }
+    const made = makeCtx({
+      settings: { attachPdf: false, invoiceStatus: 'draft' },
+      answer: zoho(remote, {
+        'DELETE /invoices/zi1': () => ({ code: 0 }),
+        'POST /invoices/zi1/status/void': () => ({ code: 0 }),
+      }),
+    })
+    await run(made.ctx, 'accounting.invoice', 'svc1')
+    remote.status = status
+    remote.balance = balance
+    loadInvoice.mockResolvedValue(null)
+    made.calls.length = 0
+    return made
+  }
+
+  it('deletes a draft, which frees its number', async () => {
+    const { ctx, calls } = await linked('draft')
+    expect((await run(ctx, 'accounting.invoice', 'svc1')).summary).toContain(
+      'deleted in Zoho Books'
+    )
+    expect(calls.some((c) => c.method === 'DELETE' && c.path === '/invoices/zi1')).toBe(true)
+  })
+
+  it('voids a sent one', async () => {
+    const { ctx, calls } = await linked('sent')
+    expect((await run(ctx, 'accounting.invoice', 'svc1')).summary).toContain('voided in Zoho Books')
+    expect(calls.some((c) => c.path === '/invoices/zi1/status/void')).toBe(true)
+  })
+
+  it('never touches one with payments in Zoho', async () => {
+    const { ctx, calls, logs } = await linked('partially_paid', 250)
+    expect((await run(ctx, 'accounting.invoice', 'svc1')).summary).toBe(
+      'left in Zoho Books, has payments'
+    )
+    expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0)
+    expect(logs.some((l) => /has payments in Zoho Books/.test(l.message))).toBe(true)
+  })
+})
+
+describe('payments', () => {
+  it('records a payment into the account for its method, trimmed to the balance when a few øre over', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [{ ...cashPayment, amount: 1250.03 }] })
+    const remote: Remote = { status: 'draft', total: 1250, balance: 1250 }
+    const { ctx, calls } = makeCtx({
+      settings: { attachPdf: false, cashAccount: 'acc-cash' },
+      answer: zoho(remote),
+    })
+    const out = await run(ctx, 'accounting.invoice', 'svc1')
+    expect(out.summary).toContain('1 payments recorded')
+    expect(calls.find((c) => c.path === '/customerpayments')?.body).toMatchObject({
+      customer_id: 'zc1',
+      payment_mode: 'cash',
+      amount: 1250,
+      account_id: 'acc-cash',
+      invoices: [{ invoice_id: 'zi1', amount_applied: 1250 }],
+    })
+  })
+
+  it('waits while the invoice is a draft in Zoho, and goes once it is sent', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [cashPayment] })
+    loadPayment.mockResolvedValue(cashPayment)
+    const remote: Remote = { status: 'draft', total: 1250, balance: 1250 }
+    const { ctx, calls } = makeCtx({
+      settings: { attachPdf: false, invoiceStatus: 'draft' },
+      answer: zoho(remote),
+    })
+    const waiting = await run(ctx, 'accounting.payment', 'pay1')
+    expect(waiting).toMatchObject({ rescheduleInSeconds: 3600 })
+    expect(calls.some((c) => c.path === '/customerpayments')).toBe(false)
+    remote.status = 'sent'
+    expect((await run(ctx, 'accounting.payment', 'pay1')).summary).toBe('payment recorded')
+  })
+
+  it('brings back a payment recorded in Zoho, and removes one deleted there', async () => {
+    loadInvoice.mockResolvedValue(invoice)
+    const remote: Remote = { status: 'sent', total: 1250, balance: 1250 }
+    let theirs = [
+      { payment_id: 'zp9', amount: 500, date: '2026-11-10', reference_number: 'BANK-1' },
+    ]
+    const { ctx } = makeCtx({
+      settings: { attachPdf: false },
+      answer: zoho(remote, {
+        'GET /invoices': (call) =>
+          call.query.last_modified_time
+            ? {
+                code: 0,
+                invoices: [{ invoice_id: 'zi1', status: 'partially_paid', customer_id: 'zc1' }],
+              }
+            : { code: 0, invoices: [] },
+        'GET /invoices/zi1/payments': () => ({ code: 0, payments: theirs }),
+      }),
+    })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    recordPulledPayment.mockResolvedValue({ id: 'local9', created: true })
+    expect((await run(ctx, 'accounting.pull')).summary).toBe('1 payments recorded')
+    expect(recordPulledPayment).toHaveBeenCalledWith(
+      'org1',
+      expect.objectContaining({
+        serviceRecordId: 'svc1',
+        amount: 500,
+        provider: 'zoho-books',
+        externalId: 'zp9',
+        note: 'Recorded in Zoho Books. Ref BANK-1',
+      })
+    )
+
+    theirs = []
+    loadInvoice.mockResolvedValue({
+      ...invoice,
+      payments: [
+        { ...cashPayment, id: 'local9', amount: 500, provider: 'zoho-books', externalId: 'zp9' },
+      ],
+    })
+    removePulledPayment.mockImplementation(async () => {
+      // Gone here too, as the real removal deletes the row.
+      loadInvoice.mockResolvedValue(invoice)
+      return true
+    })
+    expect((await run(ctx, 'accounting.pull')).summary).toBe('1 payments removed')
+    expect(removePulledPayment).toHaveBeenCalledWith('org1', 'local9', 'zoho-books')
+  })
+
+  it('keeps a payment from here that the bookkeeper removed in Zoho, and never sends it again', async () => {
+    const sent = { ...cashPayment, amount: 500 }
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [sent] })
+    const remote: Remote = { status: 'sent', total: 1250, balance: 1250 }
+    const { ctx, calls, logs } = makeCtx({
+      settings: { attachPdf: false },
+      answer: zoho(remote, {
+        'GET /invoices': (call) =>
+          call.query.last_modified_time
+            ? { code: 0, invoices: [{ invoice_id: 'zi1', status: 'sent', customer_id: 'zc1' }] }
+            : { code: 0, invoices: [] },
+      }),
+    })
+    await run(ctx, 'accounting.invoice', 'svc1')
+    expect(calls.filter((c) => c.path === '/customerpayments')).toHaveLength(1)
+    // Removed in Zoho: its payments list is empty from now on.
+    await run(ctx, 'accounting.pull')
+    await run(ctx, 'accounting.pull')
+    await run(ctx, 'accounting.invoice', 'svc1')
+    expect(calls.filter((c) => c.path === '/customerpayments')).toHaveLength(1)
+    expect(removePulledPayment).not.toHaveBeenCalled()
+    expect(logs.filter((l) => /was removed in Zoho Books/.test(l.message))).toHaveLength(1)
+  })
+})

+ 526 - 0
src/__tests__/features/integrations/zoho-books.test.ts

@@ -0,0 +1,526 @@
+import { describe, expect, it } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+} from '@/features/integrations/Lib/accounting-sync'
+import { tokenUrlFor } from '@/features/integrations/Lib/oauth'
+import { manifest } from '@/integrations/zoho-books/manifest'
+import {
+  type InvoiceOptions,
+  apiBase,
+  buildContact,
+  buildInvoice,
+  buildPayment,
+  contactName,
+  errorParts,
+  exactCents,
+  invoiceUrl,
+  modifiedSince,
+  paymentMode,
+  referenceFor,
+  sameName,
+  webBase,
+  zohoText,
+} from '@/integrations/zoho-books/mapping'
+
+/**
+ * What a Torqvoice invoice looks like when it reaches Zoho Books. Field
+ * names are Zoho's own (Books API v3); the rules about text, omitted fields
+ * and lines were learned from a live organisation on 14 Nov 2026, so a
+ * wrong one here is a refused invoice there.
+ */
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Kari Nordmann',
+  email: 'kari@example.com',
+  phone: '+47 912 34 567',
+  address: 'Storgata 1\n0150 Oslo',
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  title: 'Oil service',
+  invoiceNumber: '2026-1054',
+  status: 'completed',
+  issuedAt: new Date('2026-11-04T09:00:00Z'),
+  invoiceDate: new Date('2026-11-04T09:00:00Z'),
+  serviceDate: new Date('2026-11-03T09:00:00Z'),
+  dueDate: new Date('2026-11-18T09:00:00Z'),
+  mileage: 206382,
+  notes: 'Brake pads at <3 mm, replace soon & check discs 🔧',
+  subtotal: 4045,
+  taxableSubtotal: null,
+  discountType: 'percentage',
+  discountValue: 10,
+  discountAmount: 404.5,
+  taxRate: 25,
+  taxAmount: 910.13,
+  taxInclusive: false,
+  totalAmount: 4550.63,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2019, make: 'BMW', model: '5-Serie', licensePlate: 'BT90387' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Oil change',
+      partNumber: null,
+      quantity: 1,
+      unitPrice: 350,
+      total: 350,
+      taxable: true,
+    },
+    {
+      kind: 'part',
+      description: 'Castrol Edge',
+      partNumber: 'CEL-5W30',
+      quantity: 7,
+      unitPrice: 317,
+      total: 2219,
+      taxable: true,
+    },
+    {
+      kind: 'part',
+      description: 'Oil filter',
+      partNumber: 'OX<371>',
+      quantity: 1,
+      unitPrice: 275,
+      total: 275,
+      taxable: true,
+    },
+    {
+      kind: 'part',
+      description: 'Diesel filter',
+      partNumber: null,
+      quantity: 1,
+      unitPrice: 706,
+      total: 706,
+      taxable: true,
+    },
+    {
+      kind: 'part',
+      description: 'Air filter',
+      partNumber: null,
+      quantity: 1,
+      unitPrice: 495,
+      total: 495,
+      taxable: true,
+    },
+  ],
+  payments: [],
+}
+
+function options(overrides: Partial<InvoiceOptions> = {}): InvoiceOptions {
+  return {
+    customerId: 'zc1',
+    invoiceNumber: '2026-1054',
+    reference: 'BT90387 BMW 5-Serie',
+    dueDate: '2026-11-18',
+    timezone: 'Europe/Oslo',
+    includeVehicle: true,
+    vehicleLabel: 'Vehicle',
+    includeNotes: true,
+    notesPosition: 'customerNotes',
+    includePartNumbers: true,
+    distanceUnit: 'km',
+    templateId: null,
+    locationId: null,
+    currencyId: null,
+    accounts: { labor: 'acc-labour', parts: 'acc-parts', shopFee: null },
+    items: { labor: null, parts: null },
+    partItems: new Map(),
+    taxId: 'tax25',
+    zeroTaxId: '',
+    taxExempt: false,
+    lineIds: [],
+    adjustment: 0,
+    ...overrides,
+  }
+}
+
+type Line = Record<string, unknown> & { rate: number; quantity: number; discount: number }
+
+function linesOf(body: Record<string, unknown>): Line[] {
+  return body.line_items as Line[]
+}
+
+/** What Zoho computes from the body: line totals after discount, then tax per rate, rounded once. */
+function zohoTotal(body: Record<string, unknown>, rate: number): number {
+  const lines = linesOf(body)
+  const net = lines.reduce((a, l) => a + Math.round((l.rate * l.quantity - l.discount) * 100), 0)
+  const taxed = lines
+    .filter((l) => l.tax_id)
+    .reduce((a, l) => a + Math.round((l.rate * l.quantity - l.discount) * 100), 0)
+  const tax = Math.round((taxed * rate) / 100)
+  return (net + tax) / 100
+}
+
+describe('text Zoho Books accepts', () => {
+  it('turns angle brackets into lookalikes, since Zoho refuses a whole request over one', () => {
+    expect(zohoText('Torque 5<10 Nm, a > b', 100)).toBe('Torque 5‹10 Nm, a › b')
+  })
+
+  it('drops emoji, which Zoho would store as question marks, and keeps letters like æøå', () => {
+    expect(zohoText('Check discs 🔧 on the Ærø ferry 👍🏽', 100)).toBe(
+      'Check discs  on the Ærø ferry'
+    )
+  })
+
+  it('keeps ampersands, quotes and line breaks, which Zoho takes as they are', () => {
+    expect(zohoText('a & b\n"50" it\'s', 100)).toBe('a & b\n"50" it\'s')
+  })
+
+  it('cuts to the field length', () => {
+    expect(zohoText('x'.repeat(3000), 2000)).toHaveLength(2000)
+  })
+})
+
+describe('invoices', () => {
+  it('adds up to the printed total, with the discount on the lines and the tax left to Zoho', () => {
+    const body = buildInvoice(invoice, options())
+    expect(zohoTotal(body, 25)).toBe(4550.63)
+    expect(linesOf(body).reduce((a, l) => a + l.discount, 0)).toBeCloseTo(404.5, 2)
+    expect(body.discount_type).toBe('item_level')
+    expect(body.is_discount_before_tax).toBe(true)
+  })
+
+  it('always sends notes, adjustment and every line discount, since Zoho keeps old values for fields left out', () => {
+    const body = buildInvoice(
+      { ...invoice, notes: null, discountAmount: 0, taxAmount: 1011.25, totalAmount: 5056.25 },
+      options({ includeVehicle: false })
+    )
+    expect(body).toHaveProperty('notes', '')
+    expect(body).toHaveProperty('adjustment', 0)
+    expect(body).toHaveProperty('adjustment_description', '')
+    for (const l of linesOf(body)) expect(l).toHaveProperty('discount', 0)
+  })
+
+  it('puts the vehicle and the plain-text notes in Zoho’s customer notes, made safe', () => {
+    const body = buildInvoice(invoice, options())
+    expect(body.notes).toBe(
+      'Vehicle: 2019 BMW 5-Serie, BT90387, 206,382 km\n\nBrake pads at ‹3 mm, replace soon & check discs'
+    )
+    expect(String(body.notes)).not.toMatch(/[<>]/)
+  })
+
+  it('puts them in a text line below or above the charges when asked, and leaves the notes field empty', () => {
+    const below = buildInvoice(invoice, options({ notesPosition: 'below' }))
+    expect(below.notes).toBe('')
+    const last = linesOf(below).at(-1)
+    expect(last).toMatchObject({ rate: 0, quantity: 1, tax_id: '' })
+    expect(String(last?.description)).toContain('Vehicle: 2019 BMW')
+    const above = buildInvoice(invoice, options({ notesPosition: 'above' }))
+    expect(String(linesOf(above)[0].description)).toContain('Brake pads at ‹3 mm')
+  })
+
+  it('writes part numbers safely in front of the part, and books each kind of line to its account', () => {
+    const lines = linesOf(buildInvoice(invoice, options()))
+    expect(lines[0]).toMatchObject({ description: 'Oil change', account_id: 'acc-labour' })
+    expect(lines[1]).toMatchObject({
+      description: 'CEL-5W30 Castrol Edge',
+      account_id: 'acc-parts',
+    })
+    expect(lines[2].description).toBe('OX‹371› Oil filter')
+  })
+
+  it('leaves out the vehicle, notes and part numbers when switched off', () => {
+    const body = buildInvoice(
+      invoice,
+      options({ includeVehicle: false, includeNotes: false, includePartNumbers: false })
+    )
+    expect(body.notes).toBe('')
+    expect(linesOf(body)[1].description).toBe('Castrol Edge')
+  })
+
+  it('sends the Torqvoice number, or none so Zoho numbers it', () => {
+    expect(buildInvoice(invoice, options()).invoice_number).toBe('2026-1054')
+    expect(buildInvoice(invoice, options({ invoiceNumber: null }))).not.toHaveProperty(
+      'invoice_number'
+    )
+  })
+
+  it('books untaxed lines without tax, or under the chosen 0% tax, and a tax-exempt customer the same', () => {
+    const mixed: AccountingInvoice = {
+      ...invoice,
+      discountAmount: 0,
+      taxableSubtotal: 3690,
+      taxAmount: 922.5,
+      totalAmount: 4967.5,
+      lines: invoice.lines.map((l, i) => (i === 0 ? { ...l, taxable: false } : l)),
+    }
+    expect(linesOf(buildInvoice(mixed, options()))[0].tax_id).toBe('')
+    expect(linesOf(buildInvoice(mixed, options({ zeroTaxId: 'tax0' })))[0].tax_id).toBe('tax0')
+    const exempt = linesOf(buildInvoice(mixed, options({ taxExempt: true, zeroTaxId: 'tax0' })))
+    expect(exempt.every((l) => l.tax_id === 'tax0')).toBe(true)
+  })
+
+  it('sends a line whose price has more than two places as one unit at its amount, keeping the figures in the text', () => {
+    const odd: AccountingInvoice = {
+      ...invoice,
+      discountAmount: 0,
+      subtotal: 50,
+      taxAmount: 12.5,
+      totalAmount: 62.5,
+      lines: [
+        {
+          kind: 'labor',
+          description: 'Diagnosis',
+          partNumber: null,
+          quantity: 1.5,
+          unitPrice: 33.3333,
+          total: 50,
+          taxable: true,
+        },
+      ],
+    }
+    const [line] = linesOf(
+      buildInvoice(odd, options({ includeVehicle: false, includeNotes: false }))
+    )
+    expect(line).toMatchObject({ quantity: 1, rate: 50, description: 'Diagnosis (1.5 × 33.3333)' })
+  })
+
+  it('keeps the quantity of a line sold as a Zoho item, so the right count leaves the shelf', () => {
+    // 2.5 of a SKU priced 19.99 is 49.975: no exact two-place rate gives it.
+    const stock: AccountingInvoice = {
+      ...invoice,
+      discountAmount: 0,
+      subtotal: 49.98,
+      taxAmount: 12.5,
+      totalAmount: 62.48,
+      lines: [
+        {
+          kind: 'part',
+          description: 'Wiper blade',
+          partNumber: 'WB-19',
+          quantity: 2.5,
+          unitPrice: 19.99,
+          total: 49.975,
+          taxable: true,
+        },
+      ],
+    }
+    const [line] = linesOf(
+      buildInvoice(
+        stock,
+        options({
+          includeVehicle: false,
+          includeNotes: false,
+          partItems: new Map([['WB-19', 'item-wb']]),
+        })
+      )
+    )
+    expect(line).toMatchObject({
+      item_id: 'item-wb',
+      quantity: 2.5,
+      rate: 19.99,
+      description: 'WB-19 Wiper blade',
+    })
+  })
+
+  it('writes the vehicle label in the workshop’s own language', () => {
+    const body = buildInvoice(invoice, options({ vehicleLabel: 'Kjøretøy' }))
+    expect(String(body.notes)).toMatch(/^Kjøretøy: 2019 BMW 5-Serie/)
+  })
+
+  it('keeps line ids by position so an edit changes lines instead of replacing them', () => {
+    const lines = linesOf(buildInvoice(invoice, options({ lineIds: ['l1', 'l2'] })))
+    expect(lines[0].line_item_id).toBe('l1')
+    expect(lines[1].line_item_id).toBe('l2')
+    expect(lines[2]).not.toHaveProperty('line_item_id')
+  })
+
+  it('sends a tax-inclusive invoice’s discount net of tax, since Zoho takes it off before tax', () => {
+    // XT-1018 from the walk: prices with 25% tax, 100 off, 1449.70 to pay.
+    const inclusive: AccountingInvoice = {
+      ...invoice,
+      taxInclusive: true,
+      subtotal: 1549.7,
+      discountType: 'fixed',
+      discountValue: 100,
+      discountAmount: 100,
+      taxAmount: 289.94,
+      totalAmount: 1449.7,
+      lines: [
+        {
+          kind: 'labor',
+          description: 'Wheel change',
+          partNumber: null,
+          quantity: 1,
+          unitPrice: 1250,
+          total: 1250,
+          taxable: true,
+        },
+        {
+          kind: 'part',
+          description: 'Valve',
+          partNumber: null,
+          quantity: 3,
+          unitPrice: 99.9,
+          total: 299.7,
+          taxable: true,
+        },
+      ],
+    }
+    const body = buildInvoice(inclusive, options({ includeVehicle: false, includeNotes: false }))
+    const lines = linesOf(body)
+    expect(lines.reduce((a, l) => a + l.discount, 0)).toBeCloseTo(80, 2)
+    // Zoho: net = gross / 1.25 less the discount, tax 25% of that, rounded once.
+    const net = lines.reduce(
+      (a, l) => a + Math.round(((l.rate * l.quantity) / 1.25 - l.discount) * 100),
+      0
+    )
+    const total = (net + Math.round(net * 0.25)) / 100
+    expect(total).toBeCloseTo(1449.7, 1)
+  })
+
+  it('marks a tax-inclusive invoice as such only when it carries tax', () => {
+    expect(buildInvoice({ ...invoice, taxInclusive: true }, options()).is_inclusive_tax).toBe(true)
+    expect(
+      buildInvoice({ ...invoice, taxInclusive: true }, options({ taxId: null })).is_inclusive_tax
+    ).toBe(false)
+  })
+
+  it('finds a cent-exact quantity and rate, and says when there is none', () => {
+    expect(exactCents(7, 317)).toBe(221900)
+    expect(exactCents(1.5, 33.33)).toBeNull()
+    expect(exactCents(1, 512.373)).toBeNull()
+  })
+})
+
+describe('contacts', () => {
+  it('names the primary person with email and phone, and keeps that person on an update', () => {
+    const body = buildContact(customer, contactName(customer), 'cp1')
+    expect(body).toMatchObject({
+      contact_name: 'Kari Nordmann',
+      company_name: '',
+      contact_type: 'customer',
+      customer_sub_type: 'individual',
+      billing_address: { address: 'Storgata 1', street2: '0150 Oslo' },
+    })
+    expect((body.contact_persons as Record<string, unknown>[])[0]).toEqual({
+      contact_person_id: 'cp1',
+      first_name: 'Kari',
+      last_name: 'Nordmann',
+      email: 'kari@example.com',
+      phone: '+47 912 34 567',
+      is_primary_contact: true,
+    })
+  })
+
+  it('bills a business in the company name', () => {
+    const business = { ...customer, company: 'Nordmann Transport AS' }
+    expect(buildContact(business, contactName(business))).toMatchObject({
+      contact_name: 'Nordmann Transport AS',
+      company_name: 'Nordmann Transport AS',
+      customer_sub_type: 'business',
+    })
+  })
+
+  it('tells customers apart by name, so a shared email alone is not enough', () => {
+    expect(sameName(customer, { contact_name: 'kari nordmann' })).toBe(true)
+    expect(
+      sameName(customer, { contact_name: 'X', first_name: 'Kari', last_name: 'Nordmann' })
+    ).toBe(true)
+    expect(sameName(customer, { contact_name: 'Ola Nordmann' })).toBe(false)
+  })
+})
+
+describe('payments and references', () => {
+  it('records a payment against the invoice into the chosen account, by Zoho’s payment mode', () => {
+    const body = buildPayment(
+      {
+        id: 'p1',
+        serviceRecordId: 'svc1',
+        amount: 1000,
+        date: new Date('2026-11-14T23:30:00Z'),
+        method: 'card',
+        note: 'Terminal <2>',
+        provider: null,
+        externalId: null,
+      },
+      {
+        customerId: 'zc1',
+        invoiceId: 'zi1',
+        accountId: 'acc-bank',
+        timezone: 'Europe/Oslo',
+        amount: 1000,
+      }
+    )
+    expect(body).toEqual({
+      customer_id: 'zc1',
+      payment_mode: 'creditcard',
+      amount: 1000,
+      // 23:30 UTC is already the next day in Oslo.
+      date: '2026-11-15',
+      reference_number: 'Torqvoice card',
+      description: 'Terminal ‹2›',
+      account_id: 'acc-bank',
+      invoices: [{ invoice_id: 'zi1', amount_applied: 1000 }],
+    })
+    expect(paymentMode({ method: 'cash', provider: null })).toBe('cash')
+    expect(paymentMode({ method: 'transfer', provider: null })).toBe('banktransfer')
+    expect(paymentMode({ method: 'stripe', provider: 'stripe' })).toBe('others')
+  })
+
+  it('fills the reference from the workshop’s choice', () => {
+    expect(referenceFor(invoice, 'vehicle')).toBe('BT90387 BMW 5-Serie')
+    expect(referenceFor(invoice, 'title')).toBe('Oil service')
+    expect(referenceFor(invoice, 'invoiceNumber')).toBe('2026-1054')
+    expect(referenceFor(invoice, 'none')).toBe('')
+  })
+})
+
+describe('regions and hosts', () => {
+  it('calls the API host Zoho named for the account’s region, and no other', () => {
+    expect(apiBase('https://www.zohoapis.eu')).toBe('https://www.zohoapis.eu/books/v3')
+    expect(apiBase('https://www.zohoapis.com.au')).toBe('https://www.zohoapis.com.au/books/v3')
+    expect(apiBase(undefined)).toBe('https://www.zohoapis.com/books/v3')
+    expect(() => apiBase('https://evil.example')).toThrow(/not Zoho/)
+    expect(() => apiBase('http://www.zohoapis.eu')).toThrow(/not Zoho/)
+  })
+
+  it('links into the Books web app of the same region', () => {
+    expect(webBase('https://www.zohoapis.eu')).toBe('https://books.zoho.eu')
+    expect(webBase('https://www.zohoapis.ca')).toBe('https://books.zohocloud.ca')
+    expect(invoiceUrl('https://www.zohoapis.eu', '201', 'i9')).toBe(
+      'https://books.zoho.eu/app/201#/invoices/i9'
+    )
+  })
+
+  it('sends the code exchange and refreshes to the region’s sign-in server, and refuses any other', () => {
+    if (manifest.auth.type !== 'oauth2') throw new Error('oauth2 expected')
+    const spec = manifest.auth
+    expect(tokenUrlFor(spec, { 'accounts-server': 'https://accounts.zoho.eu' })).toBe(
+      'https://accounts.zoho.eu/oauth/v2/token'
+    )
+    expect(tokenUrlFor(spec, { 'accounts-server': 'https://accounts.zohocloud.ca' })).toBe(
+      'https://accounts.zohocloud.ca/oauth/v2/token'
+    )
+    expect(tokenUrlFor(spec, {})).toBe('https://accounts.zoho.com/oauth/v2/token')
+    expect(() => tokenUrlFor(spec, { 'accounts-server': 'https://attacker.example' })).toThrow(
+      /not one of the provider/
+    )
+    expect(() => tokenUrlFor(spec, { 'accounts-server': 'http://accounts.zoho.eu' })).toThrow()
+  })
+
+  it('asks for comma-joined scopes and a refresh token every time', () => {
+    if (manifest.auth.type !== 'oauth2') throw new Error('oauth2 expected')
+    expect(manifest.auth.scopeSeparator).toBe(',')
+    expect(manifest.auth.authorizeParams).toEqual({ access_type: 'offline', prompt: 'consent' })
+    expect(manifest.auth.authScheme).toBe('Zoho-oauthtoken')
+  })
+
+  it('reads Zoho’s error code and sentence, and writes its modified-since time', () => {
+    expect(errorParts('{"code":1001,"message":"Invoice 2026-1054 already exists"}')).toEqual({
+      code: 1001,
+      message: 'Invoice 2026-1054 already exists',
+    })
+    expect(modifiedSince(new Date('2026-11-14T19:05:06.789Z'))).toBe('2026-11-14T19:05:06+0000')
+  })
+})

+ 2 - 1
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -1417,7 +1417,8 @@ function SettingFieldList({
                 }
                 onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
               >
-                <SelectTrigger className="h-8">
+                {/* The column is fixed; a long choice is cut short rather than pushing past the card. */}
+                <SelectTrigger className="h-8 w-full min-w-0 *:data-[slot=select-value]:truncate">
                   <SelectValue placeholder={t('connection.choose')} />
                 </SelectTrigger>
                 <SelectContent>

+ 14 - 12
src/app/api/integrations/[connector]/oauth/callback/route.ts

@@ -58,6 +58,19 @@ export async function GET(
   const client = resolveClient(spec, previous as unknown as Record<string, unknown>)
   if (!client) return fail('no-client')
 
+  // What the vendor tells us only here, such as which company was chosen,
+  // goes into the sealed credentials, next to the tokens, so identify and
+  // every job after it can read it and it never sits in the database in the
+  // clear: Intuit's rules put the realm id under the same encryption as the
+  // refresh token. A token refresh spreads the previous credentials, so it
+  // survives those. Zoho names its regional sign-in server here, which the
+  // code exchange itself must go to.
+  const extra: Record<string, string> = {}
+  for (const name of spec.callbackParams ?? []) {
+    const value = params.get(name)
+    if (value) extra[name] = value
+  }
+
   let credentials: OAuthCredentials
   try {
     credentials = await exchangeCode({
@@ -68,6 +81,7 @@ export async function GET(
       codeVerifier: previous.codeVerifier,
       state,
       previous,
+      callbackExtras: extra,
     })
   } catch (err) {
     // The vendor's own words, such as invalid_client for a wrong secret, are
@@ -87,18 +101,6 @@ export async function GET(
     return fail('exchange')
   }
 
-  // What the vendor tells us only here, such as which company was chosen,
-  // goes into the sealed credentials, next to the tokens, so identify and
-  // every job after it can read it and it never sits in the database in the
-  // clear: Intuit's rules put the realm id under the same encryption as the
-  // refresh token. A token refresh spreads the previous credentials, so it
-  // survives those.
-  const extra: Record<string, string> = {}
-  for (const name of spec.callbackParams ?? []) {
-    const value = params.get(name)
-    if (value) extra[name] = value
-  }
-
   await db.integrationConnection.update({
     where: { id: connection.id },
     data: {

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

@@ -9,6 +9,7 @@
  */
 
 import { db } from '@/lib/db'
+import { organizationTranslator } from '@/i18n/organization-translator'
 import type { Prisma } from '@/generated/prisma/client'
 import { effectiveInvoiceDate } from '@/lib/invoice-utils'
 import { richTextToPlain } from '@/lib/rich-text-plain'
@@ -304,6 +305,20 @@ export async function workshopCurrency(organizationId: string): Promise<string |
   return code && /^[A-Z]{3}$/.test(code) ? code : null
 }
 
+/**
+ * The word in front of the vehicle on a ledger's invoice, in the language
+ * the workshop's documents print in: "Vehicle", or "Vessel" for a workshop
+ * that fixes boats. The same words the invoice PDF uses.
+ */
+export async function workshopVehicleLabel(organizationId: string): Promise<string> {
+  const row = await db.appSetting.findFirst({
+    where: { organizationId, key: SETTING_KEYS.SERVICE_TYPE },
+    select: { value: true },
+  })
+  const t = await organizationTranslator(organizationId, 'pdf')
+  return t(row?.value === 'marine' ? 'invoice.vehicleMarine' : 'invoice.vehicle')
+}
+
 /** 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({

+ 1 - 0
src/features/integrations/Lib/connections.ts

@@ -128,6 +128,7 @@ export async function loadConnection(
     writeLog(connectionId, level, message, details, options.jobId)
 
   const http = createConnectorHttp({
+    shouldRetry: server.shouldRetry?.bind(server),
     connectionId,
     credentials,
     auth: oauthSpec(manifest) ? { oauth: oauthSpec(manifest) ?? undefined } : {},

+ 16 - 1
src/features/integrations/Lib/http.ts

@@ -87,6 +87,8 @@ export function createConnectorHttp(input: {
   credentials: Record<string, unknown>
   auth: HttpAuth
   log: (level: LogLevel, message: string, details?: Record<string, unknown>) => Promise<void>
+  /** The connector's say on whether a 429 or 5xx is worth retrying; see ConnectorServer.shouldRetry. */
+  shouldRetry?: (status: number, body: string) => boolean
 }): ConnectorHttp {
   let credentials = input.credentials
 
@@ -101,7 +103,10 @@ export function createConnectorHttp(input: {
         const fresh = await refreshUnderLock(input.connectionId, input.auth.oauth)
         credentials = fresh as unknown as Record<string, unknown>
       }
-      return { Authorization: `Bearer ${(credentials as unknown as OAuthCredentials).accessToken}` }
+      const scheme = input.auth.oauth.authScheme ?? 'Bearer'
+      return {
+        Authorization: `${scheme} ${(credentials as unknown as OAuthCredentials).accessToken}`,
+      }
     }
     if (input.auth.headers) return input.auth.headers(credentials)
     const token = typeof credentials.accessToken === 'string' ? credentials.accessToken : ''
@@ -132,9 +137,19 @@ export function createConnectorHttp(input: {
       }
       if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
         last = res
+        // The vendor's own words say which limit it was (per minute, per
+        // day, concurrent calls), which decides whether waiting helps.
+        const said = await res
+          .clone()
+          .text()
+          .catch(() => '')
+        if (input.shouldRetry && !input.shouldRetry(res.status, said)) return res
         await input.log('warn', `Retrying ${res.status} from ${new URL(url).host}`, {
           status: res.status,
           attempt: attempt + 1,
+          method: init?.method ?? 'GET',
+          path: new URL(url).pathname,
+          ...(said && { response: said.slice(0, 300) }),
         })
         await sleep(retryDelayMs(attempt, res))
         continue

+ 57 - 14
src/features/integrations/Lib/oauth.ts

@@ -77,7 +77,8 @@ export function buildAuthorizeUrl(input: {
   url.searchParams.set('client_id', input.client.clientId)
   url.searchParams.set('redirect_uri', input.redirectUri)
   // A vendor without scopes, such as Fiken, gets no empty scope parameter.
-  if (input.spec.scopes.length > 0) url.searchParams.set('scope', input.spec.scopes.join(' '))
+  if (input.spec.scopes.length > 0)
+    url.searchParams.set('scope', input.spec.scopes.join(input.spec.scopeSeparator ?? ' '))
   url.searchParams.set('state', input.state)
   for (const [k, v] of Object.entries(input.spec.authorizeParams ?? {})) url.searchParams.set(k, v)
   if (input.codeVerifier) {
@@ -93,14 +94,41 @@ interface TokenResponse {
   expires_in?: number | string
   token_type?: string
   scope?: string
+  /** Zoho: the API host for the account's region, such as https://www.zohoapis.eu. */
+  api_domain?: string
   error?: string
   error_description?: string
 }
 
+/**
+ * Where token requests go for this connection: the regional server the
+ * callback named when the vendor has one per region and the host is one it
+ * owns, otherwise the manifest's token URL. An unknown host is refused
+ * rather than skipped, so a tampered callback cannot send the client
+ * secret anywhere, and cannot quietly fall back to the wrong region either.
+ */
+export function tokenUrlFor(spec: OAuth2Spec, credentials: Record<string, unknown> = {}): string {
+  const regional = spec.regionalTokenServer
+  if (!regional) return spec.tokenUrl
+  const named = credentials[regional.param]
+  if (typeof named !== 'string' || !named) return spec.tokenUrl
+  let url: URL
+  try {
+    url = new URL(named)
+  } catch {
+    throw new Error(`The sign-in server ${named} is not a valid address`)
+  }
+  if (url.protocol !== 'https:' || !regional.allowedHosts.includes(url.hostname)) {
+    throw new Error(`The sign-in server ${url.hostname} is not one of the provider's own`)
+  }
+  return `https://${url.hostname}${regional.path}`
+}
+
 async function tokenRequest(
   spec: OAuth2Spec,
   client: OAuthClient,
-  params: Record<string, string>
+  params: Record<string, string>,
+  tokenUrl: string = spec.tokenUrl
 ): Promise<TokenResponse> {
   const headers: Record<string, string> = {
     'Content-Type': 'application/x-www-form-urlencoded',
@@ -114,7 +142,7 @@ async function tokenRequest(
     form.client_id = client.clientId
     form.client_secret = client.clientSecret
   }
-  const res = await fetch(spec.tokenUrl, {
+  const res = await fetch(tokenUrl, {
     method: 'POST',
     headers,
     body: new URLSearchParams(form).toString(),
@@ -143,6 +171,9 @@ function toCredentials(body: TokenResponse, previous: OAuthCredentials): OAuthCr
       Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : undefined,
     tokenType: body.token_type ?? previous.tokenType,
     scope: body.scope ?? previous.scope,
+    ...((body.api_domain ?? previous.apiDomain) && {
+      apiDomain: body.api_domain ?? previous.apiDomain,
+    }),
   }
 }
 
@@ -155,14 +186,21 @@ export async function exchangeCode(input: {
   /** The state the callback carried, for vendors that want it again here. */
   state?: string
   previous?: OAuthCredentials
+  /** The extra parameters the callback carried (callbackParams), which may name the token server. */
+  callbackExtras?: Record<string, string>
 }): Promise<OAuthCredentials> {
-  const body = await tokenRequest(input.spec, input.client, {
-    grant_type: 'authorization_code',
-    code: input.code,
-    redirect_uri: input.redirectUri,
-    ...(input.codeVerifier && { code_verifier: input.codeVerifier }),
-    ...(input.spec.stateOnExchange && input.state && { state: input.state }),
-  })
+  const body = await tokenRequest(
+    input.spec,
+    input.client,
+    {
+      grant_type: 'authorization_code',
+      code: input.code,
+      redirect_uri: input.redirectUri,
+      ...(input.codeVerifier && { code_verifier: input.codeVerifier }),
+      ...(input.spec.stateOnExchange && input.state && { state: input.state }),
+    },
+    tokenUrlFor(input.spec, input.callbackExtras)
+  )
   // A new consent starts its own tokens: a refresh token kept from the one
   // it replaces may belong to another app or account, so it is not carried.
   // The PKCE verifier is spent here and only here; a refresh while a
@@ -183,10 +221,15 @@ export async function refreshToken(input: {
 }): Promise<OAuthCredentials> {
   if (!input.credentials.refreshToken)
     throw new Error('No refresh token; reconnect the integration')
-  const body = await tokenRequest(input.spec, input.client, {
-    grant_type: 'refresh_token',
-    refresh_token: input.credentials.refreshToken,
-  })
+  const body = await tokenRequest(
+    input.spec,
+    input.client,
+    {
+      grant_type: 'refresh_token',
+      refresh_token: input.credentials.refreshToken,
+    },
+    tokenUrlFor(input.spec, input.credentials as unknown as Record<string, unknown>)
+  )
   return toCredentials(body, input.credentials)
 }
 

+ 30 - 0
src/features/integrations/Lib/types.ts

@@ -113,6 +113,8 @@ export type AuthSpec =
       authorizeUrl: string
       tokenUrl: string
       scopes: string[]
+      /** How scopes are joined on the authorize request: a space by the standard, a comma for Zoho. */
+      scopeSeparator?: string
       /** Extra query parameters on the authorize request, such as access_type=offline. */
       authorizeParams?: Record<string, string>
       pkce?: boolean
@@ -134,6 +136,25 @@ export type AuthSpec =
        * the authorize request; Fiken lists it as required on both.
        */
       stateOnExchange?: boolean
+      /**
+       * The word before the access token in the Authorization header. Bearer
+       * unless the vendor wants its own: Zoho reads `Zoho-oauthtoken`.
+       */
+      authScheme?: string
+      /**
+       * A vendor that runs a sign-in server per region (Zoho) names the
+       * workshop's own on the callback. The token exchange and every refresh
+       * go there instead of `tokenUrl`, but only to a host on `allowedHosts`:
+       * the parameter comes back through the browser, and the request
+       * carries the app's client secret.
+       */
+      regionalTokenServer?: {
+        /** The callback parameter that names the server, such as `accounts-server`. Must be listed in callbackParams. */
+        param: string
+        /** Path of the token endpoint on that server, such as `/oauth/v2/token`. */
+        path: string
+        allowedHosts: string[]
+      }
       /** Environment variable names holding the platform-owned app's client id and secret. */
       platformEnv?: { clientId: string; clientSecret: string }
       /** Fields a workshop fills in to use its own app. */
@@ -969,6 +990,13 @@ export interface ConnectorServer {
   identify?(ctx: ConnectorContext): Promise<{ id: string; name: string }>
   /** Cheap round trip that proves the credentials still work. */
   test(ctx: ConnectorContext): Promise<{ ok: boolean; message?: string }>
+  /**
+   * Whether a 429 or 5xx is worth retrying, from the vendor's answer. The
+   * client retries them by default; a vendor that says the caller is
+   * blocked for a while or out of calls for the day (Zoho) is only held
+   * longer by retries, so the call fails at once with the vendor's words.
+   */
+  shouldRetry?(status: number, body: string): boolean
   /**
    * Send a real message to the signed-in user, for vendors where a key check
    * is not proof that mail or texts actually go out. Throws with the vendor's
@@ -1193,6 +1221,8 @@ export interface OAuthCredentials {
   expiresAt?: number
   tokenType?: string
   scope?: string
+  /** The API host the vendor named for this account, when it has one per region (Zoho). */
+  apiDomain?: string
   /** Present for tenant-owned apps. */
   clientId?: string
   clientSecret?: string

+ 2 - 0
src/integrations/registry.ts

@@ -27,6 +27,7 @@ import { manifest as paystack } from './paystack/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 zohoBooks } from './zoho-books/manifest'
 import { manifest as rdw } from './rdw/manifest'
 import { manifest as regcheck } from './regcheck/manifest'
 import { manifest as resend } from './resend/manifest'
@@ -85,6 +86,7 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: paystack, load: () => import('./paystack/server') },
   { manifest: quickbooks, load: () => import('./quickbooks/server') },
   { manifest: xero, load: () => import('./xero/server') },
+  { manifest: zohoBooks, load: () => import('./zoho-books/server') },
   { manifest: fiken, load: () => import('./fiken/server') },
   { manifest: s3, load: () => import('./s3/server') },
   { manifest: localFolder, load: () => import('./local-folder/server') },

+ 388 - 0
src/integrations/zoho-books/manifest.ts

@@ -0,0 +1,388 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * Zoho's sign-in servers, one per region. The callback names the workshop's
+ * own and the token exchange and refreshes must go there; only these hosts
+ * are trusted with the client secret. From Zoho's serverinfo endpoint.
+ */
+export const ZOHO_ACCOUNTS_HOSTS = [
+  'accounts.zoho.com',
+  'accounts.zoho.eu',
+  'accounts.zoho.in',
+  'accounts.zoho.com.au',
+  'accounts.zoho.jp',
+  'accounts.zohocloud.ca',
+  'accounts.zoho.sa',
+  'accounts.zoho.uk',
+  'accounts.zoho.ae',
+  'accounts.zoho.sg',
+  'accounts.zohohq.in',
+  'accounts.zoho.com.cn',
+]
+
+/**
+ * Zoho Books: issued invoices, their customers and their payments go to the
+ * books, and payments recorded in Zoho come back.
+ *
+ * Zoho runs a data centre per region, and an account lives in one. Sign-in
+ * starts at the US server, Zoho sends the workshop on to its own, and the
+ * callback names that server (`accounts-server`); the token answer names the
+ * API host (`api_domain`). One Zoho user can hold several organisations, so
+ * which one to write to is the first setting. Everything else a bookkeeper
+ * decides lives in the settings: status, numbering, reference, template and
+ * location, the account, item and tax for each kind of line, who sends the
+ * invoice, contact updates, deletes, and the account each kind of payment
+ * lands in.
+ *
+ * Zoho joins scopes with commas. `access_type=offline` with `prompt=consent`
+ * is what yields a refresh token on every connect, not only the first.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'zoho-books',
+  name: 'Zoho Books',
+  category: 'accounting',
+  countries: 'global',
+  logo: '/images/integrations/zoho-books.svg',
+  docs: '/docs/integrations/zoho-books',
+  auth: {
+    type: 'oauth2',
+    authorizeUrl: 'https://accounts.zoho.com/oauth/v2/auth',
+    tokenUrl: 'https://accounts.zoho.com/oauth/v2/token',
+    scopes: [
+      'ZohoBooks.contacts.ALL',
+      'ZohoBooks.invoices.ALL',
+      'ZohoBooks.customerpayments.ALL',
+      'ZohoBooks.settings.READ',
+      'ZohoBooks.accountants.READ',
+    ],
+    scopeSeparator: ',',
+    authorizeParams: { access_type: 'offline', prompt: 'consent' },
+    authScheme: 'Zoho-oauthtoken',
+    callbackParams: ['accounts-server', 'location'],
+    regionalTokenServer: {
+      param: 'accounts-server',
+      path: '/oauth/v2/token',
+      allowedHosts: ZOHO_ACCOUNTS_HOSTS,
+    },
+    platformEnv: {
+      clientId: 'ZOHO_BOOKS_INTEGRATION_CLIENT_ID',
+      clientSecret: 'ZOHO_BOOKS_INTEGRATION_CLIENT_SECRET',
+    },
+    tenantFields: [
+      { key: 'clientId', label: 'clientId', type: 'text', required: true },
+      { key: 'clientSecret', label: 'clientSecret', type: 'password', required: true },
+    ],
+    tenantHelp: 'tenantHelp',
+  },
+  capabilities: ['accounting.invoices', 'accounting.customers', 'accounting.payments'],
+  settings: [
+    {
+      key: 'organizationId',
+      type: 'remote-select',
+      label: 'organizationId',
+      help: 'organizationIdHelp',
+      source: 'organizations',
+      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: 'sent',
+      options: [
+        { value: 'sent', label: 'invoiceStatusSent' },
+        { 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: 'zoho', label: 'invoiceNumbersZoho' },
+      ],
+      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: 'notesPosition',
+      type: 'select',
+      label: 'notesPosition',
+      help: 'notesPositionHelp',
+      default: 'customerNotes',
+      options: [
+        { value: 'customerNotes', label: 'notesPositionCustomerNotes' },
+        { value: 'below', label: 'notesPositionBelow' },
+        { value: 'above', label: 'notesPositionAbove' },
+      ],
+      // It places the vehicle too, so it stays visible with the notes off.
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'includePartNumbers',
+      type: 'boolean',
+      label: 'includePartNumbers',
+      help: 'includePartNumbersHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'templateId',
+      type: 'remote-select',
+      label: 'templateId',
+      help: 'templateIdHelp',
+      source: 'templates',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'locationId',
+      type: 'remote-select',
+      label: 'locationId',
+      help: 'locationIdHelp',
+      source: 'locations',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- accounts and items ---------- */
+    {
+      key: 'laborAccount',
+      type: 'remote-select',
+      label: 'laborAccount',
+      help: 'laborAccountHelp',
+      source: 'incomeAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'partsAccount',
+      type: 'remote-select',
+      label: 'partsAccount',
+      help: 'partsAccountHelp',
+      source: 'incomeAccounts',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'shopFeeAccount',
+      type: 'remote-select',
+      label: 'shopFeeAccount',
+      help: 'shopFeeAccountHelp',
+      source: 'incomeAccounts',
+      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: 'taxId',
+      type: 'remote-select',
+      label: 'taxId',
+      help: 'taxIdHelp',
+      source: 'taxes',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'zeroTaxId',
+      type: 'remote-select',
+      label: 'zeroTaxId',
+      help: 'zeroTaxIdHelp',
+      source: 'zeroTaxes',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'roundingAdjustment',
+      type: 'boolean',
+      label: 'roundingAdjustment',
+      help: 'roundingAdjustmentHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+
+    /* ---------- delivery ---------- */
+    {
+      key: 'sendFromZoho',
+      type: 'boolean',
+      label: 'sendFromZoho',
+      help: 'sendFromZohoHelp',
+      default: false,
+      showWhen: { key: 'invoiceStatus', equals: 'sent' },
+    },
+    {
+      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: 'sameEmail',
+      type: 'select',
+      label: 'sameEmail',
+      help: 'sameEmailHelp',
+      default: 'create',
+      options: [
+        { value: 'create', label: 'sameEmailCreate' },
+        { value: 'reuse', label: 'sameEmailReuse' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'voidOnDelete',
+      type: 'boolean',
+      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 }],
+}

+ 549 - 0
src/integrations/zoho-books/mapping.ts

@@ -0,0 +1,549 @@
+/**
+ * Torqvoice records in the shapes the Zoho Books API takes, and Zoho's
+ * answers read back. Pure functions only: the server decides what to send
+ * and when, this decides what it looks like.
+ *
+ * Learned from the organisation this was built against (14 Nov 2026), not
+ * from the reference alone:
+ * - Zoho refuses a whole request when any text field holds `<` or `>`
+ *   ("Invalid value passed for Notes"), even "5<10 Nm". Every text goes
+ *   through zohoText, which swaps them for ‹ ›.
+ * - Characters outside the Basic Multilingual Plane (emoji) come back as
+ *   "?", so they are left out.
+ * - A field left out of an update keeps its old value (a line's discount,
+ *   the adjustment, the notes), and a new invoice without notes gets Zoho's
+ *   "Thanks for your business." So every field is always sent.
+ * - Lines need no item and take an income account of their own.
+ */
+
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingLine,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import { roundAsPrinted } from '@/lib/money'
+import { zonedDayKey } from '@/lib/timezone'
+import { distribute, normalName, vehicleDescription } from '../xero/mapping'
+
+export { distribute, normalName, vehicleDescription }
+
+/** The contact a counter sale with no customer is billed to. */
+export const WALK_IN_NAME = 'Walk-in customer'
+/** Said to Zoho when an invoice it already marked sent changes; Zoho requires a reason. */
+export const UPDATE_REASON = 'Changed in Torqvoice'
+export const ROUNDING_LABEL = 'Rounding'
+
+/* ---------- Zoho's shapes ---------- */
+
+export interface ZohoOrganization {
+  organization_id: string
+  name?: string
+  currency_id?: string
+  currency_code?: string
+  country_code?: string
+  is_default_org?: boolean
+  is_location_enabled?: boolean
+  price_precision?: number
+  is_org_active?: boolean
+  plan_name?: string
+}
+
+export interface ZohoContactPerson {
+  contact_person_id?: string
+  first_name?: string
+  last_name?: string
+  email?: string
+  is_primary_contact?: boolean
+}
+
+export interface ZohoContact {
+  contact_id: string
+  contact_name?: string
+  company_name?: string
+  first_name?: string
+  last_name?: string
+  email?: string
+  status?: string
+  contact_type?: string
+  contact_persons?: ZohoContactPerson[]
+}
+
+export interface ZohoAccount {
+  account_id: string
+  account_name?: string
+  account_code?: string
+  account_type?: string
+  is_active?: boolean
+}
+
+export interface ZohoItem {
+  item_id: string
+  name?: string
+  sku?: string
+  status?: string
+  item_type?: string
+  account_id?: string
+}
+
+export interface ZohoTax {
+  tax_id: string
+  tax_name?: string
+  tax_percentage?: number
+  tax_type?: string
+}
+
+export interface ZohoInvoicePayment {
+  payment_id: string
+  invoice_payment_id?: string
+  date?: string
+  amount?: number
+  reference_number?: string
+  payment_mode?: string
+}
+
+export interface ZohoDocument {
+  document_id: string
+  file_name?: string
+}
+
+export interface ZohoInvoice {
+  invoice_id: string
+  invoice_number?: string
+  status?: string
+  customer_id?: string
+  date?: string
+  total?: number
+  balance?: number
+  adjustment?: number
+  last_modified_time?: string
+  documents?: ZohoDocument[]
+}
+
+export interface ZohoLine {
+  line_item_id?: string
+  item_id?: string
+  name: string
+  description: string
+  rate: number
+  quantity: number
+  discount: number
+  tax_id: string
+  account_id?: string
+}
+
+/* ---------- text ---------- */
+
+/**
+ * Text as Zoho takes it: no angle brackets, which make it refuse the whole
+ * request, and no characters it would store as "?". Kept readable rather
+ * than dropped: "5<10 Nm" goes over as "5‹10 Nm".
+ */
+export function zohoText(value: string | null | undefined, max: number): string {
+  if (!value) return ''
+  return (
+    value
+      .replace(/</g, '‹')
+      .replace(/>/g, '›')
+      // Outside the Basic Multilingual Plane (emoji, rare symbols).
+      .replace(/[\u{10000}-\u{10FFFF}]/gu, '')
+      // Variation selectors and joiners left behind by emoji.
+      .replace(/[︎️‍]/g, '')
+      .replace(/[ \t]+\n/g, '\n')
+      .trim()
+      .slice(0, max)
+  )
+}
+
+/** Money as the printed invoice rounds it, so both systems agree to the cent. */
+export function round2(n: number): number {
+  return roundAsPrinted(n)
+}
+
+/* ---------- contacts ---------- */
+
+/** A business customer is billed in the company's name; Zoho does not need names to be unique. */
+export function contactName(c: AccountingCustomer): string {
+  return zohoText(c.company, 200) || zohoText(c.name, 200) || 'Customer'
+}
+
+/**
+ * Whether a Zoho contact is plausibly this customer by name: the person's
+ * name, or the company's, as the contact, its person or its company. An
+ * email address can be shared (a family, a company's accounts inbox), so it
+ * only counts with this.
+ */
+export function sameName(
+  c: Pick<AccountingCustomer, 'name' | 'company'>,
+  contact: Pick<ZohoContact, 'contact_name' | 'company_name' | 'first_name' | 'last_name'>
+): boolean {
+  const ours = [normalName(c.name), normalName(c.company)].filter(Boolean)
+  const theirs = [
+    normalName(contact.contact_name),
+    normalName(contact.company_name),
+    normalName([contact.first_name, contact.last_name].filter(Boolean).join(' ')),
+  ].filter(Boolean)
+  return ours.some((o) => theirs.includes(o))
+}
+
+/**
+ * The customer as a Zoho contact. Email and phone live on the primary
+ * contact person; on an update that person is named by id, or Zoho adds a
+ * second one. The address stays one free-text block, as it is here.
+ */
+export function buildContact(
+  c: AccountingCustomer,
+  name: string,
+  primaryPersonId?: string | null
+): Record<string, unknown> {
+  const business = Boolean(c.company?.trim())
+  const [first, ...rest] = zohoText(c.name, 200).split(/\s+/).filter(Boolean)
+  const person: Record<string, unknown> = {
+    first_name: (first ?? '').slice(0, 100),
+    last_name: rest.join(' ').slice(0, 100),
+    email: zohoText(c.email, 100),
+    phone: zohoText(c.phone, 50),
+    is_primary_contact: true,
+  }
+  if (primaryPersonId) person.contact_person_id = primaryPersonId
+  const lines = zohoText(c.address, 500)
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .filter(Boolean)
+  return {
+    contact_name: name,
+    company_name: business ? zohoText(c.company, 200) : '',
+    contact_type: 'customer',
+    customer_sub_type: business ? 'business' : 'individual',
+    contact_persons: [person],
+    billing_address: {
+      address: (lines[0] ?? '').slice(0, 500),
+      street2: lines.slice(1).join(', ').slice(0, 255),
+    },
+  }
+}
+
+/* ---------- invoices ---------- */
+
+export type ReferenceChoice = 'vehicle' | 'title' | 'invoiceNumber' | 'none'
+
+/** The reference field, from the workshop's choice. */
+export function referenceFor(inv: AccountingInvoice, choice: ReferenceChoice): string {
+  if (choice === 'none') return ''
+  if (choice === 'invoiceNumber') return zohoText(inv.invoiceNumber, 100)
+  if (choice === 'title' || !inv.vehicle) return zohoText(inv.title, 100)
+  const name = [inv.vehicle.make, inv.vehicle.model].filter(Boolean).join(' ')
+  return zohoText([inv.vehicle.licensePlate, name].filter(Boolean).join(' '), 100)
+}
+
+export interface InvoiceOptions {
+  customerId: string
+  /** The number to send, or null to let Zoho number it. */
+  invoiceNumber: string | null
+  reference: string
+  dueDate: string
+  timezone: string
+  includeVehicle: boolean
+  /** "Vehicle" or "Vessel" in the workshop's document language, in front of the vehicle line. */
+  vehicleLabel: string
+  includeNotes: boolean
+  /** Where the vehicle and the notes go: Zoho's customer notes, or a line above or below the charges. */
+  notesPosition: 'customerNotes' | 'above' | 'below'
+  includePartNumbers: boolean
+  distanceUnit: string
+  templateId: string | null
+  locationId: string | null
+  currencyId: string | null
+  accounts: { labor: string | null; parts: string | null; shopFee: string | null }
+  items: { labor: string | null; parts: string | null }
+  /** Zoho item ids by SKU, for parts matched by part number; empty when that is off. */
+  partItems: ReadonlyMap<string, string>
+  /** The tax for taxed lines, or null when the invoice carries none. */
+  taxId: string | null
+  /** The tax for untaxed lines; '' leaves them without one. */
+  zeroTaxId: string
+  taxExempt: boolean
+  /** Existing line ids in order, so an update edits lines instead of replacing them all. */
+  lineIds: string[]
+  /** The rounding difference from the last answer, carried until it is recomputed. */
+  adjustment: number
+}
+
+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 sku = line.partNumber?.trim()
+    const matched = sku ? o.partItems.get(sku) : undefined
+    return matched ?? 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
+}
+
+function plainNumber(n: number): string {
+  return String(Number(n.toFixed(4)))
+}
+
+/**
+ * Quantity times a two-place rate in whole cents, when Zoho would get
+ * exactly that: the rate has no more than the two places Zoho keeps, and
+ * the product no part of a cent. Null otherwise.
+ */
+export function exactCents(quantity: number, rate: number): number | null {
+  const r = Math.round(rate * 100)
+  if (Math.abs(r / 100 - rate) > 1e-9) return null
+  const q = Math.round(quantity * 10_000)
+  if (Math.abs(q / 10_000 - quantity) > 1e-9) return null
+  const product = BigInt(q) * BigInt(r)
+  const scale = BigInt(10_000)
+  if (product % scale !== BigInt(0)) return null
+  return Number(product / scale)
+}
+
+/**
+ * The invoice as Zoho takes it, adding up to the printed invoice to the cent.
+ *
+ * As for Xero: the printed subtotal is split over the lines in whole cents,
+ * the printed discount over those, and a line goes as quantity times rate
+ * only when that product is exactly its amount; otherwise as one unit at its
+ * amount with the quantity and price kept in the description. Zoho works out
+ * the tax itself, per rate on the line totals, the way Torqvoice does; what
+ * is left is a cent or two, which the adjustment takes (see the server).
+ */
+export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<string, unknown> {
+  const taxed = Boolean(o.taxId) && inv.taxRate > 0 && !o.taxExempt
+  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 grossDiscounts =
+    discount > 0 ? distribute(Math.min(discount, linesSum), amounts) : priced.map(() => 0)
+  // Zoho takes a line discount off the amount before tax, even when prices
+  // include it. Torqvoice's discount on a tax-inclusive invoice is off the
+  // price with tax, so a taxed line gets its share net of tax: 100 off a
+  // 25% line goes over as 80, and Zoho's total lands where the invoice did.
+  const inclusive = inv.taxInclusive && taxed
+  const netShares = grossDiscounts.map((d, i) =>
+    inclusive && priced[i].taxable ? d / (1 + inv.taxRate / 100) : d
+  )
+  const discounts = inclusive
+    ? distribute(
+        netShares.reduce((a, b) => a + b, 0),
+        netShares
+      )
+    : grossDiscounts
+
+  const vehicle = o.includeVehicle ? vehicleDescription(inv, o.distanceUnit) : null
+  const notes = o.includeNotes ? inv.notes?.trim() || null : null
+  const extra = [vehicle ? `${o.vehicleLabel}: ${vehicle}` : null, notes]
+    .filter(Boolean)
+    .join('\n\n')
+
+  const lines: ZohoLine[] = []
+  const textLine = (text: string): ZohoLine => ({
+    name: '',
+    description: zohoText(text, 2000),
+    rate: 0,
+    quantity: 1,
+    discount: 0,
+    tax_id: '',
+  })
+  if (extra && o.notesPosition === 'above') lines.push(textLine(extra))
+  priced.forEach((line, i) => {
+    const cents = Math.round(amounts[i] * 100)
+    const description = lineDescription(line, o.includePartNumbers)
+    const item = itemFor(line, o)
+    let quantity: number
+    let rate: number
+    let text = description
+    if (exactCents(line.quantity, line.unitPrice) === cents) {
+      quantity = line.quantity
+      rate = line.unitPrice
+    } else if (item && line.quantity > 0) {
+      // A line sold as a Zoho item keeps its quantity, or Zoho takes the
+      // wrong count off the shelf. The rate moves to the two places Zoho
+      // keeps; a cent the line is then off goes into the rounding adjustment.
+      quantity = line.quantity
+      rate = round2(amounts[i] / line.quantity)
+    } else {
+      // One unit at the line's amount, so Zoho has nothing left to round.
+      quantity = 1
+      rate = amounts[i]
+      if (!(line.quantity === 1 && Math.abs(line.unitPrice - amounts[i]) < 0.005)) {
+        text = `${description} (${plainNumber(line.quantity)} × ${plainNumber(line.unitPrice)})`
+      }
+    }
+    const zl: ZohoLine = {
+      name: '',
+      description: zohoText(text, 2000),
+      rate,
+      quantity,
+      discount: discounts[i],
+      tax_id: taxed && line.taxable ? (o.taxId ?? '') : o.zeroTaxId,
+    }
+    if (item) zl.item_id = item
+    const account = accountFor(line, o)
+    if (account) zl.account_id = account
+    lines.push(zl)
+  })
+  if (extra && o.notesPosition === 'below') lines.push(textLine(extra))
+  // Lines are matched to the ones Zoho has by position, so an edit keeps their ids.
+  lines.forEach((l, i) => {
+    if (o.lineIds[i]) l.line_item_id = o.lineIds[i]
+  })
+
+  const body: Record<string, unknown> = {
+    customer_id: o.customerId,
+    date: zonedDayKey(inv.invoiceDate, o.timezone),
+    due_date: o.dueDate,
+    reference_number: o.reference,
+    // Always sent: left out, Zoho keeps the old notes or adds its own.
+    notes: o.notesPosition === 'customerNotes' ? zohoText(extra, 5000) : '',
+    is_inclusive_tax: inv.taxInclusive && taxed,
+    discount_type: 'item_level',
+    is_discount_before_tax: true,
+    line_items: lines,
+    adjustment: o.adjustment,
+    adjustment_description: o.adjustment ? ROUNDING_LABEL : '',
+  }
+  if (o.invoiceNumber) body.invoice_number = zohoText(o.invoiceNumber, 100)
+  if (o.templateId) body.template_id = o.templateId
+  if (o.locationId) body.location_id = o.locationId
+  if (o.currencyId) body.currency_id = o.currencyId
+  return body
+}
+
+/* ---------- payments ---------- */
+
+/** Zoho's payment modes, from the method recorded here. */
+export function paymentMode(p: Pick<AccountingPayment, 'method' | 'provider'>): string {
+  if (p.provider) return 'others'
+  if (p.method === 'cash') return 'cash'
+  if (p.method === 'card') return 'creditcard'
+  if (p.method === 'transfer') return 'banktransfer'
+  return 'others'
+}
+
+export function buildPayment(
+  p: AccountingPayment,
+  o: { customerId: string; invoiceId: string; accountId: string; timezone: string; amount: number }
+): Record<string, unknown> {
+  const reference = [`Torqvoice ${p.method}`, p.provider ? `via ${p.provider}` : null]
+    .filter(Boolean)
+    .join(' ')
+  const description = [p.externalId, p.note].filter(Boolean).join(', ')
+  return {
+    customer_id: o.customerId,
+    payment_mode: paymentMode(p),
+    amount: round2(o.amount),
+    date: zonedDayKey(p.date, o.timezone),
+    reference_number: zohoText(reference, 100),
+    description: zohoText(description, 500),
+    account_id: o.accountId,
+    invoices: [{ invoice_id: o.invoiceId, amount_applied: round2(o.amount) }],
+  }
+}
+
+/** 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 Zoho's answers ---------- */
+
+/** Zoho's answer to a refused call: a code and a sentence. */
+export function errorParts(body: string): { code: number | null; message: string } {
+  try {
+    const parsed = JSON.parse(body) as { code?: number; message?: string }
+    return {
+      code: typeof parsed.code === 'number' ? parsed.code : null,
+      message: parsed.message?.trim() || body.slice(0, 300) || 'no details',
+    }
+  } catch {
+    return { code: null, message: body.slice(0, 300) || 'no details' }
+  }
+}
+
+/**
+ * The API host the token answer named, if it is one of Zoho's: the stored
+ * value came through a token response, but it decides where the workshop's
+ * token is sent, so anything else is refused.
+ */
+export function apiBase(apiDomain: unknown): string {
+  const raw = typeof apiDomain === 'string' && apiDomain ? apiDomain : 'https://www.zohoapis.com'
+  let url: URL
+  try {
+    url = new URL(raw)
+  } catch {
+    throw new Error(`Zoho named an API host that is not an address: ${raw}`)
+  }
+  if (
+    url.protocol !== 'https:' ||
+    !/^www\.zohoapis\.(com|eu|in|com\.au|jp|ca|com\.cn|sa|ae|uk|sg)$/.test(url.hostname)
+  ) {
+    throw new Error(`Zoho named an API host that is not Zoho's: ${url.hostname}`)
+  }
+  return `https://${url.hostname}/books/v3`
+}
+
+/** Zoho Books' web app for the API host's region, for links into it. */
+export function webBase(apiDomain: unknown): string {
+  const host = new URL(apiBase(apiDomain)).hostname.replace(/^www\.zohoapis\./, '')
+  return host === 'ca' ? 'https://books.zohocloud.ca' : `https://books.zoho.${host}`
+}
+
+export function invoiceUrl(apiDomain: unknown, organizationId: string, invoiceId: string): string {
+  return `${webBase(apiDomain)}/app/${organizationId}#/invoices/${invoiceId}`
+}
+
+export function contactUrl(apiDomain: unknown, organizationId: string, contactId: string): string {
+  return `${webBase(apiDomain)}/app/${organizationId}#/contacts/${contactId}`
+}
+
+/** Zoho's last_modified_time filter: ISO with a numeric offset and no colon, as its answers write it. */
+export function modifiedSince(at: Date): string {
+  return `${at.toISOString().slice(0, 19)}+0000`
+}
+
+/**
+ * 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.
+ */
+export { paymentDay } from '../xero/mapping'
+
+export { checksumOf } from '@/features/integrations/Lib/checksum'

+ 1665 - 0
src/integrations/zoho-books/server.ts

@@ -0,0 +1,1665 @@
+import { createHash } from 'node:crypto'
+import {
+  type AccountingCustomer,
+  type AccountingInvoice,
+  type AccountingPayment,
+  CUSTOMER_ENTITY,
+  INVOICE_ENTITY,
+  PAYMENT_ENTITY,
+  loadCustomerForAccounting,
+  loadInvoiceForAccounting,
+  loadInvoicePdfForAccounting,
+  loadPaymentForAccounting,
+  recordPulledPayment,
+  removePulledPayment,
+  workshopCurrency,
+  workshopDistanceUnit,
+  workshopVehicleLabel,
+} from '@/features/integrations/Lib/accounting-sync'
+import { oauthSpec, resolveClient, tokenUrlFor } 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 {
+  type ReferenceChoice,
+  UPDATE_REASON,
+  WALK_IN_NAME,
+  type ZohoAccount,
+  type ZohoContact,
+  type ZohoInvoice,
+  type ZohoInvoicePayment,
+  type ZohoItem,
+  type ZohoOrganization,
+  type ZohoTax,
+  apiBase,
+  buildContact,
+  buildInvoice,
+  buildPayment,
+  checksumOf,
+  contactName,
+  contactUrl,
+  errorParts,
+  invoiceUrl,
+  methodAccountKey,
+  modifiedSince,
+  paymentDay,
+  referenceFor,
+  round2,
+  sameName,
+} from './mapping'
+
+const PROVIDER = 'zoho-books'
+/** The lines add up to the printed invoice; more than this apart after the adjustment means Zoho changed something. */
+const TOTAL_TOLERANCE = 0.005
+/** The most the rounding adjustment may carry. A larger gap is a real difference, which the log reports instead. */
+const MAX_ADJUSTMENT = 1
+/** How long the organisation's lists (taxes, accounts, items) are trusted. */
+const CATALOG_TTL_MS = 6 * 60 * 60 * 1000
+/** A payment waiting for its invoice to leave draft in Zoho is retried this often, for this long. */
+const DRAFT_RETRY_SECONDS = 60 * 60
+const DRAFT_WAIT_DAYS = 30
+/** How far a payment may exceed Zoho's balance and still be the same payment, rounded differently. */
+const ROUNDING_SLACK = 0.05
+const NO_ORG = 'choose the Zoho Books organisation in the settings'
+
+/** Zoho's codes worth reacting to, from its answers to this connector. */
+const CODE_NOT_FOUND = [1002, 1004, 1006]
+const CODE_NUMBER_TAKEN = 1001
+const CODE_DAILY_LIMIT = 45
+
+class ZohoError extends Error {
+  status: number
+  code: number | null
+  constructor(status: number, body: string) {
+    const { code, message } = errorParts(body)
+    super(
+      code === CODE_DAILY_LIMIT
+        ? `Zoho Books: the organisation has used its API calls for today (${message}). The rest goes over tomorrow; a higher Zoho plan allows more calls a day.`
+        : `Zoho Books: ${message}${code ? ` (code ${code})` : ''}`
+    )
+    this.status = status
+    this.code = code
+  }
+}
+
+function isNotFound(err: unknown): boolean {
+  return (
+    err instanceof ZohoError &&
+    (err.status === 404 || (err.code !== null && CODE_NOT_FOUND.includes(err.code)))
+  )
+}
+
+/* ---------- settings and state ---------- */
+
+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 reference = str('reference')
+  const notesPosition = str('notesPosition')
+  return {
+    organizationId: str('organizationId'),
+    pushInvoices: s.pushInvoices !== false,
+    pushOnComplete: s.pushOnComplete === true,
+    startDate: startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? startDate : null,
+    markSent: s.invoiceStatus !== 'draft',
+    zohoNumbers: s.invoiceNumbers === 'zoho',
+    reference: (reference === 'title' || reference === 'invoiceNumber' || reference === 'none'
+      ? reference
+      : 'vehicle') as ReferenceChoice,
+    includeVehicle: s.includeVehicle !== false,
+    includeNotes: s.includeNotes !== false,
+    notesPosition: (notesPosition === 'above' || notesPosition === 'below'
+      ? notesPosition
+      : 'customerNotes') as 'customerNotes' | 'above' | 'below',
+    includePartNumbers: s.includePartNumbers !== false,
+    templateId: str('templateId'),
+    locationId: str('locationId'),
+    laborAccount: str('laborAccount'),
+    partsAccount: str('partsAccount'),
+    shopFeeAccount: str('shopFeeAccount'),
+    laborItem: str('laborItem'),
+    partsItem: str('partsItem'),
+    matchItemsByPartNumber: s.matchItemsByPartNumber === true,
+    taxId: str('taxId'),
+    zeroTaxId: str('zeroTaxId'),
+    roundingAdjustment: s.roundingAdjustment !== false,
+    sendFromZoho: s.sendFromZoho === true && s.invoiceStatus !== 'draft',
+    attachPdf: s.attachPdf !== false,
+    updateContacts: s.updateContacts !== false,
+    reuseByEmail: s.sameEmail === 'reuse',
+    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 {
+  organizationId: string
+  fetchedAt: string
+  name: string
+  currencyCode: string | null
+  locationsEnabled: boolean
+}
+
+interface Catalog {
+  organizationId: string
+  fetchedAt: string
+  taxes: { id: string; name: string; percent: number }[]
+  /** Item ids by SKU, for matching parts by part number. */
+  skus: Record<string, string>
+  currencies: Record<string, string>
+}
+
+interface State {
+  org: OrgInfo | null
+  catalog: Catalog | null
+  walkIn: { organizationId: string; contactId: string } | null
+  lastPull: { organizationId: string; at: string } | null
+  /** The account a payment lands in when none is chosen: Zoho's Undeposited Funds, else any cash or bank account. */
+  deposit: { organizationId: string; fetchedAt: string; accountId: string | null } | 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'],
+    deposit: obj(s.deposit) as State['deposit'],
+  }
+}
+
+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>
+  /** Calls made before an organisation is chosen (the list of organisations itself). */
+  noOrganization?: boolean
+}
+
+/**
+ * At most this many calls to Zoho at once per connection. Zoho's free plan
+ * allows five concurrent calls per organisation and answers 429 beyond it;
+ * the settings form alone asks for several lists together.
+ */
+const MAX_CONCURRENT = 3
+const gates = new Map<string, { running: number; waiting: (() => void)[] }>()
+
+async function gated<T>(connectionId: string, fn: () => Promise<T>): Promise<T> {
+  let gate = gates.get(connectionId)
+  if (!gate) {
+    gate = { running: 0, waiting: [] }
+    gates.set(connectionId, gate)
+  }
+  const g = gate
+  if (g.running >= MAX_CONCURRENT) await new Promise<void>((resolve) => g.waiting.push(resolve))
+  g.running++
+  try {
+    return await fn()
+  } finally {
+    g.running--
+    g.waiting.shift()?.()
+  }
+}
+
+/**
+ * One call to Zoho Books for the chosen organisation, on the API host of
+ * the account's region. Every call names the organisation; Zoho answers
+ * `code: 0` on success and a code and a sentence otherwise.
+ */
+async function api<T>(ctx: ConnectorContext, path: string, init: CallInit = {}): Promise<T> {
+  const organizationId = settingsOf(ctx).organizationId
+  if (!organizationId && !init.noOrganization) {
+    throw new Error(`No Zoho Books organisation chosen; ${NO_ORG}`)
+  }
+  const url = new URL(`${apiBase(ctx.credentials.apiDomain)}/${path}`)
+  if (!init.noOrganization && organizationId)
+    url.searchParams.set('organization_id', organizationId)
+  for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v)
+  try {
+    return await gated(ctx.connection.id, () =>
+      ctx.http.json<T>(url.toString(), {
+        method: init.method ?? (init.body !== undefined ? 'POST' : 'GET'),
+        headers: { Accept: 'application/json' },
+        ...(init.body !== undefined && { body: JSON.stringify(init.body) }),
+      })
+    )
+  } catch (err) {
+    if (err instanceof ConnectorHttpError) throw new ZohoError(err.status, err.body)
+    throw err
+  }
+}
+
+/** Every page of a list, 200 at a time, as far as Zoho says there is more. */
+async function listAll<T>(
+  ctx: ConnectorContext,
+  path: string,
+  key: string,
+  query: Record<string, string> = {}
+): Promise<T[]> {
+  const out: T[] = []
+  for (let page = 1; page <= 50; page++) {
+    const res = await api<Record<string, unknown>>(ctx, path, {
+      query: { ...query, page: String(page), per_page: '200' },
+    })
+    const rows = (res[key] as T[] | undefined) ?? []
+    out.push(...rows)
+    const more = (res.page_context as { has_more_page?: boolean } | undefined)?.has_more_page
+    // A short page is the last one, whatever the flag says.
+    if (!more || rows.length < 200) break
+  }
+  return out
+}
+
+async function organizationsOf(ctx: ConnectorContext): Promise<ZohoOrganization[]> {
+  const res = await api<{ organizations?: ZohoOrganization[] }>(ctx, 'organizations', {
+    noOrganization: true,
+  })
+  return (res.organizations ?? []).filter((o) => o.is_org_active !== false)
+}
+
+/* ---------- organisation and lists ---------- */
+
+async function loadOrg(ctx: ConnectorContext, opts: { refresh?: boolean } = {}): Promise<OrgInfo> {
+  const organizationId = settingsOf(ctx).organizationId
+  if (!organizationId) throw new Error(`No Zoho Books organisation chosen; ${NO_ORG}`)
+  const cached = stateOf(ctx).org
+  if (!opts.refresh && cached?.organizationId === organizationId && fresh(cached.fetchedAt)) {
+    return cached
+  }
+  const orgs = await organizationsOf(ctx)
+  const o = orgs.find((x) => x.organization_id === organizationId)
+  if (!o) {
+    throw new Error(
+      'The chosen Zoho Books organisation is no longer reachable with this connection. Reconnect, or choose another organisation.'
+    )
+  }
+  const org: OrgInfo = {
+    organizationId,
+    fetchedAt: new Date().toISOString(),
+    name: o.name ?? 'Zoho Books',
+    currencyCode: o.currency_code ?? null,
+    locationsEnabled: o.is_location_enabled === true,
+  }
+  await ctx.saveState({ org })
+  return org
+}
+
+async function loadCatalog(
+  ctx: ConnectorContext,
+  opts: { refresh?: boolean } = {}
+): Promise<Catalog> {
+  const organizationId = settingsOf(ctx).organizationId
+  if (!organizationId) throw new Error(`No Zoho Books organisation chosen; ${NO_ORG}`)
+  const cached = stateOf(ctx).catalog
+  if (!opts.refresh && cached?.organizationId === organizationId && fresh(cached.fetchedAt)) {
+    return cached
+  }
+  const settings = settingsOf(ctx)
+  // One at a time: Zoho limits concurrent calls per organisation (five on
+  // the free plan) and answers 429 beyond that.
+  const taxes = await api<{ taxes?: ZohoTax[] }>(ctx, 'settings/taxes')
+  const items = settings.matchItemsByPartNumber
+    ? await listAll<ZohoItem>(ctx, 'items', 'items', { filter_by: 'Status.Active' })
+    : []
+  const currencies = await api<{ currencies?: { currency_id: string; currency_code: string }[] }>(
+    ctx,
+    'settings/currencies'
+  )
+  const catalog: Catalog = {
+    organizationId,
+    fetchedAt: new Date().toISOString(),
+    taxes: (taxes.taxes ?? []).map((t) => ({
+      id: t.tax_id,
+      name: t.tax_name ?? t.tax_id,
+      percent: Number(t.tax_percentage ?? 0),
+    })),
+    skus: Object.fromEntries(
+      items.filter((i) => i.sku?.trim()).map((i) => [String(i.sku).trim(), i.item_id])
+    ),
+    currencies: Object.fromEntries(
+      (currencies.currencies ?? []).map((c) => [c.currency_code, c.currency_id])
+    ),
+  }
+  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?.organizationId === settingsOf(ctx).organizationId ? 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, organizationId: settingsOf(ctx).organizationId },
+  })
+}
+
+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 readContact(ctx: ConnectorContext, contactId: string): Promise<ZohoContact | null> {
+  try {
+    const res = await api<{ contact?: ZohoContact }>(ctx, `contacts/${contactId}`)
+    return res.contact ?? null
+  } catch (err) {
+    if (isNotFound(err)) return null
+    throw err
+  }
+}
+
+async function findContacts(
+  ctx: ConnectorContext,
+  query: Record<string, string>
+): Promise<ZohoContact[]> {
+  const res = await api<{ contacts?: ZohoContact[] }>(ctx, 'contacts', {
+    query: { ...query, contact_type: 'customer', filter_by: 'Status.Active' },
+  })
+  return res.contacts ?? []
+}
+
+async function walkInContact(ctx: ConnectorContext): Promise<string> {
+  const organizationId = settingsOf(ctx).organizationId
+  const cached = stateOf(ctx).walkIn
+  if (cached && cached.organizationId === organizationId) return cached.contactId
+  const found = (await findContacts(ctx, { contact_name: WALK_IN_NAME }))[0]
+  const contactId =
+    found?.contact_id ??
+    (
+      await api<{ contact?: ZohoContact }>(ctx, 'contacts', {
+        body: { contact_name: WALK_IN_NAME, contact_type: 'customer' },
+      })
+    ).contact?.contact_id
+  if (!contactId) throw new Error('Zoho Books did not return the walk-in contact')
+  await ctx.saveState({ walkIn: { organizationId, contactId } })
+  return contactId
+}
+
+function primaryPersonId(contact: ZohoContact | null | undefined): string | null {
+  const persons = contact?.contact_persons ?? []
+  return (persons.find((p) => p.is_primary_contact) ?? persons[0])?.contact_person_id ?? null
+}
+
+/**
+ * The Zoho contact for a customer, made or updated as needed. A contact
+ * already in Zoho is found by email, used only when its name agrees too
+ * (unless the workshop chose to trust email alone), else by name when the
+ * customer has no email and exactly one contact carries that name; Zoho
+ * lets names repeat, so a name alone is not trusted further than that.
+ */
+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 organizationId = settings.organizationId ?? ''
+  const name = contactName(c)
+  const checksum = checksumOf(buildContact(c, name))
+  const result = (id: string) => ({ id, email: c.email, taxExempt: c.taxExempt })
+  const link = await linkFor(ctx, CUSTOMER_ENTITY, c.id)
+  if (link) {
+    if (link.checksum === checksum || !settings.updateContacts) return result(link.remoteId)
+    const current = await readContact(ctx, link.remoteId)
+    if (current) {
+      await api(ctx, `contacts/${link.remoteId}`, {
+        method: 'PUT',
+        body: buildContact(c, name, primaryPersonId(current)),
+      })
+      await setLink(
+        ctx,
+        CUSTOMER_ENTITY,
+        c.id,
+        {
+          remoteId: link.remoteId,
+          remoteUrl: contactUrl(ctx.credentials.apiDomain, organizationId, link.remoteId),
+          checksum,
+        },
+        link.metadata ?? {}
+      )
+      return result(link.remoteId)
+    }
+    await ctx.links.remove(CUSTOMER_ENTITY, c.id)
+  }
+
+  const email = c.email?.trim()
+  let existing: ZohoContact | null = null
+  let sharesEmail: ZohoContact | null = null
+  if (email) {
+    // Checked again here: Zoho's email filter has answered with contacts
+    // that do not carry the address at all.
+    const wanted = email.toLowerCase()
+    const withEmail = (await findContacts(ctx, { email })).filter(
+      (x) => x.email?.trim().toLowerCase() === wanted
+    )
+    existing =
+      withEmail.find((x) => sameName(c, x)) ??
+      (settings.reuseByEmail ? (withEmail[0] ?? null) : null)
+    if (!existing) sharesEmail = withEmail[0] ?? null
+  } else {
+    const named = await findContacts(ctx, { contact_name: name })
+    if (named.length === 1) existing = named[0]
+  }
+  let contactId: string
+  if (existing) {
+    contactId = existing.contact_id
+  } else {
+    const made = await api<{ contact?: ZohoContact }>(ctx, 'contacts', {
+      body: buildContact(c, name),
+    })
+    if (!made.contact?.contact_id) throw new Error('Zoho Books did not return the new contact')
+    contactId = made.contact.contact_id
+    if (sharesEmail) {
+      await ctx.log(
+        'warn',
+        `Customer ${c.name} got a new Zoho Books contact, "${name}": the contact "${sharesEmail.contact_name ?? sharesEmail.contact_id}" has the same email address but another name. If they are the same customer, merge the two contacts in Zoho Books, or choose to use the existing contact under "Customers with the same email" in the integration settings.`
+      )
+    }
+  }
+  await setLink(
+    ctx,
+    CUSTOMER_ENTITY,
+    c.id,
+    {
+      remoteId: contactId,
+      remoteUrl: contactUrl(ctx.credentials.apiDomain, organizationId, contactId),
+      // A contact found rather than made is not ours to overwrite until the customer changes here.
+      checksum,
+    },
+    { reused: Boolean(existing) }
+  )
+  return result(contactId)
+}
+
+/* ---------- tax ---------- */
+
+interface ResolvedTax {
+  taxId: string | null
+  zeroTaxId: string
+  note: string | null
+}
+
+async function resolveTax(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  taxExempt: boolean
+): Promise<ResolvedTax> {
+  const settings = settingsOf(ctx)
+  let catalog = await loadCatalog(ctx)
+  const zero = settings.zeroTaxId && catalog.taxes.some((t) => t.id === settings.zeroTaxId)
+  const zeroTaxId = zero ? (settings.zeroTaxId ?? '') : ''
+  if (!(inv.taxRate > 0) || taxExempt) return { taxId: null, zeroTaxId, note: null }
+  const pick = () => {
+    const chosen = settings.taxId ? catalog.taxes.find((t) => t.id === settings.taxId) : undefined
+    const sameRate = catalog.taxes.find((t) => Math.abs(t.percent - inv.taxRate) < 0.0001)
+    return { chosen, sameRate }
+  }
+  let { chosen, sameRate } = pick()
+  if (!sameRate && !chosen) {
+    // The rate may be new in Zoho since the list was read.
+    catalog = await loadCatalog(ctx, { refresh: true })
+    ;({ chosen, sameRate } = pick())
+  }
+  // A rate with the invoice's percentage wins; the chosen one is for when several match or none does.
+  const entry =
+    chosen && Math.abs(chosen.percent - inv.taxRate) < 0.0001 ? chosen : (sameRate ?? chosen)
+  if (!entry) {
+    return {
+      taxId: null,
+      zeroTaxId,
+      note: `No Zoho Books tax carries ${inv.taxRate}%, so invoice ${inv.invoiceNumber} went over without tax. Add a ${inv.taxRate}% tax under Settings, Taxes in Zoho Books, or choose one in the integration settings.`,
+    }
+  }
+  return {
+    taxId: entry.id,
+    zeroTaxId,
+    note:
+      Math.abs(entry.percent - inv.taxRate) < 0.0001
+        ? null
+        : `Invoice ${inv.invoiceNumber} charges ${inv.taxRate}% but was booked under "${entry.name}" (${entry.percent}%), the tax chosen in the integration settings.`,
+  }
+}
+
+/* ---------- invoices ---------- */
+
+/** Why the invoice stays out of Zoho, 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
+}
+
+async function readInvoice(
+  ctx: ConnectorContext,
+  invoiceId: string
+): Promise<ZohoInvoice & { line_items?: { line_item_id: string }[] }> {
+  const res = await api<{ invoice?: ZohoInvoice & { line_items?: { line_item_id: string }[] } }>(
+    ctx,
+    `invoices/${invoiceId}`
+  )
+  if (!res.invoice) throw new ZohoError(404, '{"code":1002,"message":"Invoice does not exist."}')
+  return res.invoice
+}
+
+function isGone(inv: ZohoInvoice): boolean {
+  return inv.status === 'void'
+}
+
+function hasMoney(inv: ZohoInvoice): boolean {
+  return (
+    inv.status === 'paid' ||
+    inv.status === 'partially_paid' ||
+    (typeof inv.total === 'number' &&
+      typeof inv.balance === 'number' &&
+      inv.balance < inv.total - 0.005)
+  )
+}
+
+/**
+ * An invoice voided in Zoho keeps its number for good. 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 Zoho numbering it simply goes over again as new.
+ */
+async function markGone(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  remote: ZohoInvoice
+): Promise<JobOutcome> {
+  await setLink(
+    ctx,
+    INVOICE_ENTITY,
+    inv.id,
+    { remoteId: remote.invoice_id, remoteUrl: null, checksum: null },
+    { goneInZoho: 'voided', invoiceNumber: inv.invoiceNumber }
+  )
+  await ctx.log(
+    'warn',
+    `Invoice ${inv.invoiceNumber} was voided in Zoho Books, and Zoho keeps its number, so edits here are not sent. Give the invoice a new number in Torqvoice to send it as a new invoice, or mark it as draft again in Zoho Books.`
+  )
+  return { summary: `invoice ${inv.invoiceNumber} voided in Zoho Books; not sent again` }
+}
+
+/** The record is gone here: void or delete Zoho'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 Zoho Books, voiding switched off' }
+  }
+  try {
+    const current = await readInvoice(ctx, link.remoteId)
+    const label = current.invoice_number ?? current.invoice_id
+    if (isGone(current)) {
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: `invoice ${label} already voided in Zoho Books` }
+    }
+    if (hasMoney(current)) {
+      await ctx.log(
+        'warn',
+        `Invoice ${label} was deleted here but has payments in Zoho Books; left as is`
+      )
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: 'left in Zoho Books, has payments' }
+    }
+    // A draft is deleted, which frees its number; a sent one is voided, as a bookkeeper would.
+    if (current.status === 'draft') {
+      await api(ctx, `invoices/${current.invoice_id}`, { method: 'DELETE' })
+    } else {
+      await api(ctx, `invoices/${current.invoice_id}/status/void`, { method: 'POST' })
+    }
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return {
+      summary: `invoice ${label} ${current.status === 'draft' ? 'deleted' : 'voided'} in Zoho Books`,
+    }
+  } catch (err) {
+    if (!isNotFound(err)) throw err
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: 'already gone from Zoho Books' }
+  }
+}
+
+/**
+ * The currency to put on the invoice: none when the workshop bills in the
+ * organisation's own currency. Another one goes by its Zoho id; Zoho fills
+ * in the exchange rate when the organisation has the currency with
+ * automatic rates, and refuses otherwise, which the log then says.
+ */
+async function currencyFor(ctx: ConnectorContext, org: OrgInfo): Promise<string | null> {
+  const ours = await workshopCurrency(ctx.connection.organizationId)
+  if (!ours || !org.currencyCode || ours === org.currencyCode) return null
+  const catalog = await loadCatalog(ctx)
+  const id = catalog.currencies[ours]
+  if (!id) {
+    throw new Error(
+      `Torqvoice bills in ${ours} but the Zoho Books organisation works in ${org.currencyCode} and has no ${ours} currency. Add ${ours} under Settings, Currencies in Zoho Books.`
+    )
+  }
+  return id
+}
+
+async function buildBody(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  contact: { id: string; taxExempt: boolean },
+  org: OrgInfo,
+  opts: { lineIds: string[]; adjustment: number }
+): Promise<{ body: Record<string, unknown>; note: string | null }> {
+  const settings = settingsOf(ctx)
+  const tax = await resolveTax(ctx, inv, contact.taxExempt)
+  const catalog = settings.matchItemsByPartNumber ? await loadCatalog(ctx) : null
+  const body = buildInvoice(inv, {
+    customerId: contact.id,
+    invoiceNumber: settings.zohoNumbers ? null : inv.invoiceNumber,
+    reference: referenceFor(inv, settings.reference),
+    dueDate: zonedDayKey(inv.dueDate ?? inv.invoiceDate, ctx.timezone),
+    timezone: ctx.timezone,
+    includeVehicle: settings.includeVehicle,
+    vehicleLabel: settings.includeVehicle
+      ? await workshopVehicleLabel(ctx.connection.organizationId)
+      : 'Vehicle',
+    includeNotes: settings.includeNotes,
+    notesPosition: settings.notesPosition,
+    includePartNumbers: settings.includePartNumbers,
+    distanceUnit: await workshopDistanceUnit(ctx.connection.organizationId),
+    templateId: settings.templateId,
+    locationId: org.locationsEnabled ? settings.locationId : null,
+    currencyId: await currencyFor(ctx, org),
+    accounts: {
+      labor: settings.laborAccount,
+      parts: settings.partsAccount,
+      shopFee: settings.shopFeeAccount,
+    },
+    items: { labor: settings.laborItem, parts: settings.partsItem },
+    partItems: new Map(Object.entries(catalog?.skus ?? {})),
+    taxId: tax.taxId,
+    zeroTaxId: tax.zeroTaxId,
+    taxExempt: contact.taxExempt,
+    lineIds: opts.lineIds,
+    adjustment: opts.adjustment,
+  })
+  return { body, note: tax.note }
+}
+
+/** The body without what changes on every answer, for telling whether anything here changed. */
+function checksumFor(body: Record<string, unknown>, settings: Settings): string {
+  const { adjustment: _a, adjustment_description: _d, ...rest } = body
+  const lines = (rest.line_items as Record<string, unknown>[]).map(
+    ({ line_item_id: _id, ...l }) => l
+  )
+  return checksumOf({
+    ...rest,
+    line_items: lines,
+    markSent: settings.markSent,
+    rounding: settings.roundingAdjustment,
+  })
+}
+
+function numberQuery(settings: Settings): Record<string, string> {
+  return settings.zohoNumbers ? {} : { ignore_auto_number_generation: 'true' }
+}
+
+/**
+ * Writes the invoice and makes it add up to the printed total. Zoho works
+ * out the tax itself; when that lands a cent or two from the printed
+ * invoice, the difference goes in as Zoho's rounding adjustment, which is
+ * what the field is for. A bigger gap is left for the log.
+ */
+async function writeInvoice(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  body: Record<string, unknown>,
+  existing: (ZohoInvoice & { status?: string }) | null
+): Promise<ZohoInvoice & { line_items?: { line_item_id: string }[] }> {
+  const settings = settingsOf(ctx)
+  type Answer = { invoice?: ZohoInvoice & { line_items?: { line_item_id: string }[] } }
+  const save = async (b: Record<string, unknown>, id: string | null) => {
+    const res = id
+      ? await api<Answer>(ctx, `invoices/${id}`, {
+          method: 'PUT',
+          query: numberQuery(settings),
+          // Zoho asks why a sent invoice changed.
+          body:
+            existing?.status && existing.status !== 'draft' ? { ...b, reason: UPDATE_REASON } : b,
+        })
+      : await api<Answer>(ctx, 'invoices', { body: b, query: numberQuery(settings) })
+    if (!res.invoice) throw new Error('Zoho Books did not return the invoice')
+    return res.invoice
+  }
+  let saved = await save(body, existing?.invoice_id ?? null)
+  const target = round2(inv.totalAmount)
+  const total = typeof saved.total === 'number' ? saved.total : null
+  if (settings.roundingAdjustment && total !== null) {
+    const current = Number(body.adjustment ?? 0)
+    const gap = round2(target - total)
+    if (Math.abs(gap) > TOTAL_TOLERANCE && Math.abs(current + gap) <= MAX_ADJUSTMENT) {
+      const ids = (saved.line_items ?? []).map((l) => l.line_item_id)
+      const lines = (body.line_items as Record<string, unknown>[]).map((l, i) =>
+        ids[i] ? { ...l, line_item_id: ids[i] } : l
+      )
+      const next = round2(current + gap)
+      saved = await save(
+        {
+          ...body,
+          line_items: lines,
+          adjustment: next,
+          adjustment_description: next ? 'Rounding' : '',
+        },
+        saved.invoice_id
+      )
+    }
+  }
+  return saved
+}
+
+function checkTotals(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  saved: ZohoInvoice,
+  settings: Settings
+): void {
+  if (typeof saved.total !== 'number') return
+  if (Math.abs(saved.total - inv.totalAmount) <= TOTAL_TOLERANCE) return
+  const hint = settings.roundingAdjustment
+    ? 'check the tax in the integration settings and in Zoho Books'
+    : 'switch on "Match the total to the cent" in the integration settings to let a rounding adjustment close the gap'
+  void ctx.log(
+    'warn',
+    `Invoice ${inv.invoiceNumber}: Zoho Books total ${round2(saved.total)} differs from ${round2(inv.totalAmount)} here; ${hint}`,
+    { zoho: saved.total, torqvoice: inv.totalAmount }
+  )
+}
+
+/**
+ * A new invoice in Zoho. When the Torqvoice number is used and Zoho already
+ * has an invoice of that number for the same customer, an earlier push lost
+ * its link and that invoice is adopted instead of doubled.
+ */
+async function findByNumber(ctx: ConnectorContext, number: string): Promise<ZohoInvoice | null> {
+  const res = await api<{ invoices?: ZohoInvoice[] }>(ctx, 'invoices', {
+    query: { invoice_number: number },
+  })
+  return (res.invoices ?? []).find((i) => i.invoice_number === number) ?? null
+}
+
+async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.organizationId) return { summary: NO_ORG }
+  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)
+  let link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+  if (link?.metadata?.goneInZoho) {
+    if (!settings.zohoNumbers && link.metadata.invoiceNumber === inv.invoiceNumber) {
+      return { summary: `invoice ${inv.invoiceNumber} voided in Zoho Books; not sent again` }
+    }
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    link = null
+  }
+
+  // The checksum is of the body without line ids and adjustment, which change with Zoho's answers.
+  const draft = await buildBody(ctx, inv, contact, org, { lineIds: [], adjustment: 0 })
+  const checksum = checksumFor(draft.body, settings)
+
+  let saved: (ZohoInvoice & { line_items?: { line_item_id: string }[] }) | null = null
+  let action = 'unchanged'
+  if (!link || link.checksum !== checksum) {
+    if (draft.note) await ctx.log('warn', draft.note)
+    let current: (ZohoInvoice & { line_items?: { line_item_id: string }[] }) | null = null
+    if (link) {
+      try {
+        current = await readInvoice(ctx, link.remoteId)
+      } catch (err) {
+        if (!isNotFound(err)) throw err
+        await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+        link = null
+      }
+      if (current && isGone(current)) {
+        if (!settings.zohoNumbers) return markGone(ctx, inv, current)
+        await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+        link = null
+        current = null
+      }
+    }
+    if (!current && !settings.zohoNumbers && inv.invoiceNumber) {
+      const found = await findByNumber(ctx, inv.invoiceNumber)
+      if (found) {
+        if (isGone(found)) return markGone(ctx, inv, found)
+        if (found.customer_id !== contact.id) {
+          throw new Error(
+            `Zoho Books already has an invoice numbered ${inv.invoiceNumber} for another customer, so this one cannot be added under that number. Renumber one of them, or choose "Let Zoho Books number them" in the integration settings.`
+          )
+        }
+        await ctx.log(
+          'info',
+          `Invoice ${inv.invoiceNumber} was already in Zoho Books; linked to it`
+        )
+        current = await readInvoice(ctx, found.invoice_id)
+        await relinkPayments(ctx, inv, current)
+        action = 'found in Zoho Books and linked again'
+      }
+    }
+    const { body } = await buildBody(ctx, inv, contact, org, {
+      lineIds: (current?.line_items ?? []).map((l) => l.line_item_id),
+      adjustment: Number(current?.adjustment ?? 0),
+    })
+    try {
+      saved = await writeInvoice(ctx, inv, body, current)
+    } catch (err) {
+      if (err instanceof ZohoError && err.code === CODE_NUMBER_TAKEN && inv.invoiceNumber) {
+        const found = await findByNumber(ctx, inv.invoiceNumber)
+        if (found && isGone(found)) return markGone(ctx, inv, found)
+      }
+      throw err
+    }
+    if (action === 'unchanged') action = current ? 'updated' : 'created'
+    await setLink(
+      ctx,
+      INVOICE_ENTITY,
+      serviceRecordId,
+      {
+        remoteId: saved.invoice_id,
+        remoteUrl: invoiceUrl(ctx.credentials.apiDomain, org.organizationId, saved.invoice_id),
+        checksum,
+      },
+      {
+        invoiceNumber: saved.invoice_number ?? null,
+        contactId: contact.id,
+        // The PDF changed with the invoice; the old copy is replaced.
+        attached: false,
+        documentId: link?.metadata?.documentId ?? null,
+        emailed: link?.metadata?.emailed ?? false,
+      }
+    )
+    checkTotals(ctx, inv, saved, settings)
+  }
+
+  link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+  if (link) {
+    if (settings.attachPdf && link.metadata?.attached !== true) {
+      const documentId = await attachPdf(
+        ctx,
+        link.remoteId,
+        inv,
+        (link.metadata?.documentId as string | null) ?? null
+      )
+      if (documentId) {
+        await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, {
+          attached: true,
+          documentId,
+        })
+        link = await linkFor(ctx, INVOICE_ENTITY, serviceRecordId)
+      }
+    }
+    if (link) {
+      const status = saved?.status ?? (await readInvoice(ctx, link.remoteId)).status
+      if (settings.sendFromZoho && link.metadata?.emailed !== true) {
+        if (await emailFromZoho(ctx, link.remoteId, inv, contact.email)) {
+          await patchLink(ctx, INVOICE_ENTITY, serviceRecordId, link, { emailed: true })
+        }
+      } else if (settings.markSent && status === 'draft') {
+        await api(ctx, `invoices/${link.remoteId}/status/sent`, { method: 'POST' })
+      }
+    }
+  }
+
+  let paymentsPushed = 0
+  let waiting = false
+  if (settings.pushPayments && link) {
+    const pushed = await pushPendingPayments(ctx, inv, link.remoteId, contact.id)
+    paymentsPushed = pushed.count
+    waiting = pushed.waitingForDraft
+  }
+  const summary = `invoice ${inv.invoiceNumber} ${action}`
+  const extra = [
+    paymentsPushed ? `${paymentsPushed} payments recorded` : null,
+    waiting ? 'payments wait until the invoice leaves draft in Zoho Books' : null,
+  ].filter(Boolean)
+  return { summary: extra.length ? `${summary}, ${extra.join(', ')}` : summary }
+}
+
+/**
+ * After a lost link, the payments Zoho 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.
+ */
+async function relinkPayments(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  remote: ZohoInvoice
+): Promise<void> {
+  const theirs = await invoicePayments(ctx, remote.invoice_id)
+  const taken = new Set<string>()
+  for (const p of inv.payments) {
+    if (await linkFor(ctx, PAYMENT_ENTITY, p.id)) continue
+    const fromZoho = p.provider === PROVIDER && p.externalId
+    const day = zonedDayKey(p.date, ctx.timezone)
+    const match = fromZoho
+      ? theirs.find((x) => x.payment_id === p.externalId)
+      : theirs.find(
+          (x) =>
+            !taken.has(x.payment_id) &&
+            Math.abs((x.amount ?? 0) - round2(p.amount)) <= ROUNDING_SLACK &&
+            x.date === day
+        )
+    if (!match) continue
+    taken.add(match.payment_id)
+    await setLink(
+      ctx,
+      PAYMENT_ENTITY,
+      p.id,
+      { remoteId: match.payment_id },
+      { createdByUs: !fromZoho, serviceRecordId: inv.id, relinked: true }
+    )
+  }
+}
+
+/**
+ * The invoice the customer received, attached to Zoho's copy. Zoho adds a
+ * file rather than replacing one of the same name, so the copy attached
+ * before an edit is removed first.
+ */
+async function attachPdf(
+  ctx: ConnectorContext,
+  invoiceId: string,
+  inv: AccountingInvoice,
+  previousDocumentId: string | null
+): Promise<string | null> {
+  try {
+    const pdf = await loadInvoicePdfForAccounting(ctx.connection.organizationId, inv.id)
+    if (!pdf) return null
+    if (previousDocumentId) {
+      try {
+        await api(ctx, `invoices/${invoiceId}/documents/${previousDocumentId}`, {
+          method: 'DELETE',
+        })
+      } catch (err) {
+        if (!isNotFound(err)) throw err
+      }
+    }
+    const filename = `${String(inv.invoiceNumber).replace(/[^\w.-]+/g, '_')}.pdf`
+    const form = new FormData()
+    form.append(
+      'attachment',
+      new Blob([pdf.buffer as BlobPart], { type: 'application/pdf' }),
+      filename
+    )
+    const url = new URL(`${apiBase(ctx.credentials.apiDomain)}/invoices/${invoiceId}/attachment`)
+    url.searchParams.set('organization_id', settingsOf(ctx).organizationId ?? '')
+    url.searchParams.set('can_send_in_mail', 'true')
+    const res = await ctx.http.fetch(url.toString(), { method: 'POST', body: form })
+    const text = await res.text()
+    if (!res.ok) throw new ZohoError(res.status, text)
+    const parsed = JSON.parse(text) as {
+      documents?: { document_id?: string; file_name?: string }[]
+    }
+    const doc =
+      (parsed.documents ?? []).find((d) => d.file_name === filename) ?? parsed.documents?.[0]
+    return doc?.document_id ?? 'attached'
+  } catch (err) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} is in Zoho Books but its PDF could not be attached: ${err instanceof Error ? err.message : String(err)}`
+    )
+    return null
+  }
+}
+
+/** Zoho emails its own copy to the customer, with Zoho's subject and message, and marks it sent. */
+async function emailFromZoho(
+  ctx: ConnectorContext,
+  invoiceId: string,
+  inv: AccountingInvoice,
+  email: string | null
+): Promise<boolean> {
+  if (!email) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} was not emailed from Zoho Books: the customer has no email address`
+    )
+    return true
+  }
+  try {
+    const defaults = await api<{ data?: { subject?: string; body?: string } }>(
+      ctx,
+      `invoices/${invoiceId}/email`
+    )
+    await api(ctx, `invoices/${invoiceId}/email`, {
+      body: {
+        to_mail_ids: [email],
+        subject: defaults.data?.subject ?? `Invoice ${inv.invoiceNumber}`,
+        body: defaults.data?.body ?? '',
+      },
+    })
+    return true
+  } catch (err) {
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.invoiceNumber} could not be emailed from Zoho Books: ${err instanceof Error ? err.message : String(err)}`
+    )
+    return true
+  }
+}
+
+/* ---------- payments ---------- */
+
+async function invoicePayments(
+  ctx: ConnectorContext,
+  invoiceId: string
+): Promise<ZohoInvoicePayment[]> {
+  const res = await api<{ payments?: ZohoInvoicePayment[] }>(ctx, `invoices/${invoiceId}/payments`)
+  return res.payments ?? []
+}
+
+/** The cash or bank 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 organizationId = settings.organizationId ?? ''
+  const cached = stateOf(ctx).deposit
+  let accountId: string | null
+  if (cached?.organizationId === organizationId && fresh(cached.fetchedAt)) {
+    accountId = cached.accountId
+  } else {
+    // Read only when no account is chosen, and kept: the chart of accounts
+    // is the list Zoho guards most tightly against repeated reads.
+    const accounts = await listAll<ZohoAccount>(ctx, 'chartofaccounts', 'chartofaccounts', {
+      filter_by: 'AccountType.Active',
+    })
+    const deposit =
+      accounts.find(
+        (a) => a.account_type === 'cash' && /undeposited/i.test(a.account_name ?? '')
+      ) ??
+      accounts.find((a) => a.account_type === 'bank') ??
+      accounts.find((a) => a.account_type === 'cash') ??
+      null
+    accountId = deposit?.account_id ?? null
+    await ctx.saveState({
+      deposit: { organizationId, fetchedAt: new Date().toISOString(), accountId },
+    })
+  }
+  if (accountId) return accountId
+  throw new Error(
+    'The Zoho Books organisation has no cash or bank account to record payments in; add one in Zoho Books or choose an account in the integration settings'
+  )
+}
+
+/**
+ * Records the payments here that Zoho does not have yet. Zoho takes
+ * payments on an invoice that has been sent, so on a draft they wait and go
+ * over once it is sent there (the next edit, pull or retry sends them).
+ */
+async function pushPendingPayments(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  invoiceId: string,
+  customerId: string
+): Promise<{ count: number; waitingForDraft: 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, waitingForDraft: false }
+  const current = await readInvoice(ctx, invoiceId)
+  if (current.status === 'draft') return { count: 0, waitingForDraft: true }
+  if (isGone(current)) return { count: 0, waitingForDraft: false }
+  // Zoho refuses a payment above the balance. A few cents over is rounding,
+  // and the payment is trimmed to fit; more means the invoices disagree.
+  let remaining = typeof current.balance === 'number' ? current.balance : 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 Zoho Books shows as owed (${round2(Math.max(0, remaining))}); it was not recorded in Zoho Books`
+        )
+        continue
+      }
+    }
+    await pushPaymentRow(ctx, p, amount, invoiceId, customerId)
+    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,
+        },
+        due,
+        invoiceId,
+        customerId,
+        { manual: true }
+      )
+      count++
+    }
+  }
+  return { count, waitingForDraft: false }
+}
+
+async function pushPaymentRow(
+  ctx: ConnectorContext,
+  p: AccountingPayment,
+  amount: number,
+  invoiceId: string,
+  customerId: string,
+  extra: Record<string, unknown> = {}
+): Promise<void> {
+  const body = buildPayment(p, {
+    customerId,
+    invoiceId,
+    accountId: await paymentAccountFor(ctx, p),
+    timezone: ctx.timezone,
+    amount,
+  })
+  const res = await api<{ payment?: { payment_id?: string } }>(ctx, 'customerpayments', { body })
+  const id = res.payment?.payment_id
+  if (!id) throw new Error('Zoho Books did not return the payment')
+  await setLink(
+    ctx,
+    PAYMENT_ENTITY,
+    p.id,
+    { remoteId: id },
+    { createdByUs: true, serviceRecordId: p.serviceRecordId, invoiceId, ...extra }
+  )
+}
+
+async function pushPayment(ctx: ConnectorContext, paymentId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.organizationId) return { summary: NO_ORG }
+  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 Zoho made and this app then deleted stays in Zoho.
+      await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+      return { summary: 'payment came from Zoho Books; left there' }
+    }
+    try {
+      await api(ctx, `customerpayments/${link.remoteId}`, { method: 'DELETE' })
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+    }
+    await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+    return { summary: 'payment removed from Zoho Books' }
+  }
+  if (link) return { summary: 'unchanged' }
+  if (!settings.pushPayments) return { summary: 'payments not sent' }
+
+  // The payment needs its invoice in Zoho 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('leaves draft') && age < DRAFT_WAIT_DAYS * 86_400_000) {
+    return {
+      summary: 'waiting for the invoice to leave draft in Zoho Books',
+      rescheduleInSeconds: DRAFT_RETRY_SECONDS,
+    }
+  }
+  return { summary: `payment not recorded: ${outcome.summary ?? 'invoice not in Zoho Books'}` }
+}
+
+/* ---------- customers on edit ---------- */
+
+async function pushCustomer(ctx: ConnectorContext, customerId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.organizationId) return { summary: NO_ORG }
+  if (!settings.updateContacts) return { summary: 'contact updates switched off' }
+  const link = await linkFor(ctx, CUSTOMER_ENTITY, customerId)
+  // Customers reach Zoho with their first invoice; an edit before that is nothing yet.
+  if (!link) return { summary: 'not in Zoho Books 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 organizationId = settingsOf(ctx).organizationId
+  const last = stateOf(ctx).lastPull
+  const at = last && last.organizationId === organizationId ? 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 Zoho since the last pull. Zoho lists invoices by when they
+ * were modified, and a payment changes its invoice, so the pull reads the
+ * invoices from here that changed and then their payments: new ones are
+ * recorded here, ones removed there are removed here, invoices voided there
+ * lose their link, and invoices sent there get any payments that waited.
+ */
+async function pullChanges(ctx: ConnectorContext): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.organizationId) return { summary: NO_ORG }
+  const startedAt = new Date()
+  const orgId = ctx.connection.organizationId
+  const changed = await listAll<ZohoInvoice>(ctx, 'invoices', 'invoices', {
+    last_modified_time: modifiedSince(pullSince(ctx)),
+  })
+
+  let recorded = 0
+  let removed = 0
+  let unlinked = 0
+  let released = 0
+  for (const remote of changed) {
+    const link = await ctx.links.byRemoteId(INVOICE_ENTITY, remote.invoice_id)
+    if (!link || link.metadata?.organizationId !== settings.organizationId) continue
+    if (isGone(remote)) {
+      if (link.metadata?.goneInZoho) continue
+      if (settings.zohoNumbers) {
+        await ctx.links.remove(INVOICE_ENTITY, link.entityId)
+        await ctx.log(
+          'warn',
+          `Invoice ${remote.invoice_number ?? remote.invoice_id} was voided in Zoho Books; it will be sent again as a new invoice if edited here`
+        )
+      } else {
+        await patchLink(ctx, INVOICE_ENTITY, link.entityId, link, {
+          goneInZoho: 'voided',
+          invoiceNumber: remote.invoice_number ?? link.metadata?.invoiceNumber ?? null,
+        })
+        await ctx.log(
+          'warn',
+          `Invoice ${remote.invoice_number ?? remote.invoice_id} was voided in Zoho Books, and Zoho keeps its number, so edits here are not sent. Give the invoice a new number in Torqvoice to send it as a new invoice, or mark it as draft again in Zoho Books.`
+        )
+      }
+      unlinked++
+      continue
+    }
+
+    if (settings.pullPayments) {
+      const theirs = await invoicePayments(ctx, remote.invoice_id)
+      const theirIds = new Set(theirs.map((p) => p.payment_id))
+      // Payments recorded in Zoho that this app has not seen.
+      for (const p of theirs) {
+        if (await ctx.links.byRemoteId(PAYMENT_ENTITY, p.payment_id)) continue
+        const amount = p.amount ?? 0
+        if (!(amount > 0)) continue
+        const made = await recordPulledPayment(orgId, {
+          serviceRecordId: link.entityId,
+          amount: round2(amount),
+          date: paymentDay(p.date, ctx.timezone),
+          method: 'other',
+          provider: PROVIDER,
+          externalId: p.payment_id,
+          note: ['Recorded in Zoho Books', p.reference_number ? `Ref ${p.reference_number}` : null]
+            .filter(Boolean)
+            .join('. '),
+        })
+        if (!made) continue
+        await setLink(
+          ctx,
+          PAYMENT_ENTITY,
+          made.id,
+          { remoteId: p.payment_id },
+          { createdByUs: false, serviceRecordId: link.entityId, invoiceId: remote.invoice_id }
+        )
+        if (made.created) recorded++
+      }
+      // Payments this app knows on the invoice that Zoho no longer has.
+      const inv = await loadInvoiceForAccounting(orgId, link.entityId)
+      for (const p of inv?.payments ?? []) {
+        const pl = await linkFor(ctx, PAYMENT_ENTITY, p.id)
+        if (!pl || pl.metadata?.removedInZoho || theirIds.has(pl.remoteId)) continue
+        if (pl.metadata?.createdByUs === true) {
+          await ctx.log(
+            'warn',
+            `A payment of ${round2(p.amount)} sent from here was removed in Zoho Books (invoice ${remote.invoice_number ?? remote.invoice_id}); the payment here is kept`
+          )
+          // The link stays, marked, so the payment is not sent to Zoho again:
+          // the bookkeeper removed it there on purpose.
+          await patchLink(ctx, PAYMENT_ENTITY, p.id, pl, { removedInZoho: true })
+          continue
+        }
+        if (await removePulledPayment(orgId, p.id, PROVIDER)) removed++
+        await ctx.links.remove(PAYMENT_ENTITY, p.id)
+      }
+    }
+
+    if (settings.pushPayments && remote.status !== 'draft') {
+      // Sent in Zoho since the last pull: payments waiting for that go now.
+      const inv = await loadInvoiceForAccounting(orgId, link.entityId)
+      const contactId = (link.metadata?.contactId as string | undefined) ?? remote.customer_id
+      if (inv && contactId) {
+        const pushed = await pushPendingPayments(ctx, inv, remote.invoice_id, contactId)
+        released += pushed.count
+      }
+    }
+  }
+
+  await ctx.saveState({
+    lastPull: { organizationId: settings.organizationId, at: startedAt.toISOString() },
+  })
+  const parts = [
+    recorded ? `${recorded} payments recorded` : null,
+    removed ? `${removed} payments removed` : null,
+    unlinked ? `${unlinked} invoices voided in Zoho Books` : 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
+}
+
+/**
+ * Lists for the settings form, shared for a minute per connection and list.
+ * The form asks for every dropdown at once, several of them the same list
+ * (five payment-account fields), and Zoho answers a burst like that with
+ * 429; one call per list serves them all.
+ */
+const listCache = new Map<string, { at: number; value: Promise<unknown> }>()
+const LIST_TTL_MS = 60_000
+
+function shared<T>(ctx: ConnectorContext, key: string, fn: () => Promise<T>): Promise<T> {
+  const id = `${ctx.connection.id}:${settingsOf(ctx).organizationId}:${key}`
+  const hit = listCache.get(id)
+  if (hit && Date.now() - hit.at < LIST_TTL_MS) return hit.value as Promise<T>
+  const value = fn()
+  listCache.set(id, { at: Date.now(), value })
+  value.catch(() => listCache.delete(id))
+  return value
+}
+
+/** Settings lists, read for the organisation chosen, or empty before one is. */
+function forOrg<T>(fn: (ctx: ConnectorContext) => Promise<T[]>) {
+  return async (ctx: ConnectorContext) => (settingsOf(ctx).organizationId ? fn(ctx) : [])
+}
+
+function activeAccounts(ctx: ConnectorContext): Promise<ZohoAccount[]> {
+  return shared(ctx, 'accounts', () =>
+    listAll<ZohoAccount>(ctx, 'chartofaccounts', 'chartofaccounts', {
+      filter_by: 'AccountType.Active',
+    })
+  )
+}
+
+function catalogForForm(ctx: ConnectorContext): Promise<Catalog> {
+  return shared(ctx, 'catalog', () => loadCatalog(ctx, { refresh: true }))
+}
+
+function accountLabel(a: ZohoAccount): string {
+  return a.account_code
+    ? `${a.account_code} ${a.account_name ?? ''}`.trim()
+    : (a.account_name ?? a.account_id)
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+  /**
+   * Zoho's 429s say which limit it was. Blocked for a while (43), out of
+   * calls for the minute (44) or the day (45): a retry within seconds only
+   * keeps the block going, so those fail at once. Too many calls at once
+   * (1070) and server errors clear by themselves and are retried.
+   */
+  shouldRetry(status, body) {
+    if (status !== 429) return true
+    const { code } = errorParts(body)
+    return code === null || ![43, 44, CODE_DAILY_LIMIT].includes(code)
+  },
+  /** Who connected: the organisations they can reach. The ids are kept off the row; a hash tells two consents apart. */
+  async identify(ctx) {
+    const orgs = await organizationsOf(ctx)
+    if (!orgs.length) {
+      throw new Error(
+        'This Zoho account has no Zoho Books organisation; create one in Zoho Books and connect again'
+      )
+    }
+    const ids = orgs.map((o) => o.organization_id).sort()
+    const name =
+      orgs.length === 1
+        ? (orgs[0].name ?? 'Zoho Books')
+        : `${orgs[0].name ?? 'Zoho Books'} and ${orgs.length - 1} more`
+    return {
+      id: `zoho-${createHash('sha256').update(ids.join(',')).digest('hex').slice(0, 16)}`,
+      name,
+    }
+  },
+  /** One organisation reachable: it is the one, and the form need not ask. */
+  async onConnect(ctx) {
+    if (settingsOf(ctx).organizationId) return
+    const orgs = await organizationsOf(ctx)
+    if (orgs.length === 1) return { settings: { organizationId: orgs[0].organization_id } }
+  },
+  async test(ctx) {
+    try {
+      const orgs = await organizationsOf(ctx)
+      const organizationId = settingsOf(ctx).organizationId
+      if (!organizationId) return { ok: true }
+      if (!orgs.some((o) => o.organization_id === organizationId)) {
+        return {
+          ok: false,
+          message:
+            'The chosen Zoho Books organisation is no longer reachable with this connection. Reconnect, or choose another organisation.',
+        }
+      }
+      const org = await loadOrg(ctx, { refresh: true })
+      await loadCatalog(ctx, { refresh: true })
+      return { ok: true, message: org.name }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : String(err) }
+    }
+  },
+  remoteOptions: {
+    async organizations(ctx) {
+      const orgs = await shared(ctx, 'organizations', () => organizationsOf(ctx))
+      return orgs
+        .map((o) => ({
+          value: o.organization_id,
+          label: o.currency_code
+            ? `${o.name ?? o.organization_id} (${o.currency_code})`
+            : (o.name ?? o.organization_id),
+        }))
+        .sort((a, b) => a.label.localeCompare(b.label))
+    },
+    templates: forOrg(async (ctx) => {
+      const res = await shared(ctx, 'templates', () =>
+        api<{ templates?: { template_id: string; template_name?: string }[] }>(
+          ctx,
+          'invoices/templates'
+        )
+      )
+      return (res.templates ?? []).map((t) => ({
+        value: t.template_id,
+        label: t.template_name ?? t.template_id,
+      }))
+    }),
+    locations: forOrg(async (ctx) => {
+      const res = await shared(ctx, 'locations', () =>
+        api<{
+          locations?: {
+            location_id: string
+            location_name?: string
+            is_location_active?: boolean
+          }[]
+        }>(ctx, 'locations')
+      )
+      return (res.locations ?? [])
+        .filter((l) => l.is_location_active !== false)
+        .map((l) => ({ value: l.location_id, label: l.location_name ?? l.location_id }))
+    }),
+    incomeAccounts: forOrg(async (ctx) => {
+      const accounts = await activeAccounts(ctx)
+      return accounts
+        .filter((a) => a.account_type === 'income' || a.account_type === 'other_income')
+        .sort((a, b) =>
+          accountLabel(a).localeCompare(accountLabel(b), undefined, { numeric: true })
+        )
+        .map((a) => ({ value: a.account_id, label: accountLabel(a) }))
+    }),
+    paymentAccounts: forOrg(async (ctx) => {
+      const accounts = await activeAccounts(ctx)
+      return accounts
+        .filter(
+          (a) =>
+            a.account_type === 'bank' ||
+            a.account_type === 'cash' ||
+            a.account_type === 'credit_card' ||
+            a.account_type === 'other_current_asset'
+        )
+        .sort((a, b) => accountLabel(a).localeCompare(accountLabel(b)))
+        .map((a) => ({ value: a.account_id, label: accountLabel(a) }))
+    }),
+    items: forOrg(async (ctx) => {
+      const items = await shared(ctx, 'items', () =>
+        listAll<ZohoItem>(ctx, 'items', 'items', { filter_by: 'Status.Active' })
+      )
+      return items
+        .filter((i) => i.item_type !== 'purchases')
+        .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
+        .map((i) => ({
+          value: i.item_id,
+          label: i.sku ? `${i.name ?? i.item_id} (${i.sku})` : (i.name ?? i.item_id),
+        }))
+    }),
+    taxes: forOrg(async (ctx) => {
+      const catalog = await catalogForForm(ctx)
+      return catalog.taxes
+        .filter((t) => t.percent > 0)
+        .map((t) => ({ value: t.id, label: `${t.name} (${t.percent}%)` }))
+    }),
+    /** Only zero taxes: a rate with a percentage on an untaxed line would add tax nobody billed. */
+    zeroTaxes: forOrg(async (ctx) => {
+      const catalog = await catalogForForm(ctx)
+      return catalog.taxes
+        .filter((t) => t.percent === 0)
+        .map((t) => ({ value: t.id, label: `${t.name} (0%)` }))
+    }),
+  },
+  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 on the account's own sign-in server. Zoho
+   * keeps at most twenty per user and drops the oldest silently, so one
+   * left behind on every reconnect would in time cut off another app.
+   */
+  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 (!spec || !client || !token) return
+    try {
+      const revoke = tokenUrlFor(spec, ctx.credentials).replace(/\/token$/, '/token/revoke')
+      await fetch(`${revoke}?token=${encodeURIComponent(token)}`, { method: 'POST' })
+    } catch (err) {
+      await ctx.log('warn', `Could not revoke the Zoho token: ${String(err)}`)
+    }
+  },
+}