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

Add a QuickBooks Online connector: invoices, customers and payments both ways (#324)

Bernt Christian Egeland 3 недель назад
Родитель
Сommit
60dcf39c09

+ 4 - 0
.env.example

@@ -32,3 +32,7 @@ MICROSOFT_INTEGRATION_CLIENT_ID=""
 MICROSOFT_INTEGRATION_CLIENT_SECRET=""
 ZOOM_INTEGRATION_CLIENT_ID=""
 ZOOM_INTEGRATION_CLIENT_SECRET=""
+# Intuit development keys reach sandbox companies, production keys live ones;
+# the connector works out which it was given.
+QUICKBOOKS_INTEGRATION_CLIENT_ID=""
+QUICKBOOKS_INTEGRATION_CLIENT_SECRET=""

+ 45 - 0
messages/de/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Bezahlen über den Rechnungslink"
+    },
+    "accounting": {
+      "invoices": "Rechnungen ins Buchhaltungssystem",
+      "customers": "Kunden ins Buchhaltungssystem",
+      "payments": "Zahlungen in beide Richtungen"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Synchronisierung eingereiht; sie läuft innerhalb einer Minute",
     "backfill": "Geplante Aufträge übertragen",
     "backfillQueued": "Die geplanten Aufträge ab der letzten Woche wurden eingereiht",
+    "backfillInvoices": "Ausgestellte Rechnungen übertragen",
+    "backfillInvoicesQueued": "Die in den letzten 90 Tagen ausgestellten Rechnungen wurden eingereiht",
     "retryQueued": "Job erneut eingereiht",
     "settingsTitle": "Einstellungen",
     "settingsDescription": "Was synchronisiert wird und wohin. Änderungen gelten ab der nächsten Synchronisierung.",
@@ -475,6 +482,38 @@
         "sandbox": "Sandbox-Modus",
         "sandboxHelp": "Die PayPal-Sandbox mit Sandbox-App-Schlüsseln verwenden. Lassen Sie ihn an, bis eine vollständige Zahlung durchgelaufen ist, und tragen Sie dann die Schlüssel der Live-App ein."
       }
+    },
+    "quickbooks": {
+      "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an QuickBooks Online und hole dort erfasste Zahlungen zurück.",
+      "tenantHelp": "Erstelle eine App im Intuit Developer Portal. Kopiere Client-ID und Secret unter Keys and credentials und trage die Redirect-URI unten unter Settings, Redirect URIs ein. Entwicklungsschlüssel erreichen Sandbox-Firmen, Produktionsschlüssel echte.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "pushInvoices": "Ausgestellte Rechnungen an QuickBooks 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": "JJJJ-MM-TT. Leer lassen, um jede ausgestellte Rechnung zu senden. Nutze das Datum, an dem deine Bücher zu QuickBooks gewechselt sind, damit ältere Rechnungen nicht doppelt erfasst werden.",
+        "includeVehicle": "Fahrzeug auf der Rechnung nennen",
+        "includeVehicleHelp": "Eine Textzeile über den Positionen mit Baujahr, Marke, Modell, Kennzeichen und Kilometerstand, damit die Buchhaltung weiß, um welches Auto es geht.",
+        "laborItemId": "Artikel für Arbeitspositionen",
+        "laborItemIdHelp": "Leer lassen, dann wird beim ersten Mal ein Artikel namens Labour unter deinem Ertragskonto angelegt.",
+        "partsItemId": "Artikel für Teilepositionen",
+        "partsItemIdHelp": "Leer lassen, dann wird beim ersten Mal ein Artikel namens Parts unter deinem Ertragskonto angelegt.",
+        "taxCodeId": "Steuercode für steuerpflichtige Rechnungen",
+        "taxCodeIdHelp": "QuickBooks berechnet die Steuer aus diesem Code. Wähle den, dessen Satz dem Steuersatz deiner Rechnungen entspricht. In einer US-Firma TAX wählen.",
+        "zeroTaxCodeId": "Steuercode für steuerfreie Rechnungen",
+        "zeroTaxCodeIdHelp": "Wird verwendet, wenn die Rechnung keine Steuer hat oder der Kunde steuerbefreit ist. In einer US-Firma NON wählen.",
+        "pushPayments": "Zahlungen in QuickBooks erfassen",
+        "depositAccountId": "Zahlungen einzahlen auf",
+        "depositAccountIdHelp": "Leer lassen, um Undeposited Funds 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 QuickBooks eine Zahlung über den dort noch offenen Betrag. Ausgeschaltet bleibt sie dort offen, bis eine Zahlung erfasst wird.",
+        "pullPayments": "In QuickBooks erfasste Zahlungen zurückholen",
+        "pullPaymentsHelp": "Alle 30 Minuten. Eine in QuickBooks gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "HU-Termine aktualisieren"
+    },
+    "accounting": {
+      "invoice": "Rechnung an die Buchhaltung",
+      "payment": "Zahlung an die Buchhaltung",
+      "customer": "Kunde an die Buchhaltung",
+      "pull": "Zahlungen aus der Buchhaltung"
     }
   }
 }

+ 45 - 0
messages/en/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Pay from the invoice link"
+    },
+    "accounting": {
+      "invoices": "Invoices to the ledger",
+      "customers": "Customers to the ledger",
+      "payments": "Payments both ways"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Sync queued; it runs within a minute",
     "backfill": "Push scheduled work orders",
     "backfillQueued": "Queued the scheduled work orders from the last week onwards",
+    "backfillInvoices": "Push issued invoices",
+    "backfillInvoicesQueued": "Queued the invoices issued in the last 90 days",
     "retryQueued": "Job queued again",
     "settingsTitle": "Settings",
     "settingsDescription": "What to sync and where. Changes apply to the next sync.",
@@ -475,6 +482,38 @@
         "sandbox": "Sandbox mode",
         "sandboxHelp": "Use PayPal's sandbox with sandbox app keys. Leave it on until a full payment has gone through, then turn it off and enter the live app's keys."
       }
+    },
+    "quickbooks": {
+      "description": "Send issued invoices, their customers and their payments to QuickBooks Online, and bring back payments recorded there.",
+      "tenantHelp": "Create an app in the Intuit Developer portal. Copy the client id and secret from Keys and credentials, and enter the redirect URI below under Settings, Redirect URIs. Development keys reach sandbox companies, production keys live ones.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "pushInvoices": "Send issued invoices to QuickBooks",
+        "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": "YYYY-MM-DD. Leave empty to send every issued invoice. Use the date your books moved to QuickBooks so older invoices are not entered twice.",
+        "includeVehicle": "Name the vehicle on the invoice",
+        "includeVehicleHelp": "A text line above the charges with the year, make, model, plate and mileage, so the books say which car.",
+        "laborItemId": "Item for labour lines",
+        "laborItemIdHelp": "Leave empty and an item named Labour is created under your income account the first time.",
+        "partsItemId": "Item for parts lines",
+        "partsItemIdHelp": "Leave empty and an item named Parts is created under your income account the first time.",
+        "taxCodeId": "Tax code for taxable invoices",
+        "taxCodeIdHelp": "QuickBooks works the tax out from this code, so pick the one whose rate matches the tax rate on your invoices. In a US company choose TAX.",
+        "zeroTaxCodeId": "Tax code for tax-free invoices",
+        "zeroTaxCodeIdHelp": "Used when the invoice has no tax or the customer is tax exempt. In a US company choose NON.",
+        "pushPayments": "Record payments in QuickBooks",
+        "depositAccountId": "Deposit payments to",
+        "depositAccountIdHelp": "Leave empty to use Undeposited Funds.",
+        "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 QuickBooks for whatever it still shows as owed. Off, it stays open there until a payment is recorded.",
+        "pullPayments": "Bring back payments recorded in QuickBooks",
+        "pullPaymentsHelp": "Every 30 minutes. A payment received in QuickBooks against an invoice from Torqvoice is recorded on the invoice here."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Inspection date refresh"
+    },
+    "accounting": {
+      "invoice": "Invoice to ledger",
+      "payment": "Payment to ledger",
+      "customer": "Customer to ledger",
+      "pull": "Payments from ledger"
     }
   }
 }

+ 45 - 0
messages/es/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Pagar desde el enlace de la factura"
+    },
+    "accounting": {
+      "invoices": "Facturas al libro contable",
+      "customers": "Clientes al libro contable",
+      "payments": "Pagos en ambos sentidos"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Sincronización en cola; se ejecuta en menos de un minuto",
     "backfill": "Enviar órdenes de trabajo programadas",
     "backfillQueued": "Se pusieron en cola las órdenes de trabajo programadas desde la semana pasada en adelante",
+    "backfillInvoices": "Enviar facturas emitidas",
+    "backfillInvoicesQueued": "Se pusieron en cola las facturas emitidas en los últimos 90 días",
     "retryQueued": "Tarea puesta en cola de nuevo",
     "settingsTitle": "Configuración",
     "settingsDescription": "Qué sincronizar y dónde. Los cambios se aplican en la próxima sincronización.",
@@ -475,6 +482,38 @@
         "sandbox": "Modo sandbox",
         "sandboxHelp": "Usar el sandbox de PayPal con las claves de la app de sandbox. Déjalo activado hasta que un pago completo haya funcionado, luego desactívalo e introduce las claves de la app real."
       }
+    },
+    "quickbooks": {
+      "description": "Envía las facturas emitidas, sus clientes y sus pagos a QuickBooks Online y trae de vuelta los pagos registrados allí.",
+      "tenantHelp": "Crea una app en el portal de desarrolladores de Intuit. Copia el client id y el secret de Keys and credentials e introduce la URI de redirección de abajo en Settings, Redirect URIs. Las claves de desarrollo llegan a empresas sandbox, las de producción a empresas reales.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "pushInvoices": "Enviar facturas emitidas a QuickBooks",
+        "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": "AAAA-MM-DD. Déjalo vacío para enviar todas las facturas emitidas. Usa la fecha en que tu contabilidad pasó a QuickBooks para no registrar dos veces las facturas antiguas.",
+        "includeVehicle": "Indicar el vehículo en la factura",
+        "includeVehicleHelp": "Una línea de texto encima de los cargos con año, marca, modelo, matrícula y kilometraje, para que la contabilidad sepa de qué coche se trata.",
+        "laborItemId": "Artículo para líneas de mano de obra",
+        "laborItemIdHelp": "Déjalo vacío y la primera vez se creará un artículo llamado Labour en tu cuenta de ingresos.",
+        "partsItemId": "Artículo para líneas de piezas",
+        "partsItemIdHelp": "Déjalo vacío y la primera vez se creará un artículo llamado Parts en tu cuenta de ingresos.",
+        "taxCodeId": "Código de impuesto para facturas gravadas",
+        "taxCodeIdHelp": "QuickBooks calcula el impuesto a partir de este código, así que elige el que tenga el mismo tipo que tus facturas. En una empresa de EE. UU. elige TAX.",
+        "zeroTaxCodeId": "Código de impuesto para facturas sin impuesto",
+        "zeroTaxCodeIdHelp": "Se usa cuando la factura no tiene impuesto o el cliente está exento. En una empresa de EE. UU. elige NON.",
+        "pushPayments": "Registrar pagos en QuickBooks",
+        "depositAccountId": "Depositar pagos en",
+        "depositAccountIdHelp": "Déjalo vacío para usar Undeposited Funds.",
+        "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 QuickBooks 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 QuickBooks",
+        "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en QuickBooks contra una factura de Torqvoice se registra aquí en la factura."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Actualización de fechas de inspección"
+    },
+    "accounting": {
+      "invoice": "Factura a contabilidad",
+      "payment": "Pago a contabilidad",
+      "customer": "Cliente a contabilidad",
+      "pull": "Pagos desde contabilidad"
     }
   }
 }

+ 45 - 0
messages/fr/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Payer depuis le lien de la facture"
+    },
+    "accounting": {
+      "invoices": "Factures vers la comptabilité",
+      "customers": "Clients vers la comptabilité",
+      "payments": "Paiements dans les deux sens"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Synchronisation en file d'attente ; elle démarre d'ici une minute",
     "backfill": "Envoyer les ordres de travail planifiés",
     "backfillQueued": "Les ordres de travail planifiés depuis la semaine dernière ont été mis en file d'attente",
+    "backfillInvoices": "Envoyer les factures émises",
+    "backfillInvoicesQueued": "Les factures émises ces 90 derniers jours ont été mises en file",
     "retryQueued": "Tâche remise en file d'attente",
     "settingsTitle": "Paramètres",
     "settingsDescription": "Ce qu'il faut synchroniser et vers où. Les modifications s'appliquent à la prochaine synchronisation.",
@@ -475,6 +482,38 @@
         "sandbox": "Mode sandbox",
         "sandboxHelp": "Utiliser le sandbox PayPal avec les clés de l'application sandbox. Laissez-le activé jusqu'à ce qu'un paiement complet ait abouti, puis désactivez-le et saisissez les clés de l'application réelle."
       }
+    },
+    "quickbooks": {
+      "description": "Envoyez les factures émises, leurs clients et leurs paiements vers QuickBooks Online, et récupérez les paiements enregistrés là-bas.",
+      "tenantHelp": "Créez une application sur le portail développeur Intuit. Copiez le client id et le secret depuis Keys and credentials, et saisissez l'URI de redirection ci-dessous sous Settings, Redirect URIs. Les clés de développement atteignent les entreprises sandbox, les clés de production les vraies.",
+      "fields": {
+        "clientId": "Identifiant client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "pushInvoices": "Envoyer les factures émises vers QuickBooks",
+        "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": "AAAA-MM-JJ. Laissez vide pour envoyer toutes les factures émises. Utilisez la date où votre comptabilité est passée sur QuickBooks pour ne pas saisir deux fois les anciennes factures.",
+        "includeVehicle": "Indiquer le véhicule sur la facture",
+        "includeVehicleHelp": "Une ligne de texte au-dessus des lignes facturées avec l'année, la marque, le modèle, la plaque et le kilométrage, pour que la comptabilité sache de quelle voiture il s'agit.",
+        "laborItemId": "Article pour les lignes de main-d'œuvre",
+        "laborItemIdHelp": "Laissez vide et un article nommé Labour sera créé sous votre compte de produits la première fois.",
+        "partsItemId": "Article pour les lignes de pièces",
+        "partsItemIdHelp": "Laissez vide et un article nommé Parts sera créé sous votre compte de produits la première fois.",
+        "taxCodeId": "Code de taxe pour les factures taxables",
+        "taxCodeIdHelp": "QuickBooks calcule la taxe à partir de ce code : choisissez celui dont le taux correspond à celui de vos factures. Dans une entreprise américaine, choisissez TAX.",
+        "zeroTaxCodeId": "Code de taxe pour les factures sans taxe",
+        "zeroTaxCodeIdHelp": "Utilisé quand la facture n'a pas de taxe ou que le client est exonéré. Dans une entreprise américaine, choisissez NON.",
+        "pushPayments": "Enregistrer les paiements dans QuickBooks",
+        "depositAccountId": "Déposer les paiements sur",
+        "depositAccountIdHelp": "Laissez vide pour utiliser Undeposited Funds.",
+        "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 QuickBooks 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 QuickBooks",
+        "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans QuickBooks sur une facture Torqvoice est enregistré ici sur la facture."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Mise à jour des dates de contrôle"
+    },
+    "accounting": {
+      "invoice": "Facture vers la comptabilité",
+      "payment": "Paiement vers la comptabilité",
+      "customer": "Client vers la comptabilité",
+      "pull": "Paiements depuis la comptabilité"
     }
   }
 }

+ 45 - 0
messages/it/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Paga dal link della fattura"
+    },
+    "accounting": {
+      "invoices": "Fatture in contabilità",
+      "customers": "Clienti in contabilità",
+      "payments": "Pagamenti in entrambe le direzioni"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Sincronizzazione in coda; verrà eseguita entro un minuto",
     "backfill": "Invia gli ordini di lavoro pianificati",
     "backfillQueued": "Messi in coda gli ordini di lavoro pianificati dall'ultima settimana in poi",
+    "backfillInvoices": "Invia le fatture emesse",
+    "backfillInvoicesQueued": "Le fatture emesse negli ultimi 90 giorni sono in coda",
     "retryQueued": "Attività rimessa in coda",
     "settingsTitle": "Impostazioni",
     "settingsDescription": "Cosa sincronizzare e dove. Le modifiche si applicano alla prossima sincronizzazione.",
@@ -475,6 +482,38 @@
         "sandbox": "Modalità sandbox",
         "sandboxHelp": "Usa la sandbox di PayPal con le chiavi dell'app sandbox. Lasciala attiva finché un pagamento completo non è andato a buon fine, poi disattivala e inserisci le chiavi dell'app live."
       }
+    },
+    "quickbooks": {
+      "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a QuickBooks Online e riporta i pagamenti registrati lì.",
+      "tenantHelp": "Crea un'app nel portale sviluppatori Intuit. Copia client id e secret da Keys and credentials e inserisci l'URI di reindirizzamento qui sotto in Settings, Redirect URIs. Le chiavi di sviluppo raggiungono le aziende sandbox, quelle di produzione le aziende reali.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "pushInvoices": "Invia le fatture emesse a QuickBooks",
+        "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": "AAAA-MM-GG. Lascia vuoto per inviare ogni fattura emessa. Usa la data in cui la contabilità è passata a QuickBooks, così le fatture più vecchie non vengono registrate due volte.",
+        "includeVehicle": "Indicare il veicolo sulla fattura",
+        "includeVehicleHelp": "Una riga di testo sopra le voci con anno, marca, modello, targa e chilometraggio, così la contabilità sa di quale auto si tratta.",
+        "laborItemId": "Articolo per le righe di manodopera",
+        "laborItemIdHelp": "Lascia vuoto e la prima volta verrà creato un articolo chiamato Labour sotto il tuo conto ricavi.",
+        "partsItemId": "Articolo per le righe di ricambi",
+        "partsItemIdHelp": "Lascia vuoto e la prima volta verrà creato un articolo chiamato Parts sotto il tuo conto ricavi.",
+        "taxCodeId": "Codice imposta per le fatture imponibili",
+        "taxCodeIdHelp": "QuickBooks calcola l'imposta da questo codice: scegli quello con la stessa aliquota delle tue fatture. In un'azienda USA scegli TAX.",
+        "zeroTaxCodeId": "Codice imposta per le fatture senza imposta",
+        "zeroTaxCodeIdHelp": "Usato quando la fattura non ha imposta o il cliente è esente. In un'azienda USA scegli NON.",
+        "pushPayments": "Registra i pagamenti in QuickBooks",
+        "depositAccountId": "Deposita i pagamenti su",
+        "depositAccountIdHelp": "Lascia vuoto per usare Undeposited Funds.",
+        "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 QuickBooks un pagamento per quanto risulta ancora dovuto lì. Disattivato, resta aperta lì finché non viene registrato un pagamento.",
+        "pullPayments": "Riporta i pagamenti registrati in QuickBooks",
+        "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in QuickBooks su una fattura di Torqvoice viene registrato qui sulla fattura."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Aggiornamento scadenze revisione"
+    },
+    "accounting": {
+      "invoice": "Fattura in contabilità",
+      "payment": "Pagamento in contabilità",
+      "customer": "Cliente in contabilità",
+      "pull": "Pagamenti dalla contabilità"
     }
   }
 }

+ 45 - 0
messages/lt/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Mokėti iš sąskaitos nuorodos"
+    },
+    "accounting": {
+      "invoices": "Sąskaitos į apskaitą",
+      "customers": "Klientai į apskaitą",
+      "payments": "Mokėjimai abiem kryptimis"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Sinchronizavimas eilėje; jis įvyks per minutę",
     "backfill": "Perkelti suplanuotus darbo užsakymus",
     "backfillQueued": "Į eilę įtraukti suplanuoti darbo užsakymai nuo praėjusios savaitės",
+    "backfillInvoices": "Siųsti išrašytas sąskaitas",
+    "backfillInvoicesQueued": "Per pastarąsias 90 dienų išrašytos sąskaitos įtrauktos į eilę",
     "retryQueued": "Užduotis vėl įtraukta į eilę",
     "settingsTitle": "Nustatymai",
     "settingsDescription": "Ką sinchronizuoti ir kur. Pakeitimai įsigalios kito sinchronizavimo metu.",
@@ -475,6 +482,38 @@
         "sandbox": "Smėlio dėžės režimas",
         "sandboxHelp": "Naudoti PayPal smėlio dėžę su smėlio dėžės programėlės raktais. Palikite įjungtą, kol pavyks visas mokėjimas, tada išjunkite ir įveskite tikros programėlės raktus."
       }
