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

Tax system: inclusive mode, customer tax fields, universal invoice display (#138)

* apply tax

* create new tax setting page

* translations

* tax report, invoices and digital shared pages

* inclusive tax button

* customer invoice

* translations

* Convert existing records to inclusive mode

* inclusiveMode

* buttons

* tests

* computedTax
Bernt Christian Egeland 5 месяцев назад
Родитель
Сommit
0e32974cdc
100 измененных файлов с 2580 добавлено и 252 удалено
  1. 7 0
      messages/de/customers.json
  2. 1 1
      messages/de/navigation.json
  3. 6 0
      messages/de/pdf.json
  4. 4 1
      messages/de/quotes.json
  5. 6 1
      messages/de/service.json
  6. 45 0
      messages/de/settings.json
  7. 6 0
      messages/de/share.json
  8. 7 0
      messages/en/customers.json
  9. 1 1
      messages/en/navigation.json
  10. 6 0
      messages/en/pdf.json
  11. 4 1
      messages/en/quotes.json
  12. 7 2
      messages/en/service.json
  13. 45 0
      messages/en/settings.json
  14. 6 0
      messages/en/share.json
  15. 7 0
      messages/es/customers.json
  16. 1 1
      messages/es/navigation.json
  17. 6 0
      messages/es/pdf.json
  18. 4 1
      messages/es/quotes.json
  19. 6 1
      messages/es/service.json
  20. 45 0
      messages/es/settings.json
  21. 6 0
      messages/es/share.json
  22. 7 0
      messages/fr/customers.json
  23. 1 1
      messages/fr/navigation.json
  24. 6 0
      messages/fr/pdf.json
  25. 4 1
      messages/fr/quotes.json
  26. 6 1
      messages/fr/service.json
  27. 45 0
      messages/fr/settings.json
  28. 6 0
      messages/fr/share.json
  29. 7 0
      messages/it/customers.json
  30. 1 1
      messages/it/navigation.json
  31. 6 0
      messages/it/pdf.json
  32. 4 1
      messages/it/quotes.json
  33. 6 1
      messages/it/service.json
  34. 45 0
      messages/it/settings.json
  35. 6 0
      messages/it/share.json
  36. 7 0
      messages/lt/customers.json
  37. 1 1
      messages/lt/navigation.json
  38. 6 0
      messages/lt/pdf.json
  39. 4 1
      messages/lt/quotes.json
  40. 6 1
      messages/lt/service.json
  41. 45 0
      messages/lt/settings.json
  42. 6 0
      messages/lt/share.json
  43. 7 0
      messages/nb/customers.json
  44. 1 1
      messages/nb/navigation.json
  45. 6 0
      messages/nb/pdf.json
  46. 4 1
      messages/nb/quotes.json
  47. 6 1
      messages/nb/service.json
  48. 45 0
      messages/nb/settings.json
  49. 6 0
      messages/nb/share.json
  50. 7 0
      messages/nl/customers.json
  51. 1 1
      messages/nl/navigation.json
  52. 6 0
      messages/nl/pdf.json
  53. 4 1
      messages/nl/quotes.json
  54. 6 1
      messages/nl/service.json
  55. 45 0
      messages/nl/settings.json
  56. 6 0
      messages/nl/share.json
  57. 7 0
      messages/pl/customers.json
  58. 1 1
      messages/pl/navigation.json
  59. 6 0
      messages/pl/pdf.json
  60. 4 1
      messages/pl/quotes.json
  61. 6 1
      messages/pl/service.json
  62. 45 0
      messages/pl/settings.json
  63. 6 0
      messages/pl/share.json
  64. 7 0
      messages/pt-BR/customers.json
  65. 1 1
      messages/pt-BR/navigation.json
  66. 6 0
      messages/pt-BR/pdf.json
  67. 4 1
      messages/pt-BR/quotes.json
  68. 6 1
      messages/pt-BR/service.json
  69. 45 0
      messages/pt-BR/settings.json
  70. 6 0
      messages/pt-BR/share.json
  71. 7 0
      messages/ru/customers.json
  72. 1 1
      messages/ru/navigation.json
  73. 6 0
      messages/ru/pdf.json
  74. 4 1
      messages/ru/quotes.json
  75. 6 1
      messages/ru/service.json
  76. 45 0
      messages/ru/settings.json
  77. 6 0
      messages/ru/share.json
  78. 7 0
      messages/tr/customers.json
  79. 1 1
      messages/tr/navigation.json
  80. 6 0
      messages/tr/pdf.json
  81. 4 1
      messages/tr/quotes.json
  82. 6 1
      messages/tr/service.json
  83. 45 0
      messages/tr/settings.json
  84. 6 0
      messages/tr/share.json
  85. 12 0
      prisma/migrations/20260410061236_tax/migration.sql
  86. 5 0
      prisma/schema.prisma
  87. 73 0
      src/__tests__/features/public/invoice-view.test.tsx
  88. 73 0
      src/__tests__/features/public/quote-view.test.tsx
  89. 186 0
      src/__tests__/features/quotes/create-quote.test.ts
  90. 342 0
      src/__tests__/features/reports/reports-tax.test.ts
  91. 175 0
      src/__tests__/features/workorders/add-part.test.ts
  92. 176 0
      src/__tests__/features/workorders/create-workorder.test.ts
  93. 181 0
      src/__tests__/features/workorders/invoice-summary.test.tsx
  94. 436 0
      src/__tests__/lib/tax.test.ts
  95. 14 1
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  96. 2 0
      src/app/(authenticated)/customers/customers-client.tsx
  97. 0 167
      src/app/(authenticated)/settings/currency/currency-settings.tsx
  98. 0 9
      src/app/(authenticated)/settings/currency/page.tsx
  99. 2 38
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  100. 2 0
      src/app/(authenticated)/settings/settings-nav.tsx

+ 7 - 0
messages/de/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Kunde nicht gefunden",
     "emailCopied": "E-Mail in die Zwischenablage kopiert",
     "phoneCopied": "Telefonnummer in die Zwischenablage kopiert",
+    "taxIdLabel": "USt-IdNr.",
+    "taxExemptBadge": "Steuerbefreit",
     "tabs": {
       "vehicles": "Fahrzeuge ({count})",
       "messages": "Nachrichten",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "+49 151 12345678",
     "companyPlaceholder": "Firmenname",
     "addressPlaceholder": "Musterstraße 1, 12345 Musterstadt",
+    "taxId": "Steuer-/USt-IdNr.",
+    "taxIdPlaceholder": "z.B. DE123456789",
+    "taxIdHint": "Wird auf Rechnungen und Angeboten angezeigt, wenn dieser Kunde der Käufer ist.",
+    "taxExempt": "Steuerbefreit",
+    "taxExemptHint": "Wenn aktiviert, enthalten neue Rechnungen und Angebote für diesen Kunden keine Steuer.",
     "notesPlaceholder": "Weitere Anmerkungen...",
     "customerUpdated": "Kunde aktualisiert",
     "customerCreated": "Kunde angelegt",

+ 1 - 1
messages/de/navigation.json

@@ -34,7 +34,7 @@
     "team": "Team",
     "invoice": "Rechnung",
     "payment": "Zahlung",
-    "currency": "Währung",
+    "tax": "Steuer",
     "workshop": "Werkstatt",
     "appearance": "Erscheinungsbild",
     "email": "E-Mail",

+ 6 - 0
messages/de/pdf.json

@@ -3,6 +3,7 @@
     "title": "RECHNUNG",
     "due": "Fällig: {date}",
     "billTo": "Rechnungsempfänger",
+    "customerTaxId": "USt-IdNr.",
     "vehicle": "Fahrzeug",
     "vin": "VIN: {vin}",
     "plate": "Kennzeichen: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
+    "taxIncluded": "Inkl. Steuer ({rate}%)",
     "paymentsReceived": "Eingegangene Zahlungen",
     "paidInFull": "VOLLSTÄNDIG BEZAHLT",
     "amountDue": "Fälliger Betrag",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "ANGEBOT",
     "to": "An",
+    "customerTaxId": "USt-IdNr.",
     "vehicle": "Fahrzeug",
     "vin": "VIN: {vin}",
     "plate": "Kennzeichen: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
+    "taxIncluded": "Inkl. Steuer ({rate}%)",
     "validUntil": "Gültig bis: {date}",
     "attachedDocuments": "Beigefügte Dokumente",
     "attached": "{name} (beigefügt)",

+ 4 - 1
messages/de/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Teile",
     "labor": "Arbeitszeit",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discountNone": "Kein",
     "discountPercentage": "Prozentsatz",
     "discountFixed": "Festbetrag",
     "tax": "Steuer",
-    "total": "Gesamt"
+    "taxIncluded": "Steuer (inkl.)",
+    "total": "Gesamt",
+    "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer."
   },
   "shareDialog": {
     "title": "Angebot teilen",

+ 6 - 1
messages/de/service.json

@@ -125,12 +125,15 @@
     "parts": "Teile",
     "labor": "Arbeit",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discountNone": "Keine",
     "discountPercentage": "Prozentsatz",
     "discountFixed": "Festbetrag",
     "tax": "Steuern",
-    "total": "Gesamt"
+    "taxIncluded": "Steuer (inkl.)",
+    "total": "Gesamt",
+    "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer."
   },
   "notes": {
     "title": "Notizen",
@@ -157,8 +160,10 @@
     "parts": "Teile",
     "labor": "Arbeit",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "tax": "Steuern ({rate}%)",
+    "taxIncluded": "Steuer (inkl., {rate}%)",
     "total": "Gesamt",
     "paid": "Bezahlt",
     "balanceDue": "Ausstehend",

+ 45 - 0
messages/de/settings.json

@@ -38,6 +38,10 @@
         "title": "Zahlung",
         "description": "Zahlungsbedingungen & Konten"
       },
+      "tax": {
+        "title": "Steuer",
+        "description": "Steuersatz & Standardwerte"
+      },
       "currency": {
         "title": "Wahrung",
         "description": "Wahrungs- & Steuerstandards"
@@ -421,6 +425,7 @@
       "customer_address": "Adresse",
       "customer_email": "E-Mail",
       "customer_phone": "Telefon",
+      "customer_tax_id": "USt-IdNr.",
       "vehicle_name": "Fahrzeug",
       "vin": "FIN",
       "license_plate": "Kennzeichen",
@@ -573,6 +578,37 @@
     "savePayment": "Zahlungseinstellungen speichern",
     "saved": "Zahlungseinstellungen gespeichert"
   },
+  "tax": {
+    "title": "Steuer",
+    "description": "Konfigurieren Sie den Steuersatz für Rechnungen, Angebote und Arbeitsaufträge.",
+    "modeLabel": "Steuermodus",
+    "modeHint": "Wählen Sie, ob Preise auf Positionen mit bereits enthaltener Steuer (inklusiv) oder vor Steuer (exklusiv) eingegeben werden. Bestehende Datensätze behalten den Modus, mit dem sie erstellt wurden.",
+    "modeExclusive": "Exklusiv",
+    "modeInclusive": "Inklusiv",
+    "modeExclusiveExample": "Beispiel: 100 + {rate}% Steuer = 100 + Steuer obendrauf.",
+    "modeInclusiveExample": "Beispiel: 100 enthält bereits {rate}% Steuer — der Steueranteil wird zurückgerechnet.",
+    "labelLabel": "Steuerbezeichnung",
+    "labelHint": "Benutzerdefinierter Name, der auf Rechnungen und Angeboten angezeigt wird (z.B. MwSt., USt., VAT). Leer lassen für die übersetzte Standardbezeichnung.",
+    "labelPlaceholder": "MwSt.",
+    "convertInclusiveLabel": "Bestehende Einträge in inklusiven Modus umwandeln",
+    "convertInclusiveHint": "Skaliert jeden Zeilenpreis um den Steuersatz nach oben, sodass die Preise die Steuer enthalten. Kundenseitige Summen bleiben gleich. Berechtigt: {serviceRecords} Arbeitsaufträge, {quotes} Angebote.",
+    "convertInclusiveButton": "In inklusiv umwandeln",
+    "convertInclusiveConfirmTitle": "Bestehende Einträge in inklusiven Modus umwandeln?",
+    "convertInclusiveConfirmDescription": "Dies schreibt die Zeilenpreise von {serviceRecords} Arbeitsaufträgen und {quotes} Angeboten so um, dass sie die Steuer enthalten. Kundenseitige Summen ändern sich nicht, aber die gedruckten Preise auf früheren Rechnungen erscheinen höher. Dies kann nicht rückgängig gemacht werden.",
+    "convertInclusiveConfirmLabel": "Einträge umwandeln",
+    "convertInclusiveSuccess": "{serviceRecords} Arbeitsaufträge und {quotes} Angebote in inklusiven Modus umgewandelt.",
+    "convertInclusiveFailed": "Einträge konnten nicht in inklusiven Modus umgewandelt werden",
+    "convertInclusiveNoRecords": "Keine Einträge im exklusiven Modus zum Umwandeln.",
+    "convertExclusiveLabel": "Bestehende Einträge in exklusiven Modus umwandeln",
+    "convertExclusiveHint": "Skaliert jeden Zeilenpreis um den Steuersatz nach unten, sodass die Preise Beträge vor Steuer anzeigen. Kundenseitige Summen bleiben gleich. Berechtigt: {serviceRecords} Arbeitsaufträge, {quotes} Angebote.",
+    "convertExclusiveButton": "In exklusiv umwandeln",
+    "convertExclusiveConfirmTitle": "Bestehende Einträge in exklusiven Modus umwandeln?",
+    "convertExclusiveConfirmDescription": "Dies schreibt die Zeilenpreise von {serviceRecords} Arbeitsaufträgen und {quotes} Angeboten so um, dass sie Beträge vor Steuer anzeigen. Kundenseitige Summen ändern sich nicht, aber die gedruckten Preise auf früheren Rechnungen erscheinen niedriger. Dies kann nicht rückgängig gemacht werden.",
+    "convertExclusiveConfirmLabel": "Einträge umwandeln",
+    "convertExclusiveSuccess": "{serviceRecords} Arbeitsaufträge und {quotes} Angebote in exklusiven Modus umgewandelt.",
+    "convertExclusiveFailed": "Einträge konnten nicht in exklusiven Modus umgewandelt werden",
+    "convertExclusiveNoRecords": "Keine Einträge im inklusiven Modus zum Umwandeln."
+  },
   "currency": {
     "title": "Wahrung & Steuer",
     "description": "Standardwahrung und Steuersatz fur Rechnungen und Servicedaten.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Steuerberechnung auf Rechnungen und Servicedaten anzeigen",
     "defaultTaxRate": "Standard-Steuersatz (%)",
     "defaultTaxRateHint": "Wird automatisch beim Erstellen neuer Servicedaten eingetragen",
+    "applyTaxToExistingLabel": "Steuer auf bestehende Einträge anwenden",
+    "applyTaxToExistingHint": "Wendet den Standardsteuersatz auf bestehende Arbeitsaufträge und Angebote ohne Steuersatz an. Berechtigt: {serviceRecords} Arbeitsaufträge, {quotes} Angebote.",
+    "applyTaxToExistingButton": "Steuer auf bestehende Einträge anwenden",
+    "applyTaxConfirmTitle": "Steuer auf bestehende Einträge anwenden?",
+    "applyTaxConfirmDescription": "Dies setzt den Steuersatz auf {rate}% für {serviceRecords} Arbeitsaufträge und {quotes} Angebote ohne aktuellen Steuersatz und berechnet ihre Summen neu. Einträge mit einem eigenen Steuersatz werden nicht geändert. Diese Aktion kann nicht rückgängig gemacht werden.",
+    "applyTaxConfirmLabel": "Steuer anwenden",
+    "applyTaxSuccess": "{serviceRecords} Arbeitsaufträge und {quotes} Angebote aktualisiert.",
+    "applyTaxFailed": "Steuer konnte nicht auf bestehende Einträge angewendet werden",
+    "applyTaxNoRecords": "Keine Einträge benötigen den Standardsteuersatz.",
     "saveSettings": "Einstellungen speichern",
     "saved": "Einstellungen gespeichert",
     "currencies": {

+ 6 - 0
messages/de/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Checkout konnte nicht gestartet werden.",
     "errorPaymentFailed": "Zahlung konnte nicht eingeleitet werden. Bitte versuchen Sie es erneut.",
     "billTo": "Rechnungsempfänger",
+    "taxId": "USt-IdNr.",
     "vehicle": "Fahrzeug",
     "vin": "VIN: {vin}",
     "plate": "Kennzeichen: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
+    "taxIncluded": "Inkl. Steuer ({rate}%)",
     "paid": "Bezahlt",
     "paidInFull": "VOLLSTÄNDIG BEZAHLT",
     "amountDue": "Fälliger Betrag",
@@ -68,6 +71,7 @@
     "title": "Angebot",
     "downloadPdf": "PDF herunterladen",
     "preparedFor": "Erstellt für",
+    "taxId": "USt-IdNr.",
     "vehicle": "Fahrzeug",
     "vin": "VIN: {vin}",
     "plate": "Kennzeichen: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
     "subtotal": "Zwischensumme",
+    "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
+    "taxIncluded": "Inkl. Steuer ({rate}%)",
     "images": "Bilder ({count})",
     "documents": "Dokumente",
     "requestChanges": "Änderungen anfordern",

+ 7 - 0
messages/en/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Customer not found",
     "emailCopied": "Email copied to clipboard",
     "phoneCopied": "Phone number copied to clipboard",
+    "taxIdLabel": "Tax ID",
+    "taxExemptBadge": "Tax exempt",
     "tabs": {
       "vehicles": "Vehicles ({count})",
       "messages": "Messages",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "(555) 123-4567",
     "companyPlaceholder": "Company name",
     "addressPlaceholder": "123 Main St, City, State",
+    "taxId": "Tax / VAT number",
+    "taxIdPlaceholder": "e.g. NO123456789MVA, DE123456789",
+    "taxIdHint": "Shown on invoices and quotes when this customer is the buyer.",
+    "taxExempt": "Tax exempt",
+    "taxExemptHint": "When enabled, new invoices and quotes for this customer will not include tax.",
     "notesPlaceholder": "Additional notes...",
     "customerUpdated": "Customer updated",
     "customerCreated": "Customer created",

+ 1 - 1
messages/en/navigation.json

@@ -34,7 +34,7 @@
     "team": "Team",
     "invoice": "Invoice",
     "payment": "Payment",
-    "currency": "Currency",
+    "tax": "Tax",
     "workshop": "Workshop",
     "appearance": "Appearance",
     "email": "Email",

+ 6 - 0
messages/en/pdf.json

@@ -3,6 +3,7 @@
     "title": "INVOICE",
     "due": "Due: {date}",
     "billTo": "Bill To",
+    "customerTaxId": "Tax ID",
     "vehicle": "Vehicle",
     "vin": "VIN: {vin}",
     "plate": "Plate: {plate}",
@@ -27,9 +28,11 @@
     "rate": "Rate",
     "ratePerHour": "{rate}/hr",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discountPercent": "Discount ({percent}%)",
     "tax": "Tax ({rate}%)",
+    "taxIncluded": "Includes tax ({rate}%)",
     "paymentsReceived": "Payments Received",
     "paidInFull": "PAID IN FULL",
     "amountDue": "Amount Due",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "QUOTE",
     "to": "To",
+    "customerTaxId": "Tax ID",
     "vehicle": "Vehicle",
     "vin": "VIN: {vin}",
     "plate": "Plate: {plate}",
@@ -77,9 +81,11 @@
     "rate": "Rate",
     "ratePerHour": "{rate}/hr",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discountPercent": "Discount ({percent}%)",
     "tax": "Tax ({rate}%)",
+    "taxIncluded": "Includes tax ({rate}%)",
     "validUntil": "Valid until: {date}",
     "attachedDocuments": "Attached Documents",
     "attached": "{name} (attached)",

+ 4 - 1
messages/en/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Parts",
     "labor": "Labor",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discountNone": "None",
     "discountPercentage": "Percentage",
     "discountFixed": "Fixed",
     "tax": "Tax",
-    "total": "Total"
+    "taxIncluded": "Tax (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown."
   },
   "shareDialog": {
     "title": "Share Quote",

+ 7 - 2
messages/en/service.json

@@ -117,7 +117,7 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours \u00d7 rate/hr). Click to switch.",
+    "switchToHourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
     "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch."
   },
   "totals": {
@@ -125,12 +125,15 @@
     "parts": "Parts",
     "labor": "Labor",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discountNone": "None",
     "discountPercentage": "Percentage",
     "discountFixed": "Fixed",
     "tax": "Tax",
-    "total": "Total"
+    "taxIncluded": "Tax (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown."
   },
   "notes": {
     "title": "Notes",
@@ -157,8 +160,10 @@
     "parts": "Parts",
     "labor": "Labor",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "tax": "Tax ({rate}%)",
+    "taxIncluded": "Tax (incl., {rate}%)",
     "total": "Total",
     "paid": "Paid",
     "balanceDue": "Balance Due",

+ 45 - 0
messages/en/settings.json

@@ -38,6 +38,10 @@
         "title": "Payment",
         "description": "Payment terms & accounts"
       },
+      "tax": {
+        "title": "Tax",
+        "description": "Tax rate & defaults"
+      },
       "currency": {
         "title": "Currency",
         "description": "Currency & tax defaults"
@@ -421,6 +425,7 @@
       "customer_address": "Customer Address",
       "customer_email": "Customer Email",
       "customer_phone": "Customer Phone",
+      "customer_tax_id": "Customer Tax ID",
       "vehicle_name": "Vehicle",
       "vin": "VIN",
       "license_plate": "License Plate",
@@ -573,6 +578,37 @@
     "savePayment": "Save Payment Settings",
     "saved": "Payment settings saved"
   },
+  "tax": {
+    "title": "Tax",
+    "description": "Configure the tax rate applied to invoices, quotes, and work orders.",
+    "modeLabel": "Tax mode",
+    "modeHint": "Choose whether prices on line items are entered with tax already included (inclusive) or before tax (exclusive). Existing records keep the mode they were created with.",
+    "modeExclusive": "Exclusive",
+    "modeInclusive": "Inclusive",
+    "modeExclusiveExample": "Example: $100 + {rate}% tax = $100 + tax on top.",
+    "modeInclusiveExample": "Example: $100 entered already includes {rate}% tax — the tax portion is reverse-calculated.",
+    "labelLabel": "Tax label",
+    "labelHint": "Custom name shown on invoices and quotes (e.g. VAT, GST, MVA, MwSt.). Leave blank to use the localized default.",
+    "labelPlaceholder": "VAT",
+    "convertInclusiveLabel": "Convert existing records to inclusive mode",
+    "convertInclusiveHint": "Scales every line item price upward by the tax rate so prices include tax. Customer-facing totals stay the same. Eligible: {serviceRecords} work orders, {quotes} quotes.",
+    "convertInclusiveButton": "Convert to inclusive",
+    "convertInclusiveConfirmTitle": "Convert existing records to inclusive mode?",
+    "convertInclusiveConfirmDescription": "This will rewrite line item prices on {serviceRecords} work orders and {quotes} quotes so they include tax. Customer-facing totals will not change, but the printed prices on past invoices will appear higher. This cannot be undone.",
+    "convertInclusiveConfirmLabel": "Convert records",
+    "convertInclusiveSuccess": "Converted {serviceRecords} work orders and {quotes} quotes to inclusive mode.",
+    "convertInclusiveFailed": "Failed to convert records to inclusive mode",
+    "convertInclusiveNoRecords": "No exclusive-mode records to convert.",
+    "convertExclusiveLabel": "Convert existing records to exclusive mode",
+    "convertExclusiveHint": "Scales every line item price downward by the tax rate so prices show pre-tax amounts. Customer-facing totals stay the same. Eligible: {serviceRecords} work orders, {quotes} quotes.",
+    "convertExclusiveButton": "Convert to exclusive",
+    "convertExclusiveConfirmTitle": "Convert existing records to exclusive mode?",
+    "convertExclusiveConfirmDescription": "This will rewrite line item prices on {serviceRecords} work orders and {quotes} quotes so they show pre-tax amounts. Customer-facing totals will not change, but the printed prices on past invoices will appear lower. This cannot be undone.",
+    "convertExclusiveConfirmLabel": "Convert records",
+    "convertExclusiveSuccess": "Converted {serviceRecords} work orders and {quotes} quotes to exclusive mode.",
+    "convertExclusiveFailed": "Failed to convert records to exclusive mode",
+    "convertExclusiveNoRecords": "No inclusive-mode records to convert."
+  },
   "currency": {
     "title": "Currency & Tax",
     "description": "Default currency and tax rate used across invoices and service records.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Show tax calculation on invoices and service records",
     "defaultTaxRate": "Default Tax Rate (%)",
     "defaultTaxRateHint": "Auto-populated when creating new service records",
+    "applyTaxToExistingLabel": "Apply tax to existing records",
+    "applyTaxToExistingHint": "Applies the default tax rate to existing work orders and quotes that don't have a tax rate set. Eligible: {serviceRecords} work orders, {quotes} quotes.",
+    "applyTaxToExistingButton": "Apply tax to existing records",
+    "applyTaxConfirmTitle": "Apply tax to existing records?",
+    "applyTaxConfirmDescription": "This will set the tax rate to {rate}% on {serviceRecords} work orders and {quotes} quotes that currently have no tax rate, and recalculate their totals. Records with a custom tax rate will not be changed. This cannot be undone.",
+    "applyTaxConfirmLabel": "Apply tax",
+    "applyTaxSuccess": "Updated {serviceRecords} work orders and {quotes} quotes.",
+    "applyTaxFailed": "Failed to apply tax to existing records",
+    "applyTaxNoRecords": "No records need the default tax rate applied.",
     "saveSettings": "Save Settings",
     "saved": "Settings saved",
     "currencies": {

+ 6 - 0
messages/en/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Failed to start checkout.",
     "errorPaymentFailed": "Failed to initiate payment. Please try again.",
     "billTo": "Bill To",
+    "taxId": "Tax ID",
     "vehicle": "Vehicle",
     "vin": "VIN: {vin}",
     "plate": "Plate: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Rate",
     "ratePerHour": "{rate}/hr",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discountPercent": "Discount ({percent}%)",
     "tax": "Tax ({rate}%)",
+    "taxIncluded": "Includes tax ({rate}%)",
     "paid": "Paid",
     "paidInFull": "PAID IN FULL",
     "amountDue": "Amount Due",
@@ -68,6 +71,7 @@
     "title": "Quote",
     "downloadPdf": "Download PDF",
     "preparedFor": "Prepared For",
+    "taxId": "Tax ID",
     "vehicle": "Vehicle",
     "vin": "VIN: {vin}",
     "plate": "Plate: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Rate",
     "ratePerHour": "{rate}/hr",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discountPercent": "Discount ({percent}%)",
     "tax": "Tax ({rate}%)",
+    "taxIncluded": "Includes tax ({rate}%)",
     "images": "Images ({count})",
     "documents": "Documents",
     "requestChanges": "Request Changes",

+ 7 - 0
messages/es/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Cliente no encontrado",
     "emailCopied": "Correo electrónico copiado al portapapeles",
     "phoneCopied": "Número de teléfono copiado al portapapeles",
+    "taxIdLabel": "NIF",
+    "taxExemptBadge": "Exento de impuestos",
     "tabs": {
       "vehicles": "Vehículos ({count})",
       "messages": "Mensajes",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "(55) 1234-5678",
     "companyPlaceholder": "Nombre de la empresa",
     "addressPlaceholder": "Calle Principal 123, Ciudad, Estado",
+    "taxId": "NIF / CIF",
+    "taxIdPlaceholder": "p. ej. ESB12345678",
+    "taxIdHint": "Se muestra en facturas y presupuestos cuando este cliente es el comprador.",
+    "taxExempt": "Exento de impuestos",
+    "taxExemptHint": "Cuando está activado, las nuevas facturas y presupuestos para este cliente no incluirán impuesto.",
     "notesPlaceholder": "Notas adicionales...",
     "customerUpdated": "Cliente actualizado",
     "customerCreated": "Cliente creado",

+ 1 - 1
messages/es/navigation.json

@@ -34,7 +34,7 @@
     "team": "Equipo",
     "invoice": "Factura",
     "payment": "Pago",
-    "currency": "Moneda",
+    "tax": "Impuesto",
     "workshop": "Taller",
     "appearance": "Apariencia",
     "email": "Correo electrónico",

+ 6 - 0
messages/es/pdf.json

@@ -3,6 +3,7 @@
     "title": "FACTURA",
     "due": "Vence: {date}",
     "billTo": "Facturar a",
+    "customerTaxId": "NIF",
     "vehicle": "Vehículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Tarifa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discountPercent": "Descuento ({percent}%)",
     "tax": "Impuesto ({rate}%)",
+    "taxIncluded": "Con impuesto ({rate}%)",
     "paymentsReceived": "Pagos recibidos",
     "paidInFull": "PAGADO EN SU TOTALIDAD",
     "amountDue": "Monto adeudado",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "PRESUPUESTO",
     "to": "Para",
+    "customerTaxId": "NIF",
     "vehicle": "Vehículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Tarifa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discountPercent": "Descuento ({percent}%)",
     "tax": "Impuesto ({rate}%)",
+    "taxIncluded": "Con impuesto ({rate}%)",
     "validUntil": "Válido hasta: {date}",
     "attachedDocuments": "Documentos adjuntos",
     "attached": "{name} (adjunto)",

+ 4 - 1
messages/es/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Repuestos",
     "labor": "Mano de obra",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discountNone": "Ninguno",
     "discountPercentage": "Porcentaje",
     "discountFixed": "Fijo",
     "tax": "Impuesto",
-    "total": "Total"
+    "taxIncluded": "Impuesto (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto."
   },
   "shareDialog": {
     "title": "Compartir presupuesto",

+ 6 - 1
messages/es/service.json

@@ -125,12 +125,15 @@
     "parts": "Piezas",
     "labor": "Mano de Obra",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discountNone": "Ninguno",
     "discountPercentage": "Porcentaje",
     "discountFixed": "Fijo",
     "tax": "Impuesto",
-    "total": "Total"
+    "taxIncluded": "Impuesto (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto."
   },
   "notes": {
     "title": "Notas",
@@ -157,8 +160,10 @@
     "parts": "Piezas",
     "labor": "Mano de Obra",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "tax": "Impuesto ({rate}%)",
+    "taxIncluded": "Impuesto (incl., {rate}%)",
     "total": "Total",
     "paid": "Pagado",
     "balanceDue": "Saldo Pendiente",

+ 45 - 0
messages/es/settings.json

@@ -38,6 +38,10 @@
         "title": "Pago",
         "description": "Condiciones de pago y cuentas"
       },
+      "tax": {
+        "title": "Impuesto",
+        "description": "Tasa de impuesto y predeterminados"
+      },
       "currency": {
         "title": "Moneda",
         "description": "Valores predeterminados de moneda e impuestos"
@@ -421,6 +425,7 @@
       "customer_address": "Dirección",
       "customer_email": "Correo electrónico",
       "customer_phone": "Teléfono",
+      "customer_tax_id": "NIF / CIF del cliente",
       "vehicle_name": "Vehículo",
       "vin": "VIN",
       "license_plate": "Matrícula",
@@ -573,6 +578,37 @@
     "savePayment": "Guardar configuracion de pago",
     "saved": "Configuracion de pago guardada"
   },
+  "tax": {
+    "title": "Impuesto",
+    "description": "Configure la tasa de impuesto aplicada a facturas, presupuestos y ordenes de trabajo.",
+    "modeLabel": "Modo de impuesto",
+    "modeHint": "Elija si los precios de las lineas se introducen con impuesto incluido (inclusivo) o antes de impuestos (exclusivo). Los registros existentes mantienen el modo con el que fueron creados.",
+    "modeExclusive": "Exclusivo",
+    "modeInclusive": "Inclusivo",
+    "modeExclusiveExample": "Ejemplo: 100 + {rate}% impuesto = 100 + impuesto encima.",
+    "modeInclusiveExample": "Ejemplo: 100 ya incluye {rate}% impuesto — la parte del impuesto se calcula al inverso.",
+    "labelLabel": "Etiqueta de impuesto",
+    "labelHint": "Nombre personalizado que se muestra en facturas y presupuestos (p. ej. IVA, GST). Dejar vacío para usar el predeterminado localizado.",
+    "labelPlaceholder": "IVA",
+    "convertInclusiveLabel": "Convertir registros existentes a modo inclusivo",
+    "convertInclusiveHint": "Escala cada precio de línea hacia arriba por la tasa de impuesto para que los precios incluyan impuesto. Los totales para el cliente no cambian. Elegibles: {serviceRecords} órdenes de trabajo, {quotes} presupuestos.",
+    "convertInclusiveButton": "Convertir a inclusivo",
+    "convertInclusiveConfirmTitle": "¿Convertir registros existentes a modo inclusivo?",
+    "convertInclusiveConfirmDescription": "Esto reescribirá los precios de línea de {serviceRecords} órdenes de trabajo y {quotes} presupuestos para que incluyan impuesto. Los totales para el cliente no cambiarán, pero los precios impresos en facturas anteriores aparecerán más altos. Esto no se puede deshacer.",
+    "convertInclusiveConfirmLabel": "Convertir registros",
+    "convertInclusiveSuccess": "Se convirtieron {serviceRecords} órdenes de trabajo y {quotes} presupuestos a modo inclusivo.",
+    "convertInclusiveFailed": "No se pudo convertir los registros a modo inclusivo",
+    "convertInclusiveNoRecords": "No hay registros en modo exclusivo para convertir.",
+    "convertExclusiveLabel": "Convertir registros existentes a modo exclusivo",
+    "convertExclusiveHint": "Escala cada precio de línea hacia abajo por la tasa de impuesto para que los precios muestren importes sin impuesto. Los totales para el cliente no cambian. Elegibles: {serviceRecords} órdenes de trabajo, {quotes} presupuestos.",
+    "convertExclusiveButton": "Convertir a exclusivo",
+    "convertExclusiveConfirmTitle": "¿Convertir registros existentes a modo exclusivo?",
+    "convertExclusiveConfirmDescription": "Esto reescribirá los precios de línea de {serviceRecords} órdenes de trabajo y {quotes} presupuestos para que muestren importes sin impuesto. Los totales para el cliente no cambiarán, pero los precios impresos en facturas anteriores aparecerán más bajos. Esto no se puede deshacer.",
+    "convertExclusiveConfirmLabel": "Convertir registros",
+    "convertExclusiveSuccess": "Se convirtieron {serviceRecords} órdenes de trabajo y {quotes} presupuestos a modo exclusivo.",
+    "convertExclusiveFailed": "No se pudo convertir los registros a modo exclusivo",
+    "convertExclusiveNoRecords": "No hay registros en modo inclusivo para convertir."
+  },
   "currency": {
     "title": "Moneda e impuestos",
     "description": "Moneda predeterminada y tasa de impuestos para facturas y registros de servicio.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Mostrar calculo de impuestos en facturas y registros de servicio",
     "defaultTaxRate": "Tasa de impuesto predeterminada (%)",
     "defaultTaxRateHint": "Se completa automaticamente al crear nuevos registros de servicio",
+    "applyTaxToExistingLabel": "Aplicar impuesto a registros existentes",
+    "applyTaxToExistingHint": "Aplica la tasa de impuesto predeterminada a ordenes de trabajo y presupuestos existentes que no tienen tasa configurada. Elegibles: {serviceRecords} ordenes de trabajo, {quotes} presupuestos.",
+    "applyTaxToExistingButton": "Aplicar impuesto a registros existentes",
+    "applyTaxConfirmTitle": "¿Aplicar impuesto a registros existentes?",
+    "applyTaxConfirmDescription": "Esto establecera la tasa de impuesto en {rate}% en {serviceRecords} ordenes de trabajo y {quotes} presupuestos que actualmente no tienen tasa, y recalculara sus totales. Los registros con una tasa personalizada no se modificaran. Esta accion no se puede deshacer.",
+    "applyTaxConfirmLabel": "Aplicar impuesto",
+    "applyTaxSuccess": "Se actualizaron {serviceRecords} ordenes de trabajo y {quotes} presupuestos.",
+    "applyTaxFailed": "No se pudo aplicar el impuesto a los registros existentes",
+    "applyTaxNoRecords": "Ningun registro necesita la tasa de impuesto predeterminada.",
     "saveSettings": "Guardar configuracion",
     "saved": "Configuracion guardada",
     "currencies": {

+ 6 - 0
messages/es/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Error al iniciar el proceso de pago.",
     "errorPaymentFailed": "Error al iniciar el pago. Por favor intente nuevamente.",
     "billTo": "Facturar a",
+    "taxId": "NIF",
     "vehicle": "Vehículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Tarifa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discountPercent": "Descuento ({percent}%)",
     "tax": "Impuesto ({rate}%)",
+    "taxIncluded": "Con impuesto ({rate}%)",
     "paid": "Pagado",
     "paidInFull": "PAGADO EN SU TOTALIDAD",
     "amountDue": "Monto adeudado",
@@ -68,6 +71,7 @@
     "title": "Presupuesto",
     "downloadPdf": "Descargar PDF",
     "preparedFor": "Preparado para",
+    "taxId": "NIF",
     "vehicle": "Vehículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Tarifa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discountPercent": "Descuento ({percent}%)",
     "tax": "Impuesto ({rate}%)",
+    "taxIncluded": "Con impuesto ({rate}%)",
     "images": "Imágenes ({count})",
     "documents": "Documentos",
     "requestChanges": "Solicitar cambios",

+ 7 - 0
messages/fr/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Client introuvable",
     "emailCopied": "E-mail copié dans le presse-papiers",
     "phoneCopied": "Numéro de téléphone copié dans le presse-papiers",
+    "taxIdLabel": "N° TVA",
+    "taxExemptBadge": "Exonéré de taxe",
     "tabs": {
       "vehicles": "Véhicules ({count})",
       "messages": "Messages",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "01 23 45 67 89",
     "companyPlaceholder": "Nom de l'entreprise",
     "addressPlaceholder": "12 rue de la Paix, 75001 Paris",
+    "taxId": "Numéro TVA",
+    "taxIdPlaceholder": "p. ex. FR12345678901",
+    "taxIdHint": "Affiché sur les factures et devis lorsque ce client est l acheteur.",
+    "taxExempt": "Exonéré de taxe",
+    "taxExemptHint": "Lorsque activé, les nouvelles factures et devis pour ce client n incluront pas de taxe.",
     "notesPlaceholder": "Notes supplémentaires...",
     "customerUpdated": "Client mis à jour",
     "customerCreated": "Client créé",

+ 1 - 1
messages/fr/navigation.json

@@ -34,7 +34,7 @@
     "team": "Équipe",
     "invoice": "Facture",
     "payment": "Paiement",
-    "currency": "Devise",
+    "tax": "Taxe",
     "workshop": "Atelier",
     "appearance": "Apparence",
     "email": "E-mail",

+ 6 - 0
messages/fr/pdf.json

@@ -3,6 +3,7 @@
     "title": "FACTURE",
     "due": "Échéance : {date}",
     "billTo": "Facturer à",
+    "customerTaxId": "N° TVA",
     "vehicle": "Véhicule",
     "vin": "VIN : {vin}",
     "plate": "Plaque : {plate}",
@@ -26,9 +27,11 @@
     "rate": "Taux",
     "ratePerHour": "{rate}/h",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discountPercent": "Remise ({percent}%)",
     "tax": "Taxe ({rate}%)",
+    "taxIncluded": "TTC ({rate}%)",
     "paymentsReceived": "Paiements reçus",
     "paidInFull": "ENTIÈREMENT PAYÉ",
     "amountDue": "Montant dû",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "DEVIS",
     "to": "À",
+    "customerTaxId": "N° TVA",
     "vehicle": "Véhicule",
     "vin": "VIN : {vin}",
     "plate": "Plaque : {plate}",
@@ -76,9 +80,11 @@
     "rate": "Taux",
     "ratePerHour": "{rate}/h",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discountPercent": "Remise ({percent}%)",
     "tax": "Taxe ({rate}%)",
+    "taxIncluded": "TTC ({rate}%)",
     "validUntil": "Valable jusqu'au : {date}",
     "attachedDocuments": "Documents joints",
     "attached": "{name} (joint)",

+ 4 - 1
messages/fr/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Pièces",
     "labor": "Main-d'œuvre",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discountNone": "Aucune",
     "discountPercentage": "Pourcentage",
     "discountFixed": "Fixe",
     "tax": "Taxe",
-    "total": "Total"
+    "taxIncluded": "Taxe (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe."
   },
   "shareDialog": {
     "title": "Partager le devis",

+ 6 - 1
messages/fr/service.json

@@ -125,12 +125,15 @@
     "parts": "Pièces",
     "labor": "Main-d'œuvre",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discountNone": "Aucune",
     "discountPercentage": "Pourcentage",
     "discountFixed": "Fixe",
     "tax": "Taxe",
-    "total": "Total"
+    "taxIncluded": "Taxe (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe."
   },
   "notes": {
     "title": "Notes",
@@ -157,8 +160,10 @@
     "parts": "Pièces",
     "labor": "Main-d'œuvre",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "tax": "Taxe ({rate}%)",
+    "taxIncluded": "Taxe (incl., {rate}%)",
     "total": "Total",
     "paid": "Payé",
     "balanceDue": "Solde Dû",

+ 45 - 0
messages/fr/settings.json

@@ -38,6 +38,10 @@
         "title": "Paiement",
         "description": "Conditions de paiement et comptes"
       },
+      "tax": {
+        "title": "Taxe",
+        "description": "Taux de taxe et valeurs par defaut"
+      },
       "currency": {
         "title": "Devise",
         "description": "Devise et taxes par defaut"
@@ -421,6 +425,7 @@
       "customer_address": "Adresse",
       "customer_email": "E-mail",
       "customer_phone": "Téléphone",
+      "customer_tax_id": "Numéro TVA client",
       "vehicle_name": "Véhicule",
       "vin": "VIN",
       "license_plate": "Plaque d'immatriculation",
@@ -573,6 +578,37 @@
     "savePayment": "Enregistrer les parametres de paiement",
     "saved": "Parametres de paiement enregistres"
   },
+  "tax": {
+    "title": "Taxe",
+    "description": "Configurez le taux de taxe applique aux factures, devis et bons de travail.",
+    "modeLabel": "Mode de taxe",
+    "modeHint": "Choisissez si les prix des lignes sont saisis avec la taxe deja incluse (inclusif) ou hors taxe (exclusif). Les enregistrements existants conservent le mode avec lequel ils ont ete crees.",
+    "modeExclusive": "Exclusif",
+    "modeInclusive": "Inclusif",
+    "modeExclusiveExample": "Exemple : 100 + {rate}% de taxe = 100 + taxe en plus.",
+    "modeInclusiveExample": "Exemple : 100 inclut deja {rate}% de taxe — la part de taxe est calculee en sens inverse.",
+    "labelLabel": "Libellé de taxe",
+    "labelHint": "Nom personnalisé affiché sur les factures et devis (p. ex. TVA, GST). Laisser vide pour utiliser le libellé localisé par défaut.",
+    "labelPlaceholder": "TVA",
+    "convertInclusiveLabel": "Convertir les enregistrements existants en mode inclusif",
+    "convertInclusiveHint": "Augmente chaque prix de ligne par le taux de taxe afin que les prix incluent la taxe. Les totaux côté client restent identiques. Éligibles : {serviceRecords} bons de travail, {quotes} devis.",
+    "convertInclusiveButton": "Convertir en inclusif",
+    "convertInclusiveConfirmTitle": "Convertir les enregistrements existants en mode inclusif ?",
+    "convertInclusiveConfirmDescription": "Cela réécrira les prix de ligne de {serviceRecords} bons de travail et {quotes} devis pour quils incluent la taxe. Les totaux côté client ne changeront pas, mais les prix imprimés sur les factures passées apparaîtront plus élevés. Cette action est irréversible.",
+    "convertInclusiveConfirmLabel": "Convertir les enregistrements",
+    "convertInclusiveSuccess": "{serviceRecords} bons de travail et {quotes} devis convertis en mode inclusif.",
+    "convertInclusiveFailed": "Impossible de convertir les enregistrements en mode inclusif",
+    "convertInclusiveNoRecords": "Aucun enregistrement en mode exclusif à convertir.",
+    "convertExclusiveLabel": "Convertir les enregistrements existants en mode exclusif",
+    "convertExclusiveHint": "Diminue chaque prix de ligne par le taux de taxe afin que les prix affichent les montants hors taxe. Les totaux côté client restent identiques. Éligibles : {serviceRecords} bons de travail, {quotes} devis.",
+    "convertExclusiveButton": "Convertir en exclusif",
+    "convertExclusiveConfirmTitle": "Convertir les enregistrements existants en mode exclusif ?",
+    "convertExclusiveConfirmDescription": "Cela réécrira les prix de ligne de {serviceRecords} bons de travail et {quotes} devis pour quils affichent les montants hors taxe. Les totaux côté client ne changeront pas, mais les prix imprimés sur les factures passées apparaîtront plus bas. Cette action est irréversible.",
+    "convertExclusiveConfirmLabel": "Convertir les enregistrements",
+    "convertExclusiveSuccess": "{serviceRecords} bons de travail et {quotes} devis convertis en mode exclusif.",
+    "convertExclusiveFailed": "Impossible de convertir les enregistrements en mode exclusif",
+    "convertExclusiveNoRecords": "Aucun enregistrement en mode inclusif à convertir."
+  },
   "currency": {
     "title": "Devise et taxes",
     "description": "Devise par defaut et taux d'imposition pour les factures et les dossiers de service.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Afficher le calcul des taxes sur les factures et les dossiers de service",
     "defaultTaxRate": "Taux d'imposition par defaut (%)",
     "defaultTaxRateHint": "Rempli automatiquement lors de la creation de nouveaux dossiers de service",
+    "applyTaxToExistingLabel": "Appliquer la taxe aux enregistrements existants",
+    "applyTaxToExistingHint": "Applique le taux de taxe par defaut aux bons de travail et devis existants sans taux de taxe. Eligibles : {serviceRecords} bons de travail, {quotes} devis.",
+    "applyTaxToExistingButton": "Appliquer la taxe aux enregistrements existants",
+    "applyTaxConfirmTitle": "Appliquer la taxe aux enregistrements existants ?",
+    "applyTaxConfirmDescription": "Cela definira le taux de taxe a {rate}% sur {serviceRecords} bons de travail et {quotes} devis qui n'ont actuellement aucun taux de taxe, et recalculera leurs totaux. Les enregistrements avec un taux personnalise ne seront pas modifies. Cette action est irreversible.",
+    "applyTaxConfirmLabel": "Appliquer la taxe",
+    "applyTaxSuccess": "{serviceRecords} bons de travail et {quotes} devis mis a jour.",
+    "applyTaxFailed": "Impossible d'appliquer la taxe aux enregistrements existants",
+    "applyTaxNoRecords": "Aucun enregistrement ne necessite le taux de taxe par defaut.",
     "saveSettings": "Enregistrer les parametres",
     "saved": "Parametres enregistres",
     "currencies": {

+ 6 - 0
messages/fr/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Échec du démarrage du paiement.",
     "errorPaymentFailed": "Échec de l'initiation du paiement. Veuillez réessayer.",
     "billTo": "Facturer à",
+    "taxId": "N° TVA",
     "vehicle": "Véhicule",
     "vin": "VIN : {vin}",
     "plate": "Plaque : {plate}",
@@ -39,9 +40,11 @@
     "rate": "Taux",
     "ratePerHour": "{rate}/h",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discountPercent": "Remise ({percent}%)",
     "tax": "Taxe ({rate}%)",
+    "taxIncluded": "TTC ({rate}%)",
     "paid": "Payé",
     "paidInFull": "ENTIÈREMENT PAYÉ",
     "amountDue": "Montant dû",
@@ -68,6 +71,7 @@
     "title": "Devis",
     "downloadPdf": "Télécharger le PDF",
     "preparedFor": "Préparé pour",
+    "taxId": "N° TVA",
     "vehicle": "Véhicule",
     "vin": "VIN : {vin}",
     "plate": "Plaque : {plate}",
@@ -88,9 +92,11 @@
     "rate": "Taux",
     "ratePerHour": "{rate}/h",
     "subtotal": "Sous-total",
+    "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discountPercent": "Remise ({percent}%)",
     "tax": "Taxe ({rate}%)",
+    "taxIncluded": "TTC ({rate}%)",
     "images": "Images ({count})",
     "documents": "Documents",
     "requestChanges": "Demander des modifications",

+ 7 - 0
messages/it/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Cliente non trovato",
     "emailCopied": "Email copiata negli appunti",
     "phoneCopied": "Numero di telefono copiato negli appunti",
+    "taxIdLabel": "P. IVA",
+    "taxExemptBadge": "Esente da imposta",
     "tabs": {
       "vehicles": "Veicoli ({count})",
       "messages": "Messaggi",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "333 1234567",
     "companyPlaceholder": "Nome azienda",
     "addressPlaceholder": "Via Roma 1, 00100 Roma",
+    "taxId": "Partita IVA / CF",
+    "taxIdPlaceholder": "es. IT12345678901",
+    "taxIdHint": "Mostrato su fatture e preventivi quando questo cliente è l acquirente.",
+    "taxExempt": "Esente da imposta",
+    "taxExemptHint": "Quando abilitato, le nuove fatture e preventivi per questo cliente non includeranno l imposta.",
     "notesPlaceholder": "Note aggiuntive...",
     "customerUpdated": "Cliente aggiornato",
     "customerCreated": "Cliente creato",

+ 1 - 1
messages/it/navigation.json

@@ -34,7 +34,7 @@
     "team": "Team",
     "invoice": "Fattura",
     "payment": "Pagamento",
-    "currency": "Valuta",
+    "tax": "Imposta",
     "workshop": "Officina",
     "appearance": "Aspetto",
     "email": "Email",

+ 6 - 0
messages/it/pdf.json

@@ -3,6 +3,7 @@
     "title": "FATTURA",
     "due": "Scadenza: {date}",
     "billTo": "Fatturare a",
+    "customerTaxId": "P. IVA",
     "vehicle": "Veicolo",
     "vin": "VIN: {vin}",
     "plate": "Targa: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Tariffa",
     "ratePerHour": "{rate}/ora",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (IVA inclusa)",
     "discount": "Sconto",
     "discountPercent": "Sconto ({percent}%)",
     "tax": "IVA ({rate}%)",
+    "taxIncluded": "IVA inclusa ({rate}%)",
     "paymentsReceived": "Pagamenti Ricevuti",
     "paidInFull": "SALDATO",
     "amountDue": "Importo Dovuto",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "PREVENTIVO",
     "to": "A",
+    "customerTaxId": "P. IVA",
     "vehicle": "Veicolo",
     "vin": "VIN: {vin}",
     "plate": "Targa: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Tariffa",
     "ratePerHour": "{rate}/ora",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (IVA inclusa)",
     "discount": "Sconto",
     "discountPercent": "Sconto ({percent}%)",
     "tax": "IVA ({rate}%)",
+    "taxIncluded": "IVA inclusa ({rate}%)",
     "validUntil": "Valido fino al: {date}",
     "attachedDocuments": "Documenti Allegati",
     "attached": "{name} (allegato)",

+ 4 - 1
messages/it/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Ricambi",
     "labor": "Manodopera",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (IVA inclusa)",
     "discount": "Sconto",
     "discountNone": "Nessuno",
     "discountPercentage": "Percentuale",
     "discountFixed": "Fisso",
     "tax": "IVA",
-    "total": "Totale"
+    "taxIncluded": "IVA (incl.)",
+    "total": "Totale",
+    "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta."
   },
   "shareDialog": {
     "title": "Condividi preventivo",

+ 6 - 1
messages/it/service.json

@@ -125,12 +125,15 @@
     "parts": "Pezzi",
     "labor": "Manodopera",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (incl. imposta)",
     "discount": "Sconto",
     "discountNone": "Nessuno",
     "discountPercentage": "Percentuale",
     "discountFixed": "Fisso",
     "tax": "Imposta",
-    "total": "Totale"
+    "taxIncluded": "Imposta (incl.)",
+    "total": "Totale",
+    "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta."
   },
   "notes": {
     "title": "Note",
@@ -157,8 +160,10 @@
     "parts": "Pezzi",
     "labor": "Manodopera",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (incl. imposta)",
     "discount": "Sconto",
     "tax": "Imposta ({rate}%)",
+    "taxIncluded": "Imposta (incl., {rate}%)",
     "total": "Totale",
     "paid": "Pagato",
     "balanceDue": "Importo Dovuto",

+ 45 - 0
messages/it/settings.json

@@ -38,6 +38,10 @@
         "title": "Pagamento",
         "description": "Termini di pagamento e conti"
       },
+      "tax": {
+        "title": "Imposta",
+        "description": "Aliquota fiscale e predefiniti"
+      },
       "currency": {
         "title": "Valuta",
         "description": "Valuta e imposte predefinite"
@@ -421,6 +425,7 @@
       "customer_address": "Indirizzo",
       "customer_email": "E-mail",
       "customer_phone": "Telefono",
+      "customer_tax_id": "P. IVA cliente",
       "vehicle_name": "Veicolo",
       "vin": "VIN",
       "license_plate": "Targa",
@@ -573,6 +578,37 @@
     "savePayment": "Salva impostazioni pagamento",
     "saved": "Impostazioni pagamento salvate"
   },
+  "tax": {
+    "title": "Imposta",
+    "description": "Configura l'aliquota fiscale applicata a fatture, preventivi e ordini di lavoro.",
+    "modeLabel": "Modalita imposta",
+    "modeHint": "Scegli se i prezzi delle voci sono inseriti con imposta gia inclusa (inclusivo) o al netto (esclusivo). I record esistenti mantengono la modalita con cui sono stati creati.",
+    "modeExclusive": "Esclusivo",
+    "modeInclusive": "Inclusivo",
+    "modeExclusiveExample": "Esempio: 100 + {rate}% imposta = 100 + imposta sopra.",
+    "modeInclusiveExample": "Esempio: 100 include gia {rate}% imposta — la parte fiscale viene calcolata a ritroso.",
+    "labelLabel": "Etichetta imposta",
+    "labelHint": "Nome personalizzato mostrato su fatture e preventivi (es. IVA, GST). Lasciare vuoto per usare il predefinito tradotto.",
+    "labelPlaceholder": "IVA",
+    "convertInclusiveLabel": "Convertire record esistenti in modalità inclusiva",
+    "convertInclusiveHint": "Aumenta ogni prezzo di riga in base alla aliquota fiscale in modo che i prezzi includano l imposta. I totali lato cliente non cambiano. Idonei: {serviceRecords} ordini di lavoro, {quotes} preventivi.",
+    "convertInclusiveButton": "Converti in inclusivo",
+    "convertInclusiveConfirmTitle": "Convertire i record esistenti in modalità inclusiva?",
+    "convertInclusiveConfirmDescription": "Questo riscriverà i prezzi di riga di {serviceRecords} ordini di lavoro e {quotes} preventivi per includere l imposta. I totali lato cliente non cambieranno, ma i prezzi stampati sulle fatture passate appariranno più alti. Questa operazione è irreversibile.",
+    "convertInclusiveConfirmLabel": "Converti i record",
+    "convertInclusiveSuccess": "Convertiti {serviceRecords} ordini di lavoro e {quotes} preventivi in modalità inclusiva.",
+    "convertInclusiveFailed": "Impossibile convertire i record in modalità inclusiva",
+    "convertInclusiveNoRecords": "Nessun record in modalità esclusiva da convertire.",
+    "convertExclusiveLabel": "Convertire record esistenti in modalità esclusiva",
+    "convertExclusiveHint": "Riduce ogni prezzo di riga in base alla aliquota fiscale in modo che i prezzi mostrino gli importi al netto. I totali lato cliente non cambiano. Idonei: {serviceRecords} ordini di lavoro, {quotes} preventivi.",
+    "convertExclusiveButton": "Converti in esclusivo",
+    "convertExclusiveConfirmTitle": "Convertire i record esistenti in modalità esclusiva?",
+    "convertExclusiveConfirmDescription": "Questo riscriverà i prezzi di riga di {serviceRecords} ordini di lavoro e {quotes} preventivi per mostrare importi al netto. I totali lato cliente non cambieranno, ma i prezzi stampati sulle fatture passate appariranno più bassi. Questa operazione è irreversibile.",
+    "convertExclusiveConfirmLabel": "Converti i record",
+    "convertExclusiveSuccess": "Convertiti {serviceRecords} ordini di lavoro e {quotes} preventivi in modalità esclusiva.",
+    "convertExclusiveFailed": "Impossibile convertire i record in modalità esclusiva",
+    "convertExclusiveNoRecords": "Nessun record in modalità inclusiva da convertire."
+  },
   "currency": {
     "title": "Valuta e imposte",
     "description": "Valuta e aliquota fiscale predefinite utilizzate in fatture e registri di servizio.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Mostra calcolo imposte su fatture e registri di servizio",
     "defaultTaxRate": "Aliquota fiscale predefinita (%)",
     "defaultTaxRateHint": "Compilata automaticamente quando si creano nuovi registri di servizio",
+    "applyTaxToExistingLabel": "Applica imposta ai record esistenti",
+    "applyTaxToExistingHint": "Applica l'aliquota fiscale predefinita agli ordini di lavoro e preventivi esistenti senza un'aliquota impostata. Idonei: {serviceRecords} ordini di lavoro, {quotes} preventivi.",
+    "applyTaxToExistingButton": "Applica imposta ai record esistenti",
+    "applyTaxConfirmTitle": "Applicare imposta ai record esistenti?",
+    "applyTaxConfirmDescription": "Questo impostera l'aliquota fiscale al {rate}% su {serviceRecords} ordini di lavoro e {quotes} preventivi che attualmente non hanno un'aliquota, e ricalcolera i totali. I record con un'aliquota personalizzata non verranno modificati. Questa operazione e irreversibile.",
+    "applyTaxConfirmLabel": "Applica imposta",
+    "applyTaxSuccess": "Aggiornati {serviceRecords} ordini di lavoro e {quotes} preventivi.",
+    "applyTaxFailed": "Impossibile applicare l'imposta ai record esistenti",
+    "applyTaxNoRecords": "Nessun record necessita dell'aliquota fiscale predefinita.",
     "saveSettings": "Salva impostazioni",
     "saved": "Impostazioni salvate",
     "currencies": {

+ 6 - 0
messages/it/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Avvio del pagamento non riuscito.",
     "errorPaymentFailed": "Avvio del pagamento non riuscito. Riprova.",
     "billTo": "Fatturare a",
+    "taxId": "P. IVA",
     "vehicle": "Veicolo",
     "vin": "VIN: {vin}",
     "plate": "Targa: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Tariffa",
     "ratePerHour": "{rate}/ora",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (IVA inclusa)",
     "discount": "Sconto",
     "discountPercent": "Sconto ({percent}%)",
     "tax": "IVA ({rate}%)",
+    "taxIncluded": "IVA inclusa ({rate}%)",
     "paid": "Pagato",
     "paidInFull": "SALDATO",
     "amountDue": "Importo Dovuto",
@@ -68,6 +71,7 @@
     "title": "Preventivo",
     "downloadPdf": "Scarica PDF",
     "preparedFor": "Preparato Per",
+    "taxId": "P. IVA",
     "vehicle": "Veicolo",
     "vin": "VIN: {vin}",
     "plate": "Targa: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Tariffa",
     "ratePerHour": "{rate}/ora",
     "subtotal": "Subtotale",
+    "subtotalInclTax": "Subtotale (IVA inclusa)",
     "discount": "Sconto",
     "discountPercent": "Sconto ({percent}%)",
     "tax": "IVA ({rate}%)",
+    "taxIncluded": "IVA inclusa ({rate}%)",
     "images": "Immagini ({count})",
     "documents": "Documenti",
     "requestChanges": "Richiedi Modifiche",

+ 7 - 0
messages/lt/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Klientas nerastas",
     "emailCopied": "El. paštas nukopijuotas į iškarpinę",
     "phoneCopied": "Telefono numeris nukopijuotas į iškarpinę",
+    "taxIdLabel": "PVM kodas",
+    "taxExemptBadge": "Atleistas nuo mokesčio",
     "tabs": {
       "vehicles": "Transporto priemonės ({count})",
       "messages": "Žinutės",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "+370 600 12345",
     "companyPlaceholder": "Įmonės pavadinimas",
     "addressPlaceholder": "Gatvės g. 1, Miestas",
+    "taxId": "PVM kodas",
+    "taxIdPlaceholder": "pvz. LT123456789",
+    "taxIdHint": "Rodomas sąskaitose ir pasiūlymuose, kai šis klientas yra pirkėjas.",
+    "taxExempt": "Atleistas nuo mokesčio",
+    "taxExemptHint": "Kai įjungta, naujose sąskaitose ir pasiūlymuose šiam klientui nebus pridėtas mokestis.",
     "notesPlaceholder": "Papildomos pastabos...",
     "customerUpdated": "Klientas atnaujintas",
     "customerCreated": "Klientas sukurtas",

+ 1 - 1
messages/lt/navigation.json

@@ -34,7 +34,7 @@
     "team": "Komanda",
     "invoice": "Sąskaita",
     "payment": "Mokėjimas",
-    "currency": "Valiuta",
+    "tax": "Mokestis",
     "workshop": "Dirbtuvės",
     "appearance": "Išvaizda",
     "email": "El. paštas",

+ 6 - 0
messages/lt/pdf.json

@@ -3,6 +3,7 @@
     "title": "SĄSKAITA FAKTŪRA",
     "due": "Mokėjimo terminas: {date}",
     "billTo": "Gavėjas",
+    "customerTaxId": "PVM kodas",
     "vehicle": "Transporto priemonė",
     "vin": "VIN: {vin}",
     "plate": "Valst. Nr.: {plate}",
@@ -27,9 +28,11 @@
     "rate": "Tarifas",
     "ratePerHour": "{rate}/val.",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discountPercent": "Nuolaida ({percent}%)",
     "tax": "Mokestis ({rate}%)",
+    "taxIncluded": "Su mokesčiu ({rate}%)",
     "paymentsReceived": "Gauti mokėjimai",
     "paidInFull": "PILNAI APMOKĖTA",
     "amountDue": "Mokėtina suma",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "PASIŪLYMAS",
     "to": "Kam",
+    "customerTaxId": "PVM kodas",
     "vehicle": "Transporto priemonė",
     "vin": "VIN: {vin}",
     "plate": "Valst. Nr.: {plate}",
@@ -77,9 +81,11 @@
     "rate": "Tarifas",
     "ratePerHour": "{rate}/val.",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discountPercent": "Nuolaida ({percent}%)",
     "tax": "Mokestis ({rate}%)",
+    "taxIncluded": "Su mokesčiu ({rate}%)",
     "validUntil": "Galioja iki: {date}",
     "attachedDocuments": "Pridėti dokumentai",
     "attached": "{name} (pridėtas)",

+ 4 - 1
messages/lt/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Dalys",
     "labor": "Darbai",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discountNone": "Nėra",
     "discountPercentage": "Procentinė",
     "discountFixed": "Fiksuota",
     "tax": "Mokestis",
-    "total": "Iš viso"
+    "taxIncluded": "Mokestis (įsk.)",
+    "total": "Iš viso",
+    "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis."
   },
   "shareDialog": {
     "title": "Dalintis pasiūlymu",

+ 6 - 1
messages/lt/service.json

@@ -125,12 +125,15 @@
     "parts": "Dalys",
     "labor": "Darbai",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discountNone": "Nėra",
     "discountPercentage": "Procentinė",
     "discountFixed": "Fiksuota",
     "tax": "Mokestis",
-    "total": "Iš viso"
+    "taxIncluded": "Mokestis (įsk.)",
+    "total": "Iš viso",
+    "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis."
   },
   "notes": {
     "title": "Pastabos",
@@ -157,8 +160,10 @@
     "parts": "Dalys",
     "labor": "Darbai",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "tax": "Mokestis ({rate}%)",
+    "taxIncluded": "Mokestis (įsk., {rate}%)",
     "total": "Iš viso",
     "paid": "Apmokėta",
     "balanceDue": "Mokėtina suma",

+ 45 - 0
messages/lt/settings.json

@@ -38,6 +38,10 @@
         "title": "Mokėjimas",
         "description": "Mokėjimo sąlygos ir sąskaitos"
       },
+      "tax": {
+        "title": "Mokestis",
+        "description": "Mokesčių tarifas ir numatytosios reikšmės"
+      },
       "currency": {
         "title": "Valiuta",
         "description": "Valiuta ir mokesčių numatytosios reikšmės"
@@ -421,6 +425,7 @@
       "customer_address": "Kliento adresas",
       "customer_email": "Kliento el. paštas",
       "customer_phone": "Kliento telefonas",
+      "customer_tax_id": "Kliento PVM kodas",
       "vehicle_name": "Transporto priemonė",
       "vin": "VIN",
       "license_plate": "Valstybinis numeris",
@@ -573,6 +578,37 @@
     "savePayment": "Išsaugoti mokėjimo nustatymus",
     "saved": "Mokėjimo nustatymai išsaugoti"
   },
+  "tax": {
+    "title": "Mokestis",
+    "description": "Konfigūruokite mokesčių tarifą, taikomą sąskaitoms, pasiūlymams ir darbo užsakymams.",
+    "modeLabel": "Mokesčių režimas",
+    "modeHint": "Pasirinkite, ar eilučių kainos įvedamos su jau įskaičiuotu mokesčiu (su mokesčiu) ar be mokesčio (be mokesčio). Esami įrašai išlaiko režimą, su kuriuo buvo sukurti.",
+    "modeExclusive": "Be mokesčio",
+    "modeInclusive": "Su mokesčiu",
+    "modeExclusiveExample": "Pavyzdys: 100 + {rate}% mokesčio = 100 + mokestis viršuje.",
+    "modeInclusiveExample": "Pavyzdys: 100 jau apima {rate}% mokesčio — mokesčio dalis apskaičiuojama atgal.",
+    "labelLabel": "Mokesčio etiketė",
+    "labelHint": "Pasirinktinis pavadinimas, rodomas sąskaitose ir pasiūlymuose (pvz. PVM, VAT). Palikite tuščią, kad būtų naudojama lokalizuota numatytoji.",
+    "labelPlaceholder": "PVM",
+    "convertInclusiveLabel": "Konvertuoti esamus įrašus į režimą su mokesčiu",
+    "convertInclusiveHint": "Padidina kiekvienos eilutės kainą mokesčių tarifu, kad kainos apimtų mokestį. Kliento pusės sumos lieka nepakitusios. Tinkami: {serviceRecords} darbo užsakymų, {quotes} pasiūlymų.",
+    "convertInclusiveButton": "Konvertuoti į režimą su mokesčiu",
+    "convertInclusiveConfirmTitle": "Konvertuoti esamus įrašus į režimą su mokesčiu?",
+    "convertInclusiveConfirmDescription": "Tai perrašys eilučių kainas {serviceRecords} darbo užsakymuose ir {quotes} pasiūlymuose, kad jose būtų įskaičiuotas mokestis. Kliento pusės sumos nesikeis, bet ankstesnėse sąskaitose atspausdintos kainos atrodys aukštesnės. Šio veiksmo atšaukti negalima.",
+    "convertInclusiveConfirmLabel": "Konvertuoti įrašus",
+    "convertInclusiveSuccess": "Konvertuota {serviceRecords} darbo užsakymų ir {quotes} pasiūlymų į režimą su mokesčiu.",
+    "convertInclusiveFailed": "Nepavyko konvertuoti įrašų į režimą su mokesčiu",
+    "convertInclusiveNoRecords": "Nėra įrašų be mokesčio režime, kuriuos būtų galima konvertuoti.",
+    "convertExclusiveLabel": "Konvertuoti esamus įrašus į režimą be mokesčio",
+    "convertExclusiveHint": "Sumažina kiekvienos eilutės kainą mokesčių tarifu, kad kainos rodytų sumas be mokesčio. Kliento pusės sumos lieka nepakitusios. Tinkami: {serviceRecords} darbo užsakymų, {quotes} pasiūlymų.",
+    "convertExclusiveButton": "Konvertuoti į režimą be mokesčio",
+    "convertExclusiveConfirmTitle": "Konvertuoti esamus įrašus į režimą be mokesčio?",
+    "convertExclusiveConfirmDescription": "Tai perrašys eilučių kainas {serviceRecords} darbo užsakymuose ir {quotes} pasiūlymuose, kad jos rodytų sumas be mokesčio. Kliento pusės sumos nesikeis, bet ankstesnėse sąskaitose atspausdintos kainos atrodys mažesnės. Šio veiksmo atšaukti negalima.",
+    "convertExclusiveConfirmLabel": "Konvertuoti įrašus",
+    "convertExclusiveSuccess": "Konvertuota {serviceRecords} darbo užsakymų ir {quotes} pasiūlymų į režimą be mokesčio.",
+    "convertExclusiveFailed": "Nepavyko konvertuoti įrašų į režimą be mokesčio",
+    "convertExclusiveNoRecords": "Nėra įrašų su mokesčio režime, kuriuos būtų galima konvertuoti."
+  },
   "currency": {
     "title": "Valiuta ir mokesčiai",
     "description": "Numatytoji valiuta ir mokesčių tarifas, naudojami sąskaitose ir paslaugų įrašuose.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Rodyti mokesčių skaičiavimą sąskaitose ir paslaugų įrašuose",
     "defaultTaxRate": "Numatytasis mokesčių tarifas (%)",
     "defaultTaxRateHint": "Automatiškai užpildoma kuriant naujus paslaugų įrašus",
+    "applyTaxToExistingLabel": "Taikyti mokestį esamiems įrašams",
+    "applyTaxToExistingHint": "Taiko numatytąjį mokesčių tarifą esamiems darbo užsakymams ir pasiūlymams, kuriems nenustatytas mokesčių tarifas. Tinkami: {serviceRecords} darbo užsakymų, {quotes} pasiūlymų.",
+    "applyTaxToExistingButton": "Taikyti mokestį esamiems įrašams",
+    "applyTaxConfirmTitle": "Taikyti mokestį esamiems įrašams?",
+    "applyTaxConfirmDescription": "Tai nustatys {rate}% mokesčių tarifą {serviceRecords} darbo užsakymams ir {quotes} pasiūlymams, kurie šiuo metu neturi mokesčių tarifo, ir perskaičiuos jų sumas. Įrašai su individualiu mokesčių tarifu nebus pakeisti. Šio veiksmo atšaukti negalima.",
+    "applyTaxConfirmLabel": "Taikyti mokestį",
+    "applyTaxSuccess": "Atnaujinta {serviceRecords} darbo užsakymų ir {quotes} pasiūlymų.",
+    "applyTaxFailed": "Nepavyko taikyti mokesčio esamiems įrašams",
+    "applyTaxNoRecords": "Jokie įrašai nereikalauja numatytojo mokesčių tarifo.",
     "saveSettings": "Išsaugoti nustatymus",
     "saved": "Nustatymai išsaugoti",
     "currencies": {

+ 6 - 0
messages/lt/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Nepavyko pradėti mokėjimo.",
     "errorPaymentFailed": "Nepavyko inicijuoti mokėjimo. Bandykite dar kartą.",
     "billTo": "Gavėjas",
+    "taxId": "PVM kodas",
     "vehicle": "Transporto priemonė",
     "vin": "VIN: {vin}",
     "plate": "Valst. Nr.: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Tarifas",
     "ratePerHour": "{rate}/val.",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discountPercent": "Nuolaida ({percent}%)",
     "tax": "Mokestis ({rate}%)",
+    "taxIncluded": "Su mokesčiu ({rate}%)",
     "paid": "Apmokėta",
     "paidInFull": "PILNAI APMOKĖTA",
     "amountDue": "Mokėtina suma",
@@ -68,6 +71,7 @@
     "title": "Pasiūlymas",
     "downloadPdf": "Atsisiųsti PDF",
     "preparedFor": "Paruošta",
+    "taxId": "PVM kodas",
     "vehicle": "Transporto priemonė",
     "vin": "VIN: {vin}",
     "plate": "Valst. Nr.: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Tarifas",
     "ratePerHour": "{rate}/val.",
     "subtotal": "Tarpinė suma",
+    "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discountPercent": "Nuolaida ({percent}%)",
     "tax": "Mokestis ({rate}%)",
+    "taxIncluded": "Su mokesčiu ({rate}%)",
     "images": "Vaizdai ({count})",
     "documents": "Dokumentai",
     "requestChanges": "Prašyti pakeitimų",

+ 7 - 0
messages/nb/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Kunden ble ikke funnet",
     "emailCopied": "E-post kopiert til utklippstavlen",
     "phoneCopied": "Telefonnummer kopiert til utklippstavlen",
+    "taxIdLabel": "Org.nr",
+    "taxExemptBadge": "Avgiftsfritatt",
     "tabs": {
       "vehicles": "Kjøretøy ({count})",
       "messages": "Meldinger",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "123 45 678",
     "companyPlaceholder": "Bedriftsnavn",
     "addressPlaceholder": "Storgata 1, 0001 Oslo",
+    "taxId": "Org.nr / MVA-nr",
+    "taxIdPlaceholder": "f.eks. NO123456789MVA",
+    "taxIdHint": "Vises på fakturaer og tilbud når denne kunden er kjøper.",
+    "taxExempt": "Avgiftsfritatt",
+    "taxExemptHint": "Når dette er på, vil nye fakturaer og tilbud for denne kunden ikke inneholde avgift.",
     "notesPlaceholder": "Ytterligere notater...",
     "customerUpdated": "Kunde oppdatert",
     "customerCreated": "Kunde opprettet",

+ 1 - 1
messages/nb/navigation.json

@@ -34,7 +34,7 @@
     "team": "Team",
     "invoice": "Faktura",
     "payment": "Betaling",
-    "currency": "Valuta",
+    "tax": "Avgift",
     "workshop": "Verksted",
     "appearance": "Utseende",
     "email": "E-post",

+ 6 - 0
messages/nb/pdf.json

@@ -3,6 +3,7 @@
     "title": "FAKTURA",
     "due": "Forfall: {date}",
     "billTo": "Fakturert til",
+    "customerTaxId": "Org.nr",
     "vehicle": "Kjøretøy",
     "vin": "VIN: {vin}",
     "plate": "Reg.nr: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Sats",
     "ratePerHour": "{rate}/t",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. mva)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "Mva. ({rate}%)",
+    "taxIncluded": "Inkluderer mva ({rate}%)",
     "paymentsReceived": "Mottatte betalinger",
     "paidInFull": "FULLT BETALT",
     "amountDue": "Beløp å betale",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "TILBUD",
     "to": "Til",
+    "customerTaxId": "Org.nr",
     "vehicle": "Kjøretøy",
     "vin": "VIN: {vin}",
     "plate": "Reg.nr: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Sats",
     "ratePerHour": "{rate}/t",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. mva)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "Mva. ({rate}%)",
+    "taxIncluded": "Inkluderer mva ({rate}%)",
     "validUntil": "Gyldig til: {date}",
     "attachedDocuments": "Vedlagte dokumenter",
     "attached": "{name} (vedlagt)",

+ 4 - 1
messages/nb/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Deler",
     "labor": "Arbeid",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. mva)",
     "discount": "Rabatt",
     "discountNone": "Ingen",
     "discountPercentage": "Prosent",
     "discountFixed": "Fast beløp",
     "tax": "Mva",
-    "total": "Totalt"
+    "taxIncluded": "Mva (inkl.)",
+    "total": "Totalt",
+    "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift."
   },
   "shareDialog": {
     "title": "Del tilbud",

+ 6 - 1
messages/nb/service.json

@@ -125,12 +125,15 @@
     "parts": "Deler",
     "labor": "Arbeid",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. avgift)",
     "discount": "Rabatt",
     "discountNone": "Ingen",
     "discountPercentage": "Prosent",
     "discountFixed": "Fast",
     "tax": "Skatt",
-    "total": "Total"
+    "taxIncluded": "Avgift (inkl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift."
   },
   "notes": {
     "title": "Notater",
@@ -157,8 +160,10 @@
     "parts": "Deler",
     "labor": "Arbeidskraft",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. mva)",
     "discount": "Rabatt",
     "tax": "Skatt ({rate}%)",
+    "taxIncluded": "Mva. inkl. ({rate}%)",
     "total": "Total",
     "paid": "Betalt",
     "balanceDue": "Skyld beløp",

+ 45 - 0
messages/nb/settings.json

@@ -38,6 +38,10 @@
         "title": "Betaling",
         "description": "Betalingsvilkar og kontoer"
       },
+      "tax": {
+        "title": "Avgift",
+        "description": "Avgiftssats og standarder"
+      },
       "currency": {
         "title": "Valuta",
         "description": "Valuta- og avgiftsstandarder"
@@ -421,6 +425,7 @@
       "customer_address": "Adresse",
       "customer_email": "E-post",
       "customer_phone": "Telefon",
+      "customer_tax_id": "Org.nr / MVA-nr",
       "vehicle_name": "Kjøretøy",
       "vin": "VIN",
       "license_plate": "Reg.nr",
@@ -573,6 +578,37 @@
     "savePayment": "Lagre betalingsinnstillinger",
     "saved": "Betalingsinnstillinger lagret"
   },
+  "tax": {
+    "title": "Avgift",
+    "description": "Konfigurer avgiftssatsen som brukes på fakturaer, tilbud og arbeidsordrer.",
+    "modeLabel": "Avgiftsmodus",
+    "modeHint": "Velg om priser på linjeelementer skrives inn med avgift inkludert (inklusiv) eller før avgift (eksklusiv). Eksisterende oppføringer beholder modusen de ble opprettet med.",
+    "modeExclusive": "Eksklusiv",
+    "modeInclusive": "Inklusiv",
+    "modeExclusiveExample": "Eksempel: 100 + {rate}% avgift = 100 + avgift på toppen.",
+    "modeInclusiveExample": "Eksempel: 100 inkluderer allerede {rate}% avgift — avgiftsdelen beregnes baklengs.",
+    "labelLabel": "Avgiftsetikett",
+    "labelHint": "Tilpasset navn vist på fakturaer og tilbud (f.eks. MVA, GST, MwSt.). La stå tom for å bruke standardverdien.",
+    "labelPlaceholder": "MVA",
+    "convertInclusiveLabel": "Konverter eksisterende oppføringer til inklusiv modus",
+    "convertInclusiveHint": "Skalerer hver linjepris opp med avgiftssatsen slik at prisene inkluderer avgift. Kundens totaler forblir uendret. Berettiget: {serviceRecords} arbeidsordrer, {quotes} tilbud.",
+    "convertInclusiveButton": "Konverter til inklusiv",
+    "convertInclusiveConfirmTitle": "Konvertere eksisterende oppføringer til inklusiv modus?",
+    "convertInclusiveConfirmDescription": "Dette vil omskrive linjeprisene på {serviceRecords} arbeidsordrer og {quotes} tilbud slik at de inkluderer avgift. Kundens totaler endres ikke, men de utskrevne prisene på tidligere fakturaer vil vises høyere. Dette kan ikke angres.",
+    "convertInclusiveConfirmLabel": "Konverter oppføringer",
+    "convertInclusiveSuccess": "Konverterte {serviceRecords} arbeidsordrer og {quotes} tilbud til inklusiv modus.",
+    "convertInclusiveFailed": "Kunne ikke konvertere oppføringer til inklusiv modus",
+    "convertInclusiveNoRecords": "Ingen oppføringer i eksklusiv modus å konvertere.",
+    "convertExclusiveLabel": "Konverter eksisterende oppføringer til eksklusiv modus",
+    "convertExclusiveHint": "Skalerer hver linjepris ned med avgiftssatsen slik at prisene viser beløp før avgift. Kundens totaler forblir uendret. Berettiget: {serviceRecords} arbeidsordrer, {quotes} tilbud.",
+    "convertExclusiveButton": "Konverter til eksklusiv",
+    "convertExclusiveConfirmTitle": "Konvertere eksisterende oppføringer til eksklusiv modus?",
+    "convertExclusiveConfirmDescription": "Dette vil omskrive linjeprisene på {serviceRecords} arbeidsordrer og {quotes} tilbud slik at de viser beløp før avgift. Kundens totaler endres ikke, men de utskrevne prisene på tidligere fakturaer vil vises lavere. Dette kan ikke angres.",
+    "convertExclusiveConfirmLabel": "Konverter oppføringer",
+    "convertExclusiveSuccess": "Konverterte {serviceRecords} arbeidsordrer og {quotes} tilbud til eksklusiv modus.",
+    "convertExclusiveFailed": "Kunne ikke konvertere oppføringer til eksklusiv modus",
+    "convertExclusiveNoRecords": "Ingen oppføringer i inklusiv modus å konvertere."
+  },
   "currency": {
     "title": "Valuta og avgift",
     "description": "Standardvaluta og avgiftssats for fakturaer og servicedata.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Vis avgiftsberegning pa fakturaer og servicedata",
     "defaultTaxRate": "Standard avgiftssats (%)",
     "defaultTaxRateHint": "Fylles ut automatisk ved opprettelse av nye servicedata",
+    "applyTaxToExistingLabel": "Bruk avgift på eksisterende poster",
+    "applyTaxToExistingHint": "Setter standard avgiftssats på eksisterende arbeidsordrer og tilbud som ikke har avgift. Gjelder: {serviceRecords} arbeidsordrer, {quotes} tilbud.",
+    "applyTaxToExistingButton": "Bruk avgift på eksisterende poster",
+    "applyTaxConfirmTitle": "Bruke avgift på eksisterende poster?",
+    "applyTaxConfirmDescription": "Dette vil sette avgiftssatsen til {rate}% på {serviceRecords} arbeidsordrer og {quotes} tilbud som ikke har avgift, og beregne summene på nytt. Poster med egen avgiftssats endres ikke. Dette kan ikke angres.",
+    "applyTaxConfirmLabel": "Bruk avgift",
+    "applyTaxSuccess": "Oppdaterte {serviceRecords} arbeidsordrer og {quotes} tilbud.",
+    "applyTaxFailed": "Kunne ikke bruke avgift på eksisterende poster",
+    "applyTaxNoRecords": "Ingen poster trenger standard avgiftssats.",
     "saveSettings": "Lagre innstillinger",
     "saved": "Innstillinger lagret",
     "currencies": {

+ 6 - 0
messages/nb/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Kunne ikke starte betaling.",
     "errorPaymentFailed": "Kunne ikke starte betaling. Vennligst prøv igjen.",
     "billTo": "Fakturert til",
+    "taxId": "Org.nr",
     "vehicle": "Kjøretøy",
     "vin": "VIN: {vin}",
     "plate": "Reg.nr: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Sats",
     "ratePerHour": "{rate}/t",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. mva)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "Mva. ({rate}%)",
+    "taxIncluded": "Inkluderer mva ({rate}%)",
     "paid": "Betalt",
     "paidInFull": "FULLT BETALT",
     "amountDue": "Beløp å betale",
@@ -68,6 +71,7 @@
     "title": "Tilbud",
     "downloadPdf": "Last ned PDF",
     "preparedFor": "Utarbeidet for",
+    "taxId": "Org.nr",
     "vehicle": "Kjøretøy",
     "vin": "VIN: {vin}",
     "plate": "Reg.nr: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Sats",
     "ratePerHour": "{rate}/t",
     "subtotal": "Delsum",
+    "subtotalInclTax": "Delsum (inkl. mva)",
     "discount": "Rabatt",
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "Mva. ({rate}%)",
+    "taxIncluded": "Inkluderer mva ({rate}%)",
     "images": "Bilder ({count})",
     "documents": "Dokumenter",
     "requestChanges": "Be om endringer",

+ 7 - 0
messages/nl/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Klant niet gevonden",
     "emailCopied": "E-mailadres gekopieerd naar klembord",
     "phoneCopied": "Telefoonnummer gekopieerd naar klembord",
+    "taxIdLabel": "Btw-nr.",
+    "taxExemptBadge": "Belastingvrijgesteld",
     "tabs": {
       "vehicles": "Voertuigen ({count})",
       "messages": "Berichten",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "06 12345678",
     "companyPlaceholder": "Bedrijfsnaam",
     "addressPlaceholder": "Hoofdstraat 1, 1000 AA Amsterdam",
+    "taxId": "Btw-nummer",
+    "taxIdPlaceholder": "bijv. NL123456789B01",
+    "taxIdHint": "Wordt weergegeven op facturen en offertes wanneer deze klant de koper is.",
+    "taxExempt": "Belastingvrijgesteld",
+    "taxExemptHint": "Wanneer ingeschakeld, bevatten nieuwe facturen en offertes voor deze klant geen belasting.",
     "notesPlaceholder": "Aanvullende opmerkingen...",
     "customerUpdated": "Klant bijgewerkt",
     "customerCreated": "Klant aangemaakt",

+ 1 - 1
messages/nl/navigation.json

@@ -34,7 +34,7 @@
     "team": "Team",
     "invoice": "Factuur",
     "payment": "Betaling",
-    "currency": "Valuta",
+    "tax": "Belasting",
     "workshop": "Werkplaats",
     "appearance": "Weergave",
     "email": "E-mail",

+ 6 - 0
messages/nl/pdf.json

@@ -3,6 +3,7 @@
     "title": "FACTUUR",
     "due": "Vervaldatum: {date}",
     "billTo": "Factuuradres",
+    "customerTaxId": "Btw-nr.",
     "vehicle": "Voertuig",
     "vin": "VIN: {vin}",
     "plate": "Kenteken: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Tarief",
     "ratePerHour": "{rate}/uur",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discountPercent": "Korting ({percent}%)",
     "tax": "BTW ({rate}%)",
+    "taxIncluded": "Incl. btw ({rate}%)",
     "paymentsReceived": "Ontvangen betalingen",
     "paidInFull": "VOLLEDIG BETAALD",
     "amountDue": "Te betalen bedrag",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "OFFERTE",
     "to": "Aan",
+    "customerTaxId": "Btw-nr.",
     "vehicle": "Voertuig",
     "vin": "VIN: {vin}",
     "plate": "Kenteken: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Tarief",
     "ratePerHour": "{rate}/uur",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discountPercent": "Korting ({percent}%)",
     "tax": "BTW ({rate}%)",
+    "taxIncluded": "Incl. btw ({rate}%)",
     "validUntil": "Geldig tot: {date}",
     "attachedDocuments": "Bijgevoegde documenten",
     "attached": "{name} (bijgevoegd)",

+ 4 - 1
messages/nl/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Onderdelen",
     "labor": "Arbeid",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discountNone": "Geen",
     "discountPercentage": "Percentage",
     "discountFixed": "Vast bedrag",
     "tax": "Btw",
-    "total": "Totaal"
+    "taxIncluded": "Btw (incl.)",
+    "total": "Totaal",
+    "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting."
   },
   "shareDialog": {
     "title": "Offerte delen",

+ 6 - 1
messages/nl/service.json

@@ -125,12 +125,15 @@
     "parts": "Onderdelen",
     "labor": "Arbeidskosten",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discountNone": "Geen",
     "discountPercentage": "Percentage",
     "discountFixed": "Vast",
     "tax": "Belasting",
-    "total": "Totaal"
+    "taxIncluded": "Belasting (incl.)",
+    "total": "Totaal",
+    "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting."
   },
   "notes": {
     "title": "Opmerkingen",
@@ -157,8 +160,10 @@
     "parts": "Onderdelen",
     "labor": "Arbeidskosten",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "tax": "Belasting ({rate}%)",
+    "taxIncluded": "Btw (incl., {rate}%)",
     "total": "Totaal",
     "paid": "Betaald",
     "balanceDue": "Verschuldigd bedrag",

+ 45 - 0
messages/nl/settings.json

@@ -38,6 +38,10 @@
         "title": "Betaling",
         "description": "Betalingsvoorwaarden en rekeningen"
       },
+      "tax": {
+        "title": "Belasting",
+        "description": "Belastingtarief en standaardwaarden"
+      },
       "currency": {
         "title": "Valuta",
         "description": "Standaardvaluta en belasting"
@@ -421,6 +425,7 @@
       "customer_address": "Adres",
       "customer_email": "E-mail",
       "customer_phone": "Telefoon",
+      "customer_tax_id": "Btw-nummer klant",
       "vehicle_name": "Voertuig",
       "vin": "VIN",
       "license_plate": "Kenteken",
@@ -573,6 +578,37 @@
     "savePayment": "Betalingsinstellingen opslaan",
     "saved": "Betalingsinstellingen opgeslagen"
   },
+  "tax": {
+    "title": "Belasting",
+    "description": "Configureer het belastingtarief dat wordt toegepast op facturen, offertes en werkorders.",
+    "modeLabel": "Belastingmodus",
+    "modeHint": "Kies of prijzen op regelitems worden ingevoerd inclusief belasting (inclusief) of vóór belasting (exclusief). Bestaande records behouden de modus waarmee ze zijn aangemaakt.",
+    "modeExclusive": "Exclusief",
+    "modeInclusive": "Inclusief",
+    "modeExclusiveExample": "Voorbeeld: 100 + {rate}% belasting = 100 + belasting erbovenop.",
+    "modeInclusiveExample": "Voorbeeld: 100 bevat al {rate}% belasting — het belastingdeel wordt teruggerekend.",
+    "labelLabel": "Belastinglabel",
+    "labelHint": "Aangepaste naam getoond op facturen en offertes (bijv. Btw, VAT). Leeg laten voor de gelokaliseerde standaardwaarde.",
+    "labelPlaceholder": "Btw",
+    "convertInclusiveLabel": "Bestaande records converteren naar inclusieve modus",
+    "convertInclusiveHint": "Schaalt elke regelprijs omhoog met het belastingtarief zodat prijzen belasting bevatten. Klantgerichte totalen blijven hetzelfde. In aanmerking: {serviceRecords} werkorders, {quotes} offertes.",
+    "convertInclusiveButton": "Converteer naar inclusief",
+    "convertInclusiveConfirmTitle": "Bestaande records converteren naar inclusieve modus?",
+    "convertInclusiveConfirmDescription": "Dit herschrijft de regelprijzen van {serviceRecords} werkorders en {quotes} offertes zodat ze belasting bevatten. Klantgerichte totalen veranderen niet, maar gedrukte prijzen op eerdere facturen verschijnen hoger. Dit kan niet ongedaan worden gemaakt.",
+    "convertInclusiveConfirmLabel": "Records converteren",
+    "convertInclusiveSuccess": "{serviceRecords} werkorders en {quotes} offertes geconverteerd naar inclusieve modus.",
+    "convertInclusiveFailed": "Records konden niet worden geconverteerd naar inclusieve modus",
+    "convertInclusiveNoRecords": "Geen records in exclusieve modus om te converteren.",
+    "convertExclusiveLabel": "Bestaande records converteren naar exclusieve modus",
+    "convertExclusiveHint": "Schaalt elke regelprijs omlaag met het belastingtarief zodat prijzen bedragen vóór belasting tonen. Klantgerichte totalen blijven hetzelfde. In aanmerking: {serviceRecords} werkorders, {quotes} offertes.",
+    "convertExclusiveButton": "Converteer naar exclusief",
+    "convertExclusiveConfirmTitle": "Bestaande records converteren naar exclusieve modus?",
+    "convertExclusiveConfirmDescription": "Dit herschrijft de regelprijzen van {serviceRecords} werkorders en {quotes} offertes zodat ze bedragen vóór belasting tonen. Klantgerichte totalen veranderen niet, maar gedrukte prijzen op eerdere facturen verschijnen lager. Dit kan niet ongedaan worden gemaakt.",
+    "convertExclusiveConfirmLabel": "Records converteren",
+    "convertExclusiveSuccess": "{serviceRecords} werkorders en {quotes} offertes geconverteerd naar exclusieve modus.",
+    "convertExclusiveFailed": "Records konden niet worden geconverteerd naar exclusieve modus",
+    "convertExclusiveNoRecords": "Geen records in inclusieve modus om te converteren."
+  },
   "currency": {
     "title": "Valuta en belasting",
     "description": "Standaardvaluta en belastingtarief voor facturen en servicerecords.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Toon belastingberekening op facturen en servicerecords",
     "defaultTaxRate": "Standaard belastingtarief (%)",
     "defaultTaxRateHint": "Automatisch ingevuld bij het aanmaken van nieuwe servicerecords",
+    "applyTaxToExistingLabel": "Belasting toepassen op bestaande records",
+    "applyTaxToExistingHint": "Past het standaard belastingtarief toe op bestaande werkorders en offertes zonder een ingesteld tarief. In aanmerking: {serviceRecords} werkorders, {quotes} offertes.",
+    "applyTaxToExistingButton": "Belasting toepassen op bestaande records",
+    "applyTaxConfirmTitle": "Belasting toepassen op bestaande records?",
+    "applyTaxConfirmDescription": "Dit stelt het belastingtarief in op {rate}% voor {serviceRecords} werkorders en {quotes} offertes die momenteel geen tarief hebben, en herberekent hun totalen. Records met een aangepast belastingtarief worden niet gewijzigd. Dit kan niet ongedaan worden gemaakt.",
+    "applyTaxConfirmLabel": "Belasting toepassen",
+    "applyTaxSuccess": "{serviceRecords} werkorders en {quotes} offertes bijgewerkt.",
+    "applyTaxFailed": "Kon belasting niet toepassen op bestaande records",
+    "applyTaxNoRecords": "Geen records hebben het standaard belastingtarief nodig.",
     "saveSettings": "Instellingen opslaan",
     "saved": "Instellingen opgeslagen",
     "currencies": {

+ 6 - 0
messages/nl/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Afrekenen kon niet worden gestart.",
     "errorPaymentFailed": "Betaling kon niet worden geïnitieerd. Probeer het opnieuw.",
     "billTo": "Factuuradres",
+    "taxId": "Btw-nr.",
     "vehicle": "Voertuig",
     "vin": "VIN: {vin}",
     "plate": "Kenteken: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Tarief",
     "ratePerHour": "{rate}/uur",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discountPercent": "Korting ({percent}%)",
     "tax": "BTW ({rate}%)",
+    "taxIncluded": "Incl. btw ({rate}%)",
     "paid": "Betaald",
     "paidInFull": "VOLLEDIG BETAALD",
     "amountDue": "Te betalen bedrag",
@@ -68,6 +71,7 @@
     "title": "Offerte",
     "downloadPdf": "PDF downloaden",
     "preparedFor": "Opgesteld voor",
+    "taxId": "Btw-nr.",
     "vehicle": "Voertuig",
     "vin": "VIN: {vin}",
     "plate": "Kenteken: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Tarief",
     "ratePerHour": "{rate}/uur",
     "subtotal": "Subtotaal",
+    "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discountPercent": "Korting ({percent}%)",
     "tax": "BTW ({rate}%)",
+    "taxIncluded": "Incl. btw ({rate}%)",
     "images": "Afbeeldingen ({count})",
     "documents": "Documenten",
     "requestChanges": "Wijzigingen aanvragen",

+ 7 - 0
messages/pl/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Nie znaleziono klienta",
     "emailCopied": "E-mail skopiowany do schowka",
     "phoneCopied": "Numer telefonu skopiowany do schowka",
+    "taxIdLabel": "NIP",
+    "taxExemptBadge": "Zwolniony z podatku",
     "tabs": {
       "vehicles": "Pojazdy ({count})",
       "messages": "Wiadomości",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "123 456 789",
     "companyPlaceholder": "Nazwa firmy",
     "addressPlaceholder": "ul. Główna 1, 00-001 Warszawa",
+    "taxId": "NIP",
+    "taxIdPlaceholder": "np. PL1234567890",
+    "taxIdHint": "Wyświetlany na fakturach i ofertach, gdy ten klient jest nabywcą.",
+    "taxExempt": "Zwolniony z podatku",
+    "taxExemptHint": "Gdy włączone, nowe faktury i oferty dla tego klienta nie będą zawierać podatku.",
     "notesPlaceholder": "Dodatkowe uwagi...",
     "customerUpdated": "Klient zaktualizowany",
     "customerCreated": "Klient utworzony",

+ 1 - 1
messages/pl/navigation.json

@@ -34,7 +34,7 @@
     "team": "Zespół",
     "invoice": "Faktura",
     "payment": "Płatność",
-    "currency": "Waluta",
+    "tax": "Podatek",
     "workshop": "Warsztat",
     "appearance": "Wygląd",
     "email": "E-mail",

+ 6 - 0
messages/pl/pdf.json

@@ -3,6 +3,7 @@
     "title": "FAKTURA",
     "due": "Termin: {date}",
     "billTo": "Odbiorca",
+    "customerTaxId": "NIP",
     "vehicle": "Pojazd",
     "vin": "VIN: {vin}",
     "plate": "Tablica: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Stawka",
     "ratePerHour": "{rate}/godz.",
     "subtotal": "Suma częściowa",
+    "subtotalInclTax": "Suma częściowa (z podatkiem)",
     "discount": "Rabat",
     "discountPercent": "Rabat ({percent}%)",
     "tax": "Podatek ({rate}%)",
+    "taxIncluded": "Podatek wliczony ({rate}%)",
     "paymentsReceived": "Otrzymane płatności",
     "paidInFull": "ZAPŁACONO W CAŁOŚCI",
     "amountDue": "Kwota do zapłaty",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "WYCENA",
     "to": "Do",
+    "customerTaxId": "NIP",
     "vehicle": "Pojazd",
     "vin": "VIN: {vin}",
     "plate": "Tablica: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Stawka",
     "ratePerHour": "{rate}/godz.",
     "subtotal": "Suma częściowa",
+    "subtotalInclTax": "Suma częściowa (z podatkiem)",
     "discount": "Rabat",
     "discountPercent": "Rabat ({percent}%)",
     "tax": "Podatek ({rate}%)",
+    "taxIncluded": "Podatek wliczony ({rate}%)",
     "validUntil": "Ważne do: {date}",
     "attachedDocuments": "Załączone dokumenty",
     "attached": "{name} (załączono)",

+ 4 - 1
messages/pl/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Części",
     "labor": "Robocizna",
     "subtotal": "Suma częściowa",
+    "subtotalInclTax": "Suma częściowa (z podatkiem)",
     "discount": "Rabat",
     "discountNone": "Brak",
     "discountPercentage": "Procentowy",
     "discountFixed": "Stały",
     "tax": "Podatek",
-    "total": "Suma"
+    "taxIncluded": "Podatek (zaw.)",
+    "total": "Suma",
+    "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek."
   },
   "shareDialog": {
     "title": "Udostępnij wycenę",

+ 6 - 1
messages/pl/service.json

@@ -125,12 +125,15 @@
     "parts": "Części",
     "labor": "Praca",
     "subtotal": "Razem Częściowe",
+    "subtotalInclTax": "Razem Częściowe (z podatkiem)",
     "discount": "Rabat",
     "discountNone": "Brak",
     "discountPercentage": "Procent",
     "discountFixed": "Stały",
     "tax": "Podatek",
-    "total": "Razem"
+    "taxIncluded": "Podatek (zaw.)",
+    "total": "Razem",
+    "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek."
   },
   "notes": {
     "title": "Notatki",
@@ -157,8 +160,10 @@
     "parts": "Części",
     "labor": "Praca",
     "subtotal": "Razem Częściowe",
+    "subtotalInclTax": "Razem Częściowe (z podatkiem)",
     "discount": "Rabat",
     "tax": "Podatek ({rate}%)",
+    "taxIncluded": "Podatek (zaw., {rate}%)",
     "total": "Razem",
     "paid": "Zapłacone",
     "balanceDue": "Pozostało do Zapłaty",

+ 45 - 0
messages/pl/settings.json

@@ -38,6 +38,10 @@
         "title": "Platnosc",
         "description": "Warunki platnosci i konta"
       },
+      "tax": {
+        "title": "Podatek",
+        "description": "Stawka podatku i wartosci domyslne"
+      },
       "currency": {
         "title": "Waluta",
         "description": "Domyslna waluta i podatki"
@@ -421,6 +425,7 @@
       "customer_address": "Adres",
       "customer_email": "E-mail",
       "customer_phone": "Telefon",
+      "customer_tax_id": "NIP klienta",
       "vehicle_name": "Pojazd",
       "vin": "VIN",
       "license_plate": "Tablica rejestracyjna",
@@ -573,6 +578,37 @@
     "savePayment": "Zapisz ustawienia platnosci",
     "saved": "Ustawienia platnosci zapisane"
   },
+  "tax": {
+    "title": "Podatek",
+    "description": "Skonfiguruj stawke podatku stosowana do faktur, ofert i zlecen serwisowych.",
+    "modeLabel": "Tryb podatku",
+    "modeHint": "Wybierz, czy ceny pozycji sa wprowadzane z juz uwzglednionym podatkiem (brutto) czy bez podatku (netto). Istniejace rekordy zachowuja tryb, z ktorym zostaly utworzone.",
+    "modeExclusive": "Netto",
+    "modeInclusive": "Brutto",
+    "modeExclusiveExample": "Przyklad: 100 + {rate}% podatku = 100 + podatek doliczony.",
+    "modeInclusiveExample": "Przyklad: 100 juz zawiera {rate}% podatku — czesc podatkowa jest obliczana wstecz.",
+    "labelLabel": "Etykieta podatku",
+    "labelHint": "Własna nazwa wyświetlana na fakturach i ofertach (np. VAT, MwSt.). Pozostaw puste, aby użyć domyślnej zlokalizowanej nazwy.",
+    "labelPlaceholder": "VAT",
+    "convertInclusiveLabel": "Konwertuj istniejące rekordy na tryb brutto",
+    "convertInclusiveHint": "Skaluje ceny pozycji w górę o stawkę podatku, aby ceny zawierały podatek. Sumy dla klienta pozostają niezmienione. Kwalifikujące się: {serviceRecords} zleceń, {quotes} ofert.",
+    "convertInclusiveButton": "Konwertuj na brutto",
+    "convertInclusiveConfirmTitle": "Konwertować istniejące rekordy na tryb brutto?",
+    "convertInclusiveConfirmDescription": "To przepisze ceny pozycji w {serviceRecords} zleceniach i {quotes} ofertach, aby zawierały podatek. Sumy dla klienta nie zmienią się, ale wydrukowane ceny na poprzednich fakturach pojawią się wyższe. Tej operacji nie można cofnąć.",
+    "convertInclusiveConfirmLabel": "Konwertuj rekordy",
+    "convertInclusiveSuccess": "Przekonwertowano {serviceRecords} zleceń i {quotes} ofert na tryb brutto.",
+    "convertInclusiveFailed": "Nie udało się przekonwertować rekordów na tryb brutto",
+    "convertInclusiveNoRecords": "Brak rekordów w trybie netto do konwersji.",
+    "convertExclusiveLabel": "Konwertuj istniejące rekordy na tryb netto",
+    "convertExclusiveHint": "Skaluje ceny pozycji w dół o stawkę podatku, aby ceny pokazywały kwoty przed opodatkowaniem. Sumy dla klienta pozostają niezmienione. Kwalifikujące się: {serviceRecords} zleceń, {quotes} ofert.",
+    "convertExclusiveButton": "Konwertuj na netto",
+    "convertExclusiveConfirmTitle": "Konwertować istniejące rekordy na tryb netto?",
+    "convertExclusiveConfirmDescription": "To przepisze ceny pozycji w {serviceRecords} zleceniach i {quotes} ofertach, aby pokazywały kwoty przed opodatkowaniem. Sumy dla klienta nie zmienią się, ale wydrukowane ceny na poprzednich fakturach pojawią się niższe. Tej operacji nie można cofnąć.",
+    "convertExclusiveConfirmLabel": "Konwertuj rekordy",
+    "convertExclusiveSuccess": "Przekonwertowano {serviceRecords} zleceń i {quotes} ofert na tryb netto.",
+    "convertExclusiveFailed": "Nie udało się przekonwertować rekordów na tryb netto",
+    "convertExclusiveNoRecords": "Brak rekordów w trybie brutto do konwersji."
+  },
   "currency": {
     "title": "Waluta i podatki",
     "description": "Domyslna waluta i stawka podatkowa dla faktur i rekordow serwisowych.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Pokaz obliczanie podatku na fakturach i rekordach serwisowych",
     "defaultTaxRate": "Domyslna stawka podatkowa (%)",
     "defaultTaxRateHint": "Automatycznie wypelniana przy tworzeniu nowych rekordow serwisowych",
+    "applyTaxToExistingLabel": "Zastosuj podatek do istniejacych rekordow",
+    "applyTaxToExistingHint": "Stosuje domyslna stawke podatku do istniejacych zlecen serwisowych i ofert bez ustawionej stawki. Kwalifikujace sie: {serviceRecords} zlecen, {quotes} ofert.",
+    "applyTaxToExistingButton": "Zastosuj podatek do istniejacych rekordow",
+    "applyTaxConfirmTitle": "Zastosowac podatek do istniejacych rekordow?",
+    "applyTaxConfirmDescription": "Ustawi to stawke podatku na {rate}% dla {serviceRecords} zlecen serwisowych i {quotes} ofert, ktore obecnie nie maja ustawionej stawki, i przeliczy ich sumy. Rekordy z wlasna stawka podatku nie zostana zmienione. Tej operacji nie mozna cofnac.",
+    "applyTaxConfirmLabel": "Zastosuj podatek",
+    "applyTaxSuccess": "Zaktualizowano {serviceRecords} zlecen serwisowych i {quotes} ofert.",
+    "applyTaxFailed": "Nie udalo sie zastosowac podatku do istniejacych rekordow",
+    "applyTaxNoRecords": "Zadne rekordy nie wymagaja domyslnej stawki podatku.",
     "saveSettings": "Zapisz ustawienia",
     "saved": "Ustawienia zapisane",
     "currencies": {

+ 6 - 0
messages/pl/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Nie udało się uruchomić płatności.",
     "errorPaymentFailed": "Nie udało się zainicjować płatności. Spróbuj ponownie.",
     "billTo": "Odbiorca",
+    "taxId": "NIP",
     "vehicle": "Pojazd",
     "vin": "VIN: {vin}",
     "plate": "Tablica: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Stawka",
     "ratePerHour": "{rate}/godz.",
     "subtotal": "Suma częściowa",
+    "subtotalInclTax": "Suma częściowa (z podatkiem)",
     "discount": "Rabat",
     "discountPercent": "Rabat ({percent}%)",
     "tax": "Podatek ({rate}%)",
+    "taxIncluded": "Podatek wliczony ({rate}%)",
     "paid": "Zapłacono",
     "paidInFull": "ZAPŁACONO W CAŁOŚCI",
     "amountDue": "Kwota do zapłaty",
@@ -68,6 +71,7 @@
     "title": "Wycena",
     "downloadPdf": "Pobierz PDF",
     "preparedFor": "Przygotowano dla",
+    "taxId": "NIP",
     "vehicle": "Pojazd",
     "vin": "VIN: {vin}",
     "plate": "Tablica: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Stawka",
     "ratePerHour": "{rate}/godz.",
     "subtotal": "Suma częściowa",
+    "subtotalInclTax": "Suma częściowa (z podatkiem)",
     "discount": "Rabat",
     "discountPercent": "Rabat ({percent}%)",
     "tax": "Podatek ({rate}%)",
+    "taxIncluded": "Podatek wliczony ({rate}%)",
     "images": "Zdjęcia ({count})",
     "documents": "Dokumenty",
     "requestChanges": "Poproś o zmiany",

+ 7 - 0
messages/pt-BR/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Cliente não encontrado",
     "emailCopied": "E-mail copiado para a área de transferência",
     "phoneCopied": "Número de telefone copiado para a área de transferência",
+    "taxIdLabel": "CNPJ",
+    "taxExemptBadge": "Isento de imposto",
     "tabs": {
       "vehicles": "Veículos ({count})",
       "messages": "Mensagens",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "(11) 91234-5678",
     "companyPlaceholder": "Nome da empresa",
     "addressPlaceholder": "Rua Principal 123, São Paulo, SP",
+    "taxId": "CNPJ / CPF",
+    "taxIdPlaceholder": "ex. 12.345.678/0001-90",
+    "taxIdHint": "Exibido em faturas e orçamentos quando este cliente é o comprador.",
+    "taxExempt": "Isento de imposto",
+    "taxExemptHint": "Quando ativado, novas faturas e orçamentos para este cliente não incluirão imposto.",
     "notesPlaceholder": "Observações adicionais...",
     "customerUpdated": "Cliente atualizado",
     "customerCreated": "Cliente cadastrado",

+ 1 - 1
messages/pt-BR/navigation.json

@@ -34,7 +34,7 @@
     "team": "Equipe",
     "invoice": "Nota Fiscal",
     "payment": "Pagamento",
-    "currency": "Moeda",
+    "tax": "Imposto",
     "workshop": "Oficina",
     "appearance": "Aparência",
     "email": "E-mail",

+ 6 - 0
messages/pt-BR/pdf.json

@@ -3,6 +3,7 @@
     "title": "FATURA",
     "due": "Vencimento: {date}",
     "billTo": "Cobrar De",
+    "customerTaxId": "CNPJ",
     "vehicle": "Veículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Taxa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discountPercent": "Desconto ({percent}%)",
     "tax": "Imposto ({rate}%)",
+    "taxIncluded": "Imposto incluído ({rate}%)",
     "paymentsReceived": "Pagamentos Recebidos",
     "paidInFull": "PAGO INTEGRALMENTE",
     "amountDue": "Valor a Pagar",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "ORÇAMENTO",
     "to": "Para",
+    "customerTaxId": "CNPJ",
     "vehicle": "Veículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Taxa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discountPercent": "Desconto ({percent}%)",
     "tax": "Imposto ({rate}%)",
+    "taxIncluded": "Imposto incluído ({rate}%)",
     "validUntil": "Válido até: {date}",
     "attachedDocuments": "Documentos Anexados",
     "attached": "{name} (anexado)",

+ 4 - 1
messages/pt-BR/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Peças",
     "labor": "Mão de obra",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discountNone": "Nenhum",
     "discountPercentage": "Porcentagem",
     "discountFixed": "Fixo",
     "tax": "Imposto",
-    "total": "Total"
+    "taxIncluded": "Imposto (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto."
   },
   "shareDialog": {
     "title": "Compartilhar orçamento",

+ 6 - 1
messages/pt-BR/service.json

@@ -125,12 +125,15 @@
     "parts": "Peças",
     "labor": "Mão de Obra",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discountNone": "Nenhum",
     "discountPercentage": "Percentual",
     "discountFixed": "Fixo",
     "tax": "Imposto",
-    "total": "Total"
+    "taxIncluded": "Imposto (incl.)",
+    "total": "Total",
+    "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto."
   },
   "notes": {
     "title": "Notas",
@@ -157,8 +160,10 @@
     "parts": "Peças",
     "labor": "Mão de Obra",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "tax": "Imposto ({rate}%)",
+    "taxIncluded": "Imposto (incl., {rate}%)",
     "total": "Total",
     "paid": "Pago",
     "balanceDue": "Saldo Devedor",

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

@@ -38,6 +38,10 @@
         "title": "Pagamento",
         "description": "Condicoes de pagamento e contas"
       },
+      "tax": {
+        "title": "Imposto",
+        "description": "Taxa de imposto e padroes"
+      },
       "currency": {
         "title": "Moeda",
         "description": "Padroes de moeda e impostos"
@@ -421,6 +425,7 @@
       "customer_address": "Endereço",
       "customer_email": "E-mail",
       "customer_phone": "Telefone",
+      "customer_tax_id": "CNPJ do cliente",
       "vehicle_name": "Veículo",
       "vin": "VIN",
       "license_plate": "Placa",
@@ -573,6 +578,37 @@
     "savePayment": "Salvar configuracoes de pagamento",
     "saved": "Configuracoes de pagamento salvas"
   },
+  "tax": {
+    "title": "Imposto",
+    "description": "Configure a taxa de imposto aplicada a faturas, orcamentos e ordens de servico.",
+    "modeLabel": "Modo de imposto",
+    "modeHint": "Escolha se os precos das linhas sao inseridos com imposto ja incluido (inclusivo) ou antes do imposto (exclusivo). Os registros existentes mantem o modo com o qual foram criados.",
+    "modeExclusive": "Exclusivo",
+    "modeInclusive": "Inclusivo",
+    "modeExclusiveExample": "Exemplo: 100 + {rate}% de imposto = 100 + imposto por cima.",
+    "modeInclusiveExample": "Exemplo: 100 ja inclui {rate}% de imposto — a parte do imposto e calculada de tras para frente.",
+    "labelLabel": "Rótulo de imposto",
+    "labelHint": "Nome personalizado exibido em faturas e orçamentos (ex. ICMS, IVA). Deixe em branco para usar o padrão traduzido.",
+    "labelPlaceholder": "Imposto",
+    "convertInclusiveLabel": "Converter registros existentes para modo inclusivo",
+    "convertInclusiveHint": "Escala cada preço de linha para cima pela taxa de imposto para que os preços incluam imposto. Os totais para o cliente não mudam. Elegíveis: {serviceRecords} ordens de serviço, {quotes} orçamentos.",
+    "convertInclusiveButton": "Converter para inclusivo",
+    "convertInclusiveConfirmTitle": "Converter registros existentes para modo inclusivo?",
+    "convertInclusiveConfirmDescription": "Isso reescreverá os preços de linha de {serviceRecords} ordens de serviço e {quotes} orçamentos para incluir imposto. Os totais para o cliente não mudarão, mas os preços impressos em faturas anteriores aparecerão mais altos. Esta ação não pode ser desfeita.",
+    "convertInclusiveConfirmLabel": "Converter registros",
+    "convertInclusiveSuccess": "Convertidas {serviceRecords} ordens de serviço e {quotes} orçamentos para modo inclusivo.",
+    "convertInclusiveFailed": "Falha ao converter registros para modo inclusivo",
+    "convertInclusiveNoRecords": "Nenhum registro em modo exclusivo para converter.",
+    "convertExclusiveLabel": "Converter registros existentes para modo exclusivo",
+    "convertExclusiveHint": "Escala cada preço de linha para baixo pela taxa de imposto para que os preços mostrem valores antes do imposto. Os totais para o cliente não mudam. Elegíveis: {serviceRecords} ordens de serviço, {quotes} orçamentos.",
+    "convertExclusiveButton": "Converter para exclusivo",
+    "convertExclusiveConfirmTitle": "Converter registros existentes para modo exclusivo?",
+    "convertExclusiveConfirmDescription": "Isso reescreverá os preços de linha de {serviceRecords} ordens de serviço e {quotes} orçamentos para mostrar valores antes do imposto. Os totais para o cliente não mudarão, mas os preços impressos em faturas anteriores aparecerão mais baixos. Esta ação não pode ser desfeita.",
+    "convertExclusiveConfirmLabel": "Converter registros",
+    "convertExclusiveSuccess": "Convertidas {serviceRecords} ordens de serviço e {quotes} orçamentos para modo exclusivo.",
+    "convertExclusiveFailed": "Falha ao converter registros para modo exclusivo",
+    "convertExclusiveNoRecords": "Nenhum registro em modo inclusivo para converter."
+  },
   "currency": {
     "title": "Moeda e impostos",
     "description": "Moeda padrao e taxa de imposto para faturas e registros de servico.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Mostrar calculo de impostos em faturas e registros de servico",
     "defaultTaxRate": "Taxa de imposto padrao (%)",
     "defaultTaxRateHint": "Preenchido automaticamente ao criar novos registros de servico",
+    "applyTaxToExistingLabel": "Aplicar imposto aos registros existentes",
+    "applyTaxToExistingHint": "Aplica a taxa de imposto padrao as ordens de servico e orcamentos existentes sem taxa definida. Elegiveis: {serviceRecords} ordens de servico, {quotes} orcamentos.",
+    "applyTaxToExistingButton": "Aplicar imposto aos registros existentes",
+    "applyTaxConfirmTitle": "Aplicar imposto aos registros existentes?",
+    "applyTaxConfirmDescription": "Isso definira a taxa de imposto em {rate}% em {serviceRecords} ordens de servico e {quotes} orcamentos que atualmente nao tem taxa, e recalculara seus totais. Registros com uma taxa personalizada nao serao alterados. Esta acao nao pode ser desfeita.",
+    "applyTaxConfirmLabel": "Aplicar imposto",
+    "applyTaxSuccess": "Atualizadas {serviceRecords} ordens de servico e {quotes} orcamentos.",
+    "applyTaxFailed": "Falha ao aplicar imposto aos registros existentes",
+    "applyTaxNoRecords": "Nenhum registro precisa da taxa de imposto padrao.",
     "saveSettings": "Salvar configuracoes",
     "saved": "Configuracoes salvas",
     "currencies": {

+ 6 - 0
messages/pt-BR/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Falha ao iniciar o pagamento.",
     "errorPaymentFailed": "Falha ao iniciar o pagamento. Por favor, tente novamente.",
     "billTo": "Cobrar De",
+    "taxId": "CNPJ",
     "vehicle": "Veículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Taxa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discountPercent": "Desconto ({percent}%)",
     "tax": "Imposto ({rate}%)",
+    "taxIncluded": "Imposto incluído ({rate}%)",
     "paid": "Pago",
     "paidInFull": "PAGO INTEGRALMENTE",
     "amountDue": "Valor a Pagar",
@@ -68,6 +71,7 @@
     "title": "Orçamento",
     "downloadPdf": "Baixar PDF",
     "preparedFor": "Preparado Para",
+    "taxId": "CNPJ",
     "vehicle": "Veículo",
     "vin": "VIN: {vin}",
     "plate": "Placa: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Taxa",
     "ratePerHour": "{rate}/h",
     "subtotal": "Subtotal",
+    "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discountPercent": "Desconto ({percent}%)",
     "tax": "Imposto ({rate}%)",
+    "taxIncluded": "Imposto incluído ({rate}%)",
     "images": "Imagens ({count})",
     "documents": "Documentos",
     "requestChanges": "Solicitar Alterações",

+ 7 - 0
messages/ru/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Клиент не найден",
     "emailCopied": "Эл. почта скопирована в буфер обмена",
     "phoneCopied": "Номер телефона скопирован в буфер обмена",
+    "taxIdLabel": "ИНН",
+    "taxExemptBadge": "Освобожден от налога",
     "tabs": {
       "vehicles": "Транспортные средства ({count})",
       "messages": "Сообщения",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "+7 (999) 123-45-67",
     "companyPlaceholder": "Название компании",
     "addressPlaceholder": "ул. Главная, 123, Москва",
+    "taxId": "ИНН / КПП",
+    "taxIdPlaceholder": "напр. 7707083893",
+    "taxIdHint": "Отображается в счетах и коммерческих предложениях, когда этот клиент является покупателем.",
+    "taxExempt": "Освобожден от налога",
+    "taxExemptHint": "Когда включено, новые счета и коммерческие предложения для этого клиента не будут содержать налог.",
     "notesPlaceholder": "Дополнительные заметки...",
     "customerUpdated": "Клиент обновлён",
     "customerCreated": "Клиент создан",

+ 1 - 1
messages/ru/navigation.json

@@ -34,7 +34,7 @@
     "team": "Команда",
     "invoice": "Счёт",
     "payment": "Оплата",
-    "currency": "Валюта",
+    "tax": "Налог",
     "workshop": "Мастерская",
     "appearance": "Внешний вид",
     "email": "Эл. почта",

+ 6 - 0
messages/ru/pdf.json

@@ -3,6 +3,7 @@
     "title": "СЧЁТ",
     "due": "Срок: {date}",
     "billTo": "Плательщик",
+    "customerTaxId": "ИНН",
     "vehicle": "Транспортное средство",
     "vin": "VIN: {vin}",
     "plate": "Госномер: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Ставка",
     "ratePerHour": "{rate}/ч",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discountPercent": "Скидка ({percent}%)",
     "tax": "Налог ({rate}%)",
+    "taxIncluded": "С НДС ({rate}%)",
     "paymentsReceived": "Полученные платежи",
     "paidInFull": "ПОЛНОСТЬЮ ОПЛАЧЕНО",
     "amountDue": "К оплате",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ",
     "to": "Кому",
+    "customerTaxId": "ИНН",
     "vehicle": "Транспортное средство",
     "vin": "VIN: {vin}",
     "plate": "Госномер: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Ставка",
     "ratePerHour": "{rate}/ч",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discountPercent": "Скидка ({percent}%)",
     "tax": "Налог ({rate}%)",
+    "taxIncluded": "С НДС ({rate}%)",
     "validUntil": "Действительно до: {date}",
     "attachedDocuments": "Прикреплённые документы",
     "attached": "{name} (прикреплено)",

+ 4 - 1
messages/ru/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Запчасти",
     "labor": "Работа",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discountNone": "Нет",
     "discountPercentage": "Процент",
     "discountFixed": "Фиксированная",
     "tax": "Налог",
-    "total": "Итого"
+    "taxIncluded": "Налог (вкл.)",
+    "total": "Итого",
+    "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС."
   },
   "shareDialog": {
     "title": "Поделиться предложением",

+ 6 - 1
messages/ru/service.json

@@ -125,12 +125,15 @@
     "parts": "Запчасти",
     "labor": "Работа",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discountNone": "Нет",
     "discountPercentage": "Процент",
     "discountFixed": "Фиксированная",
     "tax": "Налог",
-    "total": "Итого"
+    "taxIncluded": "Налог (вкл.)",
+    "total": "Итого",
+    "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС."
   },
   "notes": {
     "title": "Заметки",
@@ -157,8 +160,10 @@
     "parts": "Запчасти",
     "labor": "Работа",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "tax": "Налог ({rate}%)",
+    "taxIncluded": "Налог (вкл., {rate}%)",
     "total": "Итого",
     "paid": "Оплачено",
     "balanceDue": "К оплате",

+ 45 - 0
messages/ru/settings.json

@@ -38,6 +38,10 @@
         "title": "Оплата",
         "description": "Условия оплаты и счета"
       },
+      "tax": {
+        "title": "Налог",
+        "description": "Налоговая ставка и значения по умолчанию"
+      },
       "currency": {
         "title": "Валюта",
         "description": "Валюта и налоги по умолчанию"
@@ -421,6 +425,7 @@
       "customer_address": "Адрес клиента",
       "customer_email": "Эл. почта клиента",
       "customer_phone": "Телефон клиента",
+      "customer_tax_id": "ИНН клиента",
       "vehicle_name": "Транспортное средство",
       "vin": "VIN",
       "license_plate": "Госномер",
@@ -573,6 +578,37 @@
     "savePayment": "Сохранить настройки оплаты",
     "saved": "Настройки оплаты сохранены"
   },
+  "tax": {
+    "title": "Налог",
+    "description": "Настройте налоговую ставку, применяемую к счетам, коммерческим предложениям и заказам на работу.",
+    "modeLabel": "Режим налога",
+    "modeHint": "Выберите, вводятся ли цены позиций с уже включённым налогом (с НДС) или без налога (без НДС). Существующие записи сохраняют режим, с которым они были созданы.",
+    "modeExclusive": "Без НДС",
+    "modeInclusive": "С НДС",
+    "modeExclusiveExample": "Пример: 100 + {rate}% налога = 100 + налог сверху.",
+    "modeInclusiveExample": "Пример: 100 уже включает {rate}% налога — налоговая часть рассчитывается в обратном порядке.",
+    "labelLabel": "Метка налога",
+    "labelHint": "Пользовательское название, отображаемое в счетах и коммерческих предложениях (напр. НДС, VAT). Оставьте пустым для локализованного значения по умолчанию.",
+    "labelPlaceholder": "НДС",
+    "convertInclusiveLabel": "Преобразовать существующие записи в режим с НДС",
+    "convertInclusiveHint": "Увеличивает каждую цену позиции на ставку налога, чтобы цены включали налог. Итоги для клиента остаются прежними. Подходит: {serviceRecords} заказов, {quotes} коммерческих предложений.",
+    "convertInclusiveButton": "Преобразовать в режим с НДС",
+    "convertInclusiveConfirmTitle": "Преобразовать существующие записи в режим с НДС?",
+    "convertInclusiveConfirmDescription": "Это перепишет цены позиций в {serviceRecords} заказах и {quotes} коммерческих предложениях, чтобы они включали налог. Итоги для клиента не изменятся, но напечатанные цены на старых счетах будут отображаться выше. Это действие нельзя отменить.",
+    "convertInclusiveConfirmLabel": "Преобразовать записи",
+    "convertInclusiveSuccess": "Преобразовано {serviceRecords} заказов и {quotes} коммерческих предложений в режим с НДС.",
+    "convertInclusiveFailed": "Не удалось преобразовать записи в режим с НДС",
+    "convertInclusiveNoRecords": "Нет записей в режиме без НДС для преобразования.",
+    "convertExclusiveLabel": "Преобразовать существующие записи в режим без НДС",
+    "convertExclusiveHint": "Уменьшает каждую цену позиции на ставку налога, чтобы цены показывали суммы без налога. Итоги для клиента остаются прежними. Подходит: {serviceRecords} заказов, {quotes} коммерческих предложений.",
+    "convertExclusiveButton": "Преобразовать в режим без НДС",
+    "convertExclusiveConfirmTitle": "Преобразовать существующие записи в режим без НДС?",
+    "convertExclusiveConfirmDescription": "Это перепишет цены позиций в {serviceRecords} заказах и {quotes} коммерческих предложениях, чтобы они показывали суммы без налога. Итоги для клиента не изменятся, но напечатанные цены на старых счетах будут отображаться ниже. Это действие нельзя отменить.",
+    "convertExclusiveConfirmLabel": "Преобразовать записи",
+    "convertExclusiveSuccess": "Преобразовано {serviceRecords} заказов и {quotes} коммерческих предложений в режим без НДС.",
+    "convertExclusiveFailed": "Не удалось преобразовать записи в режим без НДС",
+    "convertExclusiveNoRecords": "Нет записей в режиме с НДС для преобразования."
+  },
   "currency": {
     "title": "Валюта и налоги",
     "description": "Валюта и налоговая ставка по умолчанию, используемые в счетах и записях обслуживания.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Показывать расчёт налога на счетах и записях обслуживания",
     "defaultTaxRate": "Ставка налога по умолчанию (%)",
     "defaultTaxRateHint": "Автоматически заполняется при создании новых записей обслуживания",
+    "applyTaxToExistingLabel": "Применить налог к существующим записям",
+    "applyTaxToExistingHint": "Применяет налоговую ставку по умолчанию к существующим заказам на работу и коммерческим предложениям без установленной ставки. Подходит: {serviceRecords} заказов, {quotes} предложений.",
+    "applyTaxToExistingButton": "Применить налог к существующим записям",
+    "applyTaxConfirmTitle": "Применить налог к существующим записям?",
+    "applyTaxConfirmDescription": "Это установит налоговую ставку {rate}% для {serviceRecords} заказов на работу и {quotes} коммерческих предложений, у которых сейчас нет ставки, и пересчитает их суммы. Записи с пользовательской ставкой не будут изменены. Это действие нельзя отменить.",
+    "applyTaxConfirmLabel": "Применить налог",
+    "applyTaxSuccess": "Обновлено {serviceRecords} заказов на работу и {quotes} коммерческих предложений.",
+    "applyTaxFailed": "Не удалось применить налог к существующим записям",
+    "applyTaxNoRecords": "Нет записей, требующих налоговой ставки по умолчанию.",
     "saveSettings": "Сохранить настройки",
     "saved": "Настройки сохранены",
     "currencies": {

+ 6 - 0
messages/ru/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Не удалось начать оплату.",
     "errorPaymentFailed": "Не удалось инициировать оплату. Попробуйте снова.",
     "billTo": "Плательщик",
+    "taxId": "ИНН",
     "vehicle": "Транспортное средство",
     "vin": "VIN: {vin}",
     "plate": "Госномер: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Ставка",
     "ratePerHour": "{rate}/ч",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discountPercent": "Скидка ({percent}%)",
     "tax": "Налог ({rate}%)",
+    "taxIncluded": "С НДС ({rate}%)",
     "paid": "Оплачено",
     "paidInFull": "ПОЛНОСТЬЮ ОПЛАЧЕНО",
     "amountDue": "К оплате",
@@ -68,6 +71,7 @@
     "title": "Предложение",
     "downloadPdf": "Скачать PDF",
     "preparedFor": "Подготовлено для",
+    "taxId": "ИНН",
     "vehicle": "Транспортное средство",
     "vin": "VIN: {vin}",
     "plate": "Госномер: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Ставка",
     "ratePerHour": "{rate}/ч",
     "subtotal": "Подитог",
+    "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discountPercent": "Скидка ({percent}%)",
     "tax": "Налог ({rate}%)",
+    "taxIncluded": "С НДС ({rate}%)",
     "images": "Изображения ({count})",
     "documents": "Документы",
     "requestChanges": "Запросить изменения",

+ 7 - 0
messages/tr/customers.json

@@ -29,6 +29,8 @@
     "notFound": "Müşteri bulunamadı",
     "emailCopied": "E-posta panoya kopyalandı",
     "phoneCopied": "Telefon numarası panoya kopyalandı",
+    "taxIdLabel": "Vergi no",
+    "taxExemptBadge": "Vergiden muaf",
     "tabs": {
       "vehicles": "Araçlar ({count})",
       "messages": "Mesajlar",
@@ -54,6 +56,11 @@
     "phonePlaceholder": "0532 123 4567",
     "companyPlaceholder": "Şirket adı",
     "addressPlaceholder": "Atatürk Cad. No:1, 34000 İstanbul",
+    "taxId": "Vergi numarası",
+    "taxIdPlaceholder": "örn. 1234567890",
+    "taxIdHint": "Bu müşteri alıcı olduğunda faturalar ve tekliflerde gösterilir.",
+    "taxExempt": "Vergiden muaf",
+    "taxExemptHint": "Etkinleştirildiğinde, bu müşteri için yeni faturalar ve teklifler vergi içermez.",
     "notesPlaceholder": "Ek notlar...",
     "customerUpdated": "Müşteri güncellendi",
     "customerCreated": "Müşteri oluşturuldu",

+ 1 - 1
messages/tr/navigation.json

@@ -34,7 +34,7 @@
     "team": "Ekip",
     "invoice": "Fatura",
     "payment": "Ödeme",
-    "currency": "Para Birimi",
+    "tax": "Vergi",
     "workshop": "Atölye",
     "appearance": "Görünüm",
     "email": "E-posta",

+ 6 - 0
messages/tr/pdf.json

@@ -3,6 +3,7 @@
     "title": "FATURA",
     "due": "Son Tarih: {date}",
     "billTo": "Fatura Adresi",
+    "customerTaxId": "Vergi no",
     "vehicle": "Araç",
     "vin": "VIN: {vin}",
     "plate": "Plaka: {plate}",
@@ -26,9 +27,11 @@
     "rate": "Ücret",
     "ratePerHour": "{rate}/sa",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discountPercent": "İndirim ({percent}%)",
     "tax": "Vergi ({rate}%)",
+    "taxIncluded": "KDV dahil (%{rate})",
     "paymentsReceived": "Alınan Ödemeler",
     "paidInFull": "TAMAMEN ÖDENDİ",
     "amountDue": "Ödenecek Tutar",
@@ -59,6 +62,7 @@
   "quote": {
     "title": "TEKLİF",
     "to": "Alıcı",
+    "customerTaxId": "Vergi no",
     "vehicle": "Araç",
     "vin": "VIN: {vin}",
     "plate": "Plaka: {plate}",
@@ -76,9 +80,11 @@
     "rate": "Ücret",
     "ratePerHour": "{rate}/sa",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discountPercent": "İndirim ({percent}%)",
     "tax": "Vergi ({rate}%)",
+    "taxIncluded": "KDV dahil (%{rate})",
     "validUntil": "Geçerlilik tarihi: {date}",
     "attachedDocuments": "Ekli Belgeler",
     "attached": "{name} (ekli)",

+ 4 - 1
messages/tr/quotes.json

@@ -134,12 +134,15 @@
     "parts": "Parçalar",
     "labor": "İşçilik",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discountNone": "Yok",
     "discountPercentage": "Yüzde",
     "discountFixed": "Sabit",
     "tax": "Vergi",
-    "total": "Toplam"
+    "taxIncluded": "Vergi (dahil)",
+    "total": "Toplam",
+    "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir."
   },
   "shareDialog": {
     "title": "Teklifi Paylaş",

+ 6 - 1
messages/tr/service.json

@@ -125,12 +125,15 @@
     "parts": "Parçalar",
     "labor": "İşçilik",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discountNone": "Yok",
     "discountPercentage": "Yüzde",
     "discountFixed": "Sabit",
     "tax": "Vergi",
-    "total": "Toplam"
+    "taxIncluded": "Vergi (dahil)",
+    "total": "Toplam",
+    "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir."
   },
   "notes": {
     "title": "Notlar",
@@ -157,8 +160,10 @@
     "parts": "Parçalar",
     "labor": "İşçilik",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "tax": "Vergi ({rate}%)",
+    "taxIncluded": "Vergi (dahil, %{rate})",
     "total": "Toplam",
     "paid": "Ödendi",
     "balanceDue": "Kalan Bakiye",

+ 45 - 0
messages/tr/settings.json

@@ -38,6 +38,10 @@
         "title": "Ödeme",
         "description": "Ödeme koşulları & hesaplar"
       },
+      "tax": {
+        "title": "Vergi",
+        "description": "Vergi oranı ve varsayılanlar"
+      },
       "currency": {
         "title": "Para Birimi",
         "description": "Para birimi & vergi varsayılanları"
@@ -421,6 +425,7 @@
       "customer_address": "Adres",
       "customer_email": "E-posta",
       "customer_phone": "Telefon",
+      "customer_tax_id": "Müşteri vergi no",
       "vehicle_name": "Araç",
       "vin": "VIN",
       "license_plate": "Plaka",
@@ -573,6 +578,37 @@
     "savePayment": "Ödeme Ayarlarını Kaydet",
     "saved": "Ödeme ayarları kaydedildi"
   },
+  "tax": {
+    "title": "Vergi",
+    "description": "Faturalar, teklifler ve iş emirlerine uygulanan vergi oranını yapılandırın.",
+    "modeLabel": "Vergi modu",
+    "modeHint": "Satır kalemlerindeki fiyatların vergi dahil mi (dahil) yoksa vergi hariç mi (hariç) girildiğini seçin. Mevcut kayıtlar, oluşturuldukları modu korur.",
+    "modeExclusive": "Hariç",
+    "modeInclusive": "Dahil",
+    "modeExclusiveExample": "Örnek: 100 + %{rate} vergi = 100 + üstüne vergi.",
+    "modeInclusiveExample": "Örnek: 100 zaten %{rate} vergiyi içeriyor — vergi kısmı geriye doğru hesaplanır.",
+    "labelLabel": "Vergi etiketi",
+    "labelHint": "Faturalar ve tekliflerde gösterilen özel ad (örn. KDV, VAT). Yerelleştirilmiş varsayılanı kullanmak için boş bırakın.",
+    "labelPlaceholder": "KDV",
+    "convertInclusiveLabel": "Mevcut kayıtları KDV dahil moda dönüştür",
+    "convertInclusiveHint": "Her satır fiyatını vergi oranı kadar yukarı ölçekleyerek fiyatların vergi dahil olmasını sağlar. Müşteri tarafındaki toplamlar değişmez. Uygun: {serviceRecords} iş emri, {quotes} teklif.",
+    "convertInclusiveButton": "KDV dahile dönüştür",
+    "convertInclusiveConfirmTitle": "Mevcut kayıtlar KDV dahil moda dönüştürülsün mü?",
+    "convertInclusiveConfirmDescription": "Bu, {serviceRecords} iş emri ve {quotes} teklifteki satır fiyatlarını vergi dahil olacak şekilde yeniden yazacaktır. Müşteri tarafındaki toplamlar değişmeyecek, ancak geçmiş faturalardaki yazdırılmış fiyatlar daha yüksek görünecektir. Bu işlem geri alınamaz.",
+    "convertInclusiveConfirmLabel": "Kayıtları dönüştür",
+    "convertInclusiveSuccess": "{serviceRecords} iş emri ve {quotes} teklif KDV dahil moda dönüştürüldü.",
+    "convertInclusiveFailed": "Kayıtlar KDV dahil moda dönüştürülemedi",
+    "convertInclusiveNoRecords": "Dönüştürülecek KDV hariç modunda kayıt yok.",
+    "convertExclusiveLabel": "Mevcut kayıtları KDV hariç moda dönüştür",
+    "convertExclusiveHint": "Her satır fiyatını vergi oranı kadar aşağı ölçekleyerek fiyatların vergi öncesi tutarları göstermesini sağlar. Müşteri tarafındaki toplamlar değişmez. Uygun: {serviceRecords} iş emri, {quotes} teklif.",
+    "convertExclusiveButton": "KDV harice dönüştür",
+    "convertExclusiveConfirmTitle": "Mevcut kayıtlar KDV hariç moda dönüştürülsün mü?",
+    "convertExclusiveConfirmDescription": "Bu, {serviceRecords} iş emri ve {quotes} teklifteki satır fiyatlarını vergi öncesi tutarları gösterecek şekilde yeniden yazacaktır. Müşteri tarafındaki toplamlar değişmeyecek, ancak geçmiş faturalardaki yazdırılmış fiyatlar daha düşük görünecektir. Bu işlem geri alınamaz.",
+    "convertExclusiveConfirmLabel": "Kayıtları dönüştür",
+    "convertExclusiveSuccess": "{serviceRecords} iş emri ve {quotes} teklif KDV hariç moda dönüştürüldü.",
+    "convertExclusiveFailed": "Kayıtlar KDV hariç moda dönüştürülemedi",
+    "convertExclusiveNoRecords": "Dönüştürülecek KDV dahil modunda kayıt yok."
+  },
   "currency": {
     "title": "Para Birimi & Vergi",
     "description": "Faturalar ve servis kayıtlarında kullanılan varsayılan para birimi ve vergi oranı.",
@@ -584,6 +620,15 @@
     "enableTaxHint": "Faturalar ve servis kayıtlarında vergi hesaplamasını göster",
     "defaultTaxRate": "Varsayılan Vergi Oranı (%)",
     "defaultTaxRateHint": "Yeni servis kayıtları oluşturulurken otomatik olarak doldurulur",
+    "applyTaxToExistingLabel": "Mevcut kayıtlara vergi uygula",
+    "applyTaxToExistingHint": "Vergi oranı ayarlanmamış mevcut iş emirleri ve tekliflere varsayılan vergi oranını uygular. Uygun: {serviceRecords} iş emri, {quotes} teklif.",
+    "applyTaxToExistingButton": "Mevcut kayıtlara vergi uygula",
+    "applyTaxConfirmTitle": "Mevcut kayıtlara vergi uygulansın mı?",
+    "applyTaxConfirmDescription": "Bu, şu anda vergi oranı olmayan {serviceRecords} iş emri ve {quotes} teklif üzerindeki vergi oranını %{rate} olarak ayarlayacak ve toplamlarını yeniden hesaplayacaktır. Özel vergi oranı olan kayıtlar değiştirilmeyecektir. Bu işlem geri alınamaz.",
+    "applyTaxConfirmLabel": "Vergi uygula",
+    "applyTaxSuccess": "{serviceRecords} iş emri ve {quotes} teklif güncellendi.",
+    "applyTaxFailed": "Vergi mevcut kayıtlara uygulanamadı",
+    "applyTaxNoRecords": "Hiçbir kayıt varsayılan vergi oranına ihtiyaç duymuyor.",
     "saveSettings": "Ayarları Kaydet",
     "saved": "Ayarlar kaydedildi",
     "currencies": {

+ 6 - 0
messages/tr/share.json

@@ -19,6 +19,7 @@
     "errorCheckoutFailed": "Ödeme başlatılamadı.",
     "errorPaymentFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
     "billTo": "Fatura Adresi",
+    "taxId": "Vergi no",
     "vehicle": "Araç",
     "vin": "VIN: {vin}",
     "plate": "Plaka: {plate}",
@@ -39,9 +40,11 @@
     "rate": "Ücret",
     "ratePerHour": "{rate}/sa",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discountPercent": "İndirim ({percent}%)",
     "tax": "Vergi ({rate}%)",
+    "taxIncluded": "KDV dahil (%{rate})",
     "paid": "Ödendi",
     "paidInFull": "TAMAMEN ÖDENDİ",
     "amountDue": "Ödenecek Tutar",
@@ -68,6 +71,7 @@
     "title": "Teklif",
     "downloadPdf": "PDF İndir",
     "preparedFor": "Hazırlanan Kişi",
+    "taxId": "Vergi no",
     "vehicle": "Araç",
     "vin": "VIN: {vin}",
     "plate": "Plaka: {plate}",
@@ -88,9 +92,11 @@
     "rate": "Ücret",
     "ratePerHour": "{rate}/sa",
     "subtotal": "Ara Toplam",
+    "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discountPercent": "İndirim ({percent}%)",
     "tax": "Vergi ({rate}%)",
+    "taxIncluded": "KDV dahil (%{rate})",
     "images": "Görseller ({count})",
     "documents": "Belgeler",
     "requestChanges": "Değişiklik İste",

+ 12 - 0
prisma/migrations/20260410061236_tax/migration.sql

@@ -0,0 +1,12 @@
+-- AlterTable
+ALTER TABLE "customers" ADD COLUMN     "taxExempt" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN     "taxId" TEXT;
+
+-- AlterTable
+ALTER TABLE "quotes" ADD COLUMN     "taxInclusive" BOOLEAN NOT NULL DEFAULT false;
+
+-- AlterTable
+ALTER TABLE "recurring_invoices" ADD COLUMN     "taxInclusive" BOOLEAN NOT NULL DEFAULT false;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "taxInclusive" BOOLEAN NOT NULL DEFAULT false;

+ 5 - 0
prisma/schema.prisma

@@ -196,6 +196,7 @@ model ServiceRecord {
   subtotal        Float     @default(0)
   taxRate         Float     @default(0)
   taxAmount       Float     @default(0)
+  taxInclusive    Boolean   @default(false)
   totalAmount     Float     @default(0)
   invoiceNumber   String?
   invoiceDate     DateTime?
@@ -315,6 +316,8 @@ model Customer {
   phone          String?
   address        String?
   company        String?
+  taxId          String?
+  taxExempt      Boolean  @default(false)
   notes          String?
   telegramChatId String?
   createdAt      DateTime @default(now())
@@ -493,6 +496,7 @@ model Quote {
   subtotal        Float     @default(0)
   taxRate         Float     @default(0)
   taxAmount       Float     @default(0)
+  taxInclusive    Boolean   @default(false)
   discountType    String?
   discountValue   Float     @default(0)
   discountAmount  Float     @default(0)
@@ -778,6 +782,7 @@ model RecurringInvoice {
   type         String  @default("maintenance")
   cost         Float   @default(0)
   taxRate      Float   @default(0)
+  taxInclusive Boolean @default(false)
   invoiceNotes String?
 
   vehicleId String

+ 73 - 0
src/__tests__/features/public/invoice-view.test.tsx

@@ -443,4 +443,77 @@ describe("InvoiceView", () => {
       });
     });
   });
+
+  // -------------------------------------------------------------------------
+  // Universal display: net per-line, separate tax line, gross total — both modes.
+  // -------------------------------------------------------------------------
+  describe("universal tax display (inclusive mode)", () => {
+    it("renders the same totals for an exclusive and an equivalent inclusive record", () => {
+      // Exclusive: 100 net + 25 tax = 125 total
+      const exclusiveProps = {
+        ...DEFAULT_PROPS,
+        record: {
+          ...BASE_RECORD,
+          taxRate: 25,
+          taxInclusive: false,
+          partItems: [
+            { partNumber: "BP-001", name: "Brake Pad", quantity: 1, unitPrice: 100, total: 100 },
+          ],
+          subtotal: 100,
+          taxAmount: 25,
+          totalAmount: 125,
+          cost: 125,
+        },
+      };
+      const { unmount } = render(<InvoiceView {...exclusiveProps} />);
+      // Exclusive shows the net unit price (100) and the tax amount (25)
+      expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1);
+      expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1);
+      expect(screen.getAllByText(/125\.00/).length).toBeGreaterThanOrEqual(1);
+      unmount();
+
+      // Inclusive equivalent: stored gross 125, displays the same way after back-calc
+      const inclusiveProps = {
+        ...DEFAULT_PROPS,
+        record: {
+          ...BASE_RECORD,
+          taxRate: 25,
+          taxInclusive: true,
+          partItems: [
+            // Stored gross: 125
+            { partNumber: "BP-001", name: "Brake Pad", quantity: 1, unitPrice: 125, total: 125 },
+          ],
+          subtotal: 125,
+          taxAmount: 25,
+          totalAmount: 125,
+          cost: 125,
+        },
+      };
+      render(<InvoiceView {...inclusiveProps} />);
+      // The display back-calculates the line item to net 100, and shows the same tax 25 + total 125
+      expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1);
+      expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1);
+      expect(screen.getAllByText(/125\.00/).length).toBeGreaterThanOrEqual(1);
+    });
+
+    it("does not render the tax line when taxRate is 0", () => {
+      const props = {
+        ...DEFAULT_PROPS,
+        record: {
+          ...BASE_RECORD,
+          taxRate: 0,
+          taxInclusive: false,
+          partItems: [
+            { partNumber: "P", name: "Part", quantity: 1, unitPrice: 100, total: 100 },
+          ],
+          subtotal: 100,
+          taxAmount: 0,
+          totalAmount: 100,
+          cost: 100,
+        },
+      };
+      render(<InvoiceView {...props} />);
+      expect(screen.queryByText(/Tax \(\d/)).toBeNull();
+    });
+  });
 });

+ 73 - 0
src/__tests__/features/public/quote-view.test.tsx

@@ -377,4 +377,77 @@ describe("QuoteView", () => {
       });
     });
   });
+
+  // -------------------------------------------------------------------------
+  // Universal display: inclusive records must show NET line items + tax line.
+  // -------------------------------------------------------------------------
+  describe("universal tax display (inclusive mode)", () => {
+    it("shows NET per-line price and a separate tax line for an inclusive record", () => {
+      const props = {
+        ...DEFAULT_PROPS,
+        quote: {
+          ...BASE_QUOTE,
+          taxRate: 25,
+          taxInclusive: true,
+          partItems: [
+            // Stored gross: unitPrice 125, total 125 (which is 100 net + 25 tax)
+            { name: "Brake Pad", partNumber: "BP-001", quantity: 1, unitPrice: 125, total: 125 },
+          ],
+          subtotal: 125,
+          taxAmount: 25,
+          totalAmount: 125,
+        },
+      };
+      render(<QuoteView {...props} />);
+
+      // The tax line is shown as the actual tax amount (not as "incl.")
+      expect(screen.getByText("Tax (25%)")).toBeInTheDocument();
+      // Per-line unit price is back-calculated to net (100, not 125)
+      // Currency formatting may vary, so we look for any element containing the net value.
+      const netCells = screen.getAllByText(/100\.00/);
+      expect(netCells.length).toBeGreaterThanOrEqual(1);
+    });
+
+    it("the same logical quote produces identical visible totals in both modes", () => {
+      // Render an exclusive quote
+      const exclusiveProps = {
+        ...DEFAULT_PROPS,
+        quote: {
+          ...BASE_QUOTE,
+          taxRate: 25,
+          taxInclusive: false,
+          partItems: [{ name: "Brake Pad", partNumber: "BP-001", quantity: 1, unitPrice: 100, total: 100 }],
+          subtotal: 100,
+          taxAmount: 25,
+          totalAmount: 125,
+        },
+      };
+      const { unmount } = render(<QuoteView {...exclusiveProps} />);
+      // Tax line shows 25.00
+      expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1);
+      // Total shows 125.00
+      expect(screen.getAllByText(/125\.00/).length).toBeGreaterThanOrEqual(1);
+      unmount();
+
+      // Render the equivalent inclusive quote
+      const inclusiveProps = {
+        ...DEFAULT_PROPS,
+        quote: {
+          ...BASE_QUOTE,
+          taxRate: 25,
+          taxInclusive: true,
+          partItems: [{ name: "Brake Pad", partNumber: "BP-001", quantity: 1, unitPrice: 125, total: 125 }],
+          subtotal: 125,
+          taxAmount: 25,
+          totalAmount: 125,
+        },
+      };
+      render(<QuoteView {...inclusiveProps} />);
+      // Same tax line 25.00 and same total 125.00 — proves both modes produce the same display
+      expect(screen.getAllByText(/25\.00/).length).toBeGreaterThanOrEqual(1);
+      expect(screen.getAllByText(/125\.00/).length).toBeGreaterThanOrEqual(1);
+      // And the back-calculated net 100.00 appears (in the parts table line)
+      expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1);
+    });
+  });
 });

+ 186 - 0
src/__tests__/features/quotes/create-quote.test.ts

@@ -26,6 +26,7 @@ vi.mock("@/lib/db", () => ({
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
     quote: { findFirst: vi.fn() },
+    customer: { findFirst: vi.fn() },
     $transaction: vi.fn(),
   },
 }));
@@ -54,6 +55,7 @@ function setupAuth() {
 function setupQuoteCreation() {
   vi.mocked(db.appSetting.findMany).mockResolvedValue([]);
   vi.mocked(db.quote.findFirst).mockResolvedValue(null); // no previous quotes
+  vi.mocked(db.customer.findFirst).mockResolvedValue(null); // not tax-exempt by default
 }
 
 beforeEach(() => {
@@ -315,3 +317,187 @@ describe("createQuote — org scoping", () => {
     );
   });
 });
+
+// ---------------------------------------------------------------------------
+// createQuote — tax injection (org defaults + customer.taxExempt)
+// ---------------------------------------------------------------------------
+
+describe("createQuote — tax injection", () => {
+  it("injects the org default tax rate when caller sends taxRate=0", async () => {
+    setupAuth();
+    setupQuoteCreation();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+    ]);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-default-rate" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      status: "draft",
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 25 }),
+      })
+    );
+  });
+
+  it("respects an explicit non-zero taxRate from the caller (no auto-injection)", async () => {
+    setupAuth();
+    setupQuoteCreation();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+    ]);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-explicit-rate" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      status: "draft",
+      subtotal: 0,
+      taxRate: 8,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 8 }),
+      })
+    );
+  });
+
+  it("injects taxInclusive=true from the org setting", async () => {
+    setupAuth();
+    setupQuoteCreation();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.taxInclusive", value: "true" } as any,
+    ]);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-incl" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      status: "draft",
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxInclusive: true }),
+      })
+    );
+  });
+
+  it("forces taxRate=0 when the customer is tax exempt (overrides any caller value)", async () => {
+    setupAuth();
+    setupQuoteCreation();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+    ]);
+    vi.mocked(db.customer.findFirst).mockResolvedValue({ taxExempt: true } as any);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-exempt" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      customerId: "cust-exempt",
+      status: "draft",
+      subtotal: 0,
+      taxRate: 25,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 0 }),
+      })
+    );
+  });
+
+  it("does NOT zero out taxRate when the customer is NOT tax exempt", async () => {
+    setupAuth();
+    setupQuoteCreation();
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+    ]);
+    vi.mocked(db.customer.findFirst).mockResolvedValue({ taxExempt: false } as any);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "q-normal" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { create: mockCreate },
+        quotePart: { createMany: vi.fn() },
+        quoteLabor: { createMany: vi.fn() },
+      })
+    );
+
+    await createQuote({
+      title: "Test",
+      customerId: "cust-normal",
+      status: "draft",
+      subtotal: 100,
+      taxRate: 0, // empty → should pick up the org default of 25
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 100,
+    });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 25 }),
+      })
+    );
+  });
+});

