瀏覽代碼

Keep QuickBooks tax equal to the invoice, push every issued invoice, and pick from searchable lists (#330)

* Push every issued invoice to the ledger, legacy ones included, bounded by the start date rather than 90 days

* Keep QuickBooks tax equal to the invoice: billed-amount override, rate-matched codes, and a Tax amounts setting

* Pick QuickBooks items, tax codes and accounts from a searchable list that can be left empty
Bernt Christian Egeland 3 周之前
父節點
當前提交
b628271d03

+ 11 - 4
messages/de/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "{count, plural, one {# Rechnung} other {# Rechnungen}} aus den letzten 90 Tagen eingereiht",
-    "backfillInvoicesNone": "Keine in den letzten 90 Tagen ausgestellten Rechnungen zum Übertragen. Nur gesendete, geteilte oder bezahlte Rechnungen werden übertragen.",
+    "backfillInvoicesQueuedCount": "{count, plural, one {# ausgestellte Rechnung} other {# ausgestellte Rechnungen}} zum Übertragen eingereiht",
+    "backfillInvoicesNone": "Keine ausgestellten Rechnungen zum Übertragen. Nur gesendete, geteilte oder bezahlte Rechnungen werden übertragen, ab dem Startdatum, falls gesetzt.",
     "retryQueued": "Job erneut eingereiht",
     "settingsTitle": "Einstellungen",
     "settingsDescription": "Was synchronisiert wird und wohin. Änderungen gelten ab der nächsten Synchronisierung.",
@@ -111,6 +110,10 @@
     "save": "Speichern",
     "loading": "Wird geladen...",
     "choose": "Auswählen...",
+    "search": "Suchen...",
+    "noMatch": "Nichts gefunden",
+    "leaveEmpty": "Leer lassen",
+    "noLimit": "Keine Grenze",
     "planLocked": "Integrationen sind in Ihrem Tarif nicht enthalten.",
     "ownAppTitle": "Diese Installation verwendet Ihre eigene App beim Anbieter",
     "redirectUri": "Beim Anbieter zu registrierende Redirect-URI:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Steuerbeträge",
+        "taxAmountsHelp": "Wie berechnet sendet die Steuer genau so, wie sie auf der Rechnung des Kunden steht, sodass beide Systeme auf den Cent übereinstimmen. Von QuickBooks ermittelt lässt QuickBooks den Satz des Steuercodes anwenden, in den USA die Adresse des Kunden, und das Protokoll meldet abweichende Summen.",
+        "taxAmountsBilled": "Wie in Torqvoice berechnet",
+        "taxAmountsQuickbooks": "Von QuickBooks ermittelt",
         "taxCodeId": "Steuercode für steuerpflichtige Rechnungen",
-        "taxCodeIdHelp": "QuickBooks berechnet die Steuer aus diesem Code. Wähle den, dessen Satz dem Steuersatz deiner Rechnungen entspricht. Ein US-Unternehmen verwendet TAX, sofern du nichts anderes wählst.",
+        "taxCodeIdHelp": "QuickBooks bucht die Steuer unter einem Steuercode. Torqvoice wählt den Code, dessen Satz zur jeweiligen Rechnung passt; dieser wird verwendet, wenn keiner passt. Ein US-Unternehmen verwendet TAX, sofern du nichts anderes wählst.",
         "zeroTaxCodeId": "Steuercode für steuerfreie Rechnungen",
         "zeroTaxCodeIdHelp": "Wird verwendet, wenn die Rechnung keine Steuer hat oder der Kunde steuerbefreit ist. Ein US-Unternehmen verwendet NON, sofern du nichts anderes wählst.",
         "pushPayments": "Zahlungen in QuickBooks erfassen",

+ 11 - 4
messages/en/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "Queued {count, plural, one {# invoice} other {# invoices}} issued in the last 90 days",
-    "backfillInvoicesNone": "No invoices issued in the last 90 days to push. Only sent, shared or paid invoices go over.",
+    "backfillInvoicesQueuedCount": "Queued {count, plural, one {# issued invoice} other {# issued invoices}} to push",
+    "backfillInvoicesNone": "No issued invoices to push. Only sent, shared or paid invoices go over, dated on or after the start date if you set one.",
     "retryQueued": "Job queued again",
     "settingsTitle": "Settings",
     "settingsDescription": "What to sync and where. Changes apply to the next sync.",
@@ -111,6 +110,10 @@
     "save": "Save",
     "loading": "Loading...",
     "choose": "Choose...",
+    "search": "Search...",
+    "noMatch": "Nothing matches",
+    "leaveEmpty": "Leave empty",
+    "noLimit": "No limit",
     "planLocked": "Integrations are not included in your plan.",
     "ownAppTitle": "This install uses your own app with the provider",
     "redirectUri": "Redirect URI to register with the provider:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Tax amounts",
+        "taxAmountsHelp": "As billed sends the tax exactly as it stands on the invoice the customer received, so both systems agree to the cent. Worked out by QuickBooks lets QuickBooks apply the rate of the tax code, or the customer's address in the US, and the log says when the totals differ.",
+        "taxAmountsBilled": "As billed in Torqvoice",
+        "taxAmountsQuickbooks": "Worked out by QuickBooks",
         "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. A US company uses TAX unless you choose otherwise.",
+        "taxCodeIdHelp": "QuickBooks books the tax under a tax code. Torqvoice picks the code whose rate matches each invoice; this one is used when none does. A US company uses TAX unless you choose otherwise.",
         "zeroTaxCodeId": "Tax code for tax-free invoices",
         "zeroTaxCodeIdHelp": "Used when the invoice has no tax or the customer is tax exempt. A US company uses NON unless you choose otherwise.",
         "pushPayments": "Record payments in QuickBooks",

+ 11 - 4
messages/es/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "{count, plural, one {# factura emitida} other {# facturas emitidas}} en los últimos 90 días en cola",
-    "backfillInvoicesNone": "No hay facturas emitidas en los últimos 90 días para enviar. Solo pasan las facturas enviadas, compartidas o pagadas.",
+    "backfillInvoicesQueuedCount": "{count, plural, one {# factura emitida} other {# facturas emitidas}} en cola para enviar",
+    "backfillInvoicesNone": "No hay facturas emitidas para enviar. Solo pasan las facturas enviadas, compartidas o pagadas, desde la fecha de inicio si la fijaste.",
     "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.",
@@ -111,6 +110,10 @@
     "save": "Guardar",
     "loading": "Cargando...",
     "choose": "Elegir...",
+    "search": "Buscar...",
+    "noMatch": "Nada coincide",
+    "leaveEmpty": "Dejar vacío",
+    "noLimit": "Sin límite",
     "planLocked": "Las integraciones no están incluidas en su plan.",
     "ownAppTitle": "Esta instalación usa su propia aplicación con el proveedor",
     "redirectUri": "URI de redirección que debe registrar con el proveedor:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Importes de impuestos",
+        "taxAmountsHelp": "Como se facturó envía el impuesto tal como figura en la factura que recibió el cliente, así ambos sistemas coinciden al céntimo. Calculado por QuickBooks deja que QuickBooks aplique la tasa del código de impuesto, o la dirección del cliente en EE. UU., y el registro avisa cuando los totales difieren.",
+        "taxAmountsBilled": "Como se facturó en Torqvoice",
+        "taxAmountsQuickbooks": "Calculado por QuickBooks",
         "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 la misma tasa que el impuesto de tus facturas. Una empresa de EE. UU. usa TAX salvo que elijas otro código.",
+        "taxCodeIdHelp": "QuickBooks registra el impuesto bajo un código de impuesto. Torqvoice elige el código cuya tasa coincide con cada factura; este se usa cuando ninguno coincide. Una empresa de EE. UU. usa TAX salvo que elijas otro código.",
         "zeroTaxCodeId": "Código de impuesto para facturas sin impuesto",
         "zeroTaxCodeIdHelp": "Se usa cuando la factura no lleva impuesto o el cliente está exento. Una empresa de EE. UU. usa NON salvo que elijas otro código.",
         "pushPayments": "Registrar pagos en QuickBooks",

+ 11 - 4
messages/fr/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "Mise en file de {count, plural, one {# facture émise} other {# factures émises}} ces 90 derniers jours",
-    "backfillInvoicesNone": "Aucune facture émise ces 90 derniers jours à envoyer. Seules les factures envoyées, partagées ou payées sont transmises.",
+    "backfillInvoicesQueuedCount": "Mise en file de {count, plural, one {# facture émise} other {# factures émises}} à transmettre",
+    "backfillInvoicesNone": "Aucune facture émise à envoyer. Seules les factures envoyées, partagées ou payées sont transmises, à partir de la date de début si vous en avez fixé une.",
     "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.",
@@ -111,6 +110,10 @@
     "save": "Enregistrer",
     "loading": "Chargement...",
     "choose": "Choisir...",
+    "search": "Rechercher...",
+    "noMatch": "Aucun résultat",
+    "leaveEmpty": "Laisser vide",
+    "noLimit": "Sans limite",
     "planLocked": "Les intégrations ne sont pas incluses dans votre forfait.",
     "ownAppTitle": "Cette installation utilise votre propre application auprès du fournisseur",
     "redirectUri": "URI de redirection à enregistrer auprès du fournisseur :",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Montants de taxe",
+        "taxAmountsHelp": "Tel que facturé envoie la taxe exactement telle qu'elle figure sur la facture reçue par le client, pour que les deux systèmes concordent au centime. Calculé par QuickBooks laisse QuickBooks appliquer le taux du code de taxe, ou l'adresse du client aux États-Unis, et le journal signale les totaux différents.",
+        "taxAmountsBilled": "Tel que facturé dans Torqvoice",
+        "taxAmountsQuickbooks": "Calculé par QuickBooks",
         "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. Une société américaine utilise TAX sauf si vous choisissez un autre code.",
+        "taxCodeIdHelp": "QuickBooks enregistre la taxe sous un code de taxe. Torqvoice choisit le code dont le taux correspond à chaque facture ; celui-ci sert quand aucun ne correspond. Une société américaine utilise TAX sauf si vous choisissez un autre code.",
         "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é. Une société américaine utilise NON sauf si vous choisissez un autre code.",
         "pushPayments": "Enregistrer les paiements dans QuickBooks",

+ 11 - 4
messages/it/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "In coda {count, plural, one {# fattura emessa} other {# fatture emesse}} negli ultimi 90 giorni",
-    "backfillInvoicesNone": "Nessuna fattura emessa negli ultimi 90 giorni da inviare. Passano solo le fatture inviate, condivise o pagate.",
+    "backfillInvoicesQueuedCount": "In coda {count, plural, one {# fattura emessa} other {# fatture emesse}} da inviare",
+    "backfillInvoicesNone": "Nessuna fattura emessa da inviare. Passano solo le fatture inviate, condivise o pagate, dalla data di inizio se ne hai impostata una.",
     "retryQueued": "Attività rimessa in coda",
     "settingsTitle": "Impostazioni",
     "settingsDescription": "Cosa sincronizzare e dove. Le modifiche si applicano alla prossima sincronizzazione.",
@@ -111,6 +110,10 @@
     "save": "Salva",
     "loading": "Caricamento...",
     "choose": "Scegli...",
+    "search": "Cerca...",
+    "noMatch": "Nessuna corrispondenza",
+    "leaveEmpty": "Lascia vuoto",
+    "noLimit": "Nessun limite",
     "planLocked": "Le integrazioni non sono incluse nel tuo piano.",
     "ownAppTitle": "Questa installazione usa la tua app presso il provider",
     "redirectUri": "URI di reindirizzamento da registrare presso il provider:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Importi delle imposte",
+        "taxAmountsHelp": "Come fatturato invia l'imposta esattamente come compare sulla fattura ricevuta dal cliente, così i due sistemi coincidono al centesimo. Calcolato da QuickBooks lascia che QuickBooks applichi l'aliquota del codice imposta, o l'indirizzo del cliente negli USA, e il registro segnala i totali diversi.",
+        "taxAmountsBilled": "Come fatturato in Torqvoice",
+        "taxAmountsQuickbooks": "Calcolato da QuickBooks",
         "taxCodeId": "Codice imposta per le fatture imponibili",
-        "taxCodeIdHelp": "QuickBooks calcola l'imposta da questo codice: scegli quello con la stessa aliquota delle tue fatture. Un'azienda statunitense usa TAX se non scegli un altro codice.",
+        "taxCodeIdHelp": "QuickBooks registra l'imposta sotto un codice imposta. Torqvoice sceglie il codice con l'aliquota di ogni fattura; questo viene usato quando nessuno corrisponde. Un'azienda statunitense usa TAX se non scegli un altro codice.",
         "zeroTaxCodeId": "Codice imposta per le fatture senza imposta",
         "zeroTaxCodeIdHelp": "Usato quando la fattura non ha imposta o il cliente è esente. Un'azienda statunitense usa NON se non scegli un altro codice.",
         "pushPayments": "Registra i pagamenti in QuickBooks",

+ 11 - 4
messages/lt/integrations.json

@@ -101,9 +101,8 @@
     "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ę",
-    "backfillInvoicesQueuedCount": "Į eilę įtraukta {count, plural, one {# sąskaita, išrašyta} few {# sąskaitos, išrašytos} other {# sąskaitų, išrašytų}} per pastarąsias 90 dienų",
-    "backfillInvoicesNone": "Per pastarąsias 90 dienų išrašytų sąskaitų siųsti nėra. Perduodamos tik išsiųstos, pasidalintos ar apmokėtos sąskaitos.",
+    "backfillInvoicesQueuedCount": "Į eilę įtraukta {count, plural, one {# išrašyta sąskaita} few {# išrašytos sąskaitos} other {# išrašytų sąskaitų}}",
+    "backfillInvoicesNone": "Nėra išrašytų sąskaitų siųsti. Perduodamos tik išsiųstos, pasidalintos ar apmokėtos sąskaitos, nuo pradžios datos, jei ją nustatėte.",
     "retryQueued": "Užduotis vėl įtraukta į eilę",
     "settingsTitle": "Nustatymai",
     "settingsDescription": "Ką sinchronizuoti ir kur. Pakeitimai įsigalios kito sinchronizavimo metu.",
@@ -111,6 +110,10 @@
     "save": "Išsaugoti",
     "loading": "Kraunama...",
     "choose": "Pasirinkite...",
+    "search": "Ieškoti...",
+    "noMatch": "Nieko nerasta",
+    "leaveEmpty": "Palikti tuščią",
+    "noLimit": "Be ribos",
     "planLocked": "Integracijos neįtrauktos į jūsų planą.",
     "ownAppTitle": "Šis diegimas naudoja jūsų pačių programą pas tiekėją",
     "redirectUri": "Nukreipimo URI, kurį reikia užregistruoti pas tiekėją:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Mokesčių sumos",
+        "taxAmountsHelp": "Kaip sąskaitoje siunčia mokestį tiksliai tokį, koks nurodytas kliento gautoje sąskaitoje, todėl abi sistemos sutampa iki cento. Apskaičiuoja QuickBooks leidžia QuickBooks taikyti mokesčio kodo tarifą, JAV – kliento adresą, o žurnalas praneša, kai sumos skiriasi.",
+        "taxAmountsBilled": "Kaip sąskaitoje Torqvoice",
+        "taxAmountsQuickbooks": "Apskaičiuoja QuickBooks",
         "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ė naudoja TAX, nebent pasirinksite kitą kodą.",
+        "taxCodeIdHelp": "QuickBooks mokestį apskaito pagal mokesčio kodą. Torqvoice parenka kodą, kurio tarifas sutampa su kiekviena sąskaita; šis naudojamas, kai nė vienas nesutampa. JAV įmonė naudoja TAX, nebent pasirinksite kitą kodą.",
         "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ė naudoja NON, nebent pasirinksite kitą kodą.",
         "pushPayments": "Registruoti mokėjimus QuickBooks",

+ 11 - 4
messages/nb/integrations.json

@@ -101,9 +101,8 @@
     "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ø",
-    "backfillInvoicesQueuedCount": "{count, plural, one {# faktura} other {# fakturaer}} utstedt de siste 90 dagene lagt i kø",
-    "backfillInvoicesNone": "Ingen fakturaer utstedt de siste 90 dagene å sende. Bare sendte, delte eller betalte fakturaer går over.",
+    "backfillInvoicesQueuedCount": "{count, plural, one {# utstedt faktura} other {# utstedte fakturaer}} lagt i kø for sending",
+    "backfillInvoicesNone": "Ingen utstedte fakturaer å sende. Bare sendte, delte eller betalte fakturaer går over, fra startdatoen hvis du har satt en.",
     "retryQueued": "Jobben er lagt i kø på nytt",
     "settingsTitle": "Innstillinger",
     "settingsDescription": "Hva som skal synkroniseres og hvor. Endringer gjelder fra neste synkronisering.",
@@ -111,6 +110,10 @@
     "save": "Lagre",
     "loading": "Laster...",
     "choose": "Velg...",
+    "search": "Søk...",
+    "noMatch": "Ingen treff",
+    "leaveEmpty": "La stå tomt",
+    "noLimit": "Ingen grense",
     "planLocked": "Integrasjoner er ikke inkludert i abonnementet ditt.",
     "ownAppTitle": "Denne installasjonen bruker din egen app hos leverandøren",
     "redirectUri": "Omdirigerings-URI som må registreres hos leverandøren:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Avgiftsbeløp",
+        "taxAmountsHelp": "Som fakturert sender avgiften nøyaktig slik den står på fakturaen kunden fikk, så begge systemer stemmer på øret. Beregnet av QuickBooks lar QuickBooks bruke satsen på avgiftskoden, eller kundens adresse i USA, og loggen sier fra når totalene er ulike.",
+        "taxAmountsBilled": "Som fakturert i Torqvoice",
+        "taxAmountsQuickbooks": "Beregnet av QuickBooks",
         "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. Et amerikansk selskap bruker TAX med mindre du velger noe annet.",
+        "taxCodeIdHelp": "QuickBooks fører avgiften under en avgiftskode. Torqvoice velger koden med samme sats som hver faktura; denne brukes når ingen passer. Et amerikansk selskap bruker TAX med mindre du velger noe annet.",
         "zeroTaxCodeId": "Avgiftskode for avgiftsfrie fakturaer",
         "zeroTaxCodeIdHelp": "Brukes når fakturaen er uten avgift eller kunden er fritatt. Et amerikansk selskap bruker NON med mindre du velger noe annet.",
         "pushPayments": "Registrer betalinger i QuickBooks",

+ 11 - 4
messages/nl/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "{count, plural, one {# factuur} other {# facturen}} van de afgelopen 90 dagen in de wachtrij gezet",
-    "backfillInvoicesNone": "Geen facturen van de afgelopen 90 dagen om te sturen. Alleen verzonden, gedeelde of betaalde facturen gaan over.",
+    "backfillInvoicesQueuedCount": "{count, plural, one {# uitgegeven factuur} other {# uitgegeven facturen}} in de wachtrij gezet om te sturen",
+    "backfillInvoicesNone": "Geen uitgegeven facturen om te sturen. Alleen verzonden, gedeelde of betaalde facturen gaan over, vanaf de begindatum als je die hebt ingesteld.",
     "retryQueued": "Taak opnieuw in de wachtrij gezet",
     "settingsTitle": "Instellingen",
     "settingsDescription": "Wat er wordt gesynchroniseerd en waarheen. Wijzigingen gelden vanaf de volgende synchronisatie.",
@@ -111,6 +110,10 @@
     "save": "Opslaan",
     "loading": "Laden...",
     "choose": "Kiezen...",
+    "search": "Zoeken...",
+    "noMatch": "Niets gevonden",
+    "leaveEmpty": "Leeg laten",
+    "noLimit": "Geen grens",
     "planLocked": "Integraties zijn niet inbegrepen in uw abonnement.",
     "ownAppTitle": "Deze installatie gebruikt uw eigen app bij de provider",
     "redirectUri": "Redirect-URI om bij de provider te registreren:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Belastingbedragen",
+        "taxAmountsHelp": "Zoals gefactureerd stuurt de belasting precies zoals die op de factuur van de klant staat, zodat beide systemen tot op de cent overeenkomen. Berekend door QuickBooks laat QuickBooks het tarief van de belastingcode toepassen, of het adres van de klant in de VS, en het logboek meldt afwijkende totalen.",
+        "taxAmountsBilled": "Zoals gefactureerd in Torqvoice",
+        "taxAmountsQuickbooks": "Berekend door QuickBooks",
         "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. Een Amerikaans bedrijf gebruikt TAX tenzij je iets anders kiest.",
+        "taxCodeIdHelp": "QuickBooks boekt de belasting onder een belastingcode. Torqvoice kiest de code waarvan het tarief bij elke factuur past; deze wordt gebruikt als er geen past. Een Amerikaans bedrijf gebruikt TAX tenzij je iets anders kiest.",
         "zeroTaxCodeId": "Btw-code voor facturen zonder belasting",
         "zeroTaxCodeIdHelp": "Gebruikt als de factuur geen belasting heeft of de klant is vrijgesteld. Een Amerikaans bedrijf gebruikt NON tenzij je iets anders kiest.",
         "pushPayments": "Betalingen in QuickBooks registreren",

+ 11 - 4
messages/pl/integrations.json

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "Do kolejki trafiło {count, plural, one {# faktura wystawiona} few {# faktury wystawione} many {# faktur wystawionych} other {# faktury wystawionej}} w ciągu ostatnich 90 dni",
-    "backfillInvoicesNone": "Brak faktur wystawionych w ciągu ostatnich 90 dni do wysłania. Przechodzą tylko faktury wysłane, udostępnione lub opłacone.",
+    "backfillInvoicesQueuedCount": "Do kolejki trafiło {count, plural, one {# wystawiona faktura} few {# wystawione faktury} many {# wystawionych faktur} other {# wystawionej faktury}}",
+    "backfillInvoicesNone": "Brak wystawionych faktur do wysłania. Przechodzą tylko faktury wysłane, udostępnione lub opłacone, od daty początkowej, jeśli ją ustawiono.",
     "retryQueued": "Zadanie ponownie w kolejce",
     "settingsTitle": "Ustawienia",
     "settingsDescription": "Co synchronizować i dokąd. Zmiany obowiązują od następnej synchronizacji.",
@@ -111,6 +110,10 @@
     "save": "Zapisz",
     "loading": "Ładowanie...",
     "choose": "Wybierz...",
+    "search": "Szukaj...",
+    "noMatch": "Brak wyników",
+    "leaveEmpty": "Zostaw puste",
+    "noLimit": "Bez ograniczenia",
     "planLocked": "Integracje nie są objęte Twoim planem.",
     "ownAppTitle": "Ta instalacja używa własnej aplikacji u dostawcy",
     "redirectUri": "Adres przekierowania (redirect URI) do zarejestrowania u dostawcy:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Kwoty podatku",
+        "taxAmountsHelp": "Jak na fakturze wysyła podatek dokładnie taki, jaki widnieje na fakturze otrzymanej przez klienta, więc oba systemy zgadzają się co do grosza. Wyliczony przez QuickBooks pozwala QuickBooks zastosować stawkę kodu podatkowego, a w USA adres klienta, a dziennik informuje o różnicach w sumach.",
+        "taxAmountsBilled": "Jak na fakturze w Torqvoice",
+        "taxAmountsQuickbooks": "Wyliczony przez QuickBooks",
         "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. Firma z USA używa TAX, chyba że wybierzesz inny kod.",
+        "taxCodeIdHelp": "QuickBooks księguje podatek pod kodem podatkowym. Torqvoice wybiera kod o stawce zgodnej z każdą fakturą; ten jest używany, gdy żaden nie pasuje. Firma z USA używa TAX, chyba że wybierzesz inny kod.",
         "zeroTaxCodeId": "Kod podatku dla faktur bez podatku",
         "zeroTaxCodeIdHelp": "Używany, gdy faktura nie ma podatku lub klient jest zwolniony. Firma z USA używa NON, chyba że wybierzesz inny kod.",
         "pushPayments": "Rejestruj płatności w QuickBooks",

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

@@ -101,9 +101,8 @@
     "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",
-    "backfillInvoicesQueuedCount": "{count, plural, one {# fatura emitida} other {# faturas emitidas}} nos últimos 90 dias na fila",
-    "backfillInvoicesNone": "Nenhuma fatura emitida nos últimos 90 dias para enviar. Só passam faturas enviadas, compartilhadas ou pagas.",
+    "backfillInvoicesQueuedCount": "{count, plural, one {# fatura emitida} other {# faturas emitidas}} na fila para envio",
+    "backfillInvoicesNone": "Nenhuma fatura emitida para enviar. Só passam faturas enviadas, compartilhadas ou pagas, a partir da data inicial se você definiu uma.",
     "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.",
@@ -111,6 +110,10 @@
     "save": "Salvar",
     "loading": "Carregando...",
     "choose": "Escolher...",
+    "search": "Buscar...",
+    "noMatch": "Nada encontrado",
+    "leaveEmpty": "Deixar vazio",
+    "noLimit": "Sem limite",
     "planLocked": "As integrações não estão incluídas no seu plano.",
     "ownAppTitle": "Esta instalação usa seu próprio aplicativo junto ao provedor",
     "redirectUri": "URI de redirecionamento para registrar no provedor:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Valores de imposto",
+        "taxAmountsHelp": "Como faturado envia o imposto exatamente como consta na fatura que o cliente recebeu, então os dois sistemas batem ao centavo. Calculado pelo QuickBooks deixa o QuickBooks aplicar a alíquota do código de imposto, ou o endereço do cliente nos EUA, e o registro avisa quando os totais diferem.",
+        "taxAmountsBilled": "Como faturado no Torqvoice",
+        "taxAmountsQuickbooks": "Calculado pelo QuickBooks",
         "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. Uma empresa dos EUA usa TAX, a menos que você escolha outro código.",
+        "taxCodeIdHelp": "O QuickBooks lança o imposto sob um código de imposto. O Torqvoice escolhe o código cuja alíquota bate com cada fatura; este é usado quando nenhum bate. Uma empresa dos EUA usa TAX, a menos que você escolha outro código.",
         "zeroTaxCodeId": "Código de imposto para faturas sem imposto",
         "zeroTaxCodeIdHelp": "Usado quando a fatura não tem imposto ou o cliente é isento. Uma empresa dos EUA usa NON, a menos que você escolha outro código.",
         "pushPayments": "Registrar pagamentos no QuickBooks",

+ 11 - 4
messages/ru/integrations.json

@@ -101,9 +101,8 @@
     "backfill": "Отправить запланированные заказ-наряды",
     "backfillQueued": "Запланированные заказ-наряды за последнюю неделю и позже поставлены в очередь",
     "backfillInvoices": "Отправить выставленные счета",
-    "backfillInvoicesQueued": "Счета, выставленные за последние 90 дней, поставлены в очередь",
-    "backfillInvoicesQueuedCount": "В очередь поставлено {count, plural, one {# счёт, выставленный} few {# счёта, выставленных} many {# счетов, выставленных} other {# счёта, выставленных}} за последние 90 дней",
-    "backfillInvoicesNone": "Нет счетов, выставленных за последние 90 дней, для отправки. Передаются только отправленные, опубликованные или оплаченные счета.",
+    "backfillInvoicesQueuedCount": "В очередь поставлено {count, plural, one {# выставленный счёт} few {# выставленных счёта} many {# выставленных счетов} other {# выставленного счёта}}",
+    "backfillInvoicesNone": "Нет выставленных счетов для отправки. Передаются только отправленные, опубликованные или оплаченные счета, начиная с даты начала, если она задана.",
     "retryQueued": "Задача снова поставлена в очередь",
     "settingsTitle": "Настройки",
     "settingsDescription": "Что синхронизировать и куда. Изменения применяются при следующей синхронизации.",
@@ -111,6 +110,10 @@
     "save": "Сохранить",
     "loading": "Загрузка...",
     "choose": "Выберите...",
+    "search": "Поиск...",
+    "noMatch": "Ничего не найдено",
+    "leaveEmpty": "Оставить пустым",
+    "noLimit": "Без ограничения",
     "planLocked": "Интеграции не входят в ваш тариф.",
     "ownAppTitle": "Эта установка использует ваше собственное приложение у провайдера",
     "redirectUri": "URI перенаправления для регистрации у провайдера:",
@@ -507,8 +510,12 @@
         "laborItemIdHelp": "Оставьте пустым, и в первый раз под вашим счётом доходов будет создан товар с именем Labour.",
         "partsItemId": "Товар для строк запчастей",
         "partsItemIdHelp": "Оставьте пустым, и в первый раз под вашим счётом доходов будет создан товар с именем Parts.",
+        "taxAmounts": "Суммы налога",
+        "taxAmountsHelp": "Как в счёте передаёт налог ровно таким, каким он указан в счёте клиента, поэтому обе системы сходятся до копейки. Рассчитывает QuickBooks позволяет QuickBooks применить ставку налогового кода, а в США адрес клиента; журнал сообщает о расхождении итогов.",
+        "taxAmountsBilled": "Как в счёте Torqvoice",
+        "taxAmountsQuickbooks": "Рассчитывает QuickBooks",
         "taxCodeId": "Налоговый код для облагаемых счетов",
-        "taxCodeIdHelp": "QuickBooks рассчитывает налог по этому коду, поэтому выберите тот, чья ставка совпадает со ставкой в ваших счетах. Компания в США использует TAX, если не выбрать другой код.",
+        "taxCodeIdHelp": "QuickBooks учитывает налог по налоговому коду. Torqvoice подбирает код со ставкой каждого счёта; этот используется, когда подходящего нет. Компания в США использует TAX, если не выбрать другой код.",
         "zeroTaxCodeId": "Налоговый код для счетов без налога",
         "zeroTaxCodeIdHelp": "Используется, когда в счёте нет налога или клиент освобождён от него. Компания в США использует NON, если не выбрать другой код.",
         "pushPayments": "Записывать платежи в QuickBooks",

+ 11 - 4
messages/tr/integrations.json

@@ -101,9 +101,8 @@
     "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ı",
-    "backfillInvoicesQueuedCount": "Son 90 günde kesilen {count, plural, one {# fatura} other {# fatura}} kuyruğa alındı",
-    "backfillInvoicesNone": "Son 90 günde kesilmiş, gönderilecek fatura yok. Yalnızca gönderilen, paylaşılan veya ödenen faturalar aktarılır.",
+    "backfillInvoicesQueuedCount": "Gönderilmek üzere {count, plural, one {# kesilen fatura} other {# kesilen fatura}} kuyruğa alındı",
+    "backfillInvoicesNone": "Gönderilecek kesilmiş fatura yok. Yalnızca gönderilen, paylaşılan veya ödenen faturalar aktarılır; başlangıç tarihi ayarladıysanız o tarihten itibaren.",
     "retryQueued": "İş yeniden sıraya alındı",
     "settingsTitle": "Ayarlar",
     "settingsDescription": "Neyin nereye senkronize edileceği. Değişiklikler bir sonraki senkronizasyonda uygulanır.",
@@ -111,6 +110,10 @@
     "save": "Kaydet",
     "loading": "Yükleniyor...",
     "choose": "Seçin...",
+    "search": "Ara...",
+    "noMatch": "Eşleşme yok",
+    "leaveEmpty": "Boş bırak",
+    "noLimit": "Sınır yok",
     "planLocked": "Entegrasyonlar planınıza dahil değil.",
     "ownAppTitle": "Bu kurulum, sağlayıcıda kendi uygulamanızı kullanıyor",
     "redirectUri": "Sağlayıcıya kaydedilecek yönlendirme URI'si:",
@@ -507,8 +510,12 @@
         "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.",
+        "taxAmounts": "Vergi tutarları",
+        "taxAmountsHelp": "Faturadaki gibi, vergiyi müşterinin aldığı faturadaki haliyle gönderir; böylece iki sistem kuruşuna kadar uyuşur. QuickBooks hesaplasın, QuickBooks'un vergi kodunun oranını, ABD'de ise müşterinin adresini uygulamasına izin verir; toplamlar farklıysa günlük bildirir.",
+        "taxAmountsBilled": "Torqvoice faturasındaki gibi",
+        "taxAmountsQuickbooks": "QuickBooks hesaplasın",
         "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 şirketi başka bir kod seçmediğiniz sürece TAX kullanır.",
+        "taxCodeIdHelp": "QuickBooks vergiyi bir vergi koduna işler. Torqvoice her fatura için oranı eşleşen kodu kendisi seçer; hiçbiri eşleşmezse bu kod kullanılır. ABD şirketi başka bir kod seçmediğiniz sürece TAX kullanır.",
         "zeroTaxCodeId": "Vergisiz faturalar için vergi kodu",
         "zeroTaxCodeIdHelp": "Faturada vergi yoksa veya müşteri vergiden muafsa kullanılır. ABD şirketi başka bir kod seçmediğiniz sürece NON kullanır.",
         "pushPayments": "Ödemeleri QuickBooks'a kaydet",

+ 42 - 0
src/__tests__/features/integrations/accounting-sync.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+import { issuedAtOf } from '@/features/integrations/Lib/accounting-sync'
+
+const base = {
+  issuedAt: null,
+  sentAt: null,
+  manuallyPaid: false,
+  payments: [] as { date: Date }[],
+  invoiceDate: new Date('2026-05-02T00:00:00Z'),
+  serviceDate: new Date('2026-05-01T00:00:00Z'),
+}
+
+describe('issuedAtOf', () => {
+  it('is null for a draft nobody has seen', () => {
+    expect(issuedAtOf(base)).toBeNull()
+  })
+
+  it('prefers the issue stamp when there is one', () => {
+    const issuedAt = new Date('2026-06-01T10:00:00Z')
+    const sentAt = new Date('2026-05-20T10:00:00Z')
+    expect(issuedAtOf({ ...base, issuedAt, sentAt })).toBe(issuedAt)
+  })
+
+  it('counts an invoice sent before stamps existed as issued when it was sent', () => {
+    const sentAt = new Date('2026-05-20T10:00:00Z')
+    expect(issuedAtOf({ ...base, sentAt })).toBe(sentAt)
+  })
+
+  it('dates a paid legacy invoice by its first payment', () => {
+    const first = new Date('2026-05-10T00:00:00Z')
+    const payments = [{ date: first }, { date: new Date('2026-05-15T00:00:00Z') }]
+    expect(issuedAtOf({ ...base, payments })).toBe(first)
+  })
+
+  it('dates one marked paid by hand by the sheet, or the work without a sheet date', () => {
+    expect(issuedAtOf({ ...base, manuallyPaid: true })).toEqual(base.invoiceDate)
+    expect(issuedAtOf({ ...base, manuallyPaid: true, invoiceDate: null })).toEqual(base.serviceDate)
+  })
+})

+ 205 - 3
src/__tests__/features/integrations/quickbooks-server.test.ts

@@ -77,6 +77,10 @@ function makeCtx(input: {
     homeCurrency: 'GBP',
     multiCurrency: false,
     customTxnNumbers: true,
+    salesTaxEnabled: true,
+    automatedSalesTax: false,
+    // Read and empty, so a push does not go to the tax tables unless a test asks.
+    taxCatalog: { fetchedAt: new Date().toISOString(), codes: [] },
     ...input.state,
   }
   const key = (t: string, e: string) => `${t}:${e}`
@@ -350,7 +354,7 @@ describe('QuickBooks: pushing an invoice', () => {
   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'
+      'dated before the start date'
     )
     expect(t.calls).toHaveLength(0)
   })
@@ -493,7 +497,7 @@ describe('QuickBooks: pushing an invoice', () => {
 
   it('falls back to TAX and NON for a US company with no tax codes chosen', async () => {
     const t = makeCtx({
-      state: { country: 'US' },
+      state: { country: 'US', automatedSalesTax: true },
       settings: { taxCodeId: '', zeroTaxCodeId: '' },
       answer: emptyCompany(),
     })
@@ -511,7 +515,7 @@ describe('QuickBooks: pushing an invoice', () => {
 
   it('offers TAX and NON ahead of the rate codes in a US company', async () => {
     const t = makeCtx({
-      state: { country: 'US' },
+      state: { country: 'US', automatedSalesTax: true },
       answer: (call) =>
         call.query.query.includes('from TaxCode')
           ? {
@@ -532,6 +536,180 @@ describe('QuickBooks: pushing an invoice', () => {
     ])
   })
 
+  it('sends the billed tax as an override to an automated-sales-tax company', async () => {
+    const t = makeCtx({
+      state: { country: 'US', automatedSalesTax: true },
+      settings: { taxCodeId: '', zeroTaxCodeId: '' },
+      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?.TxnTaxDetail).toEqual({ TxnTaxCodeRef: { value: 'TAX' }, TotalTax: 60 })
+    expect(inv?.body?.GlobalTaxCalculation).toBeUndefined()
+  })
+
+  it('marks an untaxed invoice NON with no tax in an automated-sales-tax company', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, taxRate: 0, taxAmount: 0, totalAmount: 300 })
+    const t = makeCtx({
+      state: { country: 'US', automatedSalesTax: true },
+      settings: { taxCodeId: '', zeroTaxCodeId: '' },
+      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?.TxnTaxDetail).toEqual({ TxnTaxCodeRef: { value: 'NON' }, TotalTax: 0 })
+    const lines = (inv?.body?.Line ?? []) as {
+      SalesItemLineDetail?: { TaxCodeRef?: { value: string } }
+    }[]
+    for (const l of lines.filter((l) => l.SalesItemLineDetail)) {
+      expect(l.SalesItemLineDetail?.TaxCodeRef).toEqual({ value: 'NON' })
+    }
+  })
+
+  /** The first charge line; the vehicle text line comes before it. */
+  function firstCharge(lines: unknown) {
+    const all = (lines ?? []) as { SalesItemLineDetail?: { TaxCodeRef?: { value: string } } }[]
+    const line = all.find((l) => l.SalesItemLineDetail)
+    if (!line?.SalesItemLineDetail) throw new Error('no charge line')
+    return line as { SalesItemLineDetail: { TaxCodeRef?: { value: string } } }
+  }
+
+  /** A company with real codes: 20% and 25% VAT plus a zero-rated code. */
+  function ratesCompany(): Answer {
+    const base = emptyCompany()
+    return (call) => {
+      const q = call.query.query ?? ''
+      if (q.includes('from TaxCode'))
+        return {
+          QueryResponse: {
+            TaxCode: [
+              {
+                Id: '9',
+                Name: '25.0% NO',
+                Taxable: true,
+                SalesTaxRateList: { TaxRateDetail: { TaxRateRef: { value: 'r9' } } },
+              },
+              {
+                Id: '3',
+                Name: '20.0% S',
+                Taxable: true,
+                SalesTaxRateList: {
+                  TaxRateDetail: [
+                    { TaxRateRef: { value: 'r3' }, TaxTypeApplicable: 'TaxOnAmount' },
+                  ],
+                },
+              },
+              {
+                Id: '4',
+                Name: '0.0% Z',
+                SalesTaxRateList: { TaxRateDetail: [{ TaxRateRef: { value: 'r4' } }] },
+              },
+            ],
+          },
+        }
+      if (q.includes('from TaxRate'))
+        return {
+          QueryResponse: {
+            TaxRate: [
+              { Id: 'r3', RateValue: 20 },
+              { Id: 'r9', RateValue: 25 },
+              { Id: 'r4', RateValue: 0 },
+            ],
+          },
+        }
+      return base(call)
+    }
+  }
+
+  it('reads the tax codes once and books under the one that carries the invoice rate', async () => {
+    const t = makeCtx({
+      state: { taxCatalog: undefined },
+      settings: { taxCodeId: '9', zeroTaxCodeId: '' },
+      answer: ratesCompany(),
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    const line = firstCharge(inv?.body?.Line)
+    // The chosen code is 25%, the invoice is 20%: the 20% code wins.
+    expect(line.SalesItemLineDetail.TaxCodeRef).toEqual({ value: '3' })
+    expect(inv?.body?.TxnTaxDetail).toEqual({
+      TotalTax: 60,
+      TaxLine: [
+        {
+          Amount: 60,
+          DetailType: 'TaxLineDetail',
+          TaxLineDetail: {
+            TaxRateRef: { value: 'r3' },
+            PercentBased: true,
+            TaxPercent: 20,
+            NetAmountTaxable: 300,
+          },
+        },
+      ],
+    })
+    expect(t.logs.filter((l) => l.level === 'warn')).toEqual([])
+    const catalog = t.state.taxCatalog as { codes: { id: string; percent: number | null }[] }
+    expect(catalog.codes.map((c) => [c.id, c.percent])).toEqual([
+      ['4', 0],
+      ['3', 20],
+      ['9', 25],
+    ])
+    expect(t.calls.filter((c) => (c.query.query ?? '').includes('from TaxCode'))).toHaveLength(1)
+  })
+
+  it('warns and keeps the chosen code when no code carries the invoice rate', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, taxRate: 19, taxAmount: 57, totalAmount: 357 })
+    const t = makeCtx({
+      state: { taxCatalog: undefined },
+      settings: { taxCodeId: '9', zeroTaxCodeId: '' },
+      answer: ratesCompany(),
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    const line = firstCharge(inv?.body?.Line)
+    expect(line.SalesItemLineDetail.TaxCodeRef).toEqual({ value: '9' })
+    expect(inv?.body?.TxnTaxDetail).toBeUndefined()
+    const warn = t.logs.find((l) => l.message.includes('No QuickBooks tax code carries 19%'))
+    expect(warn?.message).toContain('"25.0% NO" (25%)')
+  })
+
+  it('lets QuickBooks work the tax out when the workshop asks for that', async () => {
+    const t = makeCtx({
+      state: { taxCatalog: undefined },
+      settings: { taxCodeId: '3', taxAmounts: 'quickbooks' },
+      answer: ratesCompany(),
+    })
+    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?.TxnTaxDetail).toBeUndefined()
+    expect(inv?.body?.GlobalTaxCalculation).toBe('TaxExcluded')
+  })
+
+  it('puts an untaxed invoice on the zero-rated code when none is chosen', async () => {
+    loadInvoice.mockResolvedValue({ ...invoice, taxRate: 0, taxAmount: 0, totalAmount: 300 })
+    const t = makeCtx({
+      state: { taxCatalog: undefined },
+      settings: { taxCodeId: '3', zeroTaxCodeId: '' },
+      answer: ratesCompany(),
+    })
+    await connector.jobs['accounting.invoice'](t.ctx, { entityId: 'svc1' })
+    const inv = t.calls.find((c) => c.method === 'POST' && c.path.endsWith('/invoice'))
+    const line = firstCharge(inv?.body?.Line)
+    expect(line.SalesItemLineDetail.TaxCodeRef).toEqual({ value: '4' })
+    expect(inv?.body?.GlobalTaxCalculation).toBe('NotApplicable')
+    expect(inv?.body?.TxnTaxDetail).toBeUndefined()
+  })
+
+  it('sends no override when sales tax is switched off in QuickBooks', async () => {
+    const t = makeCtx({
+      state: { country: 'US', automatedSalesTax: true, salesTaxEnabled: false },
+      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?.TxnTaxDetail).toBeUndefined()
+  })
+
   it('warns when QuickBooks arrives at another total', async () => {
     const t = makeCtx({
       answer: (call) => {
@@ -840,6 +1018,7 @@ describe('QuickBooks: connecting', () => {
             Preferences: {
               CurrencyPrefs: { HomeCurrency: { value: 'GBP' }, MultiCurrencyEnabled: true },
               SalesFormsPrefs: { CustomTxnNumbers: false },
+              TaxPrefs: { UsingSalesTax: true },
             },
           }
         throw new Error(`unexpected ${call.path}`)
@@ -853,11 +1032,34 @@ describe('QuickBooks: connecting', () => {
       homeCurrency: 'GBP',
       multiCurrency: true,
       customTxnNumbers: false,
+      salesTaxEnabled: true,
+      automatedSalesTax: false,
+      taxCatalog: null,
     })
     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('recognises automated sales tax and says when sales tax is off', async () => {
+    const t = makeCtx({
+      answer: (call) => {
+        if (call.path.endsWith('/companyinfo/9130357'))
+          return { CompanyInfo: { CompanyName: 'Main Street Auto', Country: 'US' } }
+        if (call.path.endsWith('/preferences'))
+          return {
+            Preferences: {
+              SalesFormsPrefs: { CustomTxnNumbers: true },
+              TaxPrefs: { UsingSalesTax: false, PartnerTaxEnabled: false },
+            },
+          }
+        throw new Error(`unexpected ${call.path}`)
+      },
+    })
+    await connector.identify?.(t.ctx)
+    expect(t.state).toMatchObject({ automatedSalesTax: true, salesTaxEnabled: false })
+    expect(t.logs.some((l) => l.message.includes('Sales tax is switched off'))).toBe(true)
+  })
+
   it('reports a failed test with the vendor wording', async () => {
     const t = makeCtx({ answer: () => fault(401, '3200', 'AuthenticationFailed') })
     t.ctx.http.json = async () => {

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

@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest'
+import {
+  astTaxDetail,
+  buildTaxCatalog,
+  globalTaxDetail,
+  pickTaxableCode,
+  pickZeroCode,
+  ratesMatch,
+} from '@/integrations/quickbooks/tax'
+
+const rates = [
+  { Id: 'r20', RateValue: 20 },
+  { Id: 'r5', RateValue: 5 },
+  { Id: 'r7', RateValue: 7 },
+  { Id: 'r0', RateValue: 0 },
+]
+
+const codes = [
+  {
+    Id: 'S',
+    Name: '20.0% S',
+    Taxable: true,
+    SalesTaxRateList: { TaxRateDetail: [{ TaxRateRef: { value: 'r20' } }] },
+  },
+  {
+    Id: 'HST',
+    Name: 'GST+PST',
+    Taxable: true,
+    SalesTaxRateList: {
+      TaxRateDetail: [
+        { TaxRateRef: { value: 'r5' }, TaxTypeApplicable: 'TaxOnAmount' },
+        { TaxRateRef: { value: 'r7' }, TaxTypeApplicable: 'TaxOnAmount' },
+      ],
+    },
+  },
+  { Id: 'Z', Name: 'Zero', SalesTaxRateList: { TaxRateDetail: { TaxRateRef: { value: 'r0' } } } },
+  { Id: 'X', Name: 'Exempt', Taxable: false },
+  {
+    Id: 'U',
+    Name: 'Unknown rate',
+    SalesTaxRateList: { TaxRateDetail: [{ TaxRateRef: { value: 'gone' } }] },
+  },
+  { Id: 'old', Name: 'Retired', Active: false, Taxable: true },
+]
+
+describe('QuickBooks tax catalog', () => {
+  const catalog = buildTaxCatalog(codes, rates)
+
+  it('adds a code’s rates up, reads one or many rate details, and drops inactive codes', () => {
+    expect(catalog.map((c) => [c.id, c.percent, c.rateIds])).toEqual([
+      ['S', 20, ['r20']],
+      ['X', null, []],
+      ['HST', 12, ['r5', 'r7']],
+      ['U', null, ['gone']],
+      ['Z', 0, ['r0']],
+    ])
+  })
+
+  it('infers taxable from the rate when the flag is missing', () => {
+    expect(catalog.find((c) => c.id === 'Z')?.taxable).toBe(false)
+    expect(catalog.find((c) => c.id === 'S')?.taxable).toBe(true)
+  })
+
+  it('prefers the chosen code when its rate fits, else any code with the rate', () => {
+    expect(pickTaxableCode(catalog, 20, 'S')).toMatchObject({ id: 'S', matched: true })
+    expect(pickTaxableCode(catalog, 12, 'S')).toMatchObject({ id: 'HST', matched: true })
+    expect(pickTaxableCode(catalog, 20, null)).toMatchObject({ id: 'S', matched: true })
+  })
+
+  it('flags a rate no code carries and falls back to the chosen code', () => {
+    expect(pickTaxableCode(catalog, 19, 'S')).toMatchObject({ id: 'S', matched: false })
+    expect(pickTaxableCode(catalog, 19, null)).toMatchObject({ id: null, matched: false })
+  })
+
+  it('trusts the chosen code when the catalog could not be read', () => {
+    expect(pickTaxableCode([], 19, 'S')).toEqual({ id: 'S', entry: null, matched: true })
+  })
+
+  it('finds a zero-rated code for untaxed invoices when none is chosen', () => {
+    expect(pickZeroCode(catalog, 'X')).toBe('X')
+    expect(pickZeroCode(catalog, null)).toBe('Z')
+    expect(pickZeroCode([], null)).toBeNull()
+  })
+
+  it('treats rates that print the same as equal', () => {
+    expect(ratesMatch(20, 20.004)).toBe(true)
+    expect(ratesMatch(20, 20.01)).toBe(false)
+  })
+
+  it('shapes the two overrides the way Intuit documents them', () => {
+    expect(astTaxDetail('TAX', 43.749)).toEqual({
+      TxnTaxCodeRef: { value: 'TAX' },
+      TotalTax: 43.75,
+    })
+    expect(globalTaxDetail({ rateId: 'r20', percent: 20, taxAmount: 60, netTaxable: 300 })).toEqual(
+      {
+        TotalTax: 60,
+        TaxLine: [
+          {
+            Amount: 60,
+            DetailType: 'TaxLineDetail',
+            TaxLineDetail: {
+              TaxRateRef: { value: 'r20' },
+              PercentBased: true,
+              TaxPercent: 20,
+              NetAmountTaxable: 300,
+            },
+          },
+        ],
+      }
+    )
+  })
+})

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

@@ -86,6 +86,7 @@ const options = {
   taxExempt: false,
   includeVehicle: false,
   customTxnNumbers: true,
+  txnTaxDetail: null,
 }
 
 /**
@@ -166,6 +167,29 @@ describe('QuickBooks invoice mapping', () => {
     expect(us.GlobalTaxCalculation).toBeUndefined()
   })
 
+  it('carries the billed tax detail when given one', () => {
+    const detail = { TotalTax: 300, TaxLine: [] }
+    expect(buildInvoice(invoice, { ...options, txnTaxDetail: detail }).TxnTaxDetail).toBe(detail)
+    expect(buildInvoice(invoice, options).TxnTaxDetail).toBeUndefined()
+  })
+
+  it('sends gross prices as net for a US company that bills tax-inclusive', () => {
+    // 1000 + 500 gross at 25% inclusive: net 800 + 400, so tax on top lands on the gross.
+    const gross = { ...invoice, taxInclusive: true, taxRate: 25 }
+    const us = buildInvoice(gross, { ...options, globalTax: false })
+    const [labor, part, discount] = us.Line as Record<string, Record<string, unknown>>[]
+    expect(labor.Amount).toBe(800)
+    expect(labor.SalesItemLineDetail.UnitPrice).toBe(400)
+    expect(part.Amount).toBe(400)
+    expect(discount.Amount).toBe(120)
+    // The same invoice in a company with real rates keeps its gross prices
+    // and says so, because QuickBooks backs the tax out itself there.
+    const global = buildInvoice(gross, options)
+    const [globalLabor] = global.Line as Record<string, Record<string, unknown>>[]
+    expect(globalLabor.Amount).toBe(1000)
+    expect(global.GlobalTaxCalculation).toBe('TaxInclusive')
+  })
+
   it('leaves out lines whose item is unknown and fixed discounts stay fixed', () => {
     const body = buildInvoice(
       { ...invoice, discountType: 'fixed', discountValue: 150 },

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

@@ -26,6 +26,7 @@ import { DocsLink } from '@/components/docs-link'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { DateInput } from '@/components/ui/date-input'
+import { OptionPicker } from './option-picker'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Switch } from '@/components/ui/switch'
@@ -811,30 +812,30 @@ function SettingFieldList({
               <DateInput
                 value={String(values[f.key] ?? '')}
                 onChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
+                placeholder={t('connection.noLimit')}
               />
             )}
-            {(f.type === 'select' || f.type === 'remote-select') && (
+            {f.type === 'remote-select' && (
+              <OptionPicker
+                value={String(values[f.key] ?? '')}
+                options={remote[f.source ?? '']}
+                onChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
+              />
+            )}
+            {f.type === 'select' && (
               <Select
                 value={String(values[f.key] ?? '')}
                 onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
               >
                 <SelectTrigger className="h-8">
-                  <SelectValue
-                    placeholder={
-                      f.type === 'remote-select' && remote[f.source ?? ''] === undefined
-                        ? t('connection.loading')
-                        : t('connection.choose')
-                    }
-                  />
+                  <SelectValue placeholder={t('connection.choose')} />
                 </SelectTrigger>
                 <SelectContent>
-                  {(f.type === 'select' ? (f.options ?? []) : (remote[f.source ?? ''] ?? [])).map(
-                    (o) => (
-                      <SelectItem key={o.value} value={o.value}>
-                        {o.label}
-                      </SelectItem>
-                    )
-                  )}
+                  {(f.options ?? []).map((o) => (
+                    <SelectItem key={o.value} value={o.value}>
+                      {tc.has(`settings.${o.label}`) ? tc(`settings.${o.label}`) : o.label}
+                    </SelectItem>
+                  ))}
                 </SelectContent>
               </Select>
             )}

+ 102 - 0
src/app/(authenticated)/settings/integrations/[connector]/option-picker.tsx

@@ -0,0 +1,102 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+import type { SettingOption } from '@/features/integrations/Lib/types'
+
+/**
+ * A picker for a list the vendor supplies: products, tax codes, accounts. A
+ * company can have hundreds, so it searches as you type, and since every one
+ * of these settings is optional there is a first entry that leaves it empty.
+ */
+export function OptionPicker({
+  value,
+  options,
+  onChange,
+  disabled,
+}: {
+  value: string
+  /** undefined while the list is still loading. */
+  options: SettingOption[] | null | undefined
+  onChange: (value: string) => void
+  disabled?: boolean
+}) {
+  const t = useTranslations('integrations')
+  const [open, setOpen] = useState(false)
+  const loading = options === undefined
+  const chosen = options?.find((o) => o.value === value) ?? null
+  // A value the list no longer has (a deleted item, a code from another
+  // company) still shows, so the workshop sees something is set.
+  const label = chosen?.label ?? (value ? value : null)
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="outline"
+          role="combobox"
+          aria-expanded={open}
+          disabled={disabled || loading}
+          className="h-8 w-full justify-between font-normal"
+        >
+          <span className={cn('truncate', !label && 'text-muted-foreground')}>
+            {loading ? t('connection.loading') : (label ?? t('connection.choose'))}
+          </span>
+          {loading ? (
+            <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
+          ) : (
+            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+          )}
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-64 p-0" align="end">
+        <Command>
+          <CommandInput placeholder={t('connection.search')} />
+          <CommandList>
+            <CommandEmpty>{t('connection.noMatch')}</CommandEmpty>
+            <CommandGroup>
+              <CommandItem
+                value="__none__"
+                onSelect={() => {
+                  onChange('')
+                  setOpen(false)
+                }}
+                className="text-muted-foreground"
+              >
+                <Check className={cn('mr-2 h-4 w-4', value ? 'opacity-0' : 'opacity-100')} />
+                {t('connection.leaveEmpty')}
+              </CommandItem>
+              {(options ?? []).map((o) => (
+                <CommandItem
+                  key={o.value}
+                  // cmdk matches on this, so search covers the label, not the id.
+                  value={`${o.label} ${o.value}`}
+                  onSelect={() => {
+                    onChange(o.value)
+                    setOpen(false)
+                  }}
+                >
+                  <Check
+                    className={cn('mr-2 h-4 w-4', o.value === value ? 'opacity-100' : 'opacity-0')}
+                  />
+                  <span className="truncate">{o.label}</span>
+                </CommandItem>
+              ))}
+            </CommandGroup>
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 22 - 13
src/features/integrations/Actions/integrationActions.ts

@@ -655,10 +655,12 @@ 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. */
+/**
+ * Push every issued invoice, for a fresh accounting connection. The
+ * connector's start date setting, when set, bounds it; a day of slack keeps
+ * the coarse query from dropping an invoice the connector's timezone-exact
+ * check would keep. Jobs go in as one write: a workshop can have thousands.
+ */
 export async function backfillIntegrationAccounting(connectorId: string) {
   return withAuth(
     async ({ organizationId }) => {
@@ -667,18 +669,25 @@ export async function backfillIntegrationAccounting(connectorId: string) {
       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 },
+        select: { id: true, status: true, settings: 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 startDate = (row.settings as Record<string, unknown> | null)?.startDate
+      const since =
+        typeof startDate === 'string' && ISO_DAY.test(startDate)
+          ? new Date(new Date(`${startDate}T00:00:00Z`).getTime() - 86_400_000)
+          : null
       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}`,
+      if (ids.length > 0) {
+        await db.integrationJob.createMany({
+          data: ids.map((id) => ({
+            connectionId: row.id,
+            organizationId,
+            kind: 'accounting.invoice',
+            payload: { entityId: id, event: 'backfill' },
+            idempotencyKey: `accounting.invoice:${id}`,
+          })),
+          skipDuplicates: true,
         })
       }
       await writeLog(row.id, 'info', `Queued ${ids.length} issued invoices for push`)

+ 57 - 6
src/features/integrations/Lib/accounting-sync.ts

@@ -9,6 +9,7 @@
  */
 
 import { db } from '@/lib/db'
+import type { Prisma } from '@/generated/prisma/client'
 import { effectiveInvoiceDate } from '@/lib/invoice-utils'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { serviceUrl } from './calendar-sync'
@@ -115,6 +116,7 @@ export async function loadInvoiceForAccounting(
       invoiceNumber: true,
       status: true,
       issuedAt: true,
+      sentAt: true,
       invoiceDate: true,
       startDateTime: true,
       serviceDate: true,
@@ -195,7 +197,7 @@ export async function loadInvoiceForAccounting(
     vehicleId: r.vehicleId,
     invoiceNumber: r.invoiceNumber,
     status: r.status,
-    issuedAt: r.issuedAt,
+    issuedAt: issuedAtOf(r),
     invoiceDate: effectiveInvoiceDate(r),
     serviceDate: r.serviceDate,
     dueDate: r.invoiceDueDate,
@@ -323,20 +325,69 @@ export async function removePulledPayment(
   return r.count > 0
 }
 
-/** Issued invoices from the last months, oldest first, for a fresh connection to push. */
+/**
+ * When the invoice reached the customer, which is when the ledger should have
+ * it. Invoices issued since locking exists carry the stamp. Older ones were
+ * sent, marked paid or paid before the stamp existed and never got one unless
+ * the workshop locked them from invoice settings; for the ledger they count
+ * as issued all the same, dated by the send or the first payment.
+ */
+export function issuedAtOf(r: {
+  issuedAt: Date | null
+  sentAt: Date | null
+  manuallyPaid: boolean
+  payments: { date: Date }[]
+  invoiceDate: Date | null
+  serviceDate: Date
+}): Date | null {
+  if (r.issuedAt) return r.issuedAt
+  if (r.sentAt) return r.sentAt
+  if (r.payments.length > 0) return r.payments[0].date
+  if (r.manuallyPaid) return r.invoiceDate ?? r.serviceDate
+  return null
+}
+
+/** Rows that count as issued for the ledger, mirroring issuedAtOf. */
+const ISSUED_WHERE: Prisma.ServiceRecordWhereInput = {
+  OR: [
+    { issuedAt: { not: null } },
+    { sentAt: { not: null } },
+    { manuallyPaid: true },
+    { payments: { some: {} } },
+  ],
+}
+
+/**
+ * Every issued invoice, oldest first, for a fresh connection to push. The
+ * date filter is coarse (the sheet's own date, or the service date without
+ * one); the connector applies the workshop's start date exactly, in its
+ * timezone, when each job runs.
+ */
 export async function invoicesForBackfill(
   organizationId: string,
-  since: Date,
-  limit = 500
+  since: Date | null,
+  limit = 5000
 ): Promise<string[]> {
   const rows = await db.serviceRecord.findMany({
     where: {
       organizationId,
       invoiceNumber: { not: null },
-      issuedAt: { not: null, gte: since },
+      AND: [
+        ISSUED_WHERE,
+        ...(since
+          ? [
+              {
+                OR: [
+                  { invoiceDate: { gte: since } },
+                  { invoiceDate: null, serviceDate: { gte: since } },
+                ],
+              },
+            ]
+          : []),
+      ],
     },
     select: { id: true },
-    orderBy: { issuedAt: 'asc' },
+    orderBy: { createdAt: 'asc' },
     take: limit,
   })
   return rows.map((r) => r.id)

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

@@ -76,6 +76,18 @@ export const manifest: ConnectorManifest = {
       source: 'items',
       showWhen: { key: 'pushInvoices', equals: true },
     },
+    {
+      key: 'taxAmounts',
+      type: 'select',
+      label: 'taxAmounts',
+      help: 'taxAmountsHelp',
+      default: 'billed',
+      options: [
+        { value: 'billed', label: 'taxAmountsBilled' },
+        { value: 'quickbooks', label: 'taxAmountsQuickbooks' },
+      ],
+      showWhen: { key: 'pushInvoices', equals: true },
+    },
     {
       key: 'taxCodeId',
       type: 'remote-select',

+ 38 - 5
src/integrations/quickbooks/mapping.ts

@@ -56,11 +56,26 @@ export interface QboItem {
   Active?: boolean
 }
 
+export interface QboTaxRateDetail {
+  TaxRateRef?: QboRef
+  /** TaxOnAmount for a plain rate; TaxOnTax marks a compounding rate in a group. */
+  TaxTypeApplicable?: string
+}
+
 export interface QboTaxCode {
   Id: string
   Name: string
   Active?: boolean
   Taxable?: boolean
+  SalesTaxRateList?: { TaxRateDetail?: QboTaxRateDetail | QboTaxRateDetail[] }
+}
+
+export interface QboTaxRate {
+  Id: string
+  Name?: string
+  Active?: boolean
+  /** The percentage, 20 for 20%. */
+  RateValue?: number
 }
 
 export interface QboAccount {
@@ -86,6 +101,12 @@ export interface QboLine {
   }
   DescriptionLineDetail?: { ServiceDate?: string }
   DiscountLineDetail?: { PercentBased: boolean; DiscountPercent?: number }
+  TaxLineDetail?: {
+    TaxRateRef: QboRef
+    PercentBased: boolean
+    TaxPercent?: number
+    NetAmountTaxable?: number
+  }
   LinkedTxn?: { TxnId: string; TxnType: string }[]
 }
 
@@ -242,6 +263,12 @@ export interface InvoiceOptions {
   taxExempt: boolean
   /** A text line naming the vehicle above the charges, so the books say which car. */
   includeVehicle: boolean
+  /**
+   * The tax as billed, for QuickBooks to keep instead of its own figure:
+   * TxnTaxDetail as the tax module builds it, or null to let QuickBooks
+   * work the tax out from the code.
+   */
+  txnTaxDetail: Record<string, unknown> | null
 }
 
 /** "2018 Toyota Corolla, AB 12345, 84 200 km", or null without a vehicle. */
@@ -256,7 +283,8 @@ function itemLine(
   line: AccountingInvoice['lines'][number],
   itemId: string,
   taxCode: string | null,
-  serviceDate: string
+  serviceDate: string,
+  netFactor: number
 ): QboLine {
   const description = [line.partNumber, line.description].filter(Boolean).join(' ').slice(0, 4000)
   // QuickBooks derives Amount from Qty × UnitPrice and rejects a line where
@@ -266,12 +294,12 @@ function itemLine(
   const unit = consistent ? line.unitPrice : line.total
   return {
     DetailType: 'SalesItemLineDetail',
-    Amount: round2(line.total),
+    Amount: round2(line.total * netFactor),
     Description: description,
     SalesItemLineDetail: {
       ItemRef: { value: itemId },
       Qty: qty,
-      UnitPrice: round2(unit),
+      UnitPrice: round2(unit * netFactor),
       ServiceDate: serviceDate,
       ...(taxCode && { TaxCodeRef: { value: taxCode } }),
     },
@@ -287,6 +315,10 @@ export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<
   const taxable = inv.taxRate > 0 && !o.taxExempt
   const taxCode = taxable ? o.taxCodeId : o.zeroTaxCodeId
   const serviceDate = zonedDayKey(inv.serviceDate, o.timezone)
+  // A US company has no tax-inclusive mode: QuickBooks adds tax on top of
+  // the lines whatever they say. Gross prices go over as net so the total,
+  // with the billed tax on top, is the gross the customer saw.
+  const netFactor = !o.globalTax && inv.taxInclusive && taxable ? 1 / (1 + inv.taxRate / 100) : 1
   const lines: QboLine[] = []
   const vehicle = o.includeVehicle ? vehicleDescription(inv) : null
   if (vehicle) {
@@ -299,13 +331,13 @@ export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<
   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))
+    lines.push(itemLine(line, itemId, taxCode, serviceDate, netFactor))
   }
   if (inv.discountAmount > 0) {
     const percent = inv.discountType === 'percentage' && inv.discountValue > 0
     lines.push({
       DetailType: 'DiscountLineDetail',
-      Amount: round2(inv.discountAmount),
+      Amount: round2(inv.discountAmount * netFactor),
       DiscountLineDetail: percent
         ? { PercentBased: true, DiscountPercent: inv.discountValue }
         : { PercentBased: false },
@@ -324,6 +356,7 @@ export function buildInvoice(inv: AccountingInvoice, o: InvoiceOptions): Record<
     ...(o.currency && { CurrencyRef: { value: o.currency } }),
     // Torqvoice taxes the amount after the discount, so the ledger must too.
     ...(inv.discountAmount > 0 && { ApplyTaxAfterDiscount: true }),
+    ...(o.txnTaxDetail && { TxnTaxDetail: o.txnTaxDetail }),
   }
   if (o.globalTax) {
     body.GlobalTaxCalculation = !taxable

+ 192 - 35
src/integrations/quickbooks/server.ts

@@ -36,6 +36,7 @@ import {
   type QboItem,
   type QboPayment,
   type QboTaxCode,
+  type QboTaxRate,
   REVOKE_URL,
   WALK_IN_NAME,
   apiHost,
@@ -53,22 +54,21 @@ import {
   round2,
   sqlString,
 } from './mapping'
+import {
+  US_NON_TAXABLE,
+  US_TAX_CODES,
+  US_TAXABLE,
+  astTaxDetail,
+  buildTaxCatalog,
+  globalTaxDetail,
+  pickTaxableCode,
+  pickZeroCode,
+  type TaxCatalogEntry,
+} from './tax'
 
 const PROVIDER = 'quickbooks'
 /** Change data capture reaches back at most a month. */
 const CDC_MAX_DAYS = 30
-/**
- * The two pseudo tax codes of a US company under automated sales tax. They
- * are what a line carries there; the rate itself comes from the customer's
- * address. They are not TaxCode rows, so they are offered and defaulted here.
- */
-const US_TAXABLE = 'TAX'
-const US_NON_TAXABLE = 'NON'
-const US_TAX_CODES = [
-  { value: US_TAXABLE, label: 'TAX (taxable)' },
-  { value: US_NON_TAXABLE, label: 'NON (not taxable)' },
-]
-
 /** Ledger and workshop totals may differ by rounding; beyond this the tax code is wrong. */
 const TOTAL_TOLERANCE = 0.05
 
@@ -90,6 +90,12 @@ interface State {
   multiCurrency: boolean
   /** Whether the company lets a transaction carry its own number; without it DocNumber is ignored. */
   customTxnNumbers: boolean
+  /** Sales tax is set up in the company; without it QuickBooks drops any tax sent. */
+  salesTaxEnabled: boolean
+  /** A US company on automated sales tax: lines carry TAX or NON, not real codes. */
+  automatedSalesTax: boolean
+  /** The company's tax codes with their rates, read at most once a day. */
+  taxCatalog: { fetchedAt: string; codes: TaxCatalogEntry[] } | null
   defaultItems: Partial<Record<'labor' | 'part', string>>
   walkInCustomerId: string | null
   lastPullAt: string | null
@@ -104,6 +110,12 @@ function stateOf(ctx: ConnectorContext): State {
     homeCurrency: typeof s.homeCurrency === 'string' ? s.homeCurrency : null,
     multiCurrency: s.multiCurrency === true,
     customTxnNumbers: s.customTxnNumbers !== false,
+    salesTaxEnabled: s.salesTaxEnabled !== false,
+    // Connections made before this was recorded: every US company opened
+    // since 2017 is on automated sales tax, so the country is the best guess.
+    automatedSalesTax:
+      typeof s.automatedSalesTax === 'boolean' ? s.automatedSalesTax : s.country === 'US',
+    taxCatalog: isTaxCatalog(s.taxCatalog) ? s.taxCatalog : null,
     defaultItems: (s.defaultItems as State['defaultItems']) ?? {},
     walkInCustomerId: typeof s.walkInCustomerId === 'string' ? s.walkInCustomerId : null,
     lastPullAt: typeof s.lastPullAt === 'string' ? s.lastPullAt : null,
@@ -123,6 +135,8 @@ function settingsOf(ctx: ConnectorContext) {
     partsItemId: str('partsItemId'),
     taxCodeId: str('taxCodeId'),
     zeroTaxCodeId: str('zeroTaxCodeId'),
+    /** billed: QuickBooks keeps the tax as it stands on the invoice; quickbooks: it works its own out. */
+    taxAmounts: s.taxAmounts === 'quickbooks' ? ('quickbooks' as const) : ('billed' as const),
     pushPayments: s.pushPayments !== false,
     depositAccountId: str('depositAccountId'),
     manualPaidAsPayment: s.manualPaidAsPayment === true,
@@ -185,6 +199,7 @@ interface Preferences {
   Preferences: {
     CurrencyPrefs?: { HomeCurrency?: { value?: string }; MultiCurrencyEnabled?: boolean }
     SalesFormsPrefs?: { CustomTxnNumbers?: boolean }
+    TaxPrefs?: { UsingSalesTax?: boolean; PartnerTaxEnabled?: boolean }
   }
 }
 
@@ -198,7 +213,120 @@ async function readCompany(ctx: ConnectorContext, env: Environment) {
     homeCurrency: prefs.Preferences.CurrencyPrefs?.HomeCurrency?.value ?? null,
     multiCurrency: prefs.Preferences.CurrencyPrefs?.MultiCurrencyEnabled === true,
     customTxnNumbers: prefs.Preferences.SalesFormsPrefs?.CustomTxnNumbers === true,
+    salesTaxEnabled: prefs.Preferences.TaxPrefs?.UsingSalesTax !== false,
+    // Intuit: PartnerTaxEnabled is present, true or false, exactly when the
+    // company is on automated sales tax; absent otherwise.
+    automatedSalesTax: prefs.Preferences.TaxPrefs?.PartnerTaxEnabled !== undefined,
+  }
+}
+
+/* ---------- tax ---------- */
+
+function isTaxCatalog(v: unknown): v is State['taxCatalog'] & object {
+  return (
+    typeof v === 'object' &&
+    v !== null &&
+    typeof (v as { fetchedAt?: unknown }).fetchedAt === 'string' &&
+    Array.isArray((v as { codes?: unknown }).codes)
+  )
+}
+
+/** A day; codes change when a rate changes, which is rare, and the settings page re-reads anyway. */
+const TAX_CATALOG_TTL_MS = 24 * 60 * 60 * 1000
+
+/**
+ * The company's tax codes with their rates. Nothing to read under automated
+ * sales tax. Cached on the connection so a push does not re-read two tables.
+ */
+async function loadTaxCatalog(
+  ctx: ConnectorContext,
+  opts: { refresh?: boolean } = {}
+): Promise<TaxCatalogEntry[]> {
+  const state = stateOf(ctx)
+  if (state.automatedSalesTax) return []
+  const cached = state.taxCatalog
+  if (
+    !opts.refresh &&
+    cached &&
+    Date.now() - new Date(cached.fetchedAt).getTime() < TAX_CATALOG_TTL_MS
+  ) {
+    return cached.codes
+  }
+  const [codes, rates] = await Promise.all([
+    query<QboTaxCode>(ctx, 'TaxCode', 'select * from TaxCode where Active = true maxresults 1000'),
+    query<QboTaxRate>(ctx, 'TaxRate', 'select * from TaxRate where Active = true maxresults 1000'),
+  ])
+  const catalog = buildTaxCatalog(codes, rates)
+  await ctx.saveState({ taxCatalog: { fetchedAt: new Date().toISOString(), codes: catalog } })
+  return catalog
+}
+
+interface ResolvedTax {
+  taxCodeId: string | null
+  zeroTaxCodeId: string | null
+  txnTaxDetail: Record<string, unknown> | null
+  /** Something the workshop should hear about, once per push. */
+  note: string | null
+}
+
+/**
+ * The codes for this invoice and, when the tax must stand as billed, the
+ * override that makes QuickBooks keep our amount. See tax.ts for the model.
+ */
+async function resolveTax(
+  ctx: ConnectorContext,
+  inv: AccountingInvoice,
+  taxExempt: boolean
+): Promise<ResolvedTax> {
+  const settings = settingsOf(ctx)
+  const state = stateOf(ctx)
+  const taxable = inv.taxRate > 0 && !taxExempt
+  // With sales tax off QuickBooks ignores TxnTaxDetail, so there is nothing to send.
+  const billed = settings.taxAmounts === 'billed' && state.salesTaxEnabled
+  if (state.automatedSalesTax) {
+    const taxCodeId = settings.taxCodeId ?? US_TAXABLE
+    const zeroTaxCodeId = settings.zeroTaxCodeId ?? US_NON_TAXABLE
+    return {
+      taxCodeId,
+      zeroTaxCodeId,
+      txnTaxDetail: billed
+        ? astTaxDetail(taxable ? taxCodeId : zeroTaxCodeId, taxable ? inv.taxAmount : 0)
+        : null,
+      note: null,
+    }
   }
+  let catalog = await loadTaxCatalog(ctx)
+  const zeroTaxCodeId = pickZeroCode(catalog, settings.zeroTaxCodeId)
+  if (!taxable)
+    return { taxCodeId: settings.taxCodeId, zeroTaxCodeId, txnTaxDetail: null, note: null }
+  let pick = pickTaxableCode(catalog, inv.taxRate, settings.taxCodeId)
+  if (!pick.matched) {
+    // The rate may be new in QuickBooks since the catalog was read.
+    catalog = await loadTaxCatalog(ctx, { refresh: true })
+    pick = pickTaxableCode(catalog, inv.taxRate, settings.taxCodeId)
+  }
+  let note: string | null = null
+  if (!pick.matched) {
+    note = pick.entry
+      ? `No QuickBooks tax code carries ${inv.taxRate}%, so invoice ${inv.invoiceNumber} was booked under "${pick.entry.name}" (${pick.entry.percent ?? '?'}%). Add a ${inv.taxRate}% code in QuickBooks or pick another in the integration settings.`
+      : `No QuickBooks tax code carries ${inv.taxRate}% and none is chosen in the integration settings, so invoice ${inv.invoiceNumber} went over without one.`
+  }
+  // Only a code that carries the rate, and carries it as one rate, can be
+  // named on the override. A group of rates is left to QuickBooks, which
+  // reaches the same total when the rates fit; a code with another rate is
+  // not dressed up as this one, the warning above says what happened.
+  const rateId =
+    pick.matched && pick.entry && pick.entry.rateIds.length === 1 ? pick.entry.rateIds[0] : null
+  const txnTaxDetail =
+    billed && rateId
+      ? globalTaxDetail({
+          rateId,
+          percent: inv.taxRate,
+          taxAmount: inv.taxAmount,
+          netTaxable: inv.totalAmount - inv.taxAmount,
+        })
+      : null
+  return { taxCodeId: pick.id, zeroTaxCodeId, txnTaxDetail, note }
 }
 
 /* ---------- customers ---------- */
@@ -358,12 +486,20 @@ async function currencyFor(ctx: ConnectorContext): Promise<string | null> {
   return null
 }
 
-function eligible(inv: AccountingInvoice, s: ReturnType<typeof settingsOf>, tz: string): boolean {
-  if (!s.pushInvoices || !inv.invoiceNumber) return false
+/** Why the invoice stays out of the ledger, or null when it goes. */
+function whyNotEligible(
+  inv: AccountingInvoice,
+  s: ReturnType<typeof settingsOf>,
+  tz: string
+): string | null {
+  if (!s.pushInvoices) return 'invoice push switched off'
+  if (!inv.invoiceNumber) return 'no invoice number'
   const issued = Boolean(inv.issuedAt) || (s.pushOnComplete && inv.status === 'completed')
-  if (!issued) return false
-  if (s.startDate && zonedDayKey(inv.invoiceDate, tz) < s.startDate) return false
-  return true
+  if (!issued) return 'not issued yet'
+  if (s.startDate && zonedDayKey(inv.invoiceDate, tz) < s.startDate) {
+    return 'dated before the start date'
+  }
+  return null
 }
 
 /** The record is gone: void the ledger's copy unless money was taken against it. */
@@ -399,20 +535,20 @@ async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Prom
   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 skip = whyNotEligible(inv, settings, ctx.timezone)
+  if (skip) return { summary: skip }
 
   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 tax = await resolveTax(ctx, inv, customer.taxExempt)
+  if (tax.note) await ctx.log('warn', tax.note)
   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 ?? (state.country === 'US' ? US_TAXABLE : null),
-    zeroTaxCodeId: settings.zeroTaxCodeId ?? (state.country === 'US' ? US_NON_TAXABLE : null),
+    ...tax,
     globalTax: state.country !== 'US',
     currency: await currencyFor(ctx),
     timezone: ctx.timezone,
@@ -457,9 +593,14 @@ async function pushInvoice(ctx: ConnectorContext, serviceRecordId: string): Prom
     })
     const theirs = saved.TotalAmt ?? 0
     if (Math.abs(theirs - inv.totalAmount) > TOTAL_TOLERANCE) {
+      const hint = !state.salesTaxEnabled
+        ? 'sales tax is switched off in QuickBooks'
+        : settings.taxAmounts === 'quickbooks'
+          ? 'QuickBooks worked the tax out itself; set Tax amounts to "as billed" to send yours'
+          : 'QuickBooks did not keep the billed tax; check the tax codes in the integration settings'
       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`,
+        `Invoice ${inv.invoiceNumber}: QuickBooks total ${round2(theirs)} differs from ${round2(inv.totalAmount)} here; ${hint}`,
         { quickbooks: theirs, torqvoice: inv.totalAmount }
       )
     }
@@ -777,7 +918,16 @@ export const connector: ConnectorServer = {
       homeCurrency: company.homeCurrency,
       multiCurrency: company.multiCurrency,
       customTxnNumbers: company.customTxnNumbers,
+      salesTaxEnabled: company.salesTaxEnabled,
+      automatedSalesTax: company.automatedSalesTax,
+      taxCatalog: null,
     })
+    if (!company.salesTaxEnabled) {
+      await ctx.log(
+        'warn',
+        'Sales tax is switched off in this QuickBooks company, so it drops the tax on every invoice sent and the totals there will be lower than here. Turn it on under Taxes in QuickBooks.'
+      )
+    }
     if (!company.customTxnNumbers) {
       await ctx.log(
         'warn',
@@ -806,18 +956,25 @@ export const connector: ConnectorServer = {
         .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'
-      )
-      const listed = codes
-        .sort((a, b) => a.Name.localeCompare(b.Name))
-        .map((c) => ({ value: c.Id, label: c.Name }))
-      // A US company runs automated sales tax: TAX and NON are the codes a
-      // line carries, and the TaxCode query does not return them, only the
-      // company's own rate codes.
-      return stateOf(ctx).country === 'US' ? [...US_TAX_CODES, ...listed] : listed
+      // Under automated sales tax TAX and NON are what a line carries, and
+      // the TaxCode query does not return them, only the company's own rate
+      // codes, which stay on offer for a workshop that wants a fixed rate.
+      if (stateOf(ctx).automatedSalesTax) {
+        const codes = await query<QboTaxCode>(
+          ctx,
+          'TaxCode',
+          'select Id, Name, Taxable from TaxCode where Active = true maxresults 1000'
+        )
+        const listed = codes
+          .sort((a, b) => a.Name.localeCompare(b.Name))
+          .map((c) => ({ value: c.Id, label: c.Name }))
+        return [...US_TAX_CODES, ...listed]
+      }
+      const catalog = await loadTaxCatalog(ctx, { refresh: true })
+      return catalog.map((c) => ({
+        value: c.id,
+        label: c.percent == null ? c.name : `${c.name} (${c.percent}%)`,
+      }))
     },
     async depositAccounts(ctx) {
       const accounts = await query<QboAccount>(

+ 139 - 0
src/integrations/quickbooks/tax.ts

@@ -0,0 +1,139 @@
+import { type QboRef, type QboTaxCode, type QboTaxRate, round2 } from './mapping'
+
+/**
+ * How the ledger and the workshop agree on tax.
+ *
+ * The workshop puts one rate on an invoice, the ledger books tax under a tax
+ * code. Two kinds of company exist. A US company on automated sales tax has
+ * no real codes: a line carries TAX or NON and QuickBooks works the rate out
+ * from the customer's address. Every other company has real codes, each a
+ * bundle of one or more rates with a percentage. Here the code whose rates
+ * add up to the invoice's own rate is picked for it, so a workshop that bills
+ * 19% one day and 7% the next needs no per-rate setup.
+ *
+ * Either way QuickBooks recalculates. When it must agree with the document
+ * the customer already holds, the tax amount is sent along as an override:
+ * TxnTaxDetail.TotalTax, with a TaxLine naming the rate where the company
+ * has real rates, which is how Intuit documents overriding for both models.
+ */
+
+/** The two pseudo codes of an automated-sales-tax company; not TaxCode rows. */
+export const US_TAXABLE = 'TAX'
+export const US_NON_TAXABLE = 'NON'
+export const US_TAX_CODES = [
+  { value: US_TAXABLE, label: 'TAX (taxable)' },
+  { value: US_NON_TAXABLE, label: 'NON (not taxable)' },
+]
+
+export interface TaxCatalogEntry {
+  id: string
+  name: string
+  taxable: boolean
+  /** The sales rates the code bundles; one for a plain VAT code, several for a group. */
+  rateIds: string[]
+  /** The rates added up, or null when a rate's value is unknown. */
+  percent: number | null
+}
+
+/** Two percentages that print the same are the same rate. */
+export function ratesMatch(a: number, b: number): boolean {
+  return Math.abs(a - b) < 0.005
+}
+
+/** The codes with their combined sales rate, from the two tables QuickBooks keeps them in. */
+export function buildTaxCatalog(codes: QboTaxCode[], rates: QboTaxRate[]): TaxCatalogEntry[] {
+  const rateValue = new Map(rates.map((r) => [r.Id, r.RateValue]))
+  return codes
+    .filter((c) => c.Active !== false)
+    .map((c) => {
+      const raw = c.SalesTaxRateList?.TaxRateDetail
+      const details = Array.isArray(raw) ? raw : raw ? [raw] : []
+      const rateIds = details
+        .filter((d) => !d.TaxTypeApplicable || d.TaxTypeApplicable === 'TaxOnAmount')
+        .map((d) => d.TaxRateRef?.value)
+        .filter((v): v is string => Boolean(v))
+      let percent: number | null = rateIds.length > 0 ? 0 : null
+      for (const id of rateIds) {
+        const v = rateValue.get(id)
+        if (typeof v !== 'number') {
+          percent = null
+          break
+        }
+        percent = (percent ?? 0) + v
+      }
+      // A code whose only rate is 0 is a zero-rated code, and the API often
+      // omits Taxable on those; treat it as not taxable.
+      const taxable = c.Taxable === true || (c.Taxable === undefined && (percent ?? 0) > 0)
+      return { id: c.Id, name: c.Name, taxable, rateIds, percent }
+    })
+    .sort((a, b) => a.name.localeCompare(b.name))
+}
+
+export interface TaxCodePick {
+  /** The code to put on the lines, or null when there is nothing to use. */
+  id: string | null
+  entry: TaxCatalogEntry | null
+  /** False when a catalog was read and no code carries the invoice's rate. */
+  matched: boolean
+}
+
+/**
+ * The code for a taxed invoice. The workshop's chosen code wins when its rate
+ * fits; otherwise any code with the rate; failing that the chosen code with a
+ * flag, so the push can say so. An empty catalog means the rates could not be
+ * read, and the chosen code is trusted.
+ */
+export function pickTaxableCode(
+  catalog: TaxCatalogEntry[],
+  rate: number,
+  configured: string | null
+): TaxCodePick {
+  const chosen = configured ? (catalog.find((c) => c.id === configured) ?? null) : null
+  if (catalog.length === 0) return { id: configured, entry: null, matched: true }
+  if (chosen?.percent != null && ratesMatch(chosen.percent, rate)) {
+    return { id: chosen.id, entry: chosen, matched: true }
+  }
+  const fits = catalog
+    .filter((c) => c.percent != null && ratesMatch(c.percent, rate))
+    .sort((a, b) => Number(b.taxable) - Number(a.taxable) || a.rateIds.length - b.rateIds.length)
+  if (fits[0]) return { id: fits[0].id, entry: fits[0], matched: true }
+  return { id: configured, entry: chosen, matched: false }
+}
+
+/** The code for an untaxed invoice: the chosen one, else a zero-rated code the company has. */
+export function pickZeroCode(catalog: TaxCatalogEntry[], configured: string | null): string | null {
+  if (configured) return configured
+  const zero = catalog
+    .filter((c) => c.percent === 0)
+    .sort((a, b) => Number(a.taxable) - Number(b.taxable))
+  return zero[0]?.id ?? null
+}
+
+/** The override for an automated-sales-tax company: the code says intent, the amount is ours. */
+export function astTaxDetail(taxCode: string, taxAmount: number): Record<string, unknown> {
+  return { TxnTaxCodeRef: { value: taxCode } satisfies QboRef, TotalTax: round2(taxAmount) }
+}
+
+/** The override for a company with real rates: the amount, and the one rate it belongs to. */
+export function globalTaxDetail(input: {
+  rateId: string
+  percent: number
+  taxAmount: number
+  netTaxable: number
+}): Record<string, unknown> {
+  return {
+    TotalTax: round2(input.taxAmount),
+    TaxLine: [
+      {
+        Amount: round2(input.taxAmount),
+        DetailType: 'TaxLineDetail',
+        TaxLineDetail: {
+          TaxRateRef: { value: input.rateId },
+          PercentBased: true,
+          TaxPercent: input.percent,
+          NetAmountTaxable: round2(input.netTaxable),
+        },
+      },
+    ],
+  }
+}