+    },
+    "quickbooks": {
+      "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į QuickBooks Online ir parsisiųskite ten užregistruotus mokėjimus.",
+      "tenantHelp": "Sukurkite programą Intuit kūrėjų portale. Nukopijuokite client id ir secret iš Keys and credentials, o žemiau esantį peradresavimo URI įveskite skiltyje Settings, Redirect URIs. Kūrimo raktai pasiekia sandbox įmones, gamybos raktai tikras.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "pushInvoices": "Siųsti išrašytas sąskaitas į QuickBooks",
+        "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": "MMMM-MM-DD. Palikite tuščią, kad būtų siunčiamos visos išrašytos sąskaitos. Naudokite datą, kada apskaita perkelta į QuickBooks, kad senesnės sąskaitos nebūtų įvestos dukart.",
+        "includeVehicle": "Nurodyti transporto priemonę sąskaitoje",
+        "includeVehicleHelp": "Teksto eilutė virš pozicijų su metais, marke, modeliu, numeriu ir rida, kad apskaita žinotų, apie kurį automobilį kalbama.",
+        "laborItemId": "Prekė darbo eilutėms",
+        "laborItemIdHelp": "Palikite tuščią ir pirmą kartą po jūsų pajamų sąskaita bus sukurta prekė pavadinimu Labour.",
+        "partsItemId": "Prekė dalių eilutėms",
+        "partsItemIdHelp": "Palikite tuščią ir pirmą kartą po jūsų pajamų sąskaita bus sukurta prekė pavadinimu Parts.",
+        "taxCodeId": "Mokesčio kodas apmokestinamoms sąskaitoms",
+        "taxCodeIdHelp": "QuickBooks apskaičiuoja mokestį pagal šį kodą, todėl pasirinkite tą, kurio tarifas sutampa su jūsų sąskaitų mokesčio tarifu. JAV įmonėje pasirinkite TAX.",
+        "zeroTaxCodeId": "Mokesčio kodas sąskaitoms be mokesčio",
+        "zeroTaxCodeIdHelp": "Naudojamas, kai sąskaitoje nėra mokesčio arba klientas atleistas nuo mokesčio. JAV įmonėje pasirinkite NON.",
+        "pushPayments": "Registruoti mokėjimus QuickBooks",
+        "depositAccountId": "Mokėjimus įnešti į",
+        "depositAccountIdHelp": "Palikite tuščią, kad būtų naudojama Undeposited Funds.",
+        "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 QuickBooks gauna vieną mokėjimą likusiai ten rodomai sumai. Išjungus ji ten lieka atvira, kol užregistruojamas mokėjimas.",
+        "pullPayments": "Parsisiųsti QuickBooks užregistruotus mokėjimus",
+        "pullPaymentsHelp": "Kas 30 minučių. QuickBooks gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Apžiūros terminų atnaujinimas"
+    },
+    "accounting": {
+      "invoice": "Sąskaita į apskaitą",
+      "payment": "Mokėjimas į apskaitą",
+      "customer": "Klientas į apskaitą",
+      "pull": "Mokėjimai iš apskaitos"
     }
   }
 }

+ 45 - 0
messages/nb/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Betal fra fakturalenken"
+    },
+    "accounting": {
+      "invoices": "Fakturaer til regnskapet",
+      "customers": "Kunder til regnskapet",
+      "payments": "Betalinger begge veier"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Synkronisering lagt i kø; den kjører innen ett minutt",
     "backfill": "Send planlagte arbeidsordre",
     "backfillQueued": "La planlagte arbeidsordre fra og med forrige uke i kø",
+    "backfillInvoices": "Send utstedte fakturaer",
+    "backfillInvoicesQueued": "Fakturaene utstedt de siste 90 dagene er lagt i kø",
     "retryQueued": "Jobben er lagt i kø på nytt",
     "settingsTitle": "Innstillinger",
     "settingsDescription": "Hva som skal synkroniseres og hvor. Endringer gjelder fra neste synkronisering.",
@@ -475,6 +482,38 @@
         "sandbox": "Sandkassemodus",
         "sandboxHelp": "Bruk PayPal sin sandkasse med sandkasse-appens nøkler. La den stå på til en hel betaling har gått gjennom, slå den så av og legg inn live-appens nøkler."
       }
+    },
+    "quickbooks": {
+      "description": "Send utstedte fakturaer, kundene deres og betalingene deres til QuickBooks Online, og hent tilbake betalinger registrert der.",
+      "tenantHelp": "Opprett en app i Intuit Developer-portalen. Kopier klient-ID og hemmelighet fra Keys and credentials, og skriv inn omdirigerings-URI-en nedenfor under Settings, Redirect URIs. Utviklingsnøkler når sandkasse-selskaper, produksjonsnøkler ekte.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "pushInvoices": "Send utstedte fakturaer til QuickBooks",
+        "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": "ÅÅÅÅ-MM-DD. La stå tomt for å sende alle utstedte fakturaer. Bruk datoen regnskapet flyttet til QuickBooks, så eldre fakturaer ikke føres to ganger.",
+        "includeVehicle": "Oppgi kjøretøyet på fakturaen",
+        "includeVehicleHelp": "En tekstlinje over postene med årsmodell, merke, modell, skilt og kilometerstand, så regnskapet vet hvilken bil det gjelder.",
+        "laborItemId": "Vare for arbeidslinjer",
+        "laborItemIdHelp": "La stå tomt, så opprettes en vare kalt Labour under inntektskontoen din første gang.",
+        "partsItemId": "Vare for delelinjer",
+        "partsItemIdHelp": "La stå tomt, så opprettes en vare kalt Parts under inntektskontoen din første gang.",
+        "taxCodeId": "Avgiftskode for avgiftspliktige fakturaer",
+        "taxCodeIdHelp": "QuickBooks regner ut avgiften fra denne koden, så velg den med samme sats som mva-satsen på fakturaene dine. I et amerikansk selskap velger du TAX.",
+        "zeroTaxCodeId": "Avgiftskode for avgiftsfrie fakturaer",
+        "zeroTaxCodeIdHelp": "Brukes når fakturaen er uten avgift eller kunden er fritatt. I et amerikansk selskap velger du NON.",
+        "pushPayments": "Registrer betalinger i QuickBooks",
+        "depositAccountId": "Sett betalinger inn på",
+        "depositAccountIdHelp": "La stå tomt for å bruke Undeposited Funds.",
+        "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 QuickBooks 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 QuickBooks",
+        "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i QuickBooks mot en faktura fra Torqvoice registreres her på fakturaen."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Oppdatering av EU-kontrollfrister"
+    },
+    "accounting": {
+      "invoice": "Faktura til regnskap",
+      "payment": "Betaling til regnskap",
+      "customer": "Kunde til regnskap",
+      "pull": "Betalinger fra regnskap"
     }
   }
 }

+ 45 - 0
messages/nl/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Betalen via de factuurlink"
+    },
+    "accounting": {
+      "invoices": "Facturen naar de boekhouding",
+      "customers": "Klanten naar de boekhouding",
+      "payments": "Betalingen in beide richtingen"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Synchronisatie in wachtrij; wordt binnen een minuut uitgevoerd",
     "backfill": "Geplande werkorders doorsturen",
     "backfillQueued": "Geplande werkorders vanaf afgelopen week in de wachtrij gezet",
+    "backfillInvoices": "Uitgegeven facturen versturen",
+    "backfillInvoicesQueued": "De facturen van de afgelopen 90 dagen staan in de wachtrij",
     "retryQueued": "Taak opnieuw in de wachtrij gezet",
     "settingsTitle": "Instellingen",
     "settingsDescription": "Wat er wordt gesynchroniseerd en waarheen. Wijzigingen gelden vanaf de volgende synchronisatie.",
@@ -475,6 +482,38 @@
         "sandbox": "Sandboxmodus",
         "sandboxHelp": "Gebruik de sandbox van PayPal met de sleutels van de sandbox-app. Laat dit aan tot een volledige betaling is gelukt, zet het dan uit en vul de sleutels van de live-app in."
       }
+    },
+    "quickbooks": {
+      "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar QuickBooks Online en haal daar geregistreerde betalingen terug.",
+      "tenantHelp": "Maak een app aan in het Intuit Developer-portaal. Kopieer de client-id en het secret uit Keys and credentials en voer de redirect-URI hieronder in onder Settings, Redirect URIs. Ontwikkelsleutels bereiken sandbox-bedrijven, productiesleutels echte.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Clientgeheim"
+      },
+      "settings": {
+        "pushInvoices": "Uitgegeven facturen naar QuickBooks 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": "JJJJ-MM-DD. Laat leeg om elke uitgegeven factuur te sturen. Gebruik de datum waarop je boekhouding naar QuickBooks is overgegaan, zodat oudere facturen niet dubbel worden ingevoerd.",
+        "includeVehicle": "Het voertuig op de factuur vermelden",
+        "includeVehicleHelp": "Een tekstregel boven de posten met bouwjaar, merk, model, kenteken en kilometerstand, zodat de boekhouding weet om welke auto het gaat.",
+        "laborItemId": "Artikel voor arbeidsregels",
+        "laborItemIdHelp": "Laat leeg en de eerste keer wordt een artikel met de naam Labour aangemaakt onder je opbrengstenrekening.",
+        "partsItemId": "Artikel voor onderdelenregels",
+        "partsItemIdHelp": "Laat leeg en de eerste keer wordt een artikel met de naam Parts aangemaakt onder je opbrengstenrekening.",
+        "taxCodeId": "Btw-code voor belaste facturen",
+        "taxCodeIdHelp": "QuickBooks berekent de belasting uit deze code, kies dus de code met hetzelfde tarief als op je facturen. In een Amerikaans bedrijf kies je TAX.",
+        "zeroTaxCodeId": "Btw-code voor facturen zonder belasting",
+        "zeroTaxCodeIdHelp": "Gebruikt als de factuur geen belasting heeft of de klant is vrijgesteld. In een Amerikaans bedrijf kies je NON.",
+        "pushPayments": "Betalingen in QuickBooks registreren",
+        "depositAccountId": "Betalingen storten op",
+        "depositAccountIdHelp": "Laat leeg om Undeposited Funds 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 QuickBooks één betaling voor wat daar nog openstaat. Uit blijft hij daar open tot er een betaling wordt geregistreerd.",
+        "pullPayments": "In QuickBooks geregistreerde betalingen terughalen",
+        "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in QuickBooks op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Keuringsdatums verversen"
+    },
+    "accounting": {
+      "invoice": "Factuur naar boekhouding",
+      "payment": "Betaling naar boekhouding",
+      "customer": "Klant naar boekhouding",
+      "pull": "Betalingen uit boekhouding"
     }
   }
 }

+ 45 - 0
messages/pl/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Płatność z linku do faktury"
+    },
+    "accounting": {
+      "invoices": "Faktury do księgowości",
+      "customers": "Klienci do księgowości",
+      "payments": "Płatności w obie strony"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Synchronizacja w kolejce; uruchomi się w ciągu minuty",
     "backfill": "Wyślij zaplanowane zlecenia",
     "backfillQueued": "Dodano do kolejki zaplanowane zlecenia od ubiegłego tygodnia wzwyż",
+    "backfillInvoices": "Wyślij wystawione faktury",
+    "backfillInvoicesQueued": "Faktury wystawione w ciągu ostatnich 90 dni trafiły do kolejki",
     "retryQueued": "Zadanie ponownie w kolejce",
     "settingsTitle": "Ustawienia",
     "settingsDescription": "Co synchronizować i dokąd. Zmiany obowiązują od następnej synchronizacji.",
@@ -475,6 +482,38 @@
         "sandbox": "Tryb sandbox",
         "sandboxHelp": "Używaj sandboxa PayPal z kluczami aplikacji sandbox. Zostaw włączony, aż pełna płatność przejdzie, potem wyłącz i wpisz klucze aplikacji produkcyjnej."
       }
+    },
+    "quickbooks": {
+      "description": "Wysyłaj wystawione faktury, ich klientów i płatności do QuickBooks Online i pobieraj płatności zarejestrowane tam.",
+      "tenantHelp": "Utwórz aplikację w portalu Intuit Developer. Skopiuj client id i secret z Keys and credentials, a poniższy adres URI przekierowania wpisz w Settings, Redirect URIs. Klucze deweloperskie działają z firmami sandbox, produkcyjne z prawdziwymi.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "pushInvoices": "Wysyłaj wystawione faktury do QuickBooks",
+        "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": "RRRR-MM-DD. Zostaw puste, aby wysyłać każdą wystawioną fakturę. Użyj daty przeniesienia księgowości do QuickBooks, aby starsze faktury nie zostały wprowadzone dwa razy.",
+        "includeVehicle": "Podaj pojazd na fakturze",
+        "includeVehicleHelp": "Wiersz tekstu nad pozycjami z rocznikiem, marką, modelem, rejestracją i przebiegiem, aby księgowość wiedziała, o który samochód chodzi.",
+        "laborItemId": "Pozycja dla robocizny",
+        "laborItemIdHelp": "Zostaw puste, a za pierwszym razem na Twoim koncie przychodów zostanie utworzona pozycja o nazwie Labour.",
+        "partsItemId": "Pozycja dla części",
+        "partsItemIdHelp": "Zostaw puste, a za pierwszym razem na Twoim koncie przychodów zostanie utworzona pozycja o nazwie Parts.",
+        "taxCodeId": "Kod podatku dla faktur opodatkowanych",
+        "taxCodeIdHelp": "QuickBooks wylicza podatek z tego kodu, więc wybierz ten, którego stawka odpowiada stawce na Twoich fakturach. W firmie z USA wybierz TAX.",
+        "zeroTaxCodeId": "Kod podatku dla faktur bez podatku",
+        "zeroTaxCodeIdHelp": "Używany, gdy faktura nie ma podatku lub klient jest zwolniony. W firmie z USA wybierz NON.",
+        "pushPayments": "Rejestruj płatności w QuickBooks",
+        "depositAccountId": "Wpłacaj płatności na",
+        "depositAccountIdHelp": "Zostaw puste, aby użyć Undeposited Funds.",
+        "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 QuickBooks 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 QuickBooks",
+        "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w QuickBooks za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Odświeżenie terminów przeglądów"
+    },
+    "accounting": {
+      "invoice": "Faktura do księgowości",
+      "payment": "Płatność do księgowości",
+      "customer": "Klient do księgowości",
+      "pull": "Płatności z księgowości"
     }
   }
 }

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

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Pagar pelo link da fatura"
+    },
+    "accounting": {
+      "invoices": "Faturas para a contabilidade",
+      "customers": "Clientes para a contabilidade",
+      "payments": "Pagamentos nos dois sentidos"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Sincronização na fila; será executada em até um minuto",
     "backfill": "Enviar ordens de serviço agendadas",
     "backfillQueued": "Ordens de serviço agendadas a partir da semana passada colocadas na fila",
+    "backfillInvoices": "Enviar faturas emitidas",
+    "backfillInvoicesQueued": "As faturas emitidas nos últimos 90 dias entraram na fila",
     "retryQueued": "Tarefa colocada na fila novamente",
     "settingsTitle": "Configurações",
     "settingsDescription": "O que sincronizar e para onde. As alterações valem a partir da próxima sincronização.",
@@ -475,6 +482,38 @@
         "sandbox": "Modo sandbox",
         "sandboxHelp": "Usar o sandbox do PayPal com as chaves do app de sandbox. Deixe ligado até um pagamento completo funcionar, depois desligue e informe as chaves do app real."
       }
+    },
+    "quickbooks": {
+      "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o QuickBooks Online e traga de volta os pagamentos registrados lá.",
+      "tenantHelp": "Crie um app no portal de desenvolvedores da Intuit. Copie o client id e o secret em Keys and credentials e informe a URI de redirecionamento abaixo em Settings, Redirect URIs. Chaves de desenvolvimento alcançam empresas sandbox, chaves de produção as reais.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "pushInvoices": "Enviar faturas emitidas para o QuickBooks",
+        "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": "AAAA-MM-DD. Deixe vazio para enviar toda fatura emitida. Use a data em que sua contabilidade passou para o QuickBooks para não lançar faturas antigas duas vezes.",
+        "includeVehicle": "Indicar o veículo na fatura",
+        "includeVehicleHelp": "Uma linha de texto acima dos itens com ano, marca, modelo, placa e quilometragem, para que a contabilidade saiba de qual carro se trata.",
+        "laborItemId": "Item para linhas de mão de obra",
+        "laborItemIdHelp": "Deixe vazio e um item chamado Labour será criado na sua conta de receita na primeira vez.",
+        "partsItemId": "Item para linhas de peças",
+        "partsItemIdHelp": "Deixe vazio e um item chamado Parts será criado na sua conta de receita na primeira vez.",
+        "taxCodeId": "Código de imposto para faturas tributáveis",
+        "taxCodeIdHelp": "O QuickBooks calcula o imposto a partir deste código, então escolha o que tem a mesma alíquota das suas faturas. Em uma empresa dos EUA, escolha TAX.",
+        "zeroTaxCodeId": "Código de imposto para faturas sem imposto",
+        "zeroTaxCodeIdHelp": "Usado quando a fatura não tem imposto ou o cliente é isento. Em uma empresa dos EUA, escolha NON.",
+        "pushPayments": "Registrar pagamentos no QuickBooks",
+        "depositAccountId": "Depositar pagamentos em",
+        "depositAccountIdHelp": "Deixe vazio para usar Undeposited Funds.",
+        "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 QuickBooks 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 QuickBooks",
+        "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registrado aqui na fatura."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Atualização de datas de inspeção"
+    },
+    "accounting": {
+      "invoice": "Fatura para a contabilidade",
+      "payment": "Pagamento para a contabilidade",
+      "customer": "Cliente para a contabilidade",
+      "pull": "Pagamentos da contabilidade"
     }
   }
 }

+ 45 - 0
messages/ru/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Оплата по ссылке на счёт"
+    },
+    "accounting": {
+      "invoices": "Счета в бухгалтерию",
+      "customers": "Клиенты в бухгалтерию",
+      "payments": "Платежи в обе стороны"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Синхронизация поставлена в очередь и запустится в течение минуты",
     "backfill": "Отправить запланированные заказ-наряды",
     "backfillQueued": "Запланированные заказ-наряды за последнюю неделю и позже поставлены в очередь",
+    "backfillInvoices": "Отправить выставленные счета",
+    "backfillInvoicesQueued": "Счета, выставленные за последние 90 дней, поставлены в очередь",
     "retryQueued": "Задача снова поставлена в очередь",
     "settingsTitle": "Настройки",
     "settingsDescription": "Что синхронизировать и куда. Изменения применяются при следующей синхронизации.",
@@ -475,6 +482,38 @@
         "sandbox": "Режим песочницы",
         "sandboxHelp": "Использовать песочницу PayPal с ключами приложения песочницы. Оставьте включённым, пока не пройдёт полный платёж, затем выключите и введите ключи боевого приложения."
       }