+ 342 - 0
src/__tests__/features/reports/reports-tax.test.ts

@@ -0,0 +1,342 @@
+/**
+ * Tests for tax handling in financial reports.
+ *
+ * Critical behaviour: reports must produce correct numbers regardless of
+ * whether records were entered in inclusive or exclusive tax mode.
+ *
+ * - getRevenueReport: parts/labor revenue must be NET (pre-tax) so net profit
+ *   math works in both modes.
+ * - getTaxReport: taxableAmount must be `totalAmount - taxAmount` (the actual
+ *   net base, valid in both modes and accounting for discounts).
+ * - getPartsUsageReport: per-line totalRevenue must be NET so it adds up with
+ *   the always-net partsCost.
+ * - getVehicleReport: monthly laborCost must be NET so the breakdown is
+ *   consistent across modes.
+ *
+ * The math itself is locked in src/__tests__/lib/tax.test.ts. These tests
+ * prove the report functions wire `netLineTotal` correctly.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/cached-session", () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    serviceRecord: { findMany: vi.fn() },
+    servicePart: { findMany: vi.fn() },
+    customer: { findMany: vi.fn() },
+    vehicle: { findFirst: vi.fn() },
+  },
+}));
+
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { db } from "@/lib/db";
+import {
+  getRevenueReport,
+  getTaxReport,
+  getPartsUsageReport,
+  getVehicleReport,
+} from "@/features/reports/Actions/reportActions";
+
+const mockSession = vi.mocked(getCachedSession);
+const mockMembership = vi.mocked(getCachedMembership);
+
+const ORG = "org-1";
+const USER_ID = "user-1";
+
+function setupAuth() {
+  mockSession.mockResolvedValue({ user: { id: USER_ID, email: "user@example.com" } } as any);
+  mockMembership.mockResolvedValue({
+    organizationId: ORG,
+    role: "owner",
+    roleId: null,
+    customRole: null,
+  } as any);
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any);
+}
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// getRevenueReport
+// ---------------------------------------------------------------------------
+
+describe("getRevenueReport — net parts/labor calculation", () => {
+  it("returns net parts/labor revenue for an exclusive record (no scaling)", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date("2026-01-15"),
+        startDateTime: new Date("2026-01-15"),
+        totalAmount: 250,
+        cost: 250,
+        type: "repair",
+        taxRate: 25,
+        taxInclusive: false,
+        manuallyPaid: false,
+        payments: [],
+        partItems: [{ unitCost: 50, quantity: 1, total: 100 }], // net
+        laborItems: [{ total: 100 }], // net
+      },
+    ] as any);
+
+    const result = await getRevenueReport({});
+
+    expect(result.success).toBe(true);
+    expect(result.data?.summary.totalPartsRevenue).toBeCloseTo(100);
+    expect(result.data?.summary.totalLaborRevenue).toBeCloseTo(100);
+    // Net profit = (parts net 100 - cost 50) + labor net 100 = 150
+    expect(result.data?.summary.netProfit).toBeCloseTo(150);
+  });
+
+  it("back-calculates net parts/labor revenue for an inclusive record", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date("2026-01-15"),
+        startDateTime: new Date("2026-01-15"),
+        totalAmount: 250, // gross customer total (same as exclusive case above)
+        cost: 250,
+        type: "repair",
+        taxRate: 25,
+        taxInclusive: true,
+        manuallyPaid: false,
+        payments: [],
+        partItems: [{ unitCost: 50, quantity: 1, total: 125 }], // gross — was 100 net
+        laborItems: [{ total: 125 }], // gross — was 100 net
+      },
+    ] as any);
+
+    const result = await getRevenueReport({});
+
+    expect(result.success).toBe(true);
+    // Net values back-calculated: 125 / 1.25 = 100
+    expect(result.data?.summary.totalPartsRevenue).toBeCloseTo(100);
+    expect(result.data?.summary.totalLaborRevenue).toBeCloseTo(100);
+    // Net profit = (parts net 100 - cost 50) + labor net 100 = 150 (SAME as exclusive)
+    expect(result.data?.summary.netProfit).toBeCloseTo(150);
+  });
+
+  it("the same logical revenue produces identical netProfit in both modes", async () => {
+    setupAuth();
+    // Exclusive: 100 net parts + 50 parts cost + 200 net labor = 250 net + 25% tax
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date("2026-01-15"),
+        startDateTime: new Date("2026-01-15"),
+        totalAmount: 312.5,
+        cost: 312.5,
+        type: "repair",
+        taxRate: 25,
+        taxInclusive: false,
+        manuallyPaid: false,
+        payments: [],
+        partItems: [{ unitCost: 50, quantity: 1, total: 100 }],
+        laborItems: [{ total: 200 }],
+      },
+    ] as any);
+    const exclusive = await getRevenueReport({});
+
+    // Inclusive equivalent: line items scaled by 1.25
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date("2026-01-15"),
+        startDateTime: new Date("2026-01-15"),
+        totalAmount: 312.5,
+        cost: 312.5,
+        type: "repair",
+        taxRate: 25,
+        taxInclusive: true,
+        manuallyPaid: false,
+        payments: [],
+        partItems: [{ unitCost: 50, quantity: 1, total: 125 }],
+        laborItems: [{ total: 250 }],
+      },
+    ] as any);
+    const inclusive = await getRevenueReport({});
+
+    expect(exclusive.data?.summary.netProfit).toBeCloseTo(inclusive.data!.summary.netProfit);
+    expect(exclusive.data?.summary.totalPartsRevenue).toBeCloseTo(inclusive.data!.summary.totalPartsRevenue);
+    expect(exclusive.data?.summary.totalLaborRevenue).toBeCloseTo(inclusive.data!.summary.totalLaborRevenue);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// getTaxReport
+// ---------------------------------------------------------------------------
+
+describe("getTaxReport — taxableAmount formula", () => {
+  it("uses (totalAmount - taxAmount) as the taxable base in exclusive mode", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date("2026-02-10"),
+        startDateTime: new Date("2026-02-10"),
+        subtotal: 200,
+        taxRate: 25,
+        taxAmount: 45,
+        taxInclusive: false,
+        totalAmount: 225, // 180 net + 45 tax
+      },
+    ] as any);
+
+    const result = await getTaxReport({});
+
+    expect(result.success).toBe(true);
+    expect(result.data?.summary.totalTaxCollected).toBeCloseTo(45);
+    // Net taxable = 225 - 45 = 180. NOT the raw subtotal of 200 (pre-discount).
+    expect(result.data?.summary.totalTaxableAmount).toBeCloseTo(180);
+  });
+
+  it("uses (totalAmount - taxAmount) as the taxable base in inclusive mode", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date("2026-02-10"),
+        startDateTime: new Date("2026-02-10"),
+        subtotal: 225, // gross
+        taxRate: 25,
+        taxAmount: 45,
+        taxInclusive: true,
+        totalAmount: 225, // gross customer total
+      },
+    ] as any);
+
+    const result = await getTaxReport({});
+
+    expect(result.success).toBe(true);
+    expect(result.data?.summary.totalTaxCollected).toBeCloseTo(45);
+    // Net taxable = 225 - 45 = 180 (same as exclusive equivalent)
+    expect(result.data?.summary.totalTaxableAmount).toBeCloseTo(180);
+  });
+
+  it("aggregates tax across mixed-mode records correctly", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      // Exclusive: 100 net + 25 tax = 125 total
+      {
+        serviceDate: new Date("2026-02-01"),
+        startDateTime: new Date("2026-02-01"),
+        subtotal: 100,
+        taxRate: 25,
+        taxAmount: 25,
+        taxInclusive: false,
+        totalAmount: 125,
+      },
+      // Inclusive: 125 gross = 100 net + 25 tax
+      {
+        serviceDate: new Date("2026-02-15"),
+        startDateTime: new Date("2026-02-15"),
+        subtotal: 125,
+        taxRate: 25,
+        taxAmount: 25,
+        taxInclusive: true,
+        totalAmount: 125,
+      },
+    ] as any);
+
+    const result = await getTaxReport({});
+
+    expect(result.data?.summary.totalTaxCollected).toBeCloseTo(50);
+    expect(result.data?.summary.totalTaxableAmount).toBeCloseTo(200); // 100 + 100
+    expect(result.data?.summary.totalInvoices).toBe(2);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// getPartsUsageReport
+// ---------------------------------------------------------------------------
+
+describe("getPartsUsageReport — per-line net calculation", () => {
+  it("uses net per-line totals so profit math is consistent for inclusive records", async () => {
+    setupAuth();
+    vi.mocked(db.servicePart.findMany).mockResolvedValue([
+      {
+        name: "Brake Pad",
+        partNumber: "BP-001",
+        quantity: 2,
+        total: 250, // gross — 200 net at 25%
+        unitCost: 50,
+        serviceRecord: { taxRate: 25, taxInclusive: true },
+      },
+    ] as any);
+
+    const result = await getPartsUsageReport({});
+
+    expect(result.success).toBe(true);
+    expect(result.data?.totalPartsRevenue).toBeCloseTo(200); // 250 / 1.25
+    expect(result.data?.totalPartsCost).toBeCloseTo(100); // 50 * 2
+    expect(result.data?.totalPartsNetProfit).toBeCloseTo(100); // 200 - 100
+  });
+
+  it("uses raw per-line totals for exclusive records (no scaling)", async () => {
+    setupAuth();
+    vi.mocked(db.servicePart.findMany).mockResolvedValue([
+      {
+        name: "Brake Pad",
+        partNumber: "BP-001",
+        quantity: 2,
+        total: 200, // already net
+        unitCost: 50,
+        serviceRecord: { taxRate: 25, taxInclusive: false },
+      },
+    ] as any);
+
+    const result = await getPartsUsageReport({});
+
+    expect(result.data?.totalPartsRevenue).toBeCloseTo(200);
+    expect(result.data?.totalPartsCost).toBeCloseTo(100);
+    expect(result.data?.totalPartsNetProfit).toBeCloseTo(100);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// getVehicleReport
+// ---------------------------------------------------------------------------
+
+describe("getVehicleReport — net labor in monthly cost", () => {
+  it("nets the labor cost in inclusive records so monthly breakdown is consistent", async () => {
+    setupAuth();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+      id: "veh-1",
+      year: 2022,
+      make: "Toyota",
+      model: "Camry",
+      vin: "X",
+      licensePlate: "Y",
+      mileage: 50000,
+      customer: { name: "John" },
+    } as any);
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        id: "sr-1",
+        title: "Oil change",
+        type: "maintenance",
+        status: "completed",
+        serviceDate: new Date("2026-03-10"),
+        startDateTime: new Date("2026-03-10"),
+        totalAmount: 250,
+        cost: 250,
+        taxRate: 25,
+        taxInclusive: true,
+        techName: "Tech",
+        technician: null,
+        partItems: [{ name: "Filter", partNumber: "F1", quantity: 1, unitCost: 50, total: 125 }],
+        laborItems: [{ hours: 1, total: 125 }], // gross
+      },
+    ] as any);
+
+    const result = await getVehicleReport({ vehicleId: "veh-1" });
+
+    expect(result.success).toBe(true);
+    // Monthly labor cost should be NET (125 / 1.25 = 100), not gross 125.
+    expect(result.data?.monthlyCosts[0].laborCost).toBeCloseTo(100);
+  });
+});

+ 175 - 0
src/__tests__/features/workorders/add-part.test.ts

@@ -0,0 +1,175 @@
+/**
+ * Tests for addPartToServiceRecord — recalculates totals after adding a part.
+ *
+ * Critical behaviour: must call calculateTotals() with the record's stored
+ * taxInclusive flag so inclusive records aren't broken when the user adds a
+ * part. The math is locked in src/__tests__/lib/tax.test.ts; these tests prove
+ * the wiring is correct.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/cached-session", () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}));
+
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), update: vi.fn() },
+    servicePart: { create: vi.fn(), aggregate: vi.fn() },
+    serviceLabor: { aggregate: vi.fn() },
+    inventoryPart: { update: vi.fn() },
+  },
+}));
+
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { db } from "@/lib/db";
+import { addPartToServiceRecord } from "@/features/vehicles/Actions/addPartToServiceRecord";
+
+const mockSession = vi.mocked(getCachedSession);
+const mockMembership = vi.mocked(getCachedMembership);
+
+const ORG = "org-1";
+const USER_ID = "user-1";
+const RECORD_ID = "sr-1";
+const VEHICLE_ID = "veh-1";
+
+function setupAuth() {
+  mockSession.mockResolvedValue({ user: { id: USER_ID, email: "user@example.com" } } as any);
+  mockMembership.mockResolvedValue({
+    organizationId: ORG,
+    role: "owner",
+    roleId: null,
+    customRole: null,
+  } as any);
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any);
+}
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+describe("addPartToServiceRecord — totals recalculation", () => {
+  it("calculates exclusive totals correctly when adding a part to an exclusive record", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      id: RECORD_ID,
+      vehicleId: VEHICLE_ID,
+      subtotal: 100,
+      taxRate: 25,
+      taxInclusive: false,
+      discountType: null,
+      discountValue: 0,
+    } as any);
+    vi.mocked(db.servicePart.create).mockResolvedValue({ id: "part-1" } as any);
+    // After the new part is added, parts agg = 200, labor agg = 0
+    vi.mocked(db.servicePart.aggregate).mockResolvedValue({ _sum: { total: 200 } } as any);
+    vi.mocked(db.serviceLabor.aggregate).mockResolvedValue({ _sum: { total: 0 } } as any);
+
+    await addPartToServiceRecord({
+      serviceRecordId: RECORD_ID,
+      name: "New Part",
+      quantity: 1,
+      unitPrice: 100,
+      total: 100,
+      unitCost: 50,
+    });
+
+    // Exclusive: subtotal=200, tax=200*0.25=50, total=250
+    expect(vi.mocked(db.serviceRecord.update)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          subtotal: 200,
+          taxAmount: 50,
+          totalAmount: 250,
+        }),
+      })
+    );
+  });
+
+  it("calculates inclusive totals correctly when adding a part to an inclusive record", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      id: RECORD_ID,
+      vehicleId: VEHICLE_ID,
+      subtotal: 125,
+      taxRate: 25,
+      taxInclusive: true,
+      discountType: null,
+      discountValue: 0,
+    } as any);
+    vi.mocked(db.servicePart.create).mockResolvedValue({ id: "part-2" } as any);
+    // After adding a 125 (gross) part, parts agg = 250
+    vi.mocked(db.servicePart.aggregate).mockResolvedValue({ _sum: { total: 250 } } as any);
+    vi.mocked(db.serviceLabor.aggregate).mockResolvedValue({ _sum: { total: 0 } } as any);
+
+    await addPartToServiceRecord({
+      serviceRecordId: RECORD_ID,
+      name: "Inclusive Part",
+      quantity: 1,
+      unitPrice: 125,
+      total: 125,
+      unitCost: 50,
+    });
+
+    // Inclusive: gross subtotal=250, net=200, tax=50, total=250 (gross)
+    const updateCall = vi.mocked(db.serviceRecord.update).mock.calls[0][0] as any;
+    expect(updateCall.data.subtotal).toBe(250);
+    expect(updateCall.data.taxAmount).toBeCloseTo(50);
+    expect(updateCall.data.totalAmount).toBeCloseTo(250);
+  });
+
+  it("respects a fixed discount when recalculating in inclusive mode", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      id: RECORD_ID,
+      vehicleId: VEHICLE_ID,
+      subtotal: 0,
+      taxRate: 10,
+      taxInclusive: true,
+      discountType: "fixed",
+      discountValue: 22, // 22 gross discount
+    } as any);
+    vi.mocked(db.servicePart.create).mockResolvedValue({ id: "part-3" } as any);
+    // After adding, parts agg = 110 (gross), labor = 0
+    vi.mocked(db.servicePart.aggregate).mockResolvedValue({ _sum: { total: 110 } } as any);
+    vi.mocked(db.serviceLabor.aggregate).mockResolvedValue({ _sum: { total: 0 } } as any);
+
+    await addPartToServiceRecord({
+      serviceRecordId: RECORD_ID,
+      name: "Discounted Part",
+      quantity: 1,
+      unitPrice: 110,
+      total: 110,
+      unitCost: 50,
+    });
+
+    // Inclusive 10%, subtotal=110, discount=22, base=88, net=80, tax=8, total=88
+    const updateCall = vi.mocked(db.serviceRecord.update).mock.calls[0][0] as any;
+    expect(updateCall.data.subtotal).toBe(110);
+    expect(updateCall.data.taxAmount).toBeCloseTo(8);
+    expect(updateCall.data.totalAmount).toBeCloseTo(88);
+  });
+
+  it("rejects when the service record does not belong to the org", async () => {
+    setupAuth();
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null);
+
+    const result = await addPartToServiceRecord({
+      serviceRecordId: "other-record",
+      name: "Test",
+      quantity: 1,
+      unitPrice: 10,
+      total: 10,
+      unitCost: 5,
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toBe("Service record not found");
+    expect(vi.mocked(db.servicePart.create)).not.toHaveBeenCalled();
+  });
+});

+ 176 - 0
src/__tests__/features/workorders/create-workorder.test.ts

@@ -608,3 +608,179 @@ describe("createDraftServiceRecord — scheduling", () => {
     expect(startDT.getMinutes()).toBe(30);
   });
 });
+
+// ---------------------------------------------------------------------------
+// Tax injection — both creation actions read settings + customer.taxExempt
+// at creation time. These tests prove the wiring (lib/tax math is locked
+// separately in src/__tests__/lib/tax.test.ts).
+// ---------------------------------------------------------------------------
+
+describe("createServiceRecord — tax injection", () => {
+  it("inherits the org's workshop.taxInclusive setting when caller doesn't pass it", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: false } } as any);
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.taxInclusive", value: "true" } as any,
+    ]);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "sr-incl" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        serviceRecord: { create: mockCreate },
+        servicePart: { createMany: vi.fn() },
+        serviceLabor: { createMany: vi.fn() },
+        serviceAttachment: { createMany: vi.fn() },
+        inventoryPart: { findFirst: vi.fn(), update: vi.fn() },
+      })
+    );
+
+    await createServiceRecord({ vehicleId: VEHICLE_ID, title: "Test" });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxInclusive: true }),
+      })
+    );
+  });
+
+  it("respects an explicit taxInclusive value from the caller (overrides org default)", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: false } } as any);
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.taxInclusive", value: "true" } as any,
+    ]);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "sr-explicit" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        serviceRecord: { create: mockCreate },
+        servicePart: { createMany: vi.fn() },
+        serviceLabor: { createMany: vi.fn() },
+        serviceAttachment: { createMany: vi.fn() },
+        inventoryPart: { findFirst: vi.fn(), update: vi.fn() },
+      })
+    );
+
+    // Caller explicitly passes taxInclusive: false. Should be respected.
+    await createServiceRecord({ vehicleId: VEHICLE_ID, title: "Test", taxInclusive: false });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxInclusive: false }),
+      })
+    );
+  });
+
+  it("forces taxRate=0 and taxAmount=0 when the customer is tax exempt", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: true } } as any);
+
+    const mockCreate = vi.fn().mockResolvedValue({ id: "sr-exempt" });
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        serviceRecord: { create: mockCreate },
+        servicePart: { createMany: vi.fn() },
+        serviceLabor: { createMany: vi.fn() },
+        serviceAttachment: { createMany: vi.fn() },
+        inventoryPart: { findFirst: vi.fn(), update: vi.fn() },
+      })
+    );
+
+    // Caller passes a non-zero rate; the exempt flag must override it.
+    await createServiceRecord({
+      vehicleId: VEHICLE_ID,
+      title: "Test",
+      taxRate: 25,
+      taxAmount: 50,
+    });
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 0, taxAmount: 0 }),
+      })
+    );
+  });
+});
+
+describe("createDraftServiceRecord — tax injection", () => {
+  it("injects the org default tax rate from settings", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: false } } as any);
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+    ]);
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: "draft-1" } as any);
+
+    await createDraftServiceRecord(VEHICLE_ID);
+
+    expect(vi.mocked(db.serviceRecord.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 25 }),
+      })
+    );
+  });
+
+  it("injects taxInclusive=true from the org setting", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: false } } as any);
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+      { key: "workshop.taxInclusive", value: "true" } as any,
+    ]);
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: "draft-incl" } as any);
+
+    await createDraftServiceRecord(VEHICLE_ID);
+
+    expect(vi.mocked(db.serviceRecord.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxInclusive: true }),
+      })
+    );
+  });
+
+  it("forces taxRate=0 when the vehicle's customer is tax exempt", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: true } } as any);
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "true" } as any,
+    ]);
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: "draft-exempt" } as any);
+
+    await createDraftServiceRecord(VEHICLE_ID);
+
+    // Even though the org default is 25%, the exempt customer overrides it.
+    expect(vi.mocked(db.serviceRecord.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 0 }),
+      })
+    );
+  });
+
+  it("uses 0 as the default rate when tax is disabled in settings", async () => {
+    setupAuth();
+    setupCommonMocks();
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ ...VEHICLE, customer: { taxExempt: false } } as any);
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: "workshop.defaultTaxRate", value: "25" } as any,
+      { key: "workshop.taxEnabled", value: "false" } as any,
+    ]);
+    vi.mocked(db.serviceRecord.create).mockResolvedValue({ id: "draft-disabled" } as any);
+
+    await createDraftServiceRecord(VEHICLE_ID);
+
+    expect(vi.mocked(db.serviceRecord.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ taxRate: 0 }),
+      })
+    );
+  });
+});

+ 181 - 0
src/__tests__/features/workorders/invoice-summary.test.tsx

@@ -0,0 +1,181 @@
+/**
+ * Tests for InvoiceSummary — the live preview pane on the work order edit page.
+ *
+ * Critical behaviour: this component must use the universal display (net per
+ * category, net subtotal, separate tax line, gross total) regardless of
+ * whether the record was entered in inclusive or exclusive mode.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { NextIntlClientProvider } from "next-intl";
+
+vi.mock("lucide-react", () => ({}));
+
+import { InvoiceSummary } from "@/features/vehicles/Components/service-detail/InvoiceSummary";
+
+const messages = {
+  service: {
+    invoice: {
+      title: "Invoice Summary",
+      parts: "Parts",
+      labor: "Labor",
+      subtotal: "Subtotal",
+      subtotalInclTax: "Subtotal (incl. tax)",
+      discount: "Discount",
+      tax: "Tax ({rate}%)",
+      taxIncluded: "Tax (incl., {rate}%)",
+      total: "Total",
+      paid: "Paid",
+      balanceDue: "Balance Due",
+      paidBadge: "PAID",
+      entityLabel: "Invoice",
+      emailSubject: "",
+      statusEmailSubject: "",
+    },
+  },
+};
+
+function renderSummary(props: Parameters<typeof InvoiceSummary>[0]) {
+  return render(
+    <NextIntlClientProvider locale="en" messages={messages as any}>
+      <InvoiceSummary {...props} />
+    </NextIntlClientProvider>,
+  );
+}
+
+describe("InvoiceSummary — universal display", () => {
+  it("shows net values + tax line + gross total for an exclusive record", () => {
+    renderSummary({
+      hasPartItems: true,
+      hasLaborItems: true,
+      partsSubtotal: 100,
+      laborSubtotal: 200,
+      subtotal: 300,
+      discountAmount: 0,
+      discountType: null,
+      discountValue: 0,
+      taxRate: 25,
+      taxAmount: 75,
+      taxInclusive: false,
+      displayTotal: 375,
+      totalPaid: 0,
+      balanceDue: 375,
+      hasPayments: false,
+      currencyCode: "USD",
+    });
+
+    // Net subtotal 300, tax 75, total 375
+    expect(screen.getAllByText(/300\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/75\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/375\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getByText("Tax (25%)")).toBeInTheDocument();
+  });
+
+  it("back-calculates net values for an inclusive record but shows the same gross total", () => {
+    renderSummary({
+      hasPartItems: true,
+      hasLaborItems: true,
+      // Stored gross subtotals (the equivalent of the exclusive case scaled by 1.25)
+      partsSubtotal: 125,
+      laborSubtotal: 250,
+      subtotal: 375,
+      discountAmount: 0,
+      discountType: null,
+      discountValue: 0,
+      taxRate: 25,
+      taxAmount: 75,
+      taxInclusive: true,
+      displayTotal: 375, // gross customer total
+      totalPaid: 0,
+      balanceDue: 375,
+      hasPayments: false,
+      currencyCode: "USD",
+    });
+
+    // Should display net parts (100), net labor (200), net subtotal (300),
+    // separate tax line (75), gross total (375).
+    expect(screen.getAllByText(/100\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/200\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/300\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/75\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/375\.00/).length).toBeGreaterThanOrEqual(1);
+    // The tax line label is universal — NOT "Tax (incl., 25%)"
+    expect(screen.getByText("Tax (25%)")).toBeInTheDocument();
+  });
+
+  it("nets the discount line in inclusive mode", () => {
+    renderSummary({
+      hasPartItems: true,
+      hasLaborItems: false,
+      partsSubtotal: 250,
+      laborSubtotal: 0,
+      subtotal: 250, // gross
+      discountAmount: 25, // gross discount
+      discountType: "fixed",
+      discountValue: 25,
+      taxRate: 25,
+      taxAmount: 45,
+      taxInclusive: true,
+      displayTotal: 225, // gross customer total after discount
+      totalPaid: 0,
+      balanceDue: 225,
+      hasPayments: false,
+      currencyCode: "USD",
+    });
+
+    // Net values: subtotal 200, discount 20, tax 45, total 225
+    expect(screen.getAllByText(/200\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/20\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/45\.00/).length).toBeGreaterThanOrEqual(1);
+    expect(screen.getAllByText(/225\.00/).length).toBeGreaterThanOrEqual(1);
+  });
+
+  it("equivalent invoices show identical visible numbers in both modes", () => {
+    // Render exclusive
+    const { unmount } = renderSummary({
+      hasPartItems: true,
+      hasLaborItems: false,
+      partsSubtotal: 100,
+      laborSubtotal: 0,
+      subtotal: 100,
+      discountAmount: 0,
+      discountType: null,
+      discountValue: 0,
+      taxRate: 25,
+      taxAmount: 25,
+      taxInclusive: false,
+      displayTotal: 125,
+      totalPaid: 0,
+      balanceDue: 125,
+      hasPayments: false,
+      currencyCode: "USD",
+    });
+    const exclusiveText = screen.getAllByText(/100\.00|25\.00|125\.00/).map(el => el.textContent);
+    unmount();
+
+    // Render the equivalent inclusive
+    renderSummary({
+      hasPartItems: true,
+      hasLaborItems: false,
+      partsSubtotal: 125,
+      laborSubtotal: 0,
+      subtotal: 125,
+      discountAmount: 0,
+      discountType: null,
+      discountValue: 0,
+      taxRate: 25,
+      taxAmount: 25,
+      taxInclusive: true,
+      displayTotal: 125,
+      totalPaid: 0,
+      balanceDue: 125,
+      hasPayments: false,
+      currencyCode: "USD",
+    });
+    const inclusiveText = screen.getAllByText(/100\.00|25\.00|125\.00/).map(el => el.textContent);
+
+    // Both modes should display the same set of monetary values.
+    expect(inclusiveText.sort()).toEqual(exclusiveText.sort());
+  });
+});

+ 436 - 0
src/__tests__/lib/tax.test.ts

@@ -0,0 +1,436 @@
+import { describe, it, expect } from "vitest";
+import { calculateTotals, netLineTotal } from "@/lib/tax";
+
+describe("calculateTotals", () => {
+  describe("exclusive mode", () => {
+    it("adds tax on top of subtotal", () => {
+      const r = calculateTotals({
+        subtotal: 100,
+        discountAmount: 0,
+        taxRate: 10,
+        taxInclusive: false,
+      });
+      expect(r.taxAmount).toBeCloseTo(10);
+      expect(r.totalAmount).toBeCloseTo(110);
+    });
+
+    it("applies discount before tax", () => {
+      const r = calculateTotals({
+        subtotal: 100,
+        discountAmount: 20,
+        taxRate: 10,
+        taxInclusive: false,
+      });
+      // base = 80, tax = 8, total = 88
+      expect(r.taxAmount).toBeCloseTo(8);
+      expect(r.totalAmount).toBeCloseTo(88);
+    });
+
+    it("returns zero tax when rate is zero", () => {
+      const r = calculateTotals({
+        subtotal: 100,
+        discountAmount: 0,
+        taxRate: 0,
+        taxInclusive: false,
+      });
+      expect(r.taxAmount).toBe(0);
+      expect(r.totalAmount).toBe(100);
+    });
+  });
+
+  describe("inclusive mode", () => {
+    it("reverse-calculates the net from a gross subtotal", () => {
+      const r = calculateTotals({
+        subtotal: 110,
+        discountAmount: 0,
+        taxRate: 10,
+        taxInclusive: true,
+      });
+      // gross = 110, net = 100, tax = 10
+      expect(r.taxAmount).toBeCloseTo(10);
+      expect(r.totalAmount).toBeCloseTo(110);
+    });
+
+    it("totalAmount equals gross subtotal minus discount in inclusive mode", () => {
+      const r = calculateTotals({
+        subtotal: 110,
+        discountAmount: 11,
+        taxRate: 10,
+        taxInclusive: true,
+      });
+      // base (gross after discount) = 99, net = 90, tax = 9, total = 99
+      expect(r.taxAmount).toBeCloseTo(9);
+      expect(r.totalAmount).toBeCloseTo(99);
+    });
+
+    it("returns zero tax when rate is zero (inclusive)", () => {
+      const r = calculateTotals({
+        subtotal: 100,
+        discountAmount: 0,
+        taxRate: 0,
+        taxInclusive: true,
+      });
+      expect(r.taxAmount).toBe(0);
+      expect(r.totalAmount).toBe(100);
+    });
+
+    it("handles 25% VAT correctly (Norwegian rate)", () => {
+      // 125 gross with 25% VAT → 100 net + 25 tax
+      const r = calculateTotals({
+        subtotal: 125,
+        discountAmount: 0,
+        taxRate: 25,
+        taxInclusive: true,
+      });
+      expect(r.taxAmount).toBeCloseTo(25);
+      expect(r.totalAmount).toBeCloseTo(125);
+    });
+  });
+
+  describe("edge cases", () => {
+    it("clamps base at zero when discount exceeds subtotal", () => {
+      const r = calculateTotals({
+        subtotal: 50,
+        discountAmount: 100,
+        taxRate: 10,
+        taxInclusive: false,
+      });
+      expect(r.taxAmount).toBe(0);
+      expect(r.totalAmount).toBe(0);
+    });
+
+    it("inclusive mode with discount: total equals gross-after-discount", () => {
+      // 2 line items at $50 gross each, $10 gross discount, 10% tax
+      const r = calculateTotals({
+        subtotal: 100,
+        discountAmount: 10,
+        taxRate: 10,
+        taxInclusive: true,
+      });
+      // base (gross after discount) = 90, net = 81.82, tax = 8.18, total = 90
+      expect(r.totalAmount).toBeCloseTo(90);
+      expect(r.taxAmount).toBeCloseTo(8.1818, 3);
+    });
+
+    it("net base equals totalAmount - taxAmount in both modes", () => {
+      const exclusive = calculateTotals({
+        subtotal: 200,
+        discountAmount: 20,
+        taxRate: 25,
+        taxInclusive: false,
+      });
+      const inclusive = calculateTotals({
+        subtotal: 225,
+        discountAmount: 25,
+        taxRate: 25,
+        taxInclusive: true,
+      });
+      // The "taxable base" formula used by the tax report:
+      const exclusiveBase = exclusive.totalAmount - exclusive.taxAmount;
+      const inclusiveBase = inclusive.totalAmount - inclusive.taxAmount;
+      expect(exclusiveBase).toBeCloseTo(180); // 200 - 20
+      expect(inclusiveBase).toBeCloseTo(160); // (225 - 25) / 1.25
+    });
+  });
+});
+
+describe("convert exclusive → inclusive (round-trip)", () => {
+  // The convertRecordsToInclusive backfill scales line items by (1 + rate/100).
+  // After scaling, calculateTotals in inclusive mode must produce the SAME
+  // taxAmount and totalAmount that the original exclusive calculation produced
+  // — i.e. the customer-facing total is preserved.
+
+  it("preserves totals for an exclusive record with no discount", () => {
+    const taxRate = 25;
+    const exclusive = calculateTotals({
+      subtotal: 100,
+      discountAmount: 0,
+      taxRate,
+      taxInclusive: false,
+    });
+    // Apply the conversion: scale subtotal by (1 + rate/100)
+    const factor = 1 + taxRate / 100;
+    const inclusive = calculateTotals({
+      subtotal: 100 * factor,
+      discountAmount: 0,
+      taxRate,
+      taxInclusive: true,
+    });
+    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount);
+    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount);
+  });
+
+  it("preserves totals when converting a record with a fixed discount", () => {
+    const taxRate = 10;
+    const exclusive = calculateTotals({
+      subtotal: 100,
+      discountAmount: 20,
+      taxRate,
+      taxInclusive: false,
+    });
+    // Both subtotal AND fixed discountAmount must scale to preserve the math
+    const factor = 1 + taxRate / 100;
+    const inclusive = calculateTotals({
+      subtotal: 100 * factor,
+      discountAmount: 20 * factor,
+      taxRate,
+      taxInclusive: true,
+    });
+    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount);
+    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount);
+  });
+
+  it("preserves totals for a percentage-discount record", () => {
+    const taxRate = 25;
+    // 10% discount: discountAmount = subtotal * 0.10
+    const exclusive = calculateTotals({
+      subtotal: 200,
+      discountAmount: 20,
+      taxRate,
+      taxInclusive: false,
+    });
+    // Both scale by the same factor; percentage stays the same so the
+    // resulting discountAmount = (200 * factor) * 0.10 = 20 * factor.
+    const factor = 1 + taxRate / 100;
+    const inclusive = calculateTotals({
+      subtotal: 200 * factor,
+      discountAmount: 20 * factor,
+      taxRate,
+      taxInclusive: true,
+    });
+    expect(inclusive.taxAmount).toBeCloseTo(exclusive.taxAmount);
+    expect(inclusive.totalAmount).toBeCloseTo(exclusive.totalAmount);
+  });
+
+  it("handles taxRate=0 (factor=1, no scaling needed)", () => {
+    const exclusive = calculateTotals({
+      subtotal: 100,
+      discountAmount: 0,
+      taxRate: 0,
+      taxInclusive: false,
+    });
+    const inclusive = calculateTotals({
+      subtotal: 100, // factor = 1
+      discountAmount: 0,
+      taxRate: 0,
+      taxInclusive: true,
+    });
+    expect(inclusive.taxAmount).toBe(exclusive.taxAmount);
+    expect(inclusive.totalAmount).toBe(exclusive.totalAmount);
+  });
+});
+
+describe("convert inclusive → exclusive (round-trip)", () => {
+  // The convertRecordsToExclusive backfill divides line items by (1 + rate/100).
+  // After scaling, calculateTotals in exclusive mode must produce the SAME
+  // taxAmount and totalAmount the original inclusive record had.
+
+  it("preserves totals for an inclusive record with no discount", () => {
+    const taxRate = 25;
+    const inclusive = calculateTotals({
+      subtotal: 125,
+      discountAmount: 0,
+      taxRate,
+      taxInclusive: true,
+    });
+    const factor = 1 + taxRate / 100;
+    const exclusive = calculateTotals({
+      subtotal: 125 / factor,
+      discountAmount: 0,
+      taxRate,
+      taxInclusive: false,
+    });
+    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount);
+    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount);
+  });
+
+  it("preserves totals when converting an inclusive record with a fixed discount back to exclusive", () => {
+    const taxRate = 10;
+    const inclusive = calculateTotals({
+      subtotal: 110,
+      discountAmount: 22,
+      taxRate,
+      taxInclusive: true,
+    });
+    const factor = 1 + taxRate / 100;
+    const exclusive = calculateTotals({
+      subtotal: 110 / factor,
+      discountAmount: 22 / factor,
+      taxRate,
+      taxInclusive: false,
+    });
+    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount);
+    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount);
+  });
+
+  it("preserves totals for an inclusive record with a percentage discount", () => {
+    const taxRate = 25;
+    // Inclusive 10% discount on 200 gross subtotal
+    const inclusive = calculateTotals({
+      subtotal: 200,
+      discountAmount: 20,
+      taxRate,
+      taxInclusive: true,
+    });
+    const factor = 1 + taxRate / 100;
+    const exclusive = calculateTotals({
+      subtotal: 200 / factor,
+      discountAmount: 20 / factor,
+      taxRate,
+      taxInclusive: false,
+    });
+    expect(exclusive.taxAmount).toBeCloseTo(inclusive.taxAmount);
+    expect(exclusive.totalAmount).toBeCloseTo(inclusive.totalAmount);
+  });
+});
+
+describe("netLineTotal", () => {
+  it("returns the line total unchanged for exclusive mode", () => {
+    expect(netLineTotal(100, 10, false)).toBe(100);
+    expect(netLineTotal(100, 25, false)).toBe(100);
+  });
+
+  it("returns the line total unchanged when tax rate is zero", () => {
+    expect(netLineTotal(100, 0, true)).toBe(100);
+  });
+
+  it("backs out tax for inclusive mode with positive rate", () => {
+    expect(netLineTotal(110, 10, true)).toBeCloseTo(100);
+    expect(netLineTotal(125, 25, true)).toBeCloseTo(100);
+  });
+
+  it("inclusive net + tax matches the original gross", () => {
+    const gross = 125;
+    const rate = 25;
+    const net = netLineTotal(gross, rate, true);
+    const tax = gross - net;
+    expect(net + tax).toBeCloseTo(gross);
+    expect(net).toBeCloseTo(100);
+    expect(tax).toBeCloseTo(25);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Universal display formula — used by all PDFs/share views/invoice summary.
+// The display always shows: net subtotal + net discount + tax + gross total,
+// regardless of whether the record was entered in inclusive or exclusive mode.
+// These tests prove the same formulas balance for both modes and locked their
+// behaviour against future regressions.
+// ---------------------------------------------------------------------------
+describe("universal invoice display formulas", () => {
+  function buildDisplay(args: {
+    subtotal: number;
+    discountAmount: number;
+    taxRate: number;
+    taxInclusive: boolean;
+  }) {
+    const { subtotal, discountAmount, taxRate, taxInclusive } = args;
+    const { taxAmount, totalAmount } = calculateTotals(args);
+    return {
+      // Per-line and category subtotals are netted for display:
+      displaySubtotal: netLineTotal(subtotal, taxRate, taxInclusive),
+      displayDiscount: netLineTotal(discountAmount, taxRate, taxInclusive),
+      // Tax and total come straight from stored values (always correct).
+      displayTax: taxAmount,
+      displayTotal: totalAmount,
+    };
+  }
+
+  it("exclusive mode: simple invoice", () => {
+    const d = buildDisplay({ subtotal: 100, discountAmount: 0, taxRate: 25, taxInclusive: false });
+    expect(d.displaySubtotal).toBeCloseTo(100);
+    expect(d.displayDiscount).toBeCloseTo(0);
+    expect(d.displayTax).toBeCloseTo(25);
+    expect(d.displayTotal).toBeCloseTo(125);
+    // The display equation must balance.
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
+  });
+
+  it("inclusive mode: simple invoice — net per-line is back-calculated", () => {
+    // User typed 125 (gross), inclusive 25%
+    const d = buildDisplay({ subtotal: 125, discountAmount: 0, taxRate: 25, taxInclusive: true });
+    expect(d.displaySubtotal).toBeCloseTo(100); // back-calculated net
+    expect(d.displayDiscount).toBeCloseTo(0);
+    expect(d.displayTax).toBeCloseTo(25);
+    expect(d.displayTotal).toBeCloseTo(125);
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
+  });
+
+  it("exclusive mode: with fixed discount", () => {
+    const d = buildDisplay({ subtotal: 200, discountAmount: 20, taxRate: 25, taxInclusive: false });
+    // base = 180, tax = 45, total = 225
+    expect(d.displaySubtotal).toBeCloseTo(200);
+    expect(d.displayDiscount).toBeCloseTo(20);
+    expect(d.displayTax).toBeCloseTo(45);
+    expect(d.displayTotal).toBeCloseTo(225);
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
+  });
+
+  it("inclusive mode: with fixed discount — discount is also back-calculated", () => {
+    // User typed 250 gross subtotal, 25 gross discount, inclusive 25%
+    // base = 225 gross, net = 180, tax = 45, total = 225
+    const d = buildDisplay({ subtotal: 250, discountAmount: 25, taxRate: 25, taxInclusive: true });
+    expect(d.displaySubtotal).toBeCloseTo(200); // 250 / 1.25
+    expect(d.displayDiscount).toBeCloseTo(20); // 25 / 1.25
+    expect(d.displayTax).toBeCloseTo(45);
+    expect(d.displayTotal).toBeCloseTo(225);
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
+  });
+
+  it("inclusive mode: 0% tax — display equals stored values", () => {
+    const d = buildDisplay({ subtotal: 100, discountAmount: 10, taxRate: 0, taxInclusive: true });
+    expect(d.displaySubtotal).toBeCloseTo(100);
+    expect(d.displayDiscount).toBeCloseTo(10);
+    expect(d.displayTax).toBe(0);
+    expect(d.displayTotal).toBeCloseTo(90);
+  });
+
+  it("Norwegian VAT (25%) inclusive — exact 180 round-trip", () => {
+    // The user's reported case: typed 180 → conversion stored 225 inclusive
+    // The universal display should show 180 again on the invoice.
+    const d = buildDisplay({ subtotal: 225, discountAmount: 0, taxRate: 25, taxInclusive: true });
+    expect(d.displaySubtotal).toBeCloseTo(180);
+    expect(d.displayTax).toBeCloseTo(45);
+    expect(d.displayTotal).toBeCloseTo(225);
+  });
+
+  it("German VAT (19%) inclusive with discount", () => {
+    // Subtotal 119 gross, 11.90 gross discount, inclusive 19%
+    // base = 107.10, net = 90, tax = 17.10, total = 107.10
+    const d = buildDisplay({ subtotal: 119, discountAmount: 11.9, taxRate: 19, taxInclusive: true });
+    expect(d.displaySubtotal).toBeCloseTo(100);
+    expect(d.displayDiscount).toBeCloseTo(10);
+    expect(d.displayTax).toBeCloseTo(17.1);
+    expect(d.displayTotal).toBeCloseTo(107.1);
+    expect(d.displaySubtotal - d.displayDiscount + d.displayTax).toBeCloseTo(d.displayTotal);
+  });
+
+  it("US sales tax (8.25%) exclusive — unchanged behaviour", () => {
+    const d = buildDisplay({ subtotal: 1000, discountAmount: 0, taxRate: 8.25, taxInclusive: false });
+    expect(d.displaySubtotal).toBeCloseTo(1000);
+    expect(d.displayTax).toBeCloseTo(82.5);
+    expect(d.displayTotal).toBeCloseTo(1082.5);
+  });
+
+  it("the same logical invoice produces identical display in both modes", () => {
+    // An exclusive record with subtotal=1000, no discount, 25% tax
+    // is logically equivalent to an inclusive record with subtotal=1250.
+    // The universal display must produce the same numbers for both.
+    const exclusive = buildDisplay({ subtotal: 1000, discountAmount: 0, taxRate: 25, taxInclusive: false });
+    const inclusive = buildDisplay({ subtotal: 1250, discountAmount: 0, taxRate: 25, taxInclusive: true });
+    expect(exclusive.displaySubtotal).toBeCloseTo(inclusive.displaySubtotal);
+    expect(exclusive.displayTax).toBeCloseTo(inclusive.displayTax);
+    expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal);
+  });
+
+  it("equivalent records with discount produce identical display in both modes", () => {
+    // Exclusive: 200 net subtotal, 20 net discount, 25% tax
+    // Inclusive equivalent: 250 gross subtotal, 25 gross discount, 25% tax
+    const exclusive = buildDisplay({ subtotal: 200, discountAmount: 20, taxRate: 25, taxInclusive: false });
+    const inclusive = buildDisplay({ subtotal: 250, discountAmount: 25, taxRate: 25, taxInclusive: true });
+    expect(exclusive.displaySubtotal).toBeCloseTo(inclusive.displaySubtotal);
+    expect(exclusive.displayDiscount).toBeCloseTo(inclusive.displayDiscount);
+    expect(exclusive.displayTax).toBeCloseTo(inclusive.displayTax);
+    expect(exclusive.displayTotal).toBeCloseTo(inclusive.displayTotal);
+  });
+});

+ 14 - 1
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -62,6 +62,8 @@ interface CustomerDetail {
   phone: string | null;
   address: string | null;
   company: string | null;
+  taxId: string | null;
+  taxExempt: boolean;
   notes: string | null;
   vehicles: {
     id: string;
@@ -137,7 +139,7 @@ export function CustomerDetailClient({
     router.replace(`/customers/${customer.id}${qs ? `?${qs}` : ""}`, { scroll: false });
   };
 
-  const hasContactInfo = customer.email || customer.phone || customer.address || customer.company || customer.notes;
+  const hasContactInfo = customer.email || customer.phone || customer.address || customer.company || customer.taxId || customer.notes;
 
   return (
     <div className="space-y-6">
@@ -197,6 +199,17 @@ export function CustomerDetailClient({
                     <span>{customer.address}</span>
                   </div>
                 )}
+                {customer.taxId && (
+                  <div className="flex items-center gap-1.5 text-muted-foreground">
+                    <span className="font-medium">{t("taxIdLabel")}:</span>
+                    <span>{customer.taxId}</span>
+                  </div>
+                )}
+                {customer.taxExempt && (
+                  <span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400">
+                    {t("taxExemptBadge")}
+                  </span>
+                )}
               </div>
             )}
             {customer.notes && (

+ 2 - 0
src/app/(authenticated)/customers/customers-client.tsx

@@ -45,6 +45,8 @@ interface Customer {
   phone: string | null;
   address: string | null;
   company: string | null;
+  taxId: string | null;
+  taxExempt: boolean;
   notes: string | null;
   _count: { vehicles: number };
 }

+ 0 - 167
src/app/(authenticated)/settings/currency/currency-settings.tsx

@@ -1,167 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { toast } from "sonner";
-import { setSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import { Coins, Loader2, Save } from "lucide-react";
-import { formatCurrency } from "@/lib/format";
-import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
-
-const CURRENCIES = [
-  { code: "USD", key: "USD" },
-  { code: "EUR", key: "EUR" },
-  { code: "GBP", key: "GBP" },
-  { code: "NOK", key: "NOK" },
-  { code: "SEK", key: "SEK" },
-  { code: "DKK", key: "DKK" },
-  { code: "CHF", key: "CHF" },
-  { code: "CAD", key: "CAD" },
-  { code: "AUD", key: "AUD" },
-  { code: "NZD", key: "NZD" },
-  { code: "JPY", key: "JPY" },
-  { code: "CNY", key: "CNY" },
-  { code: "INR", key: "INR" },
-  { code: "BRL", key: "BRL" },
-  { code: "MXN", key: "MXN" },
-  { code: "PLN", key: "PLN" },
-  { code: "CZK", key: "CZK" },
-  { code: "HUF", key: "HUF" },
-  { code: "TRY", key: "TRY" },
-  { code: "ZAR", key: "ZAR" },
-  { code: "KRW", key: "KRW" },
-  { code: "SGD", key: "SGD" },
-  { code: "HKD", key: "HKD" },
-  { code: "THB", key: "THB" },
-  { code: "ISK", key: "ISK" },
-  { code: "RON", key: "RON" },
-  { code: "ILS", key: "ILS" },
-  { code: "PHP", key: "PHP" },
-  { code: "IDR", key: "IDR" },
-  { code: "MYR", key: "MYR" },
-  { code: "AED", key: "AED" },
-  { code: "SAR", key: "SAR" },
-  { code: "RUB", key: "RUB" },
-];
-
-export function CurrencySettings({ settings }: { settings: Record<string, string> }) {
-  const router = useRouter();
-  const t = useTranslations('settings');
-  const [saving, setSaving] = useState(false);
-
-  const [currencyCode, setCurrencyCode] = useState(settings[SETTING_KEYS.CURRENCY_CODE] || "USD");
-  const [taxEnabled, setTaxEnabled] = useState(settings[SETTING_KEYS.TAX_ENABLED] !== "false");
-  const [defaultTaxRate, setDefaultTaxRate] = useState(settings[SETTING_KEYS.DEFAULT_TAX_RATE] || "0");
-  const handleSave = async () => {
-    setSaving(true);
-    await setSettings({
-      [SETTING_KEYS.CURRENCY_CODE]: currencyCode,
-      [SETTING_KEYS.TAX_ENABLED]: String(taxEnabled),
-      [SETTING_KEYS.DEFAULT_TAX_RATE]: taxEnabled ? defaultTaxRate : "0",
-    });
-    setSaving(false);
-    router.refresh();
-    toast.success(t('currency.saved'));
-  };
-
-  return (
-    <div className="space-y-6">
-      <ReadOnlyBanner />
-      <Card className="border-0 shadow-sm">
-        <CardHeader className="flex flex-row items-center gap-3 pb-4">
-          <Coins className="h-5 w-5 text-muted-foreground" />
-          <CardTitle className="text-lg">{t('currency.title')}</CardTitle>
-        </CardHeader>
-        <CardContent className="space-y-6">
-          <p className="text-sm text-muted-foreground">
-            {t('currency.description')}
-          </p>
-
-          <ReadOnlyWrapper>
-          <div className="space-y-6">
-          <div className="space-y-2">
-            <Label>{t('currency.currencyLabel')}</Label>
-            <Select value={currencyCode} onValueChange={setCurrencyCode}>
-              <SelectTrigger className="w-64">
-                <SelectValue />
-              </SelectTrigger>
-              <SelectContent>
-                {CURRENCIES.map((c) => (
-                  <SelectItem key={c.code} value={c.code}>
-                    {c.code} &mdash; {t('currency.currencies.' + c.code)}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
-            <p className="text-xs text-muted-foreground">
-              {t('currency.previewLabel', { value: formatCurrency(1234.56, currencyCode) })}
-            </p>
-          </div>
-
-          <Separator />
-
-          <div className="flex items-center justify-between">
-            <div>
-              <Label>{t('currency.enableTax')}</Label>
-              <p className="text-xs text-muted-foreground">
-                {t('currency.enableTaxHint')}
-              </p>
-            </div>
-            <Switch checked={taxEnabled} onCheckedChange={setTaxEnabled} />
-          </div>
-
-          {taxEnabled && (
-            <div className="space-y-2">
-              <Label htmlFor="defaultTaxRate">{t('currency.defaultTaxRate')}</Label>
-              <Input
-                id="defaultTaxRate"
-                type="number"
-                min="0"
-                step="0.1"
-                placeholder="0"
-                value={defaultTaxRate}
-                onChange={(e) => setDefaultTaxRate(e.target.value)}
-                className="w-32"
-              />
-              <p className="text-xs text-muted-foreground">
-                {t('currency.defaultTaxRateHint')}
-              </p>
-            </div>
-          )}
-
-          </div>
-          </ReadOnlyWrapper>
-
-          <SaveButton>
-            <Separator />
-            <div className="flex items-center gap-3">
-              <Button onClick={handleSave} disabled={saving}>
-                {saving ? (
-                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                ) : (
-                  <Save className="mr-2 h-4 w-4" />
-                )}
-                {t('currency.saveSettings')}
-              </Button>
-            </div>
-          </SaveButton>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 0 - 9
src/app/(authenticated)/settings/currency/page.tsx

@@ -1,9 +0,0 @@
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { CurrencySettings } from "./currency-settings";
-
-export default async function CurrencySettingsPage() {
-  const result = await getSettings();
-  const settings = result.success && result.data ? result.data : {};
-
-  return <CurrencySettings settings={settings} />;
-}

+ 2 - 38
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
 import { useRouter } from "next/navigation";
 import { useLocale, useTranslations } from "next-intl";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Button } from "@/components/ui/button";
 import { Separator } from "@/components/ui/separator";
@@ -133,10 +132,8 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
   const [currencyOpen, setCurrencyOpen] = useState(false);
   const [timezoneOpen, setTimezoneOpen] = useState(false);
 
-  // Currency & Tax
+  // Currency
   const [currencyCode, setCurrencyCode] = useState(settings[SETTING_KEYS.CURRENCY_CODE] || "USD");
-  const [taxEnabled, setTaxEnabled] = useState(settings[SETTING_KEYS.TAX_ENABLED] !== "false");
-  const [defaultTaxRate, setDefaultTaxRate] = useState(settings[SETTING_KEYS.DEFAULT_TAX_RATE] || "0");
 
   // Date & Time
   const [dateFormat, setDateFormat] = useState(settings[SETTING_KEYS.DATE_FORMAT] || DEFAULT_DATE_FORMAT);
@@ -156,8 +153,6 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
     setSaving(true);
     await setSettings({
       [SETTING_KEYS.CURRENCY_CODE]: currencyCode,
-      [SETTING_KEYS.TAX_ENABLED]: String(taxEnabled),
-      [SETTING_KEYS.DEFAULT_TAX_RATE]: taxEnabled ? defaultTaxRate : "0",
       [SETTING_KEYS.DATE_FORMAT]: dateFormat,
       [SETTING_KEYS.TIME_FORMAT]: timeFormat,
       [SETTING_KEYS.TIMEZONE]: timezone,
@@ -233,7 +228,7 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
         </CardContent>
       </Card>
 
-      {/* Currency & Tax */}
+      {/* Currency */}
       <Card className="border-0 shadow-sm">
         <CardHeader className="flex flex-row items-center gap-3 pb-4">
           <Coins className="h-5 w-5 text-muted-foreground" />