+    },
+    "quickbooks": {
+      "description": "Отправляйте выставленные счета, их клиентов и платежи в QuickBooks Online и получайте обратно платежи, записанные там.",
+      "tenantHelp": "Создайте приложение на портале Intuit Developer. Скопируйте client id и secret из Keys and credentials, а URI перенаправления ниже введите в Settings, Redirect URIs. Ключи разработки работают с sandbox-компаниями, production-ключи с настоящими.",
+      "fields": {
+        "clientId": "Идентификатор клиента",
+        "clientSecret": "Секрет клиента"
+      },
+      "settings": {
+        "pushInvoices": "Отправлять выставленные счета в QuickBooks",
+        "pushOnComplete": "Также отправлять счета, когда заказ отмечен как выполненный",
+        "pushOnCompleteHelp": "Без этого счёт уходит, когда он отправлен, передан по ссылке или оплачен. С этим выполненный заказ уходит сразу и обновляется при изменениях.",
+        "startDate": "Только счета с датой не раньше",
+        "startDateHelp": "ГГГГ-ММ-ДД. Оставьте пустым, чтобы отправлять все выставленные счета. Укажите дату перехода бухгалтерии на QuickBooks, чтобы старые счета не вводились дважды.",
+        "includeVehicle": "Указывать автомобиль в счёте",
+        "includeVehicleHelp": "Текстовая строка над позициями с годом, маркой, моделью, номером и пробегом, чтобы бухгалтерия знала, о какой машине речь.",
+        "laborItemId": "Товар для строк работ",
+        "laborItemIdHelp": "Оставьте пустым, и в первый раз под вашим счётом доходов будет создан товар с именем Labour.",
+        "partsItemId": "Товар для строк запчастей",
+        "partsItemIdHelp": "Оставьте пустым, и в первый раз под вашим счётом доходов будет создан товар с именем Parts.",
+        "taxCodeId": "Налоговый код для облагаемых счетов",
+        "taxCodeIdHelp": "QuickBooks рассчитывает налог по этому коду, поэтому выберите тот, чья ставка совпадает со ставкой в ваших счетах. В компании из США выберите TAX.",
+        "zeroTaxCodeId": "Налоговый код для счетов без налога",
+        "zeroTaxCodeIdHelp": "Используется, когда в счёте нет налога или клиент освобождён от него. В компании из США выберите NON.",
+        "pushPayments": "Записывать платежи в QuickBooks",
+        "depositAccountId": "Зачислять платежи на",
+        "depositAccountIdHelp": "Оставьте пустым, чтобы использовать Undeposited Funds.",
+        "manualPaidAsPayment": "Записывать платёж, когда счёт отмечен оплаченным вручную",
+        "manualPaidAsPaymentHelp": "Счёт, отмеченный в Torqvoice как оплаченный без записи платежа, получает в QuickBooks один платёж на сумму, которая там ещё числится к оплате. Выключено: он остаётся там открытым, пока не будет записан платёж.",
+        "pullPayments": "Забирать платежи, записанные в QuickBooks",
+        "pullPaymentsHelp": "Каждые 30 минут. Платёж, полученный в QuickBooks по счёту из Torqvoice, записывается здесь на счёт."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Обновление сроков техосмотра"
+    },
+    "accounting": {
+      "invoice": "Счёт в бухгалтерию",
+      "payment": "Платёж в бухгалтерию",
+      "customer": "Клиент в бухгалтерию",
+      "pull": "Платежи из бухгалтерии"
     }
   }
 }

+ 45 - 0
messages/tr/integrations.json

@@ -58,6 +58,11 @@
     },
     "payments": {
       "checkout": "Fatura bağlantısından ödeme"
+    },
+    "accounting": {
+      "invoices": "Faturalar muhasebeye",
+      "customers": "Müşteriler muhasebeye",
+      "payments": "Ödemeler iki yönlü"
     }
   },
   "statuses": {
@@ -92,6 +97,8 @@
     "syncQueued": "Senkronizasyon sıraya alındı; bir dakika içinde çalışır",
     "backfill": "Planlanmış iş emirlerini gönder",
     "backfillQueued": "Geçen haftadan itibaren planlanmış iş emirleri sıraya alındı",
+    "backfillInvoices": "Kesilen faturaları gönder",
+    "backfillInvoicesQueued": "Son 90 günde kesilen faturalar kuyruğa alındı",
     "retryQueued": "İş yeniden sıraya alındı",
     "settingsTitle": "Ayarlar",
     "settingsDescription": "Neyin nereye senkronize edileceği. Değişiklikler bir sonraki senkronizasyonda uygulanır.",
@@ -475,6 +482,38 @@
         "sandbox": "Sandbox modu",
         "sandboxHelp": "PayPal sandbox'ını sandbox uygulama anahtarlarıyla kullanın. Tam bir ödeme geçene kadar açık bırakın, sonra kapatıp canlı uygulamanın anahtarlarını girin."
       }
+    },
+    "quickbooks": {
+      "description": "Kesilen faturaları, müşterilerini ve ödemelerini QuickBooks Online'a gönderin ve orada kaydedilen ödemeleri geri alın.",
+      "tenantHelp": "Intuit Developer portalında bir uygulama oluşturun. Client id ve secret'ı Keys and credentials'dan kopyalayın, aşağıdaki yönlendirme URI'sini Settings, Redirect URIs altına girin. Geliştirme anahtarları sandbox şirketlerine, üretim anahtarları gerçek şirketlere ulaşır.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci gizli anahtarı"
+      },
+      "settings": {
+        "pushInvoices": "Kesilen faturaları QuickBooks'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": "YYYY-AA-GG. Kesilen her faturayı göndermek için boş bırakın. Eski faturaların iki kez girilmemesi için defterlerinizin QuickBooks'a taşındığı tarihi kullanın.",
+        "includeVehicle": "Aracı faturada belirt",
+        "includeVehicleHelp": "Kalemlerin üzerinde yıl, marka, model, plaka ve kilometreyi içeren bir metin satırı; böylece defterler hangi araç olduğunu söyler.",
+        "laborItemId": "İşçilik satırları için kalem",
+        "laborItemIdHelp": "Boş bırakın; ilk seferde gelir hesabınızın altında Labour adlı bir kalem oluşturulur.",
+        "partsItemId": "Parça satırları için kalem",
+        "partsItemIdHelp": "Boş bırakın; ilk seferde gelir hesabınızın altında Parts adlı bir kalem oluşturulur.",
+        "taxCodeId": "Vergili faturalar için vergi kodu",
+        "taxCodeIdHelp": "QuickBooks vergiyi bu koddan hesaplar; oranı faturalarınızdaki vergi oranıyla eşleşeni seçin. ABD şirketinde TAX seçin.",
+        "zeroTaxCodeId": "Vergisiz faturalar için vergi kodu",
+        "zeroTaxCodeIdHelp": "Faturada vergi yoksa veya müşteri vergiden muafsa kullanılır. ABD şirketinde NON seçin.",
+        "pushPayments": "Ödemeleri QuickBooks'a kaydet",
+        "depositAccountId": "Ödemeleri şuraya yatır",
+        "depositAccountIdHelp": "Undeposited Funds 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, QuickBooks'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": "QuickBooks'ta kaydedilen ödemeleri geri al",
+        "pullPaymentsHelp": "Her 30 dakikada bir. QuickBooks'ta bir Torqvoice faturası için alınan ödeme burada faturaya kaydedilir."
+      }
     }
   },
   "jobKinds": {
@@ -487,6 +526,12 @@
     },
     "inspection": {
       "refresh": "Muayene tarihlerini yenileme"
+    },
+    "accounting": {
+      "invoice": "Fatura muhasebeye",
+      "payment": "Ödeme muhasebeye",
+      "customer": "Müşteri muhasebeye",
+      "pull": "Ödemeler muhasebeden"
     }
   }
 }

+ 9 - 0
public/images/integrations/quickbooks.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="QuickBooks">
+  <circle cx="32" cy="32" r="32" fill="#2CA01C"/>
+  <g fill="none" stroke="#FFFFFF" stroke-width="5.6" stroke-linecap="butt" stroke-linejoin="round">
+    <!-- q: bowl on the left, stem on its right dropping to the bottom -->
+    <path d="M22.5 42.5h-2.7a10.3 10.3 0 0 1 0-20.6h6.4v30.7"/>
+    <!-- b: bowl on the right, stem on its left rising to the top -->
+    <path d="M41.5 21.5h2.7a10.3 10.3 0 0 1 0 20.6h-6.4V11.4"/>
+  </g>
+</svg>

+ 860 - 0
src/__tests__/features/integrations/quickbooks-server.test.ts

@@ -0,0 +1,860 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import {
+  ConnectorHttpError,
+  type ConnectorContext,
+  type LinkRecord,
+  type LogLevel,
+} from '@/features/integrations/Lib/types'
+
+/**
+ * The connector against a scripted QuickBooks: every request it makes is
+ * recorded and answered from here, so the exact URLs, query parameters and
+ * bodies that would reach Intuit are asserted without a company. The
+ * loaders that read this app's database are replaced with fixtures.
+ */
+
+const loadInvoice = vi.fn<(orgId: string, id: string) => Promise<AccountingInvoice | null>>()
+const loadPayment = vi.fn<(orgId: string, id: string) => Promise<AccountingPayment | null>>()
+const loadCustomer = vi.fn<(orgId: string, id: string) => Promise<AccountingCustomer | null>>()
+const recordPulledPayment = vi.fn()
+const removePulledPayment = vi.fn()
+const workshopCurrency = vi.fn<() => Promise<string | null>>()
+
+vi.mock('@/features/integrations/Lib/accounting-sync', () => ({
+  INVOICE_ENTITY: 'ServiceRecord',
+  CUSTOMER_ENTITY: 'Customer',
+  PAYMENT_ENTITY: 'Payment',
+  invoiceUrl: (appUrl: string, inv: { id: string; vehicleId: string | null }) =>
+    inv.vehicleId
+      ? `${appUrl}/vehicles/${inv.vehicleId}/service/${inv.id}`
+      : `${appUrl}/sales/${inv.id}`,
+  loadInvoiceForAccounting: (orgId: string, id: string) => loadInvoice(orgId, id),
+  loadPaymentForAccounting: (orgId: string, id: string) => loadPayment(orgId, id),
+  loadCustomerForAccounting: (orgId: string, id: string) => loadCustomer(orgId, id),
+  recordPulledPayment: (...args: unknown[]) => recordPulledPayment(...args),
+  removePulledPayment: (...args: unknown[]) => removePulledPayment(...args),
+  workshopCurrency: () => workshopCurrency(),
+}))
+
+const { connector } = await import('@/integrations/quickbooks/server')
+
+interface Call {
+  method: string
+  path: string
+  query: Record<string, string>
+  body: Record<string, unknown> | null
+  host: string
+  headers: Record<string, string>
+}
+
+type Answer = (call: Call) => unknown
+
+function fault(status: number, code: string, message: string): ConnectorHttpError {
+  return new ConnectorHttpError(
+    status,
+    JSON.stringify({ Fault: { Error: [{ Message: message, Detail: message, code }] } }),
+    'https://quickbooks.api.intuit.com/x'
+  )
+}
+
+function makeCtx(input: {
+  settings?: Record<string, unknown>
+  state?: Record<string, unknown>
+  answer: Answer
+}) {
+  const calls: Call[] = []
+  const links = new Map<string, LinkRecord & { entityId: string; entityType: string }>()
+  const logs: { level: LogLevel; message: string }[] = []
+  const state: Record<string, unknown> = {
+    realmId: '9130357',
+    environment: 'production',
+    country: 'GB',
+    homeCurrency: 'GBP',
+    multiCurrency: false,
+    customTxnNumbers: true,
+    ...input.state,
+  }
+  const key = (t: string, e: string) => `${t}:${e}`
+  const ctx: ConnectorContext = {
+    connection: {
+      id: 'conn1',
+      organizationId: 'org1',
+      connectorId: 'quickbooks',
+      settings: { laborItemId: '7', partsItemId: '8', taxCodeId: '3', ...input.settings },
+      state,
+      externalAccountId: '9130357',
+    },
+    credentials: { accessToken: 'tok', refreshToken: 'ref' },
+    http: {
+      fetch: async () => {
+        throw new Error('not used')
+      },
+      async json<T>(url: string, init?: RequestInit): Promise<T> {
+        const u = new URL(url)
+        const headers = Object.fromEntries(new Headers(init?.headers).entries())
+        const call: Call = {
+          method: init?.method ?? 'GET',
+          host: u.host,
+          path: u.pathname,
+          query: Object.fromEntries(u.searchParams.entries()),
+          body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
+          headers,
+        }
+        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/London',
+    appUrl: 'https://shop.example.com',
+  }
+  return { ctx, calls, links, logs, state }
+}
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: 'Anna Berg',
+  email: 'anna@example.com',
+  phone: '+44 20 7946 0958',
+  address: '1 High Street\nLondon',
+  company: null,
+  taxId: null,
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  invoiceNumber: 'INV-1001',
+  status: 'completed',
+  issuedAt: new Date('2026-09-04T10:00:00Z'),
+  invoiceDate: new Date('2026-09-04T10:00:00Z'),
+  serviceDate: new Date('2026-09-03T10:00:00Z'),
+  dueDate: new Date('2026-09-18T10:00:00Z'),
+  mileage: 84200,
+  notes: null,
+  subtotal: 300,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 20,
+  taxAmount: 60,
+  taxInclusive: false,
+  totalAmount: 360,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'AB12 CDE' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Brake service',
+      partNumber: null,
+      quantity: 2,
+      unitPrice: 100,
+      total: 200,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 1,
+      unitPrice: 100,
+      total: 100,
+    },
+  ],
+  payments: [],
+}
+
+const payment: AccountingPayment = {
+  id: 'pay1',
+  serviceRecordId: 'svc1',
+  amount: 360,
+  date: new Date('2026-09-05T10:00:00Z'),
+  method: 'card',
+  note: null,
+  provider: 'stripe',
+  externalId: 'pi_123',
+}
+
+/** A QuickBooks that has nothing yet and accepts everything. */
+function emptyCompany(): Answer {
+  let nextId = 100
+  return (call) => {
+    if (call.path.endsWith('/query')) return { QueryResponse: {} }
+    if (call.method === 'POST' && call.path.endsWith('/customer'))
+      return { Customer: { Id: '58', SyncToken: '0', DisplayName: call.body?.DisplayName } }
+    if (call.method === 'POST' && call.path.endsWith('/invoice'))
+      return {
+        Invoice: {
+          Id: String(nextId++),
+          SyncToken: '0',
+          DocNumber: call.body?.DocNumber,
+          TotalAmt: 360,
+          Balance: 360,
+        },
+      }
+    if (call.method === 'POST' && call.path.endsWith('/payment'))
+      return { Payment: { Id: String(nextId++), SyncToken: '0' } }
+    throw new Error(`unexpected ${call.method} ${call.path}`)
+  }
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  loadInvoice.mockResolvedValue(invoice)
+  loadPayment.mockResolvedValue(payment)
+  loadCustomer.mockResolvedValue(customer)
+  workshopCurrency.mockResolvedValue('GBP')
+  recordPulledPayment.mockResolvedValue({ id: 'pulled1', created: true })
+  removePulledPayment.mockResolvedValue(true)
+})
+
+describe('QuickBooks: pushing an invoice', () => {
+  it('creates the customer, then the invoice, at the company URL with the minor version', async () => {
+    const t = makeCtx({ answer: emptyCompany() })
+    const out = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(out?.summary).toBe('invoice INV-1001 created')
+
+    const [lookup, createCustomer, createInvoice] = t.calls
+    expect(lookup.host).toBe('quickbooks.api.intuit.com')
+    expect(lookup.path).toBe('/v3/company/9130357/query')
+    expect(lookup.query.minorversion).toBe('75')
+    expect(lookup.query.query).toBe(
+      "select Id, DisplayName from Customer where DisplayName = 'Anna Berg'"
+    )
+    expect(lookup.headers.accept).toBe('application/json')
+
+    expect(createCustomer.path).toBe('/v3/company/9130357/customer')
+    expect(createCustomer.body).toEqual({
+      DisplayName: 'Anna Berg',
+      PrimaryEmailAddr: { Address: 'anna@example.com' },
+      PrimaryPhone: { FreeFormNumber: '+44 20 7946 0958' },
+      BillAddr: { Line1: '1 High Street', Line2: 'London' },
+      Notes: 'Torqvoice customer C-0042',
+      Taxable: true,
+    })
+
+    expect(createInvoice.path).toBe('/v3/company/9130357/invoice')
+    expect(createInvoice.query).toEqual({ minorversion: '75' })
+    expect(createInvoice.body).toEqual({
+      CustomerRef: { value: '58' },
+      TxnDate: '2026-09-04',
+      DueDate: '2026-09-18',
+      DocNumber: 'INV-1001',
+      Line: [
+        {
+          DetailType: 'DescriptionOnly',
+          Description: 'Vehicle: 2018 Toyota Corolla, AB12 CDE, 84200 km',
+          DescriptionLineDetail: { ServiceDate: '2026-09-03' },
+        },
+        {
+          DetailType: 'SalesItemLineDetail',
+          Amount: 200,
+          Description: 'Brake service',
+          SalesItemLineDetail: {
+            ItemRef: { value: '7' },
+            Qty: 2,
+            UnitPrice: 100,
+            ServiceDate: '2026-09-03',
+            TaxCodeRef: { value: '3' },
+          },
+        },
+        {
+          DetailType: 'SalesItemLineDetail',
+          Amount: 100,
+          Description: 'BP-100 Brake pads',
+          SalesItemLineDetail: {
+            ItemRef: { value: '8' },
+            Qty: 1,
+            UnitPrice: 100,
+            ServiceDate: '2026-09-03',
+            TaxCodeRef: { value: '3' },
+          },
+        },
+      ],
+      PrivateNote: 'Torqvoice https://shop.example.com/vehicles/veh1/service/svc1',
+      BillEmail: { Address: 'anna@example.com' },
+      GlobalTaxCalculation: 'TaxExcluded',
+    })
+    expect(t.calls).toHaveLength(3)
+
+    const link = t.links.get('ServiceRecord:svc1')
+    expect(link?.remoteId).toBe('100')
+    expect(link?.remoteUrl).toBe('https://app.qbo.intuit.com/app/invoice?txnId=100')
+    expect(t.links.get('Customer:cus1')?.remoteId).toBe('58')
+    expect(t.logs.filter((l) => l.level === 'warn')).toHaveLength(0)
+  })
+
+  it('does nothing for an invoice that is not issued, and nothing twice for an unchanged one', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, issuedAt: null })
+    const t = makeCtx({ answer: emptyCompany() })
+    expect((await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' }))?.summary).toBe(
+      'not issued yet'
+    )
+    expect(t.calls).toHaveLength(0)
+
+    loadInvoice.mockResolvedValue(invoice)
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const before = t.calls.length
+    const again = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(again?.summary).toBe('invoice INV-1001 unchanged')
+    expect(t.calls).toHaveLength(before)
+  })
+
+  it('sends a completed job before it is issued only when asked to', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, issuedAt: null })
+    const t = makeCtx({ settings: { pushOnComplete: true }, answer: emptyCompany() })
+    expect((await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' }))?.summary).toBe(
+      'invoice INV-1001 created'
+    )
+  })
+
+  it('skips invoices dated before the start date', async () => {
+    const t = makeCtx({ settings: { startDate: '2026-10-01' }, answer: emptyCompany() })
+    expect((await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' }))?.summary).toBe(
+      'not issued yet'
+    )
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('updates a linked invoice with the current SyncToken as a sparse update', async () => {
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.method === 'GET' && call.path.endsWith('/invoice/100'))
+          return { Invoice: { Id: '100', SyncToken: '4', TotalAmt: 360, Balance: 360 } }
+        return emptyCompany()(call)
+      },
+    })
+    await t.ctx.links.set('Customer', 'cus1', { remoteId: '58', checksum: 'stale' })
+    await t.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100', checksum: 'stale' })
+    // The customer changed too: it is read for its SyncToken and updated first.
+    const answer = t.ctx.http.json
+    t.ctx.http.json = async (url, init) => {
+      if (url.includes('/customer/58') && (init?.method ?? 'GET') === 'GET') {
+        t.calls.push({
+          method: 'GET',
+          host: '',
+          path: '/v3/company/9130357/customer/58',
+          query: {},
+          body: null,
+          headers: {},
+        })
+        return { Customer: { Id: '58', SyncToken: '2', DisplayName: 'Anna Berg' } } as never
+      }
+      return answer(url, init)
+    }
+    const out = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(out?.summary).toBe('invoice INV-1001 updated')
+    const customerUpdate = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/customer'))
+    expect(customerUpdate?.body).toMatchObject({ Id: '58', SyncToken: '2', sparse: true })
+    const invoiceUpdate = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    expect(invoiceUpdate?.body).toMatchObject({ Id: '100', SyncToken: '4', sparse: true })
+    expect((invoiceUpdate?.body?.Line as unknown[]).length).toBe(3)
+  })
+
+  it('recreates an invoice QuickBooks no longer has', async () => {
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.method === 'GET' && call.path.endsWith('/invoice/100'))
+          throw fault(400, '610', 'Object Not Found')
+        return emptyCompany()(call)
+      },
+    })
+    await t.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100', checksum: 'stale' })
+    const out = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(out?.summary).toBe('invoice INV-1001 created')
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('100')
+  })
+
+  it('adopts an invoice it pushed before when QuickBooks reports a duplicate number', async () => {
+    let attempt = 0
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.path.endsWith('/query') && call.query.query.includes('from Invoice'))
+          return {
+            QueryResponse: {
+              Invoice: [
+                { Id: '77', SyncToken: '1', DocNumber: 'INV-1001', PrivateNote: 'Torqvoice x' },
+              ],
+            },
+          }
+        if (call.method === 'POST' && call.path.endsWith('/invoice')) {
+          attempt++
+          if (attempt === 1) throw fault(400, '6140', 'Duplicate Document Number Error')
+          return { Invoice: { Id: '77', SyncToken: '2', DocNumber: 'INV-1001', TotalAmt: 360 } }
+        }
+        return emptyCompany()(call)
+      },
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const second = t.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/invoice'))[1]
+    expect(second.body).toMatchObject({ Id: '77', SyncToken: '1', sparse: true })
+    expect(t.links.get('ServiceRecord:svc1')?.remoteId).toBe('77')
+  })
+
+  it('adds a duplicate number beside a foreign invoice and says so', async () => {
+    let attempt = 0
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.path.endsWith('/query') && call.query.query.includes('from Invoice'))
+          return {
+            QueryResponse: {
+              Invoice: [{ Id: '77', SyncToken: '1', DocNumber: 'INV-1001', PrivateNote: 'theirs' }],
+            },
+          }
+        if (call.method === 'POST' && call.path.endsWith('/invoice')) {
+          attempt++
+          if (attempt === 1) throw fault(400, '6140', 'Duplicate Document Number Error')
+          return { Invoice: { Id: '78', SyncToken: '0', DocNumber: 'INV-1001', TotalAmt: 360 } }
+        }
+        return emptyCompany()(call)
+      },
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const second = t.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/invoice'))[1]
+    expect(second.query.include).toBe('allowduplicatedocnum')
+    expect(t.logs.some((l) => l.level === 'warn' && l.message.includes('did not come from'))).toBe(
+      true
+    )
+  })
+
+  it('leaves out the document number when the company numbers invoices itself', async () => {
+    const t = makeCtx({ state: { customTxnNumbers: false }, answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const create = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    expect(create?.body?.DocNumber).toBeUndefined()
+  })
+
+  it('always names the currency in a multi-currency company, on the customer too', async () => {
+    workshopCurrency.mockResolvedValue('NOK')
+    const t = makeCtx({ state: { multiCurrency: true }, answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const cust = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/customer'))
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    expect(cust?.body?.CurrencyRef).toEqual({ value: 'NOK' })
+    expect(inv?.body?.CurrencyRef).toEqual({ value: 'NOK' })
+  })
+
+  it('warns when the workshop currency differs and multi-currency is off', async () => {
+    workshopCurrency.mockResolvedValue('NOK')
+    const t = makeCtx({ answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    expect(inv?.body?.CurrencyRef).toBeUndefined()
+    expect(t.logs.some((l) => l.level === 'warn' && l.message.includes('multi-currency'))).toBe(
+      true
+    )
+  })
+
+  it('uses no GlobalTaxCalculation for a US company', async () => {
+    const t = makeCtx({ state: { country: 'US' }, answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    expect(inv?.body?.GlobalTaxCalculation).toBeUndefined()
+  })
+
+  it('warns when QuickBooks arrives at another total', async () => {
+    const t = makeCtx({
+      answer: (call) => {
+        const res = emptyCompany()(call) as { Invoice?: { TotalAmt: number } }
+        if (res.Invoice) res.Invoice.TotalAmt = 300
+        return res
+      },
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(t.logs.some((l) => l.level === 'warn' && l.message.includes('differs'))).toBe(true)
+  })
+
+  it('books a counter sale to the walk-in customer', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, customer: null, vehicle: null, vehicleId: null })
+    const t = makeCtx({ answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const lookup = t.calls[0]
+    expect(lookup.query.query).toContain("DisplayName = 'Walk-in customer'")
+    expect(t.calls[1].body).toEqual({ DisplayName: 'Walk-in customer' })
+    expect(t.state.walkInCustomerId).toBe('58')
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    expect((inv?.body?.Line as unknown[]).length).toBe(2)
+    expect(inv?.body?.PrivateNote).toBe('Torqvoice https://shop.example.com/sales/svc1')
+  })
+
+  it('creates a Labour item under the income account when none is chosen', async () => {
+    const t = makeCtx({
+      settings: { laborItemId: '', partsItemId: '' },
+      answer: (call) => {
+        if (call.path.endsWith('/query') && call.query.query.includes('from Account'))
+          return {
+            QueryResponse: {
+              Account: [
+                { Id: '1', Name: 'Sales', AccountSubType: 'SalesOfProductIncome' },
+                { Id: '2', Name: 'Services', AccountSubType: 'ServiceFeeIncome' },
+              ],
+            },
+          }
+        if (call.method === 'POST' && call.path.endsWith('/item'))
+          return { Item: { Id: call.body?.Name === 'Labour' ? '70' : '80', Name: call.body?.Name } }
+        return emptyCompany()(call)
+      },
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const items = t.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/item'))
+    expect(items.map((c) => c.body)).toEqual([
+      { Name: 'Labour', Type: 'Service', IncomeAccountRef: { value: '2' } },
+      { Name: 'Parts', Type: 'Service', IncomeAccountRef: { value: '1' } },
+    ])
+    expect(t.state.defaultItems).toEqual({ labor: '70', part: '80' })
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    const lines = inv?.body?.Line as { SalesItemLineDetail?: { ItemRef: { value: string } } }[]
+    expect(lines[1].SalesItemLineDetail?.ItemRef.value).toBe('70')
+    expect(lines[2].SalesItemLineDetail?.ItemRef.value).toBe('80')
+  })
+
+  it('voids the ledger copy of a deleted record unless money was taken on it', async () => {
+    loadInvoice.mockResolvedValue(null)
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.method === 'GET' && call.path.endsWith('/invoice/100'))
+          return {
+            Invoice: {
+              Id: '100',
+              SyncToken: '3',
+              DocNumber: 'INV-1001',
+              TotalAmt: 360,
+              Balance: 360,
+            },
+          }
+        if (call.query.operation === 'void') return { Invoice: { Id: '100', SyncToken: '4' } }
+        throw new Error(`unexpected ${call.method} ${call.path}`)
+      },
+    })
+    await t.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100' })
+    const out = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(out?.summary).toBe('invoice INV-1001 voided')
+    const voided = t.calls.find((c) => c.query.operation === 'void')
+    expect(voided?.path).toBe('/v3/company/9130357/invoice')
+    expect(voided?.body).toEqual({ Id: '100', SyncToken: '3' })
+    expect(t.links.has('ServiceRecord:svc1')).toBe(false)
+
+    const paid = makeCtx({
+      answer: () => ({
+        Invoice: { Id: '100', SyncToken: '3', DocNumber: 'INV-1001', TotalAmt: 360, Balance: 100 },
+      }),
+    })
+    await paid.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100' })
+    const kept = await connector.jobs['accounting.invoice'](paid.ctx, { entityId: 'svc1' })
+    expect(kept?.summary).toContain('has payments')
+    expect(paid.calls.some((c) => c.query.operation === 'void')).toBe(false)
+  })
+})
+
+describe('QuickBooks: payments', () => {
+  it('records the payments on an invoice right after pushing it', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    const t = makeCtx({ settings: { depositAccountId: '35' }, answer: emptyCompany() })
+    const out = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(out?.summary).toBe('invoice INV-1001 created, 1 payments recorded')
+    const pay = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/payment'))
+    expect(pay?.body).toEqual({
+      CustomerRef: { value: '58' },
+      TotalAmt: 360,
+      TxnDate: '2026-09-05',
+      PaymentRefNum: 'pi_123',
+      PrivateNote: 'Torqvoice payment, card. via stripe',
+      DepositToAccountRef: { value: '35' },
+      Line: [{ Amount: 360, LinkedTxn: [{ TxnId: '100', TxnType: 'Invoice' }] }],
+    })
+    expect(t.links.get('Payment:pay1')?.metadata).toMatchObject({ createdByUs: true })
+  })
+
+  it('pushes the invoice first when a payment arrives on its own', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, payments: [payment] })
+    const t = makeCtx({ answer: emptyCompany() })
+    const out = await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' })
+    expect(out?.summary).toBe('payment recorded')
+    expect(t.calls.map((c) => `${c.method} ${c.path.split('/').pop()}`)).toEqual([
+      'GET query',
+      'POST customer',
+      'POST invoice',
+      'POST payment',
+    ])
+  })
+
+  it('deletes a payment it made when the payment is deleted here', async () => {
+    loadPayment.mockResolvedValue(null)
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.method === 'GET' && call.path.endsWith('/payment/200'))
+          return { Payment: { Id: '200', SyncToken: '1' } }
+        if (call.query.operation === 'delete') return { Payment: { Id: '200', status: 'Deleted' } }
+        throw new Error(`unexpected ${call.method} ${call.path}`)
+      },
+    })
+    await t.ctx.links.set('Payment', 'pay1', { remoteId: '200', metadata: { createdByUs: true } })
+    const out = await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' })
+    expect(out?.summary).toBe('payment deleted')
+    const del = t.calls.find((c) => c.query.operation === 'delete')
+    expect(del?.path).toBe('/v3/company/9130357/payment')
+    expect(del?.body).toEqual({ Id: '200', SyncToken: '1' })
+    expect(t.links.has('Payment:pay1')).toBe(false)
+  })
+
+  it('leaves a payment that came from QuickBooks alone when deleted here', async () => {
+    loadPayment.mockResolvedValue(null)
+    const t = makeCtx({ answer: emptyCompany() })
+    await t.ctx.links.set('Payment', 'pay1', { remoteId: '200', metadata: { createdByUs: false } })
+    const out = await connector.jobs['accounting.payment'](t.ctx, { entityId: 'pay1' })
+    expect(out?.summary).toContain('left there')
+    expect(t.calls).toHaveLength(0)
+  })
+
+  it('settles an invoice marked paid by hand, once, when asked to', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, manuallyPaid: true })
+    const t = makeCtx({ settings: { manualPaidAsPayment: true }, answer: emptyCompany() })
+    const out = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(out?.summary).toBe('invoice INV-1001 created, 1 payments recorded')
+    const pay = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/payment'))
+    expect(pay?.body).toMatchObject({
+      TotalAmt: 360,
+      TxnDate: '2026-09-04',
+      Line: [{ Amount: 360, LinkedTxn: [{ TxnId: '100', TxnType: 'Invoice' }] }],
+    })
+    expect(pay?.body?.PaymentRefNum).toBeUndefined()
+    expect(t.links.get('Payment:manual:svc1')?.metadata).toMatchObject({ manual: true })
+
+    const again = await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    expect(again?.summary).toBe('invoice INV-1001 unchanged')
+
+    const off = makeCtx({ answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](off.ctx, { entityId: 'svc1' })
+    expect(off.calls.some((c) => c.path.endsWith('/payment'))).toBe(false)
+  })
+})
+
+describe('QuickBooks: pulling changes', () => {
+  it('records a payment taken in QuickBooks against an invoice from here', async () => {
+    const t = makeCtx({
+      state: { lastPullAt: '2026-09-04T09:00:00.000Z' },
+      answer: (call) => {
+        expect(call.path).toBe('/v3/company/9130357/cdc')
+        expect(call.query.entities).toBe('Payment,Invoice')
+        expect(call.query.changedSince).toBe('2026-09-04T08:59:00Z')
+        return {
+          CDCResponse: [
+            {
+              QueryResponse: [
+                {
+                  Payment: [
+                    {
+                      Id: '300',
+                      SyncToken: '0',
+                      TxnDate: '2026-09-06',
+                      TotalAmt: 360,
+                      PaymentRefNum: 'CHQ 42',
+                      PaymentMethodRef: { value: '2', name: 'Bank Transfer' },
+                      Line: [{ Amount: 360, LinkedTxn: [{ TxnId: '100', TxnType: 'Invoice' }] }],
+                    },
+                    {
+                      Id: '301',
+                      SyncToken: '0',
+                      TxnDate: '2026-09-06',
+                      Line: [{ Amount: 50, LinkedTxn: [{ TxnId: '999', TxnType: 'Invoice' }] }],
+                    },
+                  ],
+                },
+                { Invoice: [{ Id: '100', SyncToken: '1' }] },
+              ],
+            },
+          ],
+        }
+      },
+    })
+    await t.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100' })
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('1 payments recorded')
+    expect(recordPulledPayment).toHaveBeenCalledTimes(1)
+    expect(recordPulledPayment).toHaveBeenCalledWith('org1', {
+      serviceRecordId: 'svc1',
+      amount: 360,
+      date: new Date('2026-09-06T12:00:00Z'),
+      method: 'transfer',
+      provider: 'quickbooks',
+      externalId: '300',
+      note: 'Ref CHQ 42. Recorded in QuickBooks',
+    })
+    expect(t.links.get('Payment:pulled1')).toMatchObject({
+      remoteId: '300',
+      metadata: { createdByUs: false, serviceRecordId: 'svc1' },
+    })
+    expect(typeof t.state.lastPullAt).toBe('string')
+  })
+
+  it('does not record a payment it pushed itself, and removes one QuickBooks deleted', async () => {
+    const t = makeCtx({
+      answer: () => ({
+        CDCResponse: [
+          {
+            QueryResponse: [
+              {
+                Payment: [
+                  {
+                    Id: '200',
+                    SyncToken: '1',
+                    Line: [{ Amount: 360, LinkedTxn: [{ TxnId: '100', TxnType: 'Invoice' }] }],
+                  },
+                  { Id: '300', status: 'Deleted', SyncToken: '1' },
+                ],
+              },
+            ],
+          },
+        ],
+      }),
+    })
+    await t.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100' })
+    await t.ctx.links.set('Payment', 'pay1', { remoteId: '200', metadata: { createdByUs: true } })
+    await t.ctx.links.set('Payment', 'pulled1', {
+      remoteId: '300',
+      metadata: { createdByUs: false },
+    })
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('1 payments removed')
+    expect(recordPulledPayment).not.toHaveBeenCalled()
+    expect(removePulledPayment).toHaveBeenCalledWith('org1', 'pulled1', 'quickbooks')
+    expect(t.links.has('Payment:pulled1')).toBe(false)
+  })
+
+  it('unlinks an invoice deleted in QuickBooks so an edit here recreates it', async () => {
+    const t = makeCtx({
+      answer: () => ({
+        CDCResponse: [{ QueryResponse: [{ Invoice: [{ Id: '100', status: 'Deleted' }] }] }],
+      }),
+    })
+    await t.ctx.links.set('ServiceRecord', 'svc1', { remoteId: '100' })
+    const out = await connector.jobs['accounting.pull'](t.ctx, {})
+    expect(out?.summary).toBe('1 invoices unlinked')
+    expect(t.links.has('ServiceRecord:svc1')).toBe(false)
+  })
+
+  it('never reaches back more than the 30 days QuickBooks keeps', async () => {
+    const t = makeCtx({
+      state: { lastPullAt: '2020-01-01T00:00:00.000Z' },
+      answer: (call) => {
+        const since = Date.parse(call.query.changedSince)
+        expect(Date.now() - since).toBeLessThanOrEqual(30 * 86_400_000 + 5_000)
+        return { CDCResponse: [] }
+      },
+    })
+    expect((await connector.jobs['accounting.pull'](t.ctx, {}))?.summary).toBe('no changes')
+  })
+})
+
+describe('QuickBooks: connecting', () => {
+  it('falls back to the sandbox host when the live one rejects the keys, and reads the company', async () => {
+    const t = makeCtx({
+      state: { environment: undefined, country: undefined, customTxnNumbers: undefined },
+      answer: (call) => {
+        if (call.host === 'quickbooks.api.intuit.com')
+          throw fault(401, '3200', 'message=AuthenticationFailed')
+        if (call.path.endsWith('/companyinfo/9130357'))
+          return { CompanyInfo: { CompanyName: 'Sandbox Garage Ltd', Country: 'GB' } }
+        if (call.path.endsWith('/preferences'))
+          return {
+            Preferences: {
+              CurrencyPrefs: { HomeCurrency: { value: 'GBP' }, MultiCurrencyEnabled: true },
+              SalesFormsPrefs: { CustomTxnNumbers: false },
+            },
+          }
+        throw new Error(`unexpected ${call.path}`)
+      },
+    })
+    const who = await connector.identify?.(t.ctx)
+    expect(who).toEqual({ id: '9130357', name: 'Sandbox Garage Ltd (sandbox)' })
+    expect(t.state).toMatchObject({
+      environment: 'sandbox',
+      country: 'GB',
+      homeCurrency: 'GBP',
+      multiCurrency: true,
+      customTxnNumbers: false,
+    })
+    expect(t.logs.some((l) => l.message.includes('Custom transaction numbers'))).toBe(true)
+    expect(t.calls.at(-1)?.host).toBe('sandbox-quickbooks.api.intuit.com')
+  })
+
+  it('reports a failed test with the vendor wording', async () => {
+    const t = makeCtx({ answer: () => fault(401, '3200', 'AuthenticationFailed') })
+    t.ctx.http.json = async () => {
+      throw fault(401, '3200', 'message=AuthenticationFailed')
+    }
+    const res = await connector.test(t.ctx)
+    expect(res.ok).toBe(false)
+    expect(res.message).toContain('AuthenticationFailed')
+  })
+
+  it('lists items, tax codes and deposit accounts for the settings page', async () => {
+    const t = makeCtx({
+      answer: (call) => {
+        const q = call.query.query
+        if (q.includes('from Item'))
+          return { QueryResponse: { Item: [{ Id: '2', Name: 'Labour', Type: 'Service' }] } }
+        if (q.includes('from TaxCode'))
+          return { QueryResponse: { TaxCode: [{ Id: '3', Name: '20.0% S' }] } }
+        if (q.includes('from Account'))
+          return {
+            QueryResponse: {
+              Account: [
+                { Id: '35', Name: 'Undeposited Funds', AccountType: 'Other Current Asset' },
+              ],
+            },
+          }
+        return { QueryResponse: {} }
+      },
+    })
+    expect(await connector.remoteOptions?.items(t.ctx)).toEqual([
+      { value: '2', label: 'Labour (Service)' },
+    ])
+    expect(await connector.remoteOptions?.taxCodes(t.ctx)).toEqual([
+      { value: '3', label: '20.0% S' },
+    ])
+    expect(await connector.remoteOptions?.depositAccounts(t.ctx)).toEqual([
+      { value: '35', label: 'Undeposited Funds (Other Current Asset)' },
+    ])
+    expect(t.calls[0].query.query).toContain("Type in ('Service', 'NonInventory', 'Inventory')")
+  })
+})

+ 355 - 0
src/__tests__/features/integrations/quickbooks.test.ts