@@ -280,37 +275,6 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
                   {t("currency.previewLabel", { value: formatCurrency(1234.56, currencyCode) })}
                 </p>
               </div>
-
-              <Separator />
-
-              <div className="flex items-center justify-between">
-                <div>
-                  <Label>{t("currency.enableTax")}</Label>
-                  <p className="text-xs text-muted-foreground">
-                    {t("currency.enableTaxHint")}
-                  </p>
-                </div>
-                <Switch checked={taxEnabled} onCheckedChange={setTaxEnabled} />
-              </div>
-
-              {taxEnabled && (
-                <div className="space-y-2">
-                  <Label htmlFor="defaultTaxRate">{t("currency.defaultTaxRate")}</Label>
-                  <Input
-                    id="defaultTaxRate"
-                    type="number"
-                    min="0"
-                    step="0.1"
-                    placeholder="0"
-                    value={defaultTaxRate}
-                    onChange={(e) => setDefaultTaxRate(e.target.value)}
-                    className="w-32"
-                  />
-                  <p className="text-xs text-muted-foreground">
-                    {t("currency.defaultTaxRateHint")}
-                  </p>
-                </div>
-              )}
             </div>
           </ReadOnlyWrapper>
         </CardContent>

+ 2 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -26,6 +26,7 @@ import {
   Layout,
   Mail,
   MessageSquare,
+  Percent,
   Send,
   Sparkles,
   UserCog,
@@ -64,6 +65,7 @@ const settingsCategories: SettingsCategory[] = [
       { key: 'invoice', href: '/settings/invoice', icon: FileText },
       { key: 'templates', href: '/settings/templates', icon: Layout, gate: 'customTemplates' },
       { key: 'payment', href: '/settings/payment', icon: Banknote, gate: 'payments' },
+      { key: 'tax', href: '/settings/tax', icon: Percent },
     ],
   },
   {

Некоторые файлы не были показаны из-за большого количества измененных файлов