@@ -0,0 +1,355 @@
+import { describe, expect, it } from 'vitest'
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+} from '@/features/integrations/Lib/accounting-sync'
+import { manifest } from '@/integrations/quickbooks/manifest'
+import {
+  buildCustomer,
+  buildInvoice,
+  buildPayment,
+  checksumOf,
+  customerDisplayName,
+  faultCode,
+  faultMessage,
+  localPaymentMethod,
+  refField,
+  sqlString,
+} from '@/integrations/quickbooks/mapping'
+
+const customer: AccountingCustomer = {
+  id: 'cus1',
+  name: "Anna O'Brien",
+  email: 'anna@example.com',
+  phone: '+47 912 34 567',
+  address: 'Storgata 1\n0155 Oslo',
+  company: 'Berg Transport AS',
+  taxId: 'NO123456789MVA',
+  taxExempt: false,
+  customerNumber: 'C-0042',
+}
+
+const invoice: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  invoiceNumber: 'INV-2026-000123',
+  status: 'completed',
+  issuedAt: new Date('2026-09-04T10:00:00Z'),
+  invoiceDate: new Date('2026-09-03T22:30:00Z'),
+  serviceDate: new Date('2026-09-02T09:00:00Z'),
+  dueDate: new Date('2026-09-18T12:00:00Z'),
+  mileage: 84200,
+  notes: 'Thank you',
+  subtotal: 1500,
+  discountType: 'percentage',
+  discountValue: 10,
+  discountAmount: 150,
+  taxRate: 25,
+  taxAmount: 337.5,
+  taxInclusive: false,
+  totalAmount: 1687.5,
+  manuallyPaid: false,
+  customer,
+  vehicle: { year: 2018, make: 'Toyota', model: 'Corolla', licensePlate: 'AB 12345' },
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Brake service',
+      partNumber: null,
+      quantity: 2,
+      unitPrice: 500,
+      total: 1000,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 2,
+      unitPrice: 250,
+      total: 500,
+    },
+  ],
+  payments: [],
+}
+
+const options = {
+  customerRef: '58',
+  customerEmail: 'anna@example.com',
+  laborItemId: '7',
+  partsItemId: '8',
+  taxCodeId: '3',
+  zeroTaxCodeId: '4',
+  globalTax: true,
+  currency: null,
+  timezone: 'Europe/Oslo',
+  url: 'https://shop.example.com/vehicles/veh1/service/svc1',
+  taxExempt: false,
+  includeVehicle: false,
+  customTxnNumbers: true,
+}
+
+/**
+ * The shape QuickBooks receives is decided here, without a company to talk
+ * to. Dates are the workshop's calendar day, references stop at the
+ * vendor's 21 characters, and lines carry the tax code the settings chose.
+ */
+describe('QuickBooks invoice mapping', () => {
+  it('builds lines, discount, dates and tax mode from the invoice', () => {
+    const body = buildInvoice(invoice, options)
+    expect(body.CustomerRef).toEqual({ value: '58' })
+    // 22:30 UTC on the 3rd is already the 4th in Oslo.
+    expect(body.TxnDate).toBe('2026-09-04')
+    expect(body.DueDate).toBe('2026-09-18')
+    expect(body.DocNumber).toBe('INV-2026-000123')
+    expect(body.GlobalTaxCalculation).toBe('TaxExcluded')
+    expect(body.BillEmail).toEqual({ Address: 'anna@example.com' })
+    expect(body.CustomerMemo).toEqual({ value: 'Thank you' })
+    expect(body.PrivateNote).toContain('https://shop.example.com/vehicles/veh1/service/svc1')
+    const lines = body.Line as Record<string, unknown>[]
+    expect(lines).toHaveLength(3)
+    expect(lines[0]).toMatchObject({
+      DetailType: 'SalesItemLineDetail',
+      Amount: 1000,
+      Description: 'Brake service',
+      SalesItemLineDetail: {
+        ItemRef: { value: '7' },
+        Qty: 2,
+        UnitPrice: 500,
+        TaxCodeRef: { value: '3' },
+      },
+    })
+    expect(lines[1]).toMatchObject({
+      Amount: 500,
+      Description: 'BP-100 Brake pads',
+      SalesItemLineDetail: { ItemRef: { value: '8' }, Qty: 2, UnitPrice: 250 },
+    })
+    expect(lines[2]).toEqual({
+      DetailType: 'DiscountLineDetail',
+      Amount: 150,
+      DiscountLineDetail: { PercentBased: true, DiscountPercent: 10 },
+    })
+  })
+
+  it('books a hand-edited line total as one unit so quantity times price still adds up', () => {
+    const body = buildInvoice(
+      {
+        ...invoice,
+        lines: [
+          {
+            kind: 'labor',
+            description: 'Diagnosis',
+            partNumber: null,
+            quantity: 1.5,
+            unitPrice: 800,
+            total: 1000,
+          },
+        ],
+      },
+      options
+    )
+    const [line] = body.Line as Record<string, Record<string, unknown>>[]
+    expect(line.SalesItemLineDetail.Qty).toBe(1)
+    expect(line.SalesItemLineDetail.UnitPrice).toBe(1000)
+    expect(line.Amount).toBe(1000)
+  })
+
+  it('uses the tax-free code and NotApplicable for exempt and untaxed invoices', () => {
+    const exempt = buildInvoice(invoice, { ...options, taxExempt: true })
+    const [line] = exempt.Line as Record<string, Record<string, unknown>>[]
+    expect(line.SalesItemLineDetail.TaxCodeRef).toEqual({ value: '4' })
+    expect(exempt.GlobalTaxCalculation).toBe('NotApplicable')
+
+    const inclusive = buildInvoice({ ...invoice, taxInclusive: true }, options)
+    expect(inclusive.GlobalTaxCalculation).toBe('TaxInclusive')
+
+    const us = buildInvoice(invoice, { ...options, globalTax: false })
+    expect(us.GlobalTaxCalculation).toBeUndefined()
+  })
+
+  it('leaves out lines whose item is unknown and fixed discounts stay fixed', () => {
+    const body = buildInvoice(
+      { ...invoice, discountType: 'fixed', discountValue: 150 },
+      { ...options, partsItemId: null }
+    )
+    const lines = body.Line as Record<string, unknown>[]
+    expect(lines).toHaveLength(2)
+    expect(lines[1]).toMatchObject({ DiscountLineDetail: { PercentBased: false } })
+  })
+
+  it('names the vehicle in a text line above the charges when asked', () => {
+    const body = buildInvoice(invoice, { ...options, includeVehicle: true })
+    const lines = body.Line as Record<string, unknown>[]
+    expect(lines).toHaveLength(4)
+    expect(lines[0]).toEqual({
+      DetailType: 'DescriptionOnly',
+      Description: 'Vehicle: 2018 Toyota Corolla, AB 12345, 84200 km',
+      DescriptionLineDetail: { ServiceDate: '2026-09-02' },
+    })
+    expect(lines[0]).not.toHaveProperty('Amount')
+    const noVehicle = buildInvoice(
+      { ...invoice, vehicle: null },
+      { ...options, includeVehicle: true }
+    )
+    expect((noVehicle.Line as unknown[]).length).toBe(3)
+  })
+
+  it('dates every charge line with the day the work was done', () => {
+    const body = buildInvoice(invoice, options)
+    const [labor, part] = body.Line as Record<string, Record<string, unknown>>[]
+    expect(labor.SalesItemLineDetail.ServiceDate).toBe('2026-09-02')
+    expect(part.SalesItemLineDetail.ServiceDate).toBe('2026-09-02')
+  })
+
+  it('taxes after the discount, as the app does, and only says so when there is one', () => {
+    expect(buildInvoice(invoice, options).ApplyTaxAfterDiscount).toBe(true)
+    expect(
+      buildInvoice({ ...invoice, discountAmount: 0, discountValue: 0 }, options)
+        .ApplyTaxAfterDiscount
+    ).toBeUndefined()
+  })
+
+  it('sends no document number when the company numbers invoices itself', () => {
+    expect(buildInvoice(invoice, { ...options, customTxnNumbers: false }).DocNumber).toBeUndefined()
+    expect(buildInvoice(invoice, options).DocNumber).toBe('INV-2026-000123')
+  })
+
+  it('cuts a long invoice number at the 21 characters QuickBooks keeps', () => {
+    const long = buildInvoice({ ...invoice, invoiceNumber: 'WORKSHOP-2026-09-000000123' }, options)
+    expect(long.DocNumber).toBe('WORKSHOP-2026-09-0000')
+  })
+
+  it('carries the currency only when told to', () => {
+    expect(buildInvoice(invoice, options).CurrencyRef).toBeUndefined()
+    expect(buildInvoice(invoice, { ...options, currency: 'NOK' }).CurrencyRef).toEqual({
+      value: 'NOK',
+    })
+  })
+
+  it('is stable for an unchanged invoice and changes when a line does', () => {
+    const a = checksumOf(buildInvoice(invoice, options))
+    const b = checksumOf(buildInvoice(invoice, options))
+    const c = checksumOf(buildInvoice({ ...invoice, totalAmount: 1 }, options))
+    const d = checksumOf(
+      buildInvoice({ ...invoice, lines: [{ ...invoice.lines[0], total: 999 }] }, options)
+    )
+    expect(a).toBe(b)
+    // Totals are not in the body; QuickBooks computes them.
+    expect(a).toBe(c)
+    expect(a).not.toBe(d)
+  })
+})
+
+describe('QuickBooks customer and payment mapping', () => {
+  it('splits the address into lines and keeps the tax id in the notes', () => {
+    const body = buildCustomer(customer, customerDisplayName(customer))
+    expect(body.DisplayName).toBe("Anna O'Brien")
+    expect(body.CompanyName).toBe('Berg Transport AS')
+    expect(body.PrimaryEmailAddr).toEqual({ Address: 'anna@example.com' })
+    expect(body.BillAddr).toEqual({ Line1: 'Storgata 1', Line2: '0155 Oslo' })
+    expect(body.Notes).toContain('C-0042')
+    expect(body.Notes).toContain('NO123456789MVA')
+    expect(body.Taxable).toBe(true)
+    expect(buildCustomer({ ...customer, taxExempt: true }, 'x').Taxable).toBe(false)
+  })
+
+  it('fixes the currency on a new customer only when given one', () => {
+    expect(buildCustomer(customer, 'x').CurrencyRef).toBeUndefined()
+    expect(buildCustomer(customer, 'x', { currency: 'NOK' }).CurrencyRef).toEqual({ value: 'NOK' })
+  })
+
+  it('keeps every field inside the lengths QuickBooks accepts', () => {
+    const body = buildCustomer(
+      {
+        ...customer,
+        company: 'C'.repeat(200),
+        phone: '1'.repeat(50),
+        email: `${'e'.repeat(120)}@x.io`,
+        address: Array.from({ length: 8 }, (_, i) => `Line ${i}`).join('\n'),
+        taxId: 'T'.repeat(3000),
+      },
+      'N'.repeat(600)
+    )
+    expect((body.CompanyName as string).length).toBe(100)
+    expect((body.PrimaryPhone as { FreeFormNumber: string }).FreeFormNumber.length).toBe(30)
+    expect((body.PrimaryEmailAddr as { Address: string }).Address.length).toBe(100)
+    expect(Object.keys(body.BillAddr as object)).toEqual([
+      'Line1',
+      'Line2',
+      'Line3',
+      'Line4',
+      'Line5',
+    ])
+    expect((body.Notes as string).length).toBe(2000)
+    expect(customerDisplayName({ ...customer, name: 'N'.repeat(600) }).length).toBe(500)
+  })
+
+  it('links a payment to its invoice and trims the reference', () => {
+    const body = buildPayment(
+      {
+        id: 'pay1',
+        serviceRecordId: 'svc1',
+        amount: 1687.499,
+        date: new Date('2026-09-05T23:30:00Z'),
+        method: 'card',
+        note: null,
+        provider: 'stripe',
+        externalId: 'pi_3Nz1234567890abcdefXYZ',
+      },
+      {
+        customerRef: '58',
+        invoiceRemoteId: '145',
+        depositAccountId: '35',
+        currency: null,
+        timezone: 'Europe/Oslo',
+      }
+    )
+    expect(body.TotalAmt).toBe(1687.5)
+    expect(body.TxnDate).toBe('2026-09-06')
+    expect(body.PaymentRefNum).toBe('pi_3Nz1234567890abcde')
+    expect(body.DepositToAccountRef).toEqual({ value: '35' })
+    expect(body.Line).toEqual([
+      { Amount: 1687.5, LinkedTxn: [{ TxnId: '145', TxnType: 'Invoice' }] },
+    ])
+    expect(String(body.PrivateNote)).toContain('card')
+  })
+
+  it('maps ledger payment method names onto the app’s own', () => {
+    expect(localPaymentMethod('Cash')).toBe('cash')
+    expect(localPaymentMethod('Credit Card')).toBe('card')
+    expect(localPaymentMethod('Bank transfer')).toBe('transfer')
+    expect(localPaymentMethod('Check')).toBe('other')
+    expect(localPaymentMethod(undefined)).toBe('other')
+  })
+})
+
+describe('QuickBooks API helpers', () => {
+  it('escapes query strings and trims references', () => {
+    expect(sqlString("Anna O'Brien")).toBe("'Anna O\\'Brien'")
+    expect(refField('  INV-1  ')).toBe('INV-1')
+    expect(refField('x'.repeat(30))).toHaveLength(21)
+    expect(refField(null)).toBeUndefined()
+  })
+
+  it('reads the vendor’s fault message and code', () => {
+    const body = JSON.stringify({
+      Fault: {
+        Error: [
+          { Message: 'Object Not Found', Detail: 'Object Not Found : Invoice 9', code: '610' },
+        ],
+        type: 'ValidationFault',
+      },
+    })
+    expect(faultMessage(body)).toBe('Object Not Found: Object Not Found : Invoice 9')
+    expect(faultCode(body)).toBe('610')
+    expect(faultMessage('<html>gateway</html>')).toBe('<html>gateway</html>')
+    expect(faultCode('nope')).toBeNull()
+  })
+
+  it('declares the company id as a callback parameter and Basic token auth', () => {
+    expect(manifest.auth.type).toBe('oauth2')
+    if (manifest.auth.type !== 'oauth2') return
+    expect(manifest.auth.callbackParams).toEqual(['realmId'])
+    expect(manifest.auth.tokenAuth).toBe('basic')
+    expect(manifest.auth.pkce).toBeFalsy()
+  })
+})

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

@@ -37,6 +37,7 @@ import {
 } from '@/components/ui/select'
 import { useConfirm } from '@/components/confirm-dialog'
 import {
+  backfillIntegrationAccounting,
   backfillIntegrationCalendar,
   type ConnectionView,
   disconnectIntegration,
@@ -162,6 +163,7 @@ export function ConnectionSettings({
   const firstSchedule = manifest.schedules?.[0]?.job
   const hasSync = Boolean(firstSchedule)
   const isCalendar = manifest.category === 'calendar'
+  const isAccounting = manifest.category === 'accounting'
   // Mail vendors can prove themselves by delivering a message to the person
   // looking at the page, which a key check cannot.
   const canSendTestEmail = manifest.capabilities.includes('email.send')
@@ -478,6 +480,27 @@ export function ConnectionSettings({
                 {t('connection.backfill')}
               </Button>
             )}
+            {isAccounting && (
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() =>
+                  run(
+                    'backfill',
+                    () => backfillIntegrationAccounting(manifest.id),
+                    t('connection.backfillInvoicesQueued')
+                  )
+                }
+                disabled={busy !== null}
+              >
+                {busy === 'backfill' ? (
+                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Upload className="mr-1 h-3.5 w-3.5" />
+                )}
+                {t('connection.backfillInvoices')}
+              </Button>
+            )}
             {isOAuth && (
               <Button variant="outline" size="sm" asChild>
                 <a href={oauthStartUrl}>

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

@@ -79,6 +79,15 @@ export async function GET(
     return fail('exchange')
   }
 
+  // What the vendor tells us only here, such as which company was chosen,
+  // goes on the state so identify and every job after it can read it.
+  const extra: Record<string, string> = {}
+  for (const name of spec.callbackParams ?? []) {
+    const value = params.get(name)
+    if (value) extra[name] = value
+  }
+  const state0 = (connection.state as Record<string, unknown>) ?? {}
+
   await db.integrationConnection.update({
     where: { id: connection.id },
     data: {
@@ -88,6 +97,7 @@ export async function GET(
       status: 'active',
       lastError: null,
       lastHealthAt: new Date(),
+      ...(Object.keys(extra).length > 0 && { state: { ...state0, ...extra } as object }),
     },
   })
 

+ 34 - 0
src/features/integrations/Actions/integrationActions.ts

@@ -9,6 +9,7 @@ import { FeatureGatedError, getFeatures, isCloudMode } from '@/lib/features'
 import { demoGuard } from '@/lib/demo'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getManifest, listManifests } from '@/integrations/registry'
+import { invoicesForBackfill } from '../Lib/accounting-sync'
 import { clearPulledEvents } from '../Lib/calendar-sync'
 import {
   INSPECTION_CAPABILITY,
@@ -651,6 +652,39 @@ export async function backfillIntegrationCalendar(connectorId: string) {
   )
 }
 
+/** How far back a fresh ledger connection reaches for issued invoices. */
+const ACCOUNTING_BACKFILL_DAYS = 90
+
+/** Push every invoice issued in the last months, for a fresh accounting connection. */
+export async function backfillIntegrationAccounting(connectorId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      const manifest = getManifest(connectorId)
+      if (manifest?.category !== 'accounting') throw new Error('Unknown integration')
+      const row = await db.integrationConnection.findUnique({
+        where: { organizationId_connectorId: { organizationId, connectorId } },
+        select: { id: true, status: true },
+      })
+      if (!row || row.status !== 'active') throw new Error('Connect the integration first')
+      const since = new Date(Date.now() - ACCOUNTING_BACKFILL_DAYS * 86_400_000)
+      const ids = await invoicesForBackfill(organizationId, since)
+      for (const id of ids) {
+        await enqueueJob({
+          connectionId: row.id,
+          organizationId,
+          kind: 'accounting.invoice',
+          payload: { entityId: id, event: 'backfill' },
+          idempotencyKey: `accounting.invoice:${id}`,
+        })
+      }
+      await writeLog(row.id, 'info', `Queued ${ids.length} issued invoices for push`)
+      return { queued: ids.length }
+    },
+    { requiredPermissions: SETTINGS_PERMISSION }
+  )
+}
+
 export async function disconnectIntegration(connectorId: string) {
   return withAuth(
     async ({ organizationId, userId }) => {

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

@@ -0,0 +1,343 @@
+/**
+ * What every accounting connector shares.
+ *
+ * An invoice, a customer and a payment are loaded here in one shape, so a
+ * ledger connector maps that shape to its vendor and never reads Prisma. The
+ * loaders are scoped to the connection's organisation, and the writers only
+ * ever touch what a pull brought back from the ledger: a payment recorded
+ * there against an invoice this app issued.
+ */
+
+import { db } from '@/lib/db'
+import { effectiveInvoiceDate } from '@/lib/invoice-utils'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { serviceUrl } from './calendar-sync'
+
+export const INVOICE_ENTITY = 'ServiceRecord'
+export const CUSTOMER_ENTITY = 'Customer'
+export const PAYMENT_ENTITY = 'Payment'
+
+export interface AccountingCustomer {
+  id: string
+  name: string
+  email: string | null
+  phone: string | null
+  address: string | null
+  company: string | null
+  taxId: string | null
+  taxExempt: boolean
+  customerNumber: string | null
+}
+
+export interface AccountingLine {
+  kind: 'labor' | 'part'
+  description: string
+  partNumber: string | null
+  quantity: number
+  unitPrice: number
+  total: number
+}
+
+export interface AccountingPayment {
+  id: string
+  serviceRecordId: string
+  amount: number
+  date: Date
+  method: string
+  note: string | null
+  provider: string | null
+  externalId: string | null
+}
+
+export interface AccountingInvoice {
+  id: string
+  vehicleId: string | null
+  invoiceNumber: string | null
+  status: string
+  issuedAt: Date | null
+  /** The date the sheet prints, which is the date the ledger should carry. */
+  invoiceDate: Date
+  /** When the work was done, for the service date on each line. */
+  serviceDate: Date
+  dueDate: Date | null
+  mileage: number | null
+  notes: string | null
+  subtotal: number
+  discountType: string | null
+  discountValue: number
+  discountAmount: number
+  taxRate: number
+  taxAmount: number
+  taxInclusive: boolean
+  totalAmount: number
+  manuallyPaid: boolean
+  customer: AccountingCustomer | null
+  vehicle: { year: number; make: string; model: string; licensePlate: string | null } | null
+  lines: AccountingLine[]
+  payments: AccountingPayment[]
+}
+
+const customerSelect = {
+  id: true,
+  name: true,
+  email: true,
+  phone: true,
+  address: true,
+  company: true,
+  taxId: true,
+  taxExempt: true,
+  customerNumber: true,
+} as const
+
+export async function loadCustomerForAccounting(
+  organizationId: string,
+  customerId: string
+): Promise<AccountingCustomer | null> {
+  return db.customer.findFirst({
+    where: { id: customerId, organizationId },
+    select: customerSelect,
+  })
+}
+
+/**
+ * The invoice as the ledger needs it. The customer is the record's own or,
+ * for a job on a vehicle, the vehicle's; a counter sale can have neither.
+ */
+export async function loadInvoiceForAccounting(
+  organizationId: string,
+  serviceRecordId: string
+): Promise<AccountingInvoice | null> {
+  const r = await db.serviceRecord.findFirst({
+    where: { id: serviceRecordId, organizationId },
+    select: {
+      id: true,
+      vehicleId: true,
+      invoiceNumber: true,
+      status: true,
+      issuedAt: true,
+      invoiceDate: true,
+      startDateTime: true,
+      serviceDate: true,
+      invoiceDueDate: true,
+      mileage: true,
+      invoiceNotes: true,
+      subtotal: true,
+      discountType: true,
+      discountValue: true,
+      discountAmount: true,
+      taxRate: true,
+      taxAmount: true,
+      taxInclusive: true,
+      totalAmount: true,
+      cost: true,
+      manuallyPaid: true,
+      customer: { select: customerSelect },
+      vehicle: {
+        select: {
+          year: true,
+          make: true,
+          model: true,
+          licensePlate: true,
+          customer: { select: customerSelect },
+        },
+      },
+      laborItems: {
+        select: {
+          description: true,
+          hours: true,
+          rate: true,
+          total: true,
+          pricingType: true,
+        },
+      },
+      partItems: {
+        select: { partNumber: true, name: true, quantity: true, unitPrice: true, total: true },
+      },
+      payments: {
+        select: {
+          id: true,
+          serviceRecordId: true,
+          amount: true,
+          date: true,
+          method: true,
+          note: true,
+          provider: true,
+          externalId: true,
+        },
+        orderBy: { date: 'asc' },
+      },
+    },
+  })
+  if (!r) return null
+  const lines: AccountingLine[] = [
+    ...r.laborItems.map((l) => {
+      const hourly = l.pricingType === 'hourly' && l.hours > 0
+      return {
+        kind: 'labor' as const,
+        description: l.description,
+        partNumber: null,
+        quantity: hourly ? l.hours : 1,
+        unitPrice: hourly ? l.rate : l.total,
+        total: l.total,
+      }
+    }),
+    ...r.partItems.map((p) => ({
+      kind: 'part' as const,
+      description: p.name,
+      partNumber: p.partNumber,
+      quantity: p.quantity > 0 ? p.quantity : 1,
+      unitPrice: p.quantity > 0 ? p.unitPrice : p.total,
+      total: p.total,
+    })),
+  ]
+  return {
+    id: r.id,
+    vehicleId: r.vehicleId,
+    invoiceNumber: r.invoiceNumber,
+    status: r.status,
+    issuedAt: r.issuedAt,
+    invoiceDate: effectiveInvoiceDate(r),
+    serviceDate: r.serviceDate,
+    dueDate: r.invoiceDueDate,
+    mileage: r.mileage,
+    notes: r.invoiceNotes,
+    subtotal: r.subtotal,
+    discountType: r.discountType,
+    discountValue: r.discountValue,
+    discountAmount: r.discountAmount,
+    taxRate: r.taxRate,
+    taxAmount: r.taxAmount,
+    taxInclusive: r.taxInclusive,
+    // Records from before itemised totals carry the amount in cost only.
+    totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
+    manuallyPaid: r.manuallyPaid,
+    customer: r.customer ?? r.vehicle?.customer ?? null,
+    vehicle: r.vehicle
+      ? {
+          year: r.vehicle.year,
+          make: r.vehicle.make,
+          model: r.vehicle.model,
+          licensePlate: r.vehicle.licensePlate,
+        }
+      : null,
+    lines,
+    payments: r.payments,
+  }
+}
+
+export function invoiceUrl(appUrl: string, invoice: Pick<AccountingInvoice, 'id' | 'vehicleId'>) {
+  return serviceUrl(appUrl, invoice)
+}
+
+export async function loadPaymentForAccounting(
+  organizationId: string,
+  paymentId: string
+): Promise<AccountingPayment | null> {
+  return db.payment.findFirst({
+    where: { id: paymentId, serviceRecord: { organizationId } },
+    select: {
+      id: true,
+      serviceRecordId: true,
+      amount: true,
+      date: true,
+      method: true,
+      note: true,
+      provider: true,
+      externalId: true,
+    },
+  })
+}
+
+/** ISO 4217 code the workshop prices in, or null when it never chose one. */
+export async function workshopCurrency(organizationId: string): Promise<string | null> {
+  const row = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key: SETTING_KEYS.CURRENCY_CODE } },
+    select: { value: true },
+  })
+  const code = row?.value?.trim().toUpperCase()
+  return code && /^[A-Z]{3}$/.test(code) ? code : null
+}
+
+export interface PulledPaymentInput {
+  serviceRecordId: string
+  amount: number
+  date: Date
+  /** One of the app's own methods: cash, card, transfer or other. */
+  method: string
+  provider: string
+  externalId: string
+  note: string | null
+}
+
+/**
+ * A payment the ledger has and this app did not: recorded against the
+ * invoice so the balance here matches the books. The provider and remote id
+ * make it idempotent; a second pull of the same payment returns the row the
+ * first one made. Money against an invoice issues it, the same as a payment
+ * typed in by hand.
+ */
+export async function recordPulledPayment(
+  organizationId: string,
+  input: PulledPaymentInput
+): Promise<{ id: string; created: boolean } | null> {
+  const record = await db.serviceRecord.findFirst({
+    where: { id: input.serviceRecordId, organizationId },
+    select: { id: true },
+  })
+  if (!record) return null
+  const existing = await db.payment.findFirst({
+    where: {
+      serviceRecordId: input.serviceRecordId,
+      provider: input.provider,
+      externalId: input.externalId,
+    },
+    select: { id: true },
+  })
+  if (existing) return { id: existing.id, created: false }
+  const created = await db.payment.create({
+    data: {
+      serviceRecordId: input.serviceRecordId,
+      amount: input.amount,
+      date: input.date,
+      method: input.method,
+      note: input.note,
+      provider: input.provider,
+      externalId: input.externalId,
+    },
+    select: { id: true },
+  })
+  const { issueInvoice } = await import('@/features/invoices/Lib/issueInvoice')
+  await issueInvoice(input.serviceRecordId, organizationId, 'paid')
+  return { id: created.id, created: true }
+}
+
+/** Undo a pulled payment the ledger has since deleted. Only rows a pull made are touched. */
+export async function removePulledPayment(
+  organizationId: string,
+  paymentId: string,
+  provider: string
+): Promise<boolean> {
+  const r = await db.payment.deleteMany({
+    where: { id: paymentId, provider, serviceRecord: { organizationId } },
+  })
+  return r.count > 0
+}
+
+/** Issued invoices from the last months, oldest first, for a fresh connection to push. */
+export async function invoicesForBackfill(
+  organizationId: string,
+  since: Date,
+  limit = 500
+): Promise<string[]> {
+  const rows = await db.serviceRecord.findMany({
+    where: {
+      organizationId,
+      invoiceNumber: { not: null },
+      issuedAt: { not: null, gte: since },
+    },
+    select: { id: true },
+    orderBy: { issuedAt: 'asc' },
+    take: limit,
+  })
+  return rows.map((r) => r.id)
+}

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

@@ -88,6 +88,19 @@ function linkStore(connectionId: string): LinkStore {
       })
       return new Set(rows.map((r) => r.remoteId))
     },
+    async byRemoteId(entityType, remoteId) {
+      const row = await db.integrationLink.findFirst({
+        where: { connectionId, entityType, remoteId },
+      })
+      if (!row) return null
+      return {
+        entityId: row.entityId,
+        remoteId: row.remoteId,
+        remoteUrl: row.remoteUrl,
+        metadata: (row.metadata as Record<string, unknown> | null) ?? null,
+        checksum: row.checksum,
+      }
+    },
   }
 }
 

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

@@ -58,6 +58,12 @@ export type AuthSpec =
        * them from the form body; Zoom and a few others want HTTP Basic auth.
        */
       tokenAuth?: 'body' | 'basic'
+      /**
+       * Query parameters the vendor adds to the callback beside code and
+       * state, kept on the connection's state under the same names. Intuit
+       * sends the company id (realmId) this way and nowhere else.
+       */
+      callbackParams?: 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. */
@@ -149,6 +155,11 @@ export interface LinkStore {
   remove(entityType: string, entityId: string): Promise<void>
   /** Remote ids this connection created, so a pull can leave them out. */
   remoteIds(entityType: string): Promise<Set<string>>
+  /** The local record behind a remote id, for a pull that starts from the vendor's side. */
+  byRemoteId(
+    entityType: string,
+    remoteId: string
+  ): Promise<(LinkRecord & { entityId: string }) | null>
 }
 
 export interface ConnectionSnapshot {

+ 13 - 0
src/features/invoices/Lib/issueInvoice.ts

@@ -101,5 +101,18 @@ export async function issueInvoice(
       issuedData: buildIssuedInvoiceData(assembly) as unknown as Prisma.InputJsonValue,
     },
   })
+  // Issuing is the moment an accounting connector wants the invoice, and
+  // sending or sharing logs no service event of its own. Lazy so this
+  // module stays free of the integrations platform.
+  import('@/features/integrations/Lib/events')
+    .then(({ notifyIntegrations }) =>
+      notifyIntegrations({
+        event: 'service.update',
+        organizationId,
+        entity: 'ServiceRecord',
+        entityId: recordId,
+      })
+    )
+    .catch((err) => console.error('[integrations] issue notify failed:', err))
   return true
 }

+ 130 - 0
src/integrations/quickbooks/manifest.ts

@@ -0,0 +1,130 @@
+import type { ConnectorManifest } from '@/features/integrations/Lib/types'
+
+/**
+ * QuickBooks Online: issued invoices, their customers and their payments go
+ * to the ledger, and payments recorded in the ledger come back.
+ *
+ * Intuit's OAuth puts the client credentials in a Basic header, does not do
+ * PKCE, and names the company (the "realm") only as a query parameter on the
+ * callback, which is why it is listed under callbackParams. One scope covers
+ * the accounting API. Which company was chosen decides every API URL, so it
+ * lives on the connection's state rather than in a setting.
+ */
+export const manifest: ConnectorManifest = {
+  id: 'quickbooks',
+  name: 'QuickBooks Online',
+  category: 'accounting',
+  countries: 'global',
+  logo: '/images/integrations/quickbooks.svg',
+  docs: '/docs/integrations/quickbooks',
+  auth: {
+    type: 'oauth2',
+    authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2',
+    tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
+    scopes: ['com.intuit.quickbooks.accounting'],
+    tokenAuth: 'basic',
+    callbackParams: ['realmId'],
+    platformEnv: {
+      clientId: 'QUICKBOOKS_INTEGRATION_CLIENT_ID',
+      clientSecret: 'QUICKBOOKS_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: '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: 'text',
+      label: 'startDate',
+      help: 'startDateHelp',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'includeVehicle',
+      type: 'boolean',
+      label: 'includeVehicle',
+      help: 'includeVehicleHelp',
+      default: true,
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'laborItemId',
+      type: 'remote-select',
+      label: 'laborItemId',
+      help: 'laborItemIdHelp',
+      source: 'items',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'partsItemId',
+      type: 'remote-select',
+      label: 'partsItemId',
+      help: 'partsItemIdHelp',
+      source: 'items',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'taxCodeId',
+      type: 'remote-select',
+      label: 'taxCodeId',
+      help: 'taxCodeIdHelp',
+      source: 'taxCodes',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    {
+      key: 'zeroTaxCodeId',
+      type: 'remote-select',
+      label: 'zeroTaxCodeId',
+      help: 'zeroTaxCodeIdHelp',
+      source: 'taxCodes',
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
+    { key: 'pushPayments', type: 'boolean', label: 'pushPayments', default: true },
+    {
+      key: 'depositAccountId',
+      type: 'remote-select',
+      label: 'depositAccountId',
+      help: 'depositAccountIdHelp',
+      source: 'depositAccounts',
+      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 }],
+}

+ 390 - 0
src/integrations/quickbooks/mapping.ts

@@ -0,0 +1,390 @@
+/**
+ * Torqvoice records as QuickBooks Online entities, and back.
+ *
+ * Pure functions so the shape the API receives can be tested without a
+ * company. QuickBooks recalculates tax from the tax code on each line, so a
+ * body carries what the workshop billed and the connector checks afterwards
+ * that the ledger arrived at the same total.
+ */
+
+import type {
+  AccountingCustomer,
+  AccountingInvoice,
+  AccountingPayment,
+} from '@/features/integrations/Lib/accounting-sync'
+import { zonedDayKey } from '@/lib/timezone'
+
+/** Intuit retires older minor versions; this one carries the current field set. */
+export const MINOR_VERSION = '75'
+/** DocNumber, PaymentRefNum and a few other reference fields stop here. */
+export const REF_MAX = 21
+export const PRODUCTION_API = 'https://quickbooks.api.intuit.com'
+export const SANDBOX_API = 'https://sandbox-quickbooks.api.intuit.com'
+export const PRODUCTION_APP = 'https://app.qbo.intuit.com'
+export const SANDBOX_APP = 'https://app.sandbox.qbo.intuit.com'
+export const REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'
+/** The customer counter sales are booked to when no customer was recorded. */
+export const WALK_IN_NAME = 'Walk-in customer'
+export const DEFAULT_ITEM_NAMES = { labor: 'Labour', part: 'Parts' } as const
+/** Opens every private note this app writes, which is how its own records are recognised later. */
+export const NOTE_MARK = 'Torqvoice'
+/** Customer notes stop here. */
+export const NOTES_MAX = 2000
+
+export type Environment = 'production' | 'sandbox'
+
+export interface QboRef {
+  value: string
+  name?: string
+}
+
+export interface QboFault {
+  Fault?: { Error?: { Message?: string; Detail?: string; code?: string }[]; type?: string }
+}
+
+export interface QboCustomer {
+  Id: string
+  SyncToken: string
+  DisplayName: string
+  Active?: boolean
+}
+
+export interface QboItem {
+  Id: string
+  Name: string
+  Type?: string
+  Active?: boolean
+}
+
+export interface QboTaxCode {
+  Id: string
+  Name: string
+  Active?: boolean
+  Taxable?: boolean
+}
+
+export interface QboAccount {
+  Id: string
+  Name: string
+  AccountType?: string
+  AccountSubType?: string
+  Active?: boolean
+}
+
+export interface QboLine {
+  Id?: string
+  DetailType: string
+  /** Required on every charge line; a description-only line carries none. */
+  Amount?: number
+  Description?: string
+  SalesItemLineDetail?: {
+    ItemRef: QboRef
+    Qty?: number
+    UnitPrice?: number
+    ServiceDate?: string
+    TaxCodeRef?: QboRef
+  }
+  DescriptionLineDetail?: { ServiceDate?: string }
+  DiscountLineDetail?: { PercentBased: boolean; DiscountPercent?: number }
+  LinkedTxn?: { TxnId: string; TxnType: string }[]
+}
+
+export interface QboInvoice {
+  Id: string
+  SyncToken: string
+  DocNumber?: string
+  TotalAmt?: number
+  Balance?: number
+  PrivateNote?: string
+  status?: string
+}
+
+export interface QboPayment {
+  Id: string
+  SyncToken: string
+  TotalAmt?: number
+  TxnDate?: string
+  PaymentRefNum?: string
+  PrivateNote?: string
+  PaymentMethodRef?: QboRef
+  CustomerRef?: QboRef
+  Line?: QboLine[]
+  status?: string
+}
+
+export function apiHost(env: Environment): string {
+  return env === 'sandbox' ? SANDBOX_API : PRODUCTION_API
+}
+
+export function appHost(env: Environment): string {
+  return env === 'sandbox' ? SANDBOX_APP : PRODUCTION_APP
+}
+
+export function invoiceUrl(env: Environment, id: string): string {
+  return `${appHost(env)}/app/invoice?txnId=${encodeURIComponent(id)}`
+}
+
+export function customerUrl(env: Environment, id: string): string {
+  return `${appHost(env)}/app/customerdetail?nameId=${encodeURIComponent(id)}`
+}
+
+export function paymentUrl(env: Environment, id: string): string {
+  return `${appHost(env)}/app/recvpayment?txnId=${encodeURIComponent(id)}`
+}
+
+export function round2(n: number): number {
+  return Math.round((n + Number.EPSILON) * 100) / 100
+}
+
+/** A value for the query language, which escapes a quote with a backslash. */
+export function sqlString(value: string): string {
+  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
+}
+
+export function refField(value: string | null | undefined): string | undefined {
+  const v = value?.trim()
+  return v ? v.slice(0, REF_MAX) : undefined
+}
+
+/** The vendor's own wording for a failed call, or the raw body when there is none. */
+export function faultMessage(body: string): string {
+  try {
+    const parsed = JSON.parse(body) as QboFault
+    const errors = parsed.Fault?.Error ?? []
+    const lines = errors
+      .map((e) => [e.Message, e.Detail].filter(Boolean).join(': '))
+      .filter(Boolean)
+    if (lines.length) return lines.join('; ')
+  } catch {
+    // not JSON
+  }
+  return body.slice(0, 300)
+}
+
+export function faultCode(body: string): string | null {
+  try {
+    const parsed = JSON.parse(body) as QboFault
+    return parsed.Fault?.Error?.[0]?.code ?? null
+  } catch {
+    return null
+  }
+}
+
+/** Object Not Found, which QuickBooks answers with a 400 rather than a 404. */
+export const NOT_FOUND_CODE = '610'
+/** Duplicate Name Exists Error. */
+export const DUPLICATE_NAME_CODE = '6240'
+/** Duplicate Document Number Error, raised only when custom transaction numbers are on. */
+export const DUPLICATE_DOCNUMBER_CODE = '6140'
+
+export function customerDisplayName(c: AccountingCustomer): string {
+  return c.name.trim().slice(0, 500) || c.company?.trim().slice(0, 500) || 'Customer'
+}
+
+/**
+ * The customer as QuickBooks stores one. The address is one free-text block
+ * here and up to five lines there, so it is split on line breaks rather than
+ * guessed into city and postcode.
+ */
+export function buildCustomer(
+  c: AccountingCustomer,
+  displayName: string,
+  options: {
+    /** Only on create: a customer's currency is fixed once it exists. */
+    currency?: string | null
+  } = {}
+) {
+  const addressLines = (c.address ?? '')
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .filter(Boolean)
+    .slice(0, 5)
+  const notes = [
+    c.customerNumber ? `Torqvoice customer ${c.customerNumber}` : null,
+    c.taxId ? `Tax id ${c.taxId}` : null,
+  ]
+    .filter(Boolean)
+    .join('\n')
+  const body: Record<string, unknown> = {
+    DisplayName: displayName,
+    ...(c.company && { CompanyName: c.company.slice(0, 100) }),
+    ...(c.email && { PrimaryEmailAddr: { Address: c.email.slice(0, 100) } }),
+    ...(c.phone && { PrimaryPhone: { FreeFormNumber: c.phone.slice(0, 30) } }),
+    ...(addressLines.length > 0 && {
+      BillAddr: Object.fromEntries(addressLines.map((l, i) => [`Line${i + 1}`, l.slice(0, 500)])),
+    }),
+    ...(notes && { Notes: notes.slice(0, NOTES_MAX) }),
+    Taxable: !c.taxExempt,
+    ...(options.currency && { CurrencyRef: { value: options.currency } }),
+  }
+  return body
+}
+
+export interface InvoiceOptions {
+  customerRef: string
+  customerEmail: string | null
+  laborItemId: string | null
+  partsItemId: string | null
+  taxCodeId: string | null
+  zeroTaxCodeId: string | null
+  /** Whether the company uses the global (non-US) tax model. */
+  globalTax: boolean
+  /** Required whenever the company runs multi-currency; null otherwise. */
+  currency: string | null
+  /**
+   * Whether the company lets a transaction carry its own number. When it
+   * does not, QuickBooks numbers the invoice itself and a DocNumber sent on
+   * create only risks duplicates, so none is sent.
+   */
+  customTxnNumbers: boolean
+  timezone: string
+  url: string
+  taxExempt: boolean
+  /** A text line naming the vehicle above the charges, so the books say which car. */
+  includeVehicle: boolean
+}
+
+/** "2018 Toyota Corolla, AB 12345, 84 200 km", or null without a vehicle. */
+export function vehicleDescription(inv: AccountingInvoice): string | null {
+  if (!inv.vehicle) return null
+  const name = [inv.vehicle.year, inv.vehicle.make, inv.vehicle.model].filter(Boolean).join(' ')
+  const parts = [name, inv.vehicle.licensePlate, inv.mileage ? `${inv.mileage} km` : null]
+  return parts.filter(Boolean).join(', ')
+}
+
+function itemLine(
+  line: AccountingInvoice['lines'][number],
+  itemId: string,
+  taxCode: string | null,
+  serviceDate: string
+): QboLine {
+  const description = [line.partNumber, line.description].filter(Boolean).join(' ').slice(0, 4000)
+  // QuickBooks derives Amount from Qty × UnitPrice and rejects a line where
+  // they disagree. A line whose total was edited by hand goes as one unit.
+  const consistent = Math.abs(line.quantity * line.unitPrice - line.total) < 0.005
+  const qty = consistent ? line.quantity : 1
+  const unit = consistent ? line.unitPrice : line.total
+  return {
+    DetailType: 'SalesItemLineDetail',
+    Amount: round2(line.total),
+    Description: description,
+    SalesItemLineDetail: {
+      ItemRef: { value: itemId },
+      Qty: qty,
+      UnitPrice: round2(unit),
+      ServiceDate: serviceDate,
+      ...(taxCode && { TaxCodeRef: { value: taxCode } }),
+    },
+  }
+}
+
+/**
+ * The invoice body for a create, or for a sparse update once Id and
+ * SyncToken are added. Lines replace what the ledger had, so an edited job
+ * arrives whole.
+ */
+export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<string, unknown> {
+  const taxable = inv.taxRate > 0 && !o.taxExempt
+  const taxCode = taxable ? o.taxCodeId : o.zeroTaxCodeId
+  const serviceDate = zonedDayKey(inv.serviceDate, o.timezone)
+  const lines: QboLine[] = []
+  const vehicle = o.includeVehicle ? vehicleDescription(inv) : null
+  if (vehicle) {
+    lines.push({
+      DetailType: 'DescriptionOnly',
+      Description: `Vehicle: ${vehicle}`.slice(0, 4000),
+      DescriptionLineDetail: { ServiceDate: serviceDate },
+    })
+  }
+  for (const line of inv.lines) {
+    const itemId = line.kind === 'labor' ? o.laborItemId : o.partsItemId
+    if (!itemId) continue
+    lines.push(itemLine(line, itemId, taxCode, serviceDate))
+  }
+  if (inv.discountAmount > 0) {
+    const percent = inv.discountType === 'percentage' && inv.discountValue > 0
+    lines.push({
+      DetailType: 'DiscountLineDetail',
+      Amount: round2(inv.discountAmount),
+      DiscountLineDetail: percent
+        ? { PercentBased: true, DiscountPercent: inv.discountValue }
+        : { PercentBased: false },
+    })
+  }
+  const body: Record<string, unknown> = {
+    CustomerRef: { value: o.customerRef },
+    TxnDate: zonedDayKey(inv.invoiceDate, o.timezone),
+    ...(inv.dueDate && { DueDate: zonedDayKey(inv.dueDate, o.timezone) }),
+    ...(o.customTxnNumbers &&
+      refField(inv.invoiceNumber) && { DocNumber: refField(inv.invoiceNumber) }),
+    Line: lines,
+    ...(inv.notes && { CustomerMemo: { value: inv.notes.slice(0, 1000) } }),
+    PrivateNote: `${NOTE_MARK} ${o.url}`.slice(0, 4000),
+    ...(o.customerEmail && { BillEmail: { Address: o.customerEmail.slice(0, 100) } }),
+    ...(o.currency && { CurrencyRef: { value: o.currency } }),
+    // Torqvoice taxes the amount after the discount, so the ledger must too.
+    ...(inv.discountAmount > 0 && { ApplyTaxAfterDiscount: true }),
+  }
+  if (o.globalTax) {
+    body.GlobalTaxCalculation = !taxable
+      ? 'NotApplicable'
+      : inv.taxInclusive
+        ? 'TaxInclusive'
+        : 'TaxExcluded'
+  }
+  return body
+}
+
+export interface PaymentOptions {
+  customerRef: string
+  invoiceRemoteId: string
+  depositAccountId: string | null
+  currency: string | null
+  timezone: string
+}
+
+export function buildPayment(p: AccountingPayment, o: PaymentOptions): Record<string, unknown> {
+  const note = [
+    `${NOTE_MARK} payment, ${p.method}`,
+    p.provider ? `via ${p.provider}` : null,
+    p.note,
+  ]
+    .filter(Boolean)
+    .join('. ')
+  return {
+    CustomerRef: { value: o.customerRef },
+    TotalAmt: round2(p.amount),
+    TxnDate: zonedDayKey(p.date, o.timezone),
+    ...(refField(p.externalId) && { PaymentRefNum: refField(p.externalId) }),
+    PrivateNote: note.slice(0, 4000),
+    ...(o.depositAccountId && { DepositToAccountRef: { value: o.depositAccountId } }),
+    ...(o.currency && { CurrencyRef: { value: o.currency } }),
+    Line: [
+      {
+        Amount: round2(p.amount),
+        LinkedTxn: [{ TxnId: o.invoiceRemoteId, TxnType: 'Invoice' }],
+      },
+    ],
+  }
+}
+
+/** A QuickBooks payment method name as one of the app's own methods. */
+export function localPaymentMethod(name: string | undefined): string {
+  const n = (name ?? '').toLowerCase()
+  if (!n) return 'other'
+  if (/cash/.test(n)) return 'cash'
+  if (/card|visa|master|amex|express/.test(n)) return 'card'
+  if (/transfer|bank|ach|eft|wire|giro|bacs|sepa/.test(n)) return 'transfer'
+  return 'other'
+}
+
+/** Stable hash of a body, so an unchanged record is not pushed twice. */
+export function checksumOf(body: unknown): string {
+  const input = JSON.stringify(body)
+  let h = 2166136261
+  for (let i = 0; i < input.length; i++) {
+    h ^= input.charCodeAt(i)
+    h = Math.imul(h, 16777619) >>> 0
+  }
+  return h.toString(16)
+}

+ 859 - 0
src/integrations/quickbooks/server.ts

@@ -0,0 +1,859 @@
+import {
+  type AccountingCustomer,
+  type AccountingInvoice,
+  type AccountingPayment,
+  CUSTOMER_ENTITY,
+  INVOICE_ENTITY,
+  PAYMENT_ENTITY,
+  invoiceUrl as appInvoiceUrl,
+  loadCustomerForAccounting,
+  loadInvoiceForAccounting,
+  loadPaymentForAccounting,
+  recordPulledPayment,
+  removePulledPayment,
+  workshopCurrency,
+} from '@/features/integrations/Lib/accounting-sync'
+import { oauthSpec, resolveClient } from '@/features/integrations/Lib/oauth'
+import {
+  ConnectorHttpError,
+  type ConnectorContext,
+  type ConnectorServer,
+  type JobOutcome,
+} from '@/features/integrations/Lib/types'
+import { zonedDayKey } from '@/lib/timezone'
+import { manifest } from './manifest'
+import {
+  DEFAULT_ITEM_NAMES,
+  DUPLICATE_DOCNUMBER_CODE,
+  DUPLICATE_NAME_CODE,
+  type Environment,
+  MINOR_VERSION,
+  NOTE_MARK,
+  NOT_FOUND_CODE,
+  type QboAccount,
+  type QboCustomer,
+  type QboInvoice,
+  type QboItem,
+  type QboPayment,
+  type QboTaxCode,
+  REVOKE_URL,
+  WALK_IN_NAME,
+  apiHost,
+  buildCustomer,
+  buildInvoice,
+  buildPayment,
+  checksumOf,
+  customerDisplayName,
+  customerUrl,
+  faultCode,
+  faultMessage,
+  invoiceUrl,
+  localPaymentMethod,
+  paymentUrl,
+  round2,
+  sqlString,
+} from './mapping'
+
+const PROVIDER = 'quickbooks'
+/** Change data capture reaches back at most a month. */
+const CDC_MAX_DAYS = 30
+/** Ledger and workshop totals may differ by rounding; beyond this the tax code is wrong. */
+const TOTAL_TOLERANCE = 0.05
+
+class QboError extends Error {
+  status: number
+  code: string | null
+  constructor(status: number, body: string) {
+    super(`QuickBooks: ${faultMessage(body)}`)
+    this.status = status
+    this.code = faultCode(body)
+  }
+}
+
+interface State {
+  realmId: string
+  environment: Environment
+  country: string | null
+  homeCurrency: string | null
+  multiCurrency: boolean
+  /** Whether the company lets a transaction carry its own number; without it DocNumber is ignored. */
+  customTxnNumbers: boolean
+  defaultItems: Partial<Record<'labor' | 'part', string>>
+  walkInCustomerId: string | null
+  lastPullAt: string | null
+}
+
+function stateOf(ctx: ConnectorContext): State {
+  const s = ctx.connection.state
+  return {
+    realmId: typeof s.realmId === 'string' ? s.realmId : '',
+    environment: s.environment === 'sandbox' ? 'sandbox' : 'production',
+    country: typeof s.country === 'string' ? s.country : null,
+    homeCurrency: typeof s.homeCurrency === 'string' ? s.homeCurrency : null,
+    multiCurrency: s.multiCurrency === true,
+    customTxnNumbers: s.customTxnNumbers !== false,
+    defaultItems: (s.defaultItems as State['defaultItems']) ?? {},
+    walkInCustomerId: typeof s.walkInCustomerId === 'string' ? s.walkInCustomerId : null,
+    lastPullAt: typeof s.lastPullAt === 'string' ? s.lastPullAt : null,
+  }
+}
+
+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')
+  return {
+    pushInvoices: s.pushInvoices !== false,
+    pushOnComplete: s.pushOnComplete === true,
+    startDate: startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? startDate : null,
+    includeVehicle: s.includeVehicle !== false,
+    laborItemId: str('laborItemId'),
+    partsItemId: str('partsItemId'),
+    taxCodeId: str('taxCodeId'),
+    zeroTaxCodeId: str('zeroTaxCodeId'),
+    pushPayments: s.pushPayments !== false,
+    depositAccountId: str('depositAccountId'),
+    manualPaidAsPayment: s.manualPaidAsPayment === true,
+    pullPayments: s.pullPayments !== false,
+  }
+}
+
+function realmOf(ctx: ConnectorContext): string {
+  const realm = stateOf(ctx).realmId
+  if (!realm) throw new Error('No QuickBooks company on this connection; reconnect')
+  return realm
+}
+
+/**
+ * One call to the company API. QuickBooks answers XML unless asked for
+ * JSON, and reports every failure as a Fault body, including "not found",
+ * which arrives as a 400 with code 610.
+ */
+async function api<T>(
+  ctx: ConnectorContext,
+  path: string,
+  init: { method?: string; body?: unknown; query?: Record<string, string>; env?: Environment } = {}
+): Promise<T> {
+  const state = stateOf(ctx)
+  const url = new URL(
+    `${apiHost(init.env ?? state.environment)}/v3/company/${realmOf(ctx)}/${path}`
+  )
+  url.searchParams.set('minorversion', MINOR_VERSION)
+  for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v)
+  try {
+    return await ctx.http.json<T>(url.toString(), {
+      method: init.method ?? (init.body ? 'POST' : 'GET'),
+      headers: { Accept: 'application/json' },
+      ...(init.body !== undefined && { body: JSON.stringify(init.body) }),
+    })
+  } catch (err) {
+    if (err instanceof ConnectorHttpError) throw new QboError(err.status, err.body)
+    throw err
+  }
+}
+
+async function query<T>(ctx: ConnectorContext, entity: string, sql: string): Promise<T[]> {
+  const res = await api<{ QueryResponse?: Record<string, T[] | undefined> }>(ctx, 'query', {
+    query: { query: sql },
+  })
+  return res.QueryResponse?.[entity] ?? []
+}
+
+function isNotFound(err: unknown): boolean {
+  return err instanceof QboError && (err.code === NOT_FOUND_CODE || err.status === 404)
+}
+
+/* ---------- company ---------- */
+
+interface CompanyInfo {
+  CompanyInfo: { CompanyName?: string; Country?: string }
+}
+
+interface Preferences {
+  Preferences: {
+    CurrencyPrefs?: { HomeCurrency?: { value?: string }; MultiCurrencyEnabled?: boolean }
+    SalesFormsPrefs?: { CustomTxnNumbers?: boolean }
+  }
+}
+
+async function readCompany(ctx: ConnectorContext, env: Environment) {
+  const realm = realmOf(ctx)
+  const info = await api<CompanyInfo>(ctx, `companyinfo/${realm}`, { env })
+  const prefs = await api<Preferences>(ctx, 'preferences', { env })
+  return {
+    name: info.CompanyInfo.CompanyName ?? realm,
+    country: info.CompanyInfo.Country ?? null,
+    homeCurrency: prefs.Preferences.CurrencyPrefs?.HomeCurrency?.value ?? null,
+    multiCurrency: prefs.Preferences.CurrencyPrefs?.MultiCurrencyEnabled === true,
+    customTxnNumbers: prefs.Preferences.SalesFormsPrefs?.CustomTxnNumbers === true,
+  }
+}
+
+/* ---------- customers ---------- */
+
+async function walkInCustomer(ctx: ConnectorContext): Promise<string> {
+  const state = stateOf(ctx)
+  if (state.walkInCustomerId) return state.walkInCustomerId
+  const found = await query<QboCustomer>(
+    ctx,
+    'Customer',
+    `select Id, DisplayName from Customer where DisplayName = ${sqlString(WALK_IN_NAME)}`
+  )
+  let id = found[0]?.Id
+  if (!id) {
+    const created = await api<{ Customer: QboCustomer }>(ctx, 'customer', {
+      body: { DisplayName: WALK_IN_NAME },
+    })
+    id = created.Customer.Id
+  }
+  await ctx.saveState({ walkInCustomerId: id })
+  return id
+}
+
+async function createCustomer(ctx: ConnectorContext, c: AccountingCustomer): Promise<QboCustomer> {
+  const name = customerDisplayName(c)
+  // A customer's currency is set once, at creation, and every invoice for
+  // them must match it, so a multi-currency company gets the workshop's.
+  const currency = await currencyFor(ctx)
+  try {
+    const res = await api<{ Customer: QboCustomer }>(ctx, 'customer', {
+      body: buildCustomer(c, name, { currency }),
+    })
+    return res.Customer
+  } catch (err) {
+    if (!(err instanceof QboError && err.code === DUPLICATE_NAME_CODE)) throw err
+    // A vendor or employee already holds that name; the customer number tells them apart.
+    const suffix = c.customerNumber ?? c.id.slice(-4)
+    const res = await api<{ Customer: QboCustomer }>(ctx, 'customer', {
+      body: buildCustomer(c, `${name} (${suffix})`.slice(0, 500), { currency }),
+    })
+    return res.Customer
+  }
+}
+
+/**
+ * The ledger's id for a customer, creating or updating as needed. A
+ * customer already in the ledger under the same display name is reused
+ * rather than duplicated, and left as the bookkeeper has it until the
+ * workshop edits the record here.
+ */
+async function ensureCustomer(
+  ctx: ConnectorContext,
+  c: AccountingCustomer | null
+): Promise<{ id: string; email: string | null; taxExempt: boolean }> {
+  if (!c) return { id: await walkInCustomer(ctx), email: null, taxExempt: false }
+  const env = stateOf(ctx).environment
+  const name = customerDisplayName(c)
+  const checksum = checksumOf(buildCustomer(c, name))
+  const link = await ctx.links.get(CUSTOMER_ENTITY, c.id)
+  if (link) {
+    if (link.checksum === checksum)
+      return { id: link.remoteId, email: c.email, taxExempt: c.taxExempt }
+    try {
+      const current = await api<{ Customer: QboCustomer }>(ctx, `customer/${link.remoteId}`)
+      const res = await api<{ Customer: QboCustomer }>(ctx, 'customer', {
+        body: {
+          ...buildCustomer(c, current.Customer.DisplayName),
+          Id: current.Customer.Id,
+          SyncToken: current.Customer.SyncToken,
+          sparse: true,
+        },
+      })
+      await ctx.links.set(CUSTOMER_ENTITY, c.id, {
+        remoteId: res.Customer.Id,
+        remoteUrl: customerUrl(env, res.Customer.Id),
+        checksum,
+      })
+      return { id: res.Customer.Id, email: c.email, taxExempt: c.taxExempt }
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+      await ctx.links.remove(CUSTOMER_ENTITY, c.id)
+    }
+  }
+  const existing = await query<QboCustomer>(
+    ctx,
+    'Customer',
+    `select Id, DisplayName from Customer where DisplayName = ${sqlString(name)}`
+  )
+  const remote = existing[0] ?? (await createCustomer(ctx, c))
+  await ctx.links.set(CUSTOMER_ENTITY, c.id, {
+    remoteId: remote.Id,
+    remoteUrl: customerUrl(env, remote.Id),
+    checksum,
+    metadata: { reused: Boolean(existing[0]) },
+  })
+  return { id: remote.Id, email: c.email, taxExempt: c.taxExempt }
+}
+
+/* ---------- items ---------- */
+
+/**
+ * The service item a line is booked to. The workshop can point at its own
+ * items; otherwise one named Labour or Parts is found or created under the
+ * company's income account, once, and remembered.
+ */
+async function ensureItem(ctx: ConnectorContext, kind: 'labor' | 'part'): Promise<string> {
+  const settings = settingsOf(ctx)
+  const chosen = kind === 'labor' ? settings.laborItemId : settings.partsItemId
+  if (chosen) return chosen
+  const state = stateOf(ctx)
+  const remembered = state.defaultItems[kind]
+  if (remembered) return remembered
+  const name = DEFAULT_ITEM_NAMES[kind]
+  const found = await query<QboItem>(
+    ctx,
+    'Item',
+    `select Id, Name from Item where Name = ${sqlString(name)}`
+  )
+  let id = found[0]?.Id
+  if (!id) {
+    const accounts = await query<QboAccount>(
+      ctx,
+      'Account',
+      "select Id, Name, AccountSubType from Account where AccountType = 'Income' and Active = true maxresults 50"
+    )
+    const preferred = kind === 'labor' ? 'ServiceFeeIncome' : 'SalesOfProductIncome'
+    const account = accounts.find((a) => a.AccountSubType === preferred) ?? accounts[0]
+    if (!account) throw new Error('QuickBooks has no income account to book sales to')
+    const created = await api<{ Item: QboItem }>(ctx, 'item', {
+      body: { Name: name, Type: 'Service', IncomeAccountRef: { value: account.Id } },
+    })
+    id = created.Item.Id
+    await ctx.log('info', `Created item "${name}" under ${account.Name}`)
+  }
+  await ctx.saveState({ defaultItems: { ...state.defaultItems, [kind]: id } })
+  return id
+}
+
+/* ---------- invoices ---------- */
+
+/**
+ * The currency to put on a transaction: required by QuickBooks whenever the
+ * company runs multi-currency, and otherwise not accepted. A workshop that
+ * bills in another currency than a single-currency company keeps its
+ * numbers, booked in the company's currency, with a warning in the log.
+ */
+async function currencyFor(ctx: ConnectorContext): Promise<string | null> {
+  const state = stateOf(ctx)
+  const ours = await workshopCurrency(ctx.connection.organizationId)
+  if (state.multiCurrency) return ours ?? state.homeCurrency
+  if (ours && state.homeCurrency && ours !== state.homeCurrency) {
+    await ctx.log(
+      'warn',
+      `Workshop bills in ${ours} but the QuickBooks company keeps ${state.homeCurrency} and multi-currency is off; amounts are booked as ${state.homeCurrency}`
+    )
+  }
+  return null
+}
+
+function eligible(inv: AccountingInvoice, s: ReturnType<typeof settingsOf>, tz: string): boolean {
+  if (!s.pushInvoices || !inv.invoiceNumber) return false
+  const issued = Boolean(inv.issuedAt) || (s.pushOnComplete && inv.status === 'completed')
+  if (!issued) return false
+  if (s.startDate && zonedDayKey(inv.invoiceDate, tz) < s.startDate) return false
+  return true
+}
+
+/** The record is gone: void the ledger's copy unless money was taken against it. */
+async function retireInvoice(ctx: ConnectorContext, serviceRecordId: string): Promise<JobOutcome> {
+  const link = await ctx.links.get(INVOICE_ENTITY, serviceRecordId)
+  if (!link) return { summary: 'nothing to do' }
+  try {
+    const current = await api<{ Invoice: QboInvoice }>(ctx, `invoice/${link.remoteId}`)
+    const inv = current.Invoice
+    if ((inv.Balance ?? 0) < (inv.TotalAmt ?? 0)) {
+      await ctx.log(
+        'warn',
+        `Invoice ${inv.DocNumber ?? inv.Id} was deleted here but has payments in QuickBooks; left as is`
+      )
+      await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      return { summary: 'left in QuickBooks, has payments' }
+    }
+    await api(ctx, 'invoice', {
+      query: { operation: 'void' },
+      body: { Id: inv.Id, SyncToken: inv.SyncToken },
+    })
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: `invoice ${inv.DocNumber ?? inv.Id} voided` }
+  } catch (err) {
+    if (!isNotFound(err)) throw err
+    await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+    return { summary: 'already gone from QuickBooks' }
+  }
+}
+
+async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  const state = stateOf(ctx)
+  const inv = await loadInvoiceForAccounting(ctx.connection.organizationId, serviceRecordId)
+  if (!inv) return retireInvoice(ctx, serviceRecordId)
+  if (!eligible(inv, settings, ctx.timezone)) {
+    return { summary: inv.invoiceNumber ? 'not issued yet' : 'no invoice number' }
+  }
+
+  const customer = await ensureCustomer(ctx, inv.customer)
+  const hasLabor = inv.lines.some((l) => l.kind === 'labor')
+  const hasParts = inv.lines.some((l) => l.kind === 'part')
+  const body = buildInvoice(inv, {
+    customerRef: customer.id,
+    customerEmail: customer.email,
+    laborItemId: hasLabor ? await ensureItem(ctx, 'labor') : null,
+    partsItemId: hasParts ? await ensureItem(ctx, 'part') : null,
+    taxCodeId: settings.taxCodeId,
+    zeroTaxCodeId: settings.zeroTaxCodeId,
+    globalTax: state.country !== 'US',
+    currency: await currencyFor(ctx),
+    timezone: ctx.timezone,
+    url: appInvoiceUrl(ctx.appUrl, inv),
+    taxExempt: customer.taxExempt,
+    includeVehicle: settings.includeVehicle,
+    customTxnNumbers: state.customTxnNumbers,
+  })
+  const checksum = checksumOf(body)
+  const link = await ctx.links.get(INVOICE_ENTITY, serviceRecordId)
+
+  let saved: QboInvoice | null = null
+  let action = 'unchanged'
+  if (!link || link.checksum !== checksum) {
+    if (link) {
+      try {
+        const current = await api<{ Invoice: QboInvoice }>(ctx, `invoice/${link.remoteId}`)
+        const res = await api<{ Invoice: QboInvoice }>(ctx, 'invoice', {
+          body: {
+            ...body,
+            Id: current.Invoice.Id,
+            SyncToken: current.Invoice.SyncToken,
+            sparse: true,
+          },
+        })
+        saved = res.Invoice
+        action = 'updated'
+      } catch (err) {
+        if (!isNotFound(err)) throw err
+        await ctx.links.remove(INVOICE_ENTITY, serviceRecordId)
+      }
+    }
+    if (!saved) {
+      saved = await createInvoice(ctx, body, inv)
+      action = 'created'
+    }
+    await ctx.links.set(INVOICE_ENTITY, serviceRecordId, {
+      remoteId: saved.Id,
+      remoteUrl: invoiceUrl(state.environment, saved.Id),
+      checksum,
+      metadata: { docNumber: saved.DocNumber ?? null, customerId: customer.id },
+    })
+    const theirs = saved.TotalAmt ?? 0
+    if (Math.abs(theirs - inv.totalAmount) > TOTAL_TOLERANCE) {
+      await ctx.log(
+        'warn',
+        `Invoice ${inv.invoiceNumber}: QuickBooks total ${round2(theirs)} differs from ${round2(inv.totalAmount)} here; check the tax codes in the integration settings`,
+        { quickbooks: theirs, torqvoice: inv.totalAmount }
+      )
+    }
+  }
+
+  let paymentsPushed = 0
+  if (settings.pushPayments) {
+    for (const p of inv.payments) {
+      if (await ctx.links.get(PAYMENT_ENTITY, p.id)) continue
+      await pushPaymentRow(ctx, p, customer.id)
+      paymentsPushed++
+    }
+    if (settings.manualPaidAsPayment && inv.manuallyPaid) {
+      if (await settleByHand(ctx, inv, customer.id, saved)) paymentsPushed++
+    }
+  }
+  const summary = `invoice ${inv.invoiceNumber} ${action}`
+  return { summary: paymentsPushed ? `${summary}, ${paymentsPushed} payments recorded` : summary }
+}
+
+/**
+ * A new invoice in the ledger. A duplicate document number means either the
+ * ledger already holds this invoice from an earlier push whose link was
+ * lost, which is then adopted, or the workshop reused a number, which is
+ * allowed through and logged so the bookkeeper hears about it.
+ */
+async function createInvoice(
+  ctx: ConnectorContext,
+  body: Record<string, unknown>,
+  inv: AccountingInvoice
+): Promise<QboInvoice> {
+  try {
+    const res = await api<{ Invoice: QboInvoice }>(ctx, 'invoice', { body })
+    return res.Invoice
+  } catch (err) {
+    if (!(err instanceof QboError && err.code === DUPLICATE_DOCNUMBER_CODE)) throw err
+    const docNumber = typeof body.DocNumber === 'string' ? body.DocNumber : ''
+    const found = await query<QboInvoice>(
+      ctx,
+      'Invoice',
+      `select Id, SyncToken, DocNumber, PrivateNote from Invoice where DocNumber = ${sqlString(docNumber)}`
+    )
+    const ours = found.find((i) => i.PrivateNote?.startsWith(NOTE_MARK))
+    if (ours) {
+      await ctx.log('info', `Invoice ${docNumber} was already in QuickBooks; linked to it`)
+      const res = await api<{ Invoice: QboInvoice }>(ctx, 'invoice', {
+        body: { ...body, Id: ours.Id, SyncToken: ours.SyncToken, sparse: true },
+      })
+      return res.Invoice
+    }
+    await ctx.log(
+      'warn',
+      `QuickBooks already has an invoice numbered ${docNumber} that did not come from Torqvoice; invoice ${inv.invoiceNumber} was added beside it`
+    )
+    const res = await api<{ Invoice: QboInvoice }>(ctx, 'invoice', {
+      body,
+      query: { include: 'allowduplicatedocnum' },
+    })
+    return res.Invoice
+  }
+}
+
+/**
+ * An invoice marked paid by hand has no payment row here, so the ledger
+ * would show it open forever. When the workshop asks for it, one payment
+ * for whatever the ledger still shows as owed closes it, once.
+ */
+async function settleByHand(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  customerRef: string,
+  saved: QboInvoice | null
+): Promise<boolean> {
+  const entityId = `manual:${inv.id}`
+  if (await ctx.links.get(PAYMENT_ENTITY, entityId)) return false
+  const invoiceLink = await ctx.links.get(INVOICE_ENTITY, inv.id)
+  if (!invoiceLink) return false
+  let balance = saved?.Balance
+  if (balance === undefined) {
+    const current = await api<{ Invoice: QboInvoice }>(ctx, `invoice/${invoiceLink.remoteId}`)
+    balance = current.Invoice.Balance ?? 0
+  }
+  if (!(balance > 0)) return false
+  const settings = settingsOf(ctx)
+  const state = stateOf(ctx)
+  const body = buildPayment(
+    {
+      id: entityId,
+      serviceRecordId: inv.id,
+      amount: balance,
+      date: inv.issuedAt ?? new Date(),
+      method: 'other',
+      note: 'Marked as paid by hand in Torqvoice',
+      provider: null,
+      externalId: null,
+    },
+    {
+      customerRef,
+      invoiceRemoteId: invoiceLink.remoteId,
+      depositAccountId: settings.depositAccountId,
+      currency: await currencyFor(ctx),
+      timezone: ctx.timezone,
+    }
+  )
+  const res = await api<{ Payment: QboPayment }>(ctx, 'payment', { body })
+  await ctx.links.set(PAYMENT_ENTITY, entityId, {
+    remoteId: res.Payment.Id,
+    remoteUrl: paymentUrl(state.environment, res.Payment.Id),
+    metadata: { createdByUs: true, serviceRecordId: inv.id, manual: true },
+  })
+  return true
+}
+
+/* ---------- payments ---------- */
+
+async function pushPaymentRow(
+  ctx: ConnectorContext,
+  p: AccountingPayment,
+  customerRef: string
+): Promise<void> {
+  const settings = settingsOf(ctx)
+  const state = stateOf(ctx)
+  const invoiceLink = await ctx.links.get(INVOICE_ENTITY, p.serviceRecordId)
+  if (!invoiceLink) return
+  const body = buildPayment(p, {
+    customerRef,
+    invoiceRemoteId: invoiceLink.remoteId,
+    depositAccountId: settings.depositAccountId,
+    currency: await currencyFor(ctx),
+    timezone: ctx.timezone,
+  })
+  const res = await api<{ Payment: QboPayment }>(ctx, 'payment', { body })
+  await ctx.links.set(PAYMENT_ENTITY, p.id, {
+    remoteId: res.Payment.Id,
+    remoteUrl: paymentUrl(state.environment, res.Payment.Id),
+    metadata: { createdByUs: true, serviceRecordId: p.serviceRecordId },
+  })
+}
+
+async function pushPayment(ctx: ConnectorContext, paymentId: string): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  const p = await loadPaymentForAccounting(ctx.connection.organizationId, paymentId)
+  const link = await ctx.links.get(PAYMENT_ENTITY, paymentId)
+  if (!p) {
+    if (!link) return { summary: 'nothing to do' }
+    if (link.metadata?.createdByUs !== true) {
+      // A payment the ledger made and this app then deleted stays in the ledger.
+      await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+      return { summary: 'payment came from QuickBooks; left there' }
+    }
+    try {
+      const current = await api<{ Payment: QboPayment }>(ctx, `payment/${link.remoteId}`)
+      await api(ctx, 'payment', {
+        query: { operation: 'delete' },
+        body: { Id: current.Payment.Id, SyncToken: current.Payment.SyncToken },
+      })
+    } catch (err) {
+      if (!isNotFound(err)) throw err
+    }
+    await ctx.links.remove(PAYMENT_ENTITY, paymentId)
+    return { summary: 'payment deleted' }
+  }
+  if (link) return { summary: 'unchanged' }
+  if (!settings.pushPayments) return { summary: 'payments not sent' }
+
+  // The payment needs its invoice in the ledger first; pushing the invoice
+  // also records every payment on it, this one included.
+  const outcome = await pushInvoice(ctx, p.serviceRecordId)
+  if (await ctx.links.get(PAYMENT_ENTITY, paymentId)) return { summary: 'payment recorded' }
+  return { summary: `payment not recorded: ${outcome.summary ?? 'invoice not in QuickBooks'}` }
+}
+
+/* ---------- customers on edit ---------- */
+
+async function pushCustomer(ctx: ConnectorContext, customerId: string): Promise<JobOutcome> {
+  const link = await ctx.links.get(CUSTOMER_ENTITY, customerId)
+  // Customers reach the ledger with their first invoice; an edit before that is nothing yet.
+  if (!link) return { summary: 'not in QuickBooks yet' }
+  const c = await loadCustomerForAccounting(ctx.connection.organizationId, customerId)
+  if (!c) return { summary: 'customer gone' }
+  await ensureCustomer(ctx, c)
+  return { summary: 'customer updated' }
+}
+
+/* ---------- pull ---------- */
+
+interface CdcResponse {
+  CDCResponse?: { QueryResponse?: { Payment?: QboPayment[]; Invoice?: QboInvoice[] }[] }[]
+}
+
+function cdcSince(state: State): Date {
+  const floor = Date.now() - CDC_MAX_DAYS * 86_400_000
+  const last = state.lastPullAt ? Date.parse(state.lastPullAt) : Number.NaN
+  const start = Number.isFinite(last) ? last : Date.now() - 86_400_000
+  // A minute of overlap so a change landing during the last pull is not missed.
+  return new Date(Math.max(floor, start - 60_000))
+}
+
+/**
+ * Payments taken in QuickBooks against invoices this app issued are recorded
+ * here, and ones deleted there are removed again. Invoices deleted in the
+ * ledger lose their link, so the next edit here recreates them.
+ */
+async function pullChanges(ctx: ConnectorContext): Promise<JobOutcome> {
+  const settings = settingsOf(ctx)
+  if (!settings.pullPayments) return { summary: 'pull switched off' }
+  const state = stateOf(ctx)
+  const startedAt = new Date()
+  const since = cdcSince(state)
+  const res = await api<CdcResponse>(ctx, 'cdc', {
+    query: {
+      entities: 'Payment,Invoice',
+      changedSince: since.toISOString().replace(/\.\d{3}Z$/, 'Z'),
+    },
+  })
+  const responses = res.CDCResponse?.flatMap((r) => r.QueryResponse ?? []) ?? []
+  const payments = responses.flatMap((r) => r.Payment ?? [])
+  const invoices = responses.flatMap((r) => r.Invoice ?? [])
+  const known = await ctx.links.remoteIds(PAYMENT_ENTITY)
+  const orgId = ctx.connection.organizationId
+
+  let recorded = 0
+  let removed = 0
+  for (const p of payments) {
+    if (p.status === 'Deleted') {
+      const link = await ctx.links.byRemoteId(PAYMENT_ENTITY, p.Id)
+      if (!link) continue
+      if (link.metadata?.createdByUs === true) {
+        await ctx.log('warn', `Payment ${p.Id} was deleted in QuickBooks; the payment here is kept`)
+        await ctx.links.remove(PAYMENT_ENTITY, link.entityId)
+        continue
+      }
+      if (await removePulledPayment(orgId, link.entityId, PROVIDER)) removed++
+      await ctx.links.remove(PAYMENT_ENTITY, link.entityId)
+      continue
+    }
+    if (known.has(p.Id)) continue
+    for (const line of p.Line ?? []) {
+      const txn = line.LinkedTxn?.find((t) => t.TxnType === 'Invoice')
+      const amount = line.Amount ?? 0
+      if (!txn || !(amount > 0)) continue
+      const invoiceLink = await ctx.links.byRemoteId(INVOICE_ENTITY, txn.TxnId)
+      if (!invoiceLink) continue
+      const made = await recordPulledPayment(orgId, {
+        serviceRecordId: invoiceLink.entityId,
+        amount: round2(amount),
+        date: p.TxnDate ? new Date(`${p.TxnDate}T12:00:00Z`) : new Date(),
+        method: localPaymentMethod(p.PaymentMethodRef?.name),
+        provider: PROVIDER,
+        externalId: p.Id,
+        note: [p.PaymentRefNum ? `Ref ${p.PaymentRefNum}` : null, 'Recorded in QuickBooks']
+          .filter(Boolean)
+          .join('. '),
+      })
+      if (!made) continue
+      await ctx.links.set(PAYMENT_ENTITY, made.id, {
+        remoteId: p.Id,
+        remoteUrl: paymentUrl(state.environment, p.Id),
+        metadata: { createdByUs: false, serviceRecordId: invoiceLink.entityId },
+      })
+      known.add(p.Id)
+      if (made.created) recorded++
+    }
+  }
+
+  let unlinked = 0
+  for (const inv of invoices) {
+    if (inv.status !== 'Deleted') continue
+    const link = await ctx.links.byRemoteId(INVOICE_ENTITY, inv.Id)
+    if (!link) continue
+    await ctx.links.remove(INVOICE_ENTITY, link.entityId)
+    await ctx.log(
+      'warn',
+      `Invoice ${inv.Id} was deleted in QuickBooks; it will be recreated if edited here`
+    )
+    unlinked++
+  }
+
+  await ctx.saveState({ lastPullAt: startedAt.toISOString() })
+  const parts = [
+    recorded ? `${recorded} payments recorded` : null,
+    removed ? `${removed} payments removed` : null,
+    unlinked ? `${unlinked} invoices unlinked` : 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
+}
+
+export const connector: ConnectorServer = {
+  manifest,
+  /**
+   * Development keys reach sandbox companies and production keys reach live
+   * ones, on different hosts. Which kind of key this is shows in whether the
+   * live host answers, so the environment is found here and kept.
+   */
+  async identify(ctx) {
+    const realm = realmOf(ctx)
+    let env: Environment = 'production'
+    let company: Awaited<ReturnType<typeof readCompany>>
+    try {
+      company = await readCompany(ctx, env)
+    } catch (err) {
+      if (!(err instanceof QboError && err.status < 500)) throw err
+      env = 'sandbox'
+      company = await readCompany(ctx, env)
+    }
+    await ctx.saveState({
+      environment: env,
+      country: company.country,
+      homeCurrency: company.homeCurrency,
+      multiCurrency: company.multiCurrency,
+      customTxnNumbers: company.customTxnNumbers,
+    })
+    if (!company.customTxnNumbers) {
+      await ctx.log(
+        'warn',
+        'Custom transaction numbers are off in this QuickBooks company, so invoices there get QuickBooks numbers instead of the Torqvoice invoice numbers. Turn them on under Account and settings, Sales, Sales form content.'
+      )
+    }
+    return { id: realm, name: env === 'sandbox' ? `${company.name} (sandbox)` : company.name }
+  },
+  async test(ctx) {
+    try {
+      await api<CompanyInfo>(ctx, `companyinfo/${realmOf(ctx)}`)
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : String(err) }
+    }
+  },
+  remoteOptions: {
+    async items(ctx) {
+      const items = await query<QboItem>(
+        ctx,
+        'Item',
+        "select Id, Name, Type from Item where Active = true and Type in ('Service', 'NonInventory', 'Inventory') maxresults 1000"
+      )
+      return items
+        .sort((a, b) => a.Name.localeCompare(b.Name))
+        .map((i) => ({ value: i.Id, label: i.Type ? `${i.Name} (${i.Type})` : i.Name }))
+    },
+    async taxCodes(ctx) {
+      const codes = await query<QboTaxCode>(
+        ctx,
+        'TaxCode',
+        'select Id, Name, Taxable from TaxCode where Active = true maxresults 1000'
+      )
+      return codes
+        .sort((a, b) => a.Name.localeCompare(b.Name))
+        .map((c) => ({ value: c.Id, label: c.Name }))
+    },
+    async depositAccounts(ctx) {
+      const accounts = await query<QboAccount>(
+        ctx,
+        'Account',
+        "select Id, Name, AccountType from Account where AccountType in ('Bank', 'Other Current Asset') and Active = true maxresults 200"
+      )
+      return accounts
+        .sort((a, b) => a.Name.localeCompare(b.Name))
+        .map((a) => ({
+          value: a.Id,
+          label: a.AccountType ? `${a.Name} (${a.AccountType})` : a.Name,
+        }))
+    },
+  },
+  jobs: {
+    'accounting.invoice': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no record id' }
+      return pushInvoice(ctx, id)
+    },
+    'accounting.payment': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no payment id' }
+      return pushPayment(ctx, id)
+    },
+    'accounting.customer': async (ctx, payload) => {
+      const id = entityId(payload)
+      if (!id) return { summary: 'no customer id' }
+      return pushCustomer(ctx, id)
+    },
+    'accounting.pull': pullChanges,
+  },
+  /** Hand the refresh token back so the company stops listing the app as connected. */
+  async onDisconnect(ctx) {
+    const spec = oauthSpec(manifest)
+    const client = spec ? resolveClient(spec, ctx.credentials) : null
+    const token =
+      typeof ctx.credentials.refreshToken === 'string' ? ctx.credentials.refreshToken : null
+    if (!client || !token) return
+    try {
+      await fetch(REVOKE_URL, {
+        method: 'POST',
+        headers: {
+          Authorization: `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`).toString('base64')}`,
+          'Content-Type': 'application/json',
+          Accept: 'application/json',
+        },
+        body: JSON.stringify({ token }),
+      })
+    } catch (err) {
+      await ctx.log('warn', `Could not revoke the QuickBooks token: ${String(err)}`)
+    }
+  },
+}

+ 2 - 0
src/integrations/registry.ts

@@ -17,6 +17,7 @@ import { manifest as openai } from './openai/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as paypal } from './paypal/manifest'
 import { manifest as postmark } from './postmark/manifest'
+import { manifest as quickbooks } from './quickbooks/manifest'
 import { manifest as rdw } from './rdw/manifest'
 import { manifest as regcheck } from './regcheck/manifest'
 import { manifest as resend } from './resend/manifest'
@@ -63,6 +64,7 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: stripe, load: () => import('./stripe/server') },
   { manifest: vipps, load: () => import('./vipps/server') },
   { manifest: paypal, load: () => import('./paypal/server') },
+  { manifest: quickbooks, load: () => import('./quickbooks/server') },
 ]
 
 const BY_ID = new Map(ENTRIES.map((e) => [e.manifest.id, e]))