Jelajahi Sumber

Split tax into several rates for Québec and other two-tax markets (#374)

Bernt Christian Egeland 3 minggu lalu
induk
melakukan
e66ec9b0d2
80 mengubah file dengan 2488 tambahan dan 166 penghapusan
  1. 245 0
      e2e/specs/work-orders/split-tax.spec.ts
  2. 15 0
      e2e/support/db.ts
  3. 45 7
      e2e/support/settings.ts
  4. 4 1
      messages/de/pdf.json
  5. 22 0
      messages/de/settings.json
  6. 4 1
      messages/en/pdf.json
  7. 22 0
      messages/en/settings.json
  8. 4 1
      messages/es/pdf.json
  9. 22 0
      messages/es/settings.json
  10. 4 1
      messages/fr/pdf.json
  11. 22 0
      messages/fr/settings.json
  12. 4 1
      messages/it/pdf.json
  13. 22 0
      messages/it/settings.json
  14. 4 1
      messages/lt/pdf.json
  15. 22 0
      messages/lt/settings.json
  16. 4 1
      messages/nb/pdf.json
  17. 22 0
      messages/nb/settings.json
  18. 4 1
      messages/nl/pdf.json
  19. 22 0
      messages/nl/settings.json
  20. 4 1
      messages/pl/pdf.json
  21. 22 0
      messages/pl/settings.json
  22. 4 1
      messages/pt-BR/pdf.json
  23. 22 0
      messages/pt-BR/settings.json
  24. 4 1
      messages/ru/pdf.json
  25. 22 0
      messages/ru/settings.json
  26. 4 1
      messages/tr/pdf.json
  27. 22 0
      messages/tr/settings.json
  28. 8 0
      prisma/migrations/20260910163444_split_tax_components/migration.sql
  29. 2 0
      prisma/schema/quotes.prisma
  30. 14 5
      prisma/schema/service-records.prisma
  31. 96 0
      src/__tests__/features/invoice-designer/sample-split-tax.test.ts
  32. 3 1
      src/__tests__/features/invoices/recurring-invoice-timezone.test.ts
  33. 58 0
      src/__tests__/features/reports/reports-tax.test.ts
  34. 201 0
      src/__tests__/features/settings/split-tax-print.test.ts
  35. 289 0
      src/__tests__/lib/tax-components.test.ts
  36. 6 2
      src/app/(authenticated)/reports/reports-client.tsx
  37. 2 0
      src/app/(authenticated)/settings/tax/page.tsx
  38. 215 8
      src/app/(authenticated)/settings/tax/tax-settings.tsx
  39. 4 0
      src/app/(authenticated)/settings/templates/page.tsx
  40. 3 0
      src/app/(authenticated)/settings/templates/template-settings.tsx
  41. 2 0
      src/app/(designer)/invoice-designer/page.tsx
  42. 3 0
      src/app/api/protected/backup/import/route.ts
  43. 5 3
      src/app/api/v1/tech/jobs/[id]/labor/route.ts
  44. 25 7
      src/features/billing/Actions/recurringInvoiceActions.ts
  45. 8 6
      src/features/inspections/Actions/inspectionActions.ts
  46. 4 0
      src/features/invoice-designer/Components/PresetPreview.tsx
  47. 66 9
      src/features/invoice-designer/Components/sample.ts
  48. 8 0
      src/features/invoice-designer/Components/types.ts
  49. 23 11
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  50. 13 11
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  51. 55 0
      src/features/invoice-designer/Pdf/taxLines.ts
  52. 57 11
      src/features/quotes/Actions/quoteActions.ts
  53. 14 2
      src/features/quotes/Components/QuoteRightColumn.tsx
  54. 2 0
      src/features/quotes/Components/quote-page-types.ts
  55. 11 1
      src/features/quotes/Components/useQuoteFormState.ts
  56. 5 5
      src/features/reports/Actions/reportActions.ts
  57. 2 1
      src/features/reports/Components/ReportPDF.tsx
  58. 40 0
      src/features/reports/Lib/taxByRate.ts
  59. 6 0
      src/features/reports/Lib/taxRateLabel.ts
  60. 2 0
      src/features/reports/Schema/reportTypes.ts
  61. 34 9
      src/features/settings/Actions/applyTaxRateToExisting.ts
  62. 112 0
      src/features/settings/Lib/workshopTax.ts
  63. 8 0
      src/features/settings/Schema/settingsSchema.ts
  64. 16 15
      src/features/tire-hotel/Actions/tireJobActions.ts
  65. 53 2
      src/features/vehicles/Actions/serviceActions.ts
  66. 2 0
      src/features/vehicles/Components/invoice-pdf/types.ts
  67. 13 3
      src/features/vehicles/Components/service-detail/InvoiceSummary.tsx
  68. 21 3
      src/features/vehicles/Components/service-edit/TotalsSection.tsx
  69. 3 0
      src/features/vehicles/Components/service-edit/form-types.ts
  70. 1 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  71. 1 0
      src/features/vehicles/Components/service-page/DetailsRightColumn.tsx
  72. 2 0
      src/features/vehicles/Components/service-page/ServiceRecordPage.tsx
  73. 10 1
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  74. 5 3
      src/features/vehicles/Lib/addPart.ts
  75. 11 10
      src/features/vehicles/Lib/createDraftRecord.ts
  76. 11 4
      src/features/vehicles/Lib/retotalServiceRecord.ts
  77. 4 2
      src/lib/cron/recurring-invoices.ts
  78. 5 5
      src/lib/cron/report-schedules.ts
  79. 160 0
      src/lib/tax-components.ts
  80. 147 7
      src/lib/tax.ts

+ 245 - 0
e2e/specs/work-orders/split-tax.spec.ts

@@ -0,0 +1,245 @@
+import { expect, type Page, test } from '@playwright/test'
+import { scheduleServiceRecordInThePast } from '../../support/db'
+import { settle } from '../../support/hydration'
+import { pdfContent } from '../../support/pdf'
+import { addQuoteLabor, addQuotePart, newQuote, saveQuote } from '../../support/quote'
+import { setTax } from '../../support/settings'
+import {
+  addLabor,
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  setDiscount,
+  shareLink,
+  totalsRow,
+} from '../../support/work-order'
+
+/**
+ * A workshop in Québec: two taxes on every job, each on its own line, each
+ * with its own registration number, and each totalled apart in the report.
+ *
+ * The job is the one the pricing spec prices, 900 before tax, so the
+ * figures here follow from those: GST at 5% is 45.00 and QST at 9.975% is
+ * 89.775, which the sheet must print as 89.78 and add to 1,034.78. With the
+ * 10% discount the base is 810, GST 40.50, QST 80.7975 printed as 80.80,
+ * and the total 931.30. The split is opt-in, so the last test turns it off
+ * again and shows a new job is back to one tax line.
+ *
+ * Serial: the tax settings are the workshop's, and every job here is created
+ * after they change.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const GST_NUMBER = '123456789 RT0001'
+const QST_NUMBER = '1234567890 TQ0001'
+
+let vehicleUrl = ''
+let job = ''
+
+/** The standard job: parts 2 × 150 = 300, labour 1.5 × 400 = 600, before tax 900. */
+async function priceTheJob(page: Page, title: string): Promise<string> {
+  const url = await newWorkOrder(page, vehicleUrl, title)
+  await addPart(page, {
+    name: `E2E brake pads ${stamp}`,
+    quantity: 2,
+    cost: 100,
+    markupPercent: 50,
+  })
+  await addLabor(page, { description: 'Replace front brake pads', hours: 1.5, rate: 400 })
+  await saveWorkOrder(page)
+  return url
+}
+
+async function expectTotals(page: Page, lines: Record<string, string>) {
+  for (const [label, figure] of Object.entries(lines)) {
+    await expect(totalsRow(page, label), `${label} on the work order`).toContainText(figure)
+  }
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  await page.close()
+})
+
+test('the settings page takes the Québec preset and remembers the registration numbers', async ({
+  page,
+}) => {
+  await page.goto('/settings/tax')
+  const split = page.locator('#taxSplit')
+  await expect(split).toBeVisible()
+  await expect(async () => {
+    if ((await split.getAttribute('aria-checked')) !== 'true') await split.click()
+    await expect(split).toHaveAttribute('aria-checked', 'true')
+  }).toPass()
+
+  // The preset fills the names and rates; the numbers are the workshop's own.
+  await page.locator('#taxPreset').click()
+  await page.getByRole('option', { name: 'Québec, Canada (GST + QST)' }).click()
+  await expect(page.locator('#taxComponentName-0')).toHaveValue('GST')
+  await expect(page.locator('#taxComponentRate-0')).toHaveValue('5')
+  await expect(page.locator('#taxComponentName-1')).toHaveValue('QST')
+  await expect(page.locator('#taxComponentRate-1')).toHaveValue('9.975')
+  await expect(page.getByTestId('tax-combined-rate')).toHaveText('14.975%')
+  // The single-rate fields have left the page: there is one rate now, the sum.
+  await expect(page.locator('#defaultTaxRate')).toHaveCount(0)
+  await expect(page.locator('#taxLabel')).toHaveCount(0)
+
+  await page.locator('#taxComponentRegistration-0').fill(GST_NUMBER)
+  await page.locator('#taxComponentRegistration-1').fill(QST_NUMBER)
+  await page.getByRole('button', { name: 'Exclusive', exact: true }).click()
+  await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
+  await expect(page.getByText('Settings saved', { exact: true })).toBeVisible()
+
+  await page.reload()
+  await settle(page)
+  await expect(page.locator('#taxSplit')).toHaveAttribute('aria-checked', 'true')
+  await expect(page.locator('#taxComponentName-1')).toHaveValue('QST')
+  await expect(page.locator('#taxComponentRegistration-0')).toHaveValue(GST_NUMBER)
+  await expect(page.locator('#taxComponentRegistration-1')).toHaveValue(QST_NUMBER)
+  await expect(page.getByTestId('tax-combined-rate')).toHaveText('14.975%')
+})
+
+test('the invoice designer draws the split on its sample sheet', async ({ page }) => {
+  await page.goto('/invoice-designer?view=designer&doc=invoice')
+  // The canvas keeps a hidden measuring copy of the sheet; only the drawn
+  // one counts, so every check is on visible text.
+  const drawn = (text: string | RegExp) => page.getByText(text).filter({ visible: true }).first()
+  await expect(drawn(/GST \(5%\)/)).toBeVisible()
+  await expect(drawn(/QST \(9\.975%\)/)).toBeVisible()
+  await expect(drawn(GST_NUMBER)).toBeVisible()
+  await expect(drawn(QST_NUMBER)).toBeVisible()
+  await expect(page.getByText(/^Tax \(/).filter({ visible: true })).toHaveCount(0)
+
+  // The template cards in settings are drawn from the same sample.
+  await page.goto('/settings/templates')
+  await expect(page.getByText('GST (5%)').first()).toBeAttached()
+})
+
+test('a job charges GST and QST on their own lines', async ({ page }) => {
+  job = await priceTheJob(page, `E2E Québec ${stamp}`)
+  await expectTotals(page, {
+    Parts: '$300.00',
+    Labor: '$600.00',
+    Subtotal: '$900.00',
+    'GST (5%)': '$45.00',
+    'QST (9.975%)': '$89.78',
+    Total: '$1,034.78',
+  })
+  // No combined line, and no rate field: the split is the workshop's.
+  await expect(totalsRow(page, 'Tax')).toHaveCount(0)
+  await expect(page.getByTestId('tax-component-row')).toHaveCount(2)
+  await expect(page.getByTestId('tax-component-row').locator('input')).toHaveCount(0)
+})
+
+test('a discount comes off before both taxes', async ({ page }) => {
+  await page.goto(job)
+  await setDiscount(page, 'Percentage', 10)
+  await saveWorkOrder(page)
+  await expectTotals(page, {
+    Discount: '-$90.00',
+    'GST (5%)': '$40.50',
+    'QST (9.975%)': '$80.80',
+    Total: '$931.30',
+  })
+
+  // The saved figures survive a reload: the server re-derived the split.
+  await page.reload()
+  await settle(page)
+  await expectTotals(page, { 'GST (5%)': '$40.50', 'QST (9.975%)': '$80.80', Total: '$931.30' })
+})
+
+test('the customer copy and the PDF print both taxes and both registration numbers', async ({
+  page,
+}) => {
+  await page.goto(job)
+  const url = await shareLink(page)
+  await page.goto(url)
+  // The sheet keeps a hidden copy of itself for measuring; only the drawn one counts.
+  for (const text of [
+    /GST \(5%\)/,
+    /QST \(9\.975%\)/,
+    '$40.50',
+    '$80.80',
+    '$931.30',
+    GST_NUMBER,
+    QST_NUMBER,
+  ]) {
+    await expect(
+      page.getByText(text).filter({ visible: true }).first(),
+      `${text} on the shared invoice`
+    ).toBeVisible()
+  }
+  await expect(page.getByText(/^Tax \(/).filter({ visible: true })).toHaveCount(0)
+
+  const id = job.split('/').pop()
+  const response = await page.request.get(`/api/protected/services/${id}/pdf`)
+  expect(response.status(), 'the invoice PDF renders').toBe(200)
+  const pdf = await pdfContent(await response.body())
+  for (const text of [
+    'GST (5%)',
+    'QST (9.975%)',
+    '40.50',
+    '80.80',
+    '931.30',
+    'GST No.',
+    GST_NUMBER,
+    'QST No.',
+    QST_NUMBER,
+  ]) {
+    expect(pdf.flat, `${text} in the PDF`).toContain(text)
+  }
+  expect(pdf.flat).not.toMatch(/Tax \(14/)
+})
+
+test('a quote shows the same split', async ({ page }) => {
+  await newQuote(page, `E2E Québec quote ${stamp}`)
+  await addQuotePart(page, { name: `E2E tyre ${stamp}`, quantity: 2, unitPrice: 150 })
+  await addQuoteLabor(page, { description: 'Fit tyres', hours: 1.5, rate: 400 })
+  await saveQuote(page)
+
+  const rows = page.getByTestId('tax-component-row')
+  await expect(rows).toHaveCount(2)
+  await expect(rows.nth(0)).toContainText('GST (5%)')
+  await expect(rows.nth(0)).toContainText('$45.00')
+  await expect(rows.nth(1)).toContainText('QST (9.975%)')
+  await expect(rows.nth(1)).toContainText('$89.78')
+})
+
+test('the tax report totals each tax by name', async ({ page }) => {
+  // The job was booked into the next free slot, which is usually tomorrow,
+  // and the report runs to the present moment; move it into the past.
+  await scheduleServiceRecordInThePast(job.split('/').pop()!)
+  await page.goto('/reports?tab=financial&subtab=tax')
+  const table = page
+    .getByRole('heading', { name: 'Tax by Rate' })
+    .locator('xpath=ancestor::div[.//table][1]')
+    .getByRole('table')
+  await expect(table).toBeVisible()
+  const gst = table.getByRole('row').filter({ hasText: 'GST (5%)' })
+  const qst = table.getByRole('row').filter({ hasText: 'QST (9.975%)' })
+  await expect(gst).toHaveCount(1)
+  await expect(qst).toHaveCount(1)
+  // Each row carries money and a count; the exact figures depend on what
+  // earlier runs left in the period, so the shape is what is pinned.
+  await expect(gst).toContainText(/\$\d/)
+  await expect(qst).toContainText(/\$\d/)
+})
+
+test('switching the split off puts a new job back on one tax line', async ({ page }) => {
+  await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
+  await page.goto('/settings/tax')
+  await expect(page.locator('#taxSplit')).toHaveAttribute('aria-checked', 'false')
+  await expect(page.locator('#defaultTaxRate')).toHaveValue('25')
+
+  await priceTheJob(page, `E2E single again ${stamp}`)
+  await expectTotals(page, { Tax: '$225.00', Total: '$1,125.00' })
+  await expect(page.getByTestId('tax-component-row')).toHaveCount(0)
+
+  // The Québec job keeps the taxes it was made with.
+  await page.goto(job)
+  await expectTotals(page, { 'GST (5%)': '$40.50', 'QST (9.975%)': '$80.80' })
+})

+ 15 - 0
e2e/support/db.ts

@@ -343,3 +343,18 @@ export async function latestAttachmentUrl(serviceRecordId: string): Promise<stri
     return url
   })
 }
+
+/**
+ * Backdates a job's scheduled start to an hour ago. A new work order is
+ * booked into the shop's next free slot, often tomorrow, and the financial
+ * reports run up to the present moment, so a job made by a spec is not in
+ * this year's tax report until it is moved into the past.
+ */
+export async function scheduleServiceRecordInThePast(serviceRecordId: string): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `update service_records set "startDateTime" = now() - interval '1 hour' where id = $1`,
+      [serviceRecordId]
+    )
+  )
+}

+ 45 - 7
e2e/support/settings.ts

@@ -1,4 +1,4 @@
-import { expect, type Page } from '@playwright/test'
+import { expect, type Locator, type Page } from '@playwright/test'
 import { fillSettled, settle } from './hydration'
 
 /**
@@ -11,23 +11,61 @@ import { fillSettled, settle } from './hydration'
  * rather than `fill`.
  */
 
+export interface TaxComponentSetup {
+  name: string
+  rate: number
+  registrationNumber?: string
+}
+
 export interface TaxSetup {
   enabled: boolean
   rate?: number
   inclusive?: boolean
   label?: string
+  /**
+   * More than one tax on a document (Québec's GST and QST). Given, the split
+   * switch is turned on and these rows replace the single rate; left out, the
+   * switch is turned off and the workshop is back to one rate.
+   */
+  components?: TaxComponentSetup[]
+}
+
+/** Flips a switch to the wanted state, retried because a click before hydration does nothing. */
+async function setSwitch(toggle: Locator, on: boolean): Promise<void> {
+  await expect(toggle).toBeVisible()
+  await expect(async () => {
+    if ((await toggle.getAttribute('aria-checked')) === String(on)) return
+    await toggle.click()
+    await expect(toggle).toHaveAttribute('aria-checked', String(on))
+  }).toPass()
 }
 
 /** Settings → Tax, saved. */
 export async function setTax(page: Page, tax: TaxSetup): Promise<void> {
   await page.goto('/settings/tax')
-  const enable = page.getByRole('switch').first()
-  await expect(enable).toBeVisible()
-  const on = (await enable.getAttribute('aria-checked')) === 'true'
-  if (on !== tax.enabled) await enable.click()
+  await setSwitch(page.getByRole('switch').first(), tax.enabled)
   if (tax.enabled) {
-    await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
-    await page.locator('#taxLabel').fill(tax.label ?? '')
+    await setSwitch(page.locator('#taxSplit'), Boolean(tax.components))
+    if (tax.components) {
+      const rows = page.getByTestId('tax-component-row')
+      while ((await rows.count()) > tax.components.length) {
+        await rows.last().getByRole('button', { name: 'Remove this tax' }).click()
+      }
+      while ((await rows.count()) < tax.components.length) {
+        await page.getByRole('button', { name: 'Add a tax' }).click()
+      }
+      for (const [i, component] of tax.components.entries()) {
+        await fillSettled(page.locator(`#taxComponentName-${i}`), component.name)
+        await fillSettled(page.locator(`#taxComponentRate-${i}`), String(component.rate))
+        await fillSettled(
+          page.locator(`#taxComponentRegistration-${i}`),
+          component.registrationNumber ?? ''
+        )
+      }
+    } else {
+      await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
+      await page.locator('#taxLabel').fill(tax.label ?? '')
+    }
     await page
       .getByRole('button', { name: tax.inclusive ? 'Inclusive' : 'Exclusive', exact: true })
       .click()

+ 4 - 1
messages/de/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
+    "taxIncludedNamed": "Inkl. {name} ({rate}%)",
     "paymentsReceived": "Eingegangene Zahlungen",
     "paidInFull": "VOLLSTÄNDIG BEZAHLT",
     "amountDue": "Fälliger Betrag",
@@ -72,7 +73,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Zusätzliche Informationen",
-    "telegramConnect": "Chatten Sie mit uns auf Telegram"
+    "telegramConnect": "Chatten Sie mit uns auf Telegram",
+    "taxRegistrationLabel": "{name}-Nr."
   },
   "quote": {
     "title": "ANGEBOT",
@@ -100,6 +102,7 @@
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
+    "taxIncludedNamed": "Inkl. {name} ({rate}%)",
     "validUntil": "Gültig bis: {date}",
     "attachedDocuments": "Beigefügte Dokumente",
     "attached": "{name} (beigefügt)",

+ 22 - 0
messages/de/settings.json

@@ -859,6 +859,28 @@
     "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.",
+    "splitLabel": "Steuer in mehrere Sätze aufteilen",
+    "splitHint": "Für Regionen, in denen auf derselben Rechnung mehr als eine Steuer erhoben wird, etwa GST und QST in Québec oder CGST und SGST in Indien. Jede Steuer erscheint als eigene Zeile mit eigener Registrierungsnummer, und der Steuerbericht führt sie getrennt auf.",
+    "splitIncomplete": "Geben Sie jeder Steuer vor dem Speichern einen Namen und einen Satz.",
+    "presetLabel": "Mit einer Vorlage beginnen",
+    "presetPlaceholder": "Region wählen",
+    "presets": {
+      "caQc": "Québec, Kanada (GST + QST)",
+      "caBc": "British Columbia, Kanada (GST + PST)",
+      "caSk": "Saskatchewan, Kanada (GST + PST)",
+      "caMb": "Manitoba, Kanada (GST + RST)",
+      "inIntra": "Indien, gleicher Bundesstaat (CGST + SGST zu 18%)"
+    },
+    "componentName": "Name",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Satz",
+    "componentRegistration": "Registrierungsnummer",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Diese Steuer entfernen",
+    "registrationHint": "Wird auf Rechnungen unter Ihren Firmendaten gedruckt. Leer lassen bei einer Steuer, für die Sie nicht registriert sind.",
+    "addComponent": "Steuer hinzufügen",
+    "combinedRate": "Gesamtsatz",
+    "combinedRateHint": "Neue Arbeitsaufträge und Angebote werden mit der Summe der obigen Sätze besteuert.",
     "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",

+ 4 - 1
messages/en/pdf.json

@@ -39,6 +39,7 @@
     "discountPercent": "Discount ({percent}%)",
     "tax": "Tax ({rate}%)",
     "taxIncluded": "Includes tax ({rate}%)",
+    "taxIncludedNamed": "Includes {name} ({rate}%)",
     "paymentsReceived": "Payments Received",
     "paidInFull": "PAID IN FULL",
     "amountDue": "Amount Due",
@@ -72,7 +73,8 @@
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
     "customFieldsTitle": "Additional Information",
-    "telegramConnect": "Chat with us on Telegram"
+    "telegramConnect": "Chat with us on Telegram",
+    "taxRegistrationLabel": "{name} No."
   },
   "quote": {
     "title": "QUOTE",
@@ -101,6 +103,7 @@
     "discountPercent": "Discount ({percent}%)",
     "tax": "Tax ({rate}%)",
     "taxIncluded": "Includes tax ({rate}%)",
+    "taxIncludedNamed": "Includes {name} ({rate}%)",
     "validUntil": "Valid until: {date}",
     "attachedDocuments": "Attached Documents",
     "attached": "{name} (attached)",

+ 22 - 0
messages/en/settings.json

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Split the tax into several rates",
+    "splitHint": "For places that charge more than one tax on the same invoice, such as GST and QST in Québec or CGST and SGST in India. Each tax prints as its own line with its own registration number, and the tax report keeps them apart.",
+    "splitIncomplete": "Give every tax a name and a rate before saving.",
+    "presetLabel": "Start from a preset",
+    "presetPlaceholder": "Choose a region",
+    "presets": {
+      "caQc": "Québec, Canada (GST + QST)",
+      "caBc": "British Columbia, Canada (GST + PST)",
+      "caSk": "Saskatchewan, Canada (GST + PST)",
+      "caMb": "Manitoba, Canada (GST + RST)",
+      "inIntra": "India, same state (CGST + SGST at 18%)"
+    },
+    "componentName": "Name",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Rate",
+    "componentRegistration": "Registration number",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Remove this tax",
+    "registrationHint": "Printed on invoices under your business details. Leave it blank for a tax you are not registered for.",
+    "addComponent": "Add a tax",
+    "combinedRate": "Combined rate",
+    "combinedRateHint": "New work orders and quotes are taxed at the rates above added together.",
     "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",

+ 4 - 1
messages/es/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Descuento ({percent}%)",
     "tax": "Impuesto ({rate}%)",
     "taxIncluded": "Con impuesto ({rate}%)",
+    "taxIncludedNamed": "Con {name} ({rate}%)",
     "paymentsReceived": "Pagos recibidos",
     "paidInFull": "PAGADO EN SU TOTALIDAD",
     "amountDue": "Monto adeudado",
@@ -72,7 +73,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Información adicional",
-    "telegramConnect": "Chatea con nosotros en Telegram"
+    "telegramConnect": "Chatea con nosotros en Telegram",
+    "taxRegistrationLabel": "Núm. {name}"
   },
   "quote": {
     "title": "PRESUPUESTO",
@@ -100,6 +102,7 @@
     "discountPercent": "Descuento ({percent}%)",
     "tax": "Impuesto ({rate}%)",
     "taxIncluded": "Con impuesto ({rate}%)",
+    "taxIncludedNamed": "Con {name} ({rate}%)",
     "validUntil": "Válido hasta: {date}",
     "attachedDocuments": "Documentos adjuntos",
     "attached": "{name} (adjunto)",

+ 22 - 0
messages/es/settings.json

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Dividir el impuesto en varias tasas",
+    "splitHint": "Para lugares que cobran más de un impuesto en la misma factura, como GST y QST en Quebec o CGST y SGST en India. Cada impuesto se imprime en su propia línea con su propio número de registro, y el informe de impuestos los mantiene separados.",
+    "splitIncomplete": "Asigne un nombre y una tasa a cada impuesto antes de guardar.",
+    "presetLabel": "Empezar desde una plantilla",
+    "presetPlaceholder": "Elija una región",
+    "presets": {
+      "caQc": "Quebec, Canadá (GST + QST)",
+      "caBc": "Columbia Británica, Canadá (GST + PST)",
+      "caSk": "Saskatchewan, Canadá (GST + PST)",
+      "caMb": "Manitoba, Canadá (GST + RST)",
+      "inIntra": "India, mismo estado (CGST + SGST al 18%)"
+    },
+    "componentName": "Nombre",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Tasa",
+    "componentRegistration": "Número de registro",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Quitar este impuesto",
+    "registrationHint": "Se imprime en las facturas bajo los datos de su empresa. Déjelo vacío para un impuesto en el que no esté registrado.",
+    "addComponent": "Añadir un impuesto",
+    "combinedRate": "Tasa combinada",
+    "combinedRateHint": "Las nuevas órdenes de trabajo y presupuestos se gravan con la suma de las tasas anteriores.",
     "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",

+ 4 - 1
messages/fr/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Remise ({percent}%)",
     "tax": "Taxe ({rate}%)",
     "taxIncluded": "TTC ({rate}%)",
+    "taxIncludedNamed": "{name} incluse ({rate}%)",
     "paymentsReceived": "Paiements reçus",
     "paidInFull": "ENTIÈREMENT PAYÉ",
     "amountDue": "Montant dû",
@@ -72,7 +73,8 @@
     "org": "Org. : {org}",
     "qtyOrHours": "Unité / Heures",
     "customFieldsTitle": "Informations complémentaires",
-    "telegramConnect": "Discutez avec nous sur Telegram"
+    "telegramConnect": "Discutez avec nous sur Telegram",
+    "taxRegistrationLabel": "N° {name}"
   },
   "quote": {
     "title": "DEVIS",
@@ -100,6 +102,7 @@
     "discountPercent": "Remise ({percent}%)",
     "tax": "Taxe ({rate}%)",
     "taxIncluded": "TTC ({rate}%)",
+    "taxIncludedNamed": "{name} incluse ({rate}%)",
     "validUntil": "Valable jusqu'au : {date}",
     "attachedDocuments": "Documents joints",
     "attached": "{name} (joint)",

+ 22 - 0
messages/fr/settings.json

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Répartir la taxe en plusieurs taux",
+    "splitHint": "Pour les régions qui appliquent plusieurs taxes sur la même facture, comme la GST et la QST au Québec ou la CGST et la SGST en Inde. Chaque taxe s'imprime sur sa propre ligne avec son propre numéro d'enregistrement, et le rapport de taxes les distingue.",
+    "splitIncomplete": "Donnez un nom et un taux à chaque taxe avant d'enregistrer.",
+    "presetLabel": "Partir d'un modèle",
+    "presetPlaceholder": "Choisir une région",
+    "presets": {
+      "caQc": "Québec, Canada (GST + QST)",
+      "caBc": "Colombie-Britannique, Canada (GST + PST)",
+      "caSk": "Saskatchewan, Canada (GST + PST)",
+      "caMb": "Manitoba, Canada (GST + RST)",
+      "inIntra": "Inde, même État (CGST + SGST à 18%)"
+    },
+    "componentName": "Nom",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Taux",
+    "componentRegistration": "Numéro d'enregistrement",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Retirer cette taxe",
+    "registrationHint": "Imprimé sur les factures sous les coordonnées de votre entreprise. Laissez vide pour une taxe à laquelle vous n'êtes pas inscrit.",
+    "addComponent": "Ajouter une taxe",
+    "combinedRate": "Taux combiné",
+    "combinedRateHint": "Les nouveaux bons de travail et devis sont taxés à la somme des taux ci-dessus.",
     "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",

+ 4 - 1
messages/it/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Sconto ({percent}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA inclusa ({rate}%)",
+    "taxIncludedNamed": "{name} inclusa ({rate}%)",
     "paymentsReceived": "Pagamenti Ricevuti",
     "paidInFull": "SALDATO",
     "amountDue": "Importo Dovuto",
@@ -72,7 +73,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Informazioni aggiuntive",
-    "telegramConnect": "Scrivici su Telegram"
+    "telegramConnect": "Scrivici su Telegram",
+    "taxRegistrationLabel": "N. {name}"
   },
   "quote": {
     "title": "PREVENTIVO",
@@ -100,6 +102,7 @@
     "discountPercent": "Sconto ({percent}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA inclusa ({rate}%)",
+    "taxIncludedNamed": "{name} inclusa ({rate}%)",
     "validUntil": "Valido fino al: {date}",
     "attachedDocuments": "Documenti Allegati",
     "attached": "{name} (allegato)",

+ 22 - 0
messages/it/settings.json

@@ -859,6 +859,28 @@
     "labelLabel": "Etichetta imposta",
     "labelHint": "Nome personalizzato mostrato su fatture e preventivi (es. IVA, GST). Lasciare vuoto per usare il predefinito tradotto.",
     "labelPlaceholder": "IVA",
+    "splitLabel": "Suddividi l'imposta in più aliquote",
+    "splitHint": "Per i luoghi in cui sulla stessa fattura si applicano più imposte, come GST e QST in Québec o CGST e SGST in India. Ogni imposta viene stampata su una riga propria con il proprio numero di registrazione, e il report delle imposte le tiene separate.",
+    "splitIncomplete": "Assegna un nome e un'aliquota a ogni imposta prima di salvare.",
+    "presetLabel": "Parti da un modello",
+    "presetPlaceholder": "Scegli una regione",
+    "presets": {
+      "caQc": "Québec, Canada (GST + QST)",
+      "caBc": "Columbia Britannica, Canada (GST + PST)",
+      "caSk": "Saskatchewan, Canada (GST + PST)",
+      "caMb": "Manitoba, Canada (GST + RST)",
+      "inIntra": "India, stesso stato (CGST + SGST al 18%)"
+    },
+    "componentName": "Nome",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Aliquota",
+    "componentRegistration": "Numero di registrazione",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Rimuovi questa imposta",
+    "registrationHint": "Stampato sulle fatture sotto i dati della tua azienda. Lascialo vuoto per un'imposta per cui non sei registrato.",
+    "addComponent": "Aggiungi un'imposta",
+    "combinedRate": "Aliquota combinata",
+    "combinedRateHint": "I nuovi ordini di lavoro e preventivi vengono tassati con la somma delle aliquote sopra indicate.",
     "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",

+ 4 - 1
messages/lt/pdf.json

@@ -39,6 +39,7 @@
     "discountPercent": "Nuolaida ({percent}%)",
     "tax": "Mokestis ({rate}%)",
     "taxIncluded": "Su mokesčiu ({rate}%)",
+    "taxIncludedNamed": "Su {name} ({rate}%)",
     "paymentsReceived": "Gauti mokėjimai",
     "paidInFull": "PILNAI APMOKĖTA",
     "amountDue": "Mokėtina suma",
@@ -72,7 +73,8 @@
     "tel": "Tel.: {phone}",
     "org": "Įm. kodas: {org}",
     "customFieldsTitle": "Papildoma informacija",
-    "telegramConnect": "Susisiekite su mumis per Telegram"
+    "telegramConnect": "Susisiekite su mumis per Telegram",
+    "taxRegistrationLabel": "{name} nr."
   },
   "quote": {
     "title": "PASIŪLYMAS",
@@ -101,6 +103,7 @@
     "discountPercent": "Nuolaida ({percent}%)",
     "tax": "Mokestis ({rate}%)",
     "taxIncluded": "Su mokesčiu ({rate}%)",
+    "taxIncludedNamed": "Su {name} ({rate}%)",
     "validUntil": "Galioja iki: {date}",
     "attachedDocuments": "Pridėti dokumentai",
     "attached": "{name} (pridėtas)",

+ 22 - 0
messages/lt/settings.json

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Padalyti mokestį į kelis tarifus",
+    "splitHint": "Vietovėms, kuriose toje pačioje sąskaitoje taikomas daugiau nei vienas mokestis, pvz. GST ir QST Kvebeke arba CGST ir SGST Indijoje. Kiekvienas mokestis spausdinamas atskira eilute su savo registracijos numeriu, o mokesčių ataskaitoje jie rodomi atskirai.",
+    "splitIncomplete": "Prieš išsaugodami kiekvienam mokesčiui nurodykite pavadinimą ir tarifą.",
+    "presetLabel": "Pradėti nuo šablono",
+    "presetPlaceholder": "Pasirinkite regioną",
+    "presets": {
+      "caQc": "Kvebekas, Kanada (GST + QST)",
+      "caBc": "Britų Kolumbija, Kanada (GST + PST)",
+      "caSk": "Saskačevanas, Kanada (GST + PST)",
+      "caMb": "Manitoba, Kanada (GST + RST)",
+      "inIntra": "Indija, ta pati valstija (CGST + SGST po 18%)"
+    },
+    "componentName": "Pavadinimas",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Tarifas",
+    "componentRegistration": "Registracijos numeris",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Pašalinti šį mokestį",
+    "registrationHint": "Spausdinamas sąskaitose po jūsų įmonės duomenimis. Palikite tuščią, jei mokesčiui nesate registruoti.",
+    "addComponent": "Pridėti mokestį",
+    "combinedRate": "Bendras tarifas",
+    "combinedRateHint": "Naujiems darbo užsakymams ir pasiūlymams taikoma aukščiau nurodytų tarifų suma.",
     "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",

+ 4 - 1
messages/nb/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "Mva. ({rate}%)",
     "taxIncluded": "Inkluderer mva ({rate}%)",
+    "taxIncludedNamed": "Inkluderer {name} ({rate}%)",
     "paymentsReceived": "Mottatte betalinger",
     "paidInFull": "FULLT BETALT",
     "amountDue": "Beløp å betale",
@@ -72,7 +73,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Tilleggsinformasjon",
-    "telegramConnect": "Chat med oss på Telegram"
+    "telegramConnect": "Chat med oss på Telegram",
+    "taxRegistrationLabel": "{name}-nr."
   },
   "quote": {
     "title": "TILBUD",
@@ -100,6 +102,7 @@
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "Mva. ({rate}%)",
     "taxIncluded": "Inkluderer mva ({rate}%)",
+    "taxIncludedNamed": "Inkluderer {name} ({rate}%)",
     "validUntil": "Gyldig til: {date}",
     "attachedDocuments": "Vedlagte dokumenter",
     "attached": "{name} (vedlagt)",

+ 22 - 0
messages/nb/settings.json

@@ -859,6 +859,28 @@
     "labelLabel": "Avgiftsetikett",
     "labelHint": "Tilpasset navn vist på fakturaer og tilbud (f.eks. MVA, GST, MwSt.). La stå tom for å bruke standardverdien.",
     "labelPlaceholder": "MVA",
+    "splitLabel": "Del avgiften opp i flere satser",
+    "splitHint": "For steder som krever mer enn én avgift på samme faktura, som GST og QST i Québec eller CGST og SGST i India. Hver avgift skrives ut som egen linje med eget registreringsnummer, og avgiftsrapporten holder dem adskilt.",
+    "splitIncomplete": "Gi hver avgift et navn og en sats før du lagrer.",
+    "presetLabel": "Start fra en mal",
+    "presetPlaceholder": "Velg en region",
+    "presets": {
+      "caQc": "Québec, Canada (GST + QST)",
+      "caBc": "British Columbia, Canada (GST + PST)",
+      "caSk": "Saskatchewan, Canada (GST + PST)",
+      "caMb": "Manitoba, Canada (GST + RST)",
+      "inIntra": "India, samme delstat (CGST + SGST med 18%)"
+    },
+    "componentName": "Navn",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Sats",
+    "componentRegistration": "Registreringsnummer",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Fjern denne avgiften",
+    "registrationHint": "Skrives ut på fakturaer under firmaopplysningene dine. La stå tom for en avgift du ikke er registrert for.",
+    "addComponent": "Legg til en avgift",
+    "combinedRate": "Samlet sats",
+    "combinedRateHint": "Nye arbeidsordrer og tilbud avgiftsberegnes med summen av satsene over.",
     "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",

+ 4 - 1
messages/nl/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Korting ({percent}%)",
     "tax": "BTW ({rate}%)",
     "taxIncluded": "Incl. btw ({rate}%)",
+    "taxIncludedNamed": "Incl. {name} ({rate}%)",
     "paymentsReceived": "Ontvangen betalingen",
     "paidInFull": "VOLLEDIG BETAALD",
     "amountDue": "Te betalen bedrag",
@@ -72,7 +73,8 @@
     "org": "KvK: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Aanvullende informatie",
-    "telegramConnect": "Chat met ons op Telegram"
+    "telegramConnect": "Chat met ons op Telegram",
+    "taxRegistrationLabel": "{name}-nr."
   },
   "quote": {
     "title": "OFFERTE",
@@ -100,6 +102,7 @@
     "discountPercent": "Korting ({percent}%)",
     "tax": "BTW ({rate}%)",
     "taxIncluded": "Incl. btw ({rate}%)",
+    "taxIncludedNamed": "Incl. {name} ({rate}%)",
     "validUntil": "Geldig tot: {date}",
     "attachedDocuments": "Bijgevoegde documenten",
     "attached": "{name} (bijgevoegd)",

+ 22 - 0
messages/nl/settings.json

@@ -859,6 +859,28 @@
     "labelLabel": "Belastinglabel",
     "labelHint": "Aangepaste naam getoond op facturen en offertes (bijv. Btw, VAT). Leeg laten voor de gelokaliseerde standaardwaarde.",
     "labelPlaceholder": "Btw",
+    "splitLabel": "Belasting opsplitsen in meerdere tarieven",
+    "splitHint": "Voor regio's die meer dan één belasting op dezelfde factuur heffen, zoals GST en QST in Québec of CGST en SGST in India. Elke belasting wordt als eigen regel afgedrukt met een eigen registratienummer, en het belastingrapport houdt ze gescheiden.",
+    "splitIncomplete": "Geef elke belasting een naam en een tarief voordat u opslaat.",
+    "presetLabel": "Beginnen met een voorinstelling",
+    "presetPlaceholder": "Kies een regio",
+    "presets": {
+      "caQc": "Québec, Canada (GST + QST)",
+      "caBc": "Brits-Columbia, Canada (GST + PST)",
+      "caSk": "Saskatchewan, Canada (GST + PST)",
+      "caMb": "Manitoba, Canada (GST + RST)",
+      "inIntra": "India, zelfde staat (CGST + SGST tegen 18%)"
+    },
+    "componentName": "Naam",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Tarief",
+    "componentRegistration": "Registratienummer",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Deze belasting verwijderen",
+    "registrationHint": "Wordt op facturen onder uw bedrijfsgegevens afgedrukt. Laat leeg voor een belasting waarvoor u niet geregistreerd bent.",
+    "addComponent": "Belasting toevoegen",
+    "combinedRate": "Gecombineerd tarief",
+    "combinedRateHint": "Nieuwe werkorders en offertes worden belast tegen de som van de bovenstaande tarieven.",
     "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",

+ 4 - 1
messages/pl/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Rabat ({percent}%)",
     "tax": "Podatek ({rate}%)",
     "taxIncluded": "Podatek wliczony ({rate}%)",
+    "taxIncludedNamed": "Zawiera {name} ({rate}%)",
     "paymentsReceived": "Otrzymane płatności",
     "paidInFull": "ZAPŁACONO W CAŁOŚCI",
     "amountDue": "Kwota do zapłaty",
@@ -72,7 +73,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Informacje dodatkowe",
-    "telegramConnect": "Napisz do nas na Telegramie"
+    "telegramConnect": "Napisz do nas na Telegramie",
+    "taxRegistrationLabel": "Nr {name}"
   },
   "quote": {
     "title": "WYCENA",
@@ -100,6 +102,7 @@
     "discountPercent": "Rabat ({percent}%)",
     "tax": "Podatek ({rate}%)",
     "taxIncluded": "Podatek wliczony ({rate}%)",
+    "taxIncludedNamed": "Zawiera {name} ({rate}%)",
     "validUntil": "Ważne do: {date}",
     "attachedDocuments": "Załączone dokumenty",
     "attached": "{name} (załączono)",

+ 22 - 0
messages/pl/settings.json

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Podziel podatek na kilka stawek",
+    "splitHint": "Dla miejsc, w których na tej samej fakturze naliczany jest więcej niż jeden podatek, np. GST i QST w Quebecu albo CGST i SGST w Indiach. Każdy podatek jest drukowany w osobnym wierszu z własnym numerem rejestracyjnym, a raport podatkowy wykazuje je oddzielnie.",
+    "splitIncomplete": "Przed zapisaniem nadaj każdemu podatkowi nazwę i stawkę.",
+    "presetLabel": "Zacznij od szablonu",
+    "presetPlaceholder": "Wybierz region",
+    "presets": {
+      "caQc": "Quebec, Kanada (GST + QST)",
+      "caBc": "Kolumbia Brytyjska, Kanada (GST + PST)",
+      "caSk": "Saskatchewan, Kanada (GST + PST)",
+      "caMb": "Manitoba, Kanada (GST + RST)",
+      "inIntra": "Indie, ten sam stan (CGST + SGST po 18%)"
+    },
+    "componentName": "Nazwa",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Stawka",
+    "componentRegistration": "Numer rejestracyjny",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Usuń ten podatek",
+    "registrationHint": "Drukowany na fakturach pod danymi Twojej firmy. Zostaw puste dla podatku, do którego nie jesteś zarejestrowany.",
+    "addComponent": "Dodaj podatek",
+    "combinedRate": "Stawka łączna",
+    "combinedRateHint": "Nowe zlecenia i oferty są opodatkowane sumą powyższych stawek.",
     "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",

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

@@ -38,6 +38,7 @@
     "discountPercent": "Desconto ({percent}%)",
     "tax": "Imposto ({rate}%)",
     "taxIncluded": "Imposto incluído ({rate}%)",
+    "taxIncludedNamed": "{name} incluído ({rate}%)",
     "paymentsReceived": "Pagamentos Recebidos",
     "paidInFull": "PAGO INTEGRALMENTE",
     "amountDue": "Valor a Pagar",
@@ -72,7 +73,8 @@
     "org": "CNPJ: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Informações adicionais",
-    "telegramConnect": "Fale conosco no Telegram"
+    "telegramConnect": "Fale conosco no Telegram",
+    "taxRegistrationLabel": "Nº {name}"
   },
   "quote": {
     "title": "ORÇAMENTO",
@@ -100,6 +102,7 @@
     "discountPercent": "Desconto ({percent}%)",
     "tax": "Imposto ({rate}%)",
     "taxIncluded": "Imposto incluído ({rate}%)",
+    "taxIncludedNamed": "{name} incluído ({rate}%)",
     "validUntil": "Válido até: {date}",
     "attachedDocuments": "Documentos Anexados",
     "attached": "{name} (anexado)",

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

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Dividir o imposto em várias taxas",
+    "splitHint": "Para lugares que cobram mais de um imposto na mesma fatura, como GST e QST no Quebec ou CGST e SGST na Índia. Cada imposto é impresso em uma linha própria com seu próprio número de registro, e o relatório de impostos os mantém separados.",
+    "splitIncomplete": "Dê um nome e uma taxa a cada imposto antes de salvar.",
+    "presetLabel": "Começar a partir de um modelo",
+    "presetPlaceholder": "Escolha uma região",
+    "presets": {
+      "caQc": "Quebec, Canadá (GST + QST)",
+      "caBc": "Colúmbia Britânica, Canadá (GST + PST)",
+      "caSk": "Saskatchewan, Canadá (GST + PST)",
+      "caMb": "Manitoba, Canadá (GST + RST)",
+      "inIntra": "Índia, mesmo estado (CGST + SGST a 18%)"
+    },
+    "componentName": "Nome",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Taxa",
+    "componentRegistration": "Número de registro",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Remover este imposto",
+    "registrationHint": "Impresso nas faturas abaixo dos dados da sua empresa. Deixe em branco para um imposto no qual você não está registrado.",
+    "addComponent": "Adicionar um imposto",
+    "combinedRate": "Taxa combinada",
+    "combinedRateHint": "Novas ordens de serviço e orçamentos são tributados pela soma das taxas acima.",
     "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",

+ 4 - 1
messages/ru/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "Скидка ({percent}%)",
     "tax": "Налог ({rate}%)",
     "taxIncluded": "С НДС ({rate}%)",
+    "taxIncludedNamed": "С {name} ({rate}%)",
     "paymentsReceived": "Полученные платежи",
     "paidInFull": "ПОЛНОСТЬЮ ОПЛАЧЕНО",
     "amountDue": "К оплате",
@@ -72,7 +73,8 @@
     "org": "Орг: {org}",
     "qtyOrHours": "Услуга / Часы",
     "customFieldsTitle": "Дополнительная информация",
-    "telegramConnect": "Напишите нам в Telegram"
+    "telegramConnect": "Напишите нам в Telegram",
+    "taxRegistrationLabel": "№ {name}"
   },
   "quote": {
     "title": "КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ",
@@ -100,6 +102,7 @@
     "discountPercent": "Скидка ({percent}%)",
     "tax": "Налог ({rate}%)",
     "taxIncluded": "С НДС ({rate}%)",
+    "taxIncludedNamed": "С {name} ({rate}%)",
     "validUntil": "Действительно до: {date}",
     "attachedDocuments": "Прикреплённые документы",
     "attached": "{name} (прикреплено)",

+ 22 - 0
messages/ru/settings.json

@@ -859,6 +859,28 @@
     "labelLabel": "Метка налога",
     "labelHint": "Пользовательское название, отображаемое в счетах и коммерческих предложениях (напр. НДС, VAT). Оставьте пустым для локализованного значения по умолчанию.",
     "labelPlaceholder": "НДС",
+    "splitLabel": "Разделить налог на несколько ставок",
+    "splitHint": "Для регионов, где в одном счёте начисляется больше одного налога, например GST и QST в Квебеке или CGST и SGST в Индии. Каждый налог печатается отдельной строкой со своим регистрационным номером, а в налоговом отчёте они учитываются раздельно.",
+    "splitIncomplete": "Перед сохранением укажите название и ставку для каждого налога.",
+    "presetLabel": "Начать с шаблона",
+    "presetPlaceholder": "Выберите регион",
+    "presets": {
+      "caQc": "Квебек, Канада (GST + QST)",
+      "caBc": "Британская Колумбия, Канада (GST + PST)",
+      "caSk": "Саскачеван, Канада (GST + PST)",
+      "caMb": "Манитоба, Канада (GST + RST)",
+      "inIntra": "Индия, один штат (CGST + SGST по 18%)"
+    },
+    "componentName": "Название",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Ставка",
+    "componentRegistration": "Регистрационный номер",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Удалить этот налог",
+    "registrationHint": "Печатается в счетах под реквизитами вашей компании. Оставьте пустым для налога, по которому вы не зарегистрированы.",
+    "addComponent": "Добавить налог",
+    "combinedRate": "Суммарная ставка",
+    "combinedRateHint": "Новые заказы и коммерческие предложения облагаются суммой указанных выше ставок.",
     "convertInclusiveLabel": "Преобразовать существующие записи в режим с НДС",
     "convertInclusiveHint": "Увеличивает каждую цену позиции на ставку налога, чтобы цены включали налог. Итоги для клиента остаются прежними. Подходит: {serviceRecords} заказов, {quotes} коммерческих предложений.",
     "convertInclusiveButton": "Преобразовать в режим с НДС",

+ 4 - 1
messages/tr/pdf.json

@@ -38,6 +38,7 @@
     "discountPercent": "İndirim ({percent}%)",
     "tax": "Vergi ({rate}%)",
     "taxIncluded": "KDV dahil (%{rate})",
+    "taxIncludedNamed": "{name} dahil (%{rate})",
     "paymentsReceived": "Alınan Ödemeler",
     "paidInFull": "TAMAMEN ÖDENDİ",
     "amountDue": "Ödenecek Tutar",
@@ -72,7 +73,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Ek bilgiler",
-    "telegramConnect": "Telegram'dan bize yazın"
+    "telegramConnect": "Telegram'dan bize yazın",
+    "taxRegistrationLabel": "{name} No."
   },
   "quote": {
     "title": "TEKLİF",
@@ -100,6 +102,7 @@
     "discountPercent": "İndirim ({percent}%)",
     "tax": "Vergi ({rate}%)",
     "taxIncluded": "KDV dahil (%{rate})",
+    "taxIncludedNamed": "{name} dahil (%{rate})",
     "validUntil": "Geçerlilik tarihi: {date}",
     "attachedDocuments": "Ekli Belgeler",
     "attached": "{name} (ekli)",

+ 22 - 0
messages/tr/settings.json

@@ -859,6 +859,28 @@
     "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",
+    "splitLabel": "Vergiyi birden fazla orana böl",
+    "splitHint": "Aynı faturada birden fazla vergi uygulanan yerler için, örneğin Québec'te GST ve QST ya da Hindistan'da CGST ve SGST. Her vergi kendi kayıt numarasıyla ayrı bir satır olarak yazdırılır ve vergi raporu bunları ayrı tutar.",
+    "splitIncomplete": "Kaydetmeden önce her vergiye bir ad ve oran verin.",
+    "presetLabel": "Bir şablondan başla",
+    "presetPlaceholder": "Bölge seçin",
+    "presets": {
+      "caQc": "Québec, Kanada (GST + QST)",
+      "caBc": "Britanya Kolumbiyası, Kanada (GST + PST)",
+      "caSk": "Saskatchewan, Kanada (GST + PST)",
+      "caMb": "Manitoba, Kanada (GST + RST)",
+      "inIntra": "Hindistan, aynı eyalet (%18 CGST + SGST)"
+    },
+    "componentName": "Ad",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Oran",
+    "componentRegistration": "Kayıt numarası",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Bu vergiyi kaldır",
+    "registrationHint": "Faturalarda işletme bilgilerinizin altına yazdırılır. Kayıtlı olmadığınız bir vergi için boş bırakın.",
+    "addComponent": "Vergi ekle",
+    "combinedRate": "Toplam oran",
+    "combinedRateHint": "Yeni iş emirleri ve teklifler yukarıdaki oranların toplamıyla vergilendirilir.",
     "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",

+ 8 - 0
prisma/migrations/20260910163444_split_tax_components/migration.sql

@@ -0,0 +1,8 @@
+-- AlterTable
+ALTER TABLE "quotes" ADD COLUMN     "taxComponents" JSONB;
+
+-- AlterTable
+ALTER TABLE "recurring_invoices" ADD COLUMN     "taxComponents" JSONB;
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "taxComponents" JSONB;

+ 2 - 0
prisma/schema/quotes.prisma

@@ -9,6 +9,8 @@ model Quote {
   taxRate         Float     @default(0)
   taxAmount       Float     @default(0)
   taxInclusive    Boolean   @default(false)
+  /// See ServiceRecord.taxComponents.
+  taxComponents   Json?
   discountType    String?
   discountValue   Float     @default(0)
   discountAmount  Float     @default(0)

+ 14 - 5
prisma/schema/service-records.prisma

@@ -19,6 +19,13 @@ model ServiceRecord {
   taxRate         Float     @default(0)
   taxAmount       Float     @default(0)
   taxInclusive    Boolean   @default(false)
+  /// The taxes this document was totalled with, when the workshop splits
+  /// its tax into more than one (Québec GST and QST, Indian CGST and SGST).
+  /// Null on single-rate documents. taxRate stays the combined rate and
+  /// taxAmount the combined amount, so anything that does not itemise needs
+  /// no change. Shape in src/lib/tax-components.ts; read leniently so an old
+  /// row renders on new code.
+  taxComponents   Json?
   totalAmount     Float     @default(0)
   invoiceNumber   String?
   invoiceDate     DateTime?
@@ -263,11 +270,13 @@ model RecurringInvoice {
   runCount    Int       @default(0)
 
   // Template fields (copied to ServiceRecord on generation)
-  type         String  @default("maintenance")
-  cost         Float   @default(0)
-  taxRate      Float   @default(0)
-  taxInclusive Boolean @default(false)
-  invoiceNotes String?
+  type          String  @default("maintenance")
+  cost          Float   @default(0)
+  taxRate       Float   @default(0)
+  taxInclusive  Boolean @default(false)
+  /// Copied onto each generated invoice, like taxRate. See ServiceRecord.
+  taxComponents Json?
+  invoiceNotes  String?
 
   vehicleId String
   vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)

+ 96 - 0
src/__tests__/features/invoice-designer/sample-split-tax.test.ts

@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import type { DesignerWorkshop } from '@/features/invoice-designer/Components/types'
+
+/**
+ * The designer's sample job follows the workshop's tax. A workshop that
+ * splits its tax sees its own lines and registration numbers on the canvas,
+ * because that is the sheet it will print; every other workshop sees the
+ * sample it always has.
+ */
+
+const t = (key: string) => key
+const labels = {
+  subtotal: 'Subtotal',
+  tax: 'Tax ({rate}%)',
+  total: 'Total',
+  taxRegistrationLabel: '{name} No.',
+  orgNumberLabel: 'Org. Number',
+}
+
+const workshop: DesignerWorkshop = {
+  name: 'Garage Tremblay',
+  address: '',
+  phone: '',
+  email: '',
+  slogan: '',
+  orgNumber: '',
+  paymentTerms: '',
+  logoUrl: '',
+}
+
+describe('the designer sample and a split tax', () => {
+  it('taxes the sample job the workshop way, one line per tax', () => {
+    const data = buildSampleData(
+      {
+        ...workshop,
+        taxComponents: [
+          { name: 'GST', rate: 5, registrationNumber: '123456789 RT0001' },
+          { name: 'QST', rate: 9.975, registrationNumber: '1234567890 TQ0001' },
+        ],
+      },
+      [],
+      t,
+      labels,
+      'invoice'
+    )
+    const totals = data.totals.map((line) => [line.label, line.value])
+    // 480.00 net: GST 24.00, QST 47.88, total 551.88
+    expect(totals).toEqual(
+      expect.arrayContaining([
+        ['Subtotal', '€ 480.00'],
+        ['GST (5%)', '€ 24.00'],
+        ['QST (9.975%)', '€ 47.88'],
+        ['Total', '€ 551.88'],
+      ])
+    )
+    expect(totals.some(([label]) => label.startsWith('Tax ('))).toBe(false)
+    expect(data.payment.map((pair) => [pair.label, pair.value])).toEqual(
+      expect.arrayContaining([
+        ['GST No.', '123456789 RT0001'],
+        ['QST No.', '1234567890 TQ0001'],
+      ])
+    )
+  })
+
+  it('leaves a component without a registration off the payment panel', () => {
+    const data = buildSampleData(
+      {
+        ...workshop,
+        taxComponents: [
+          { name: 'GST', rate: 5 },
+          { name: 'PST', rate: 7 },
+        ],
+      },
+      [],
+      t,
+      labels,
+      'invoice'
+    )
+    expect(data.payment.some((pair) => pair.label.endsWith('No.'))).toBe(false)
+    expect(data.totals.map((line) => line.label)).toEqual(
+      expect.arrayContaining(['GST (5%)', 'PST (7%)'])
+    )
+  })
+
+  it('shows the single 25% line to everyone else', () => {
+    const data = buildSampleData(workshop, [], t, labels, 'invoice')
+    expect(data.totals.map((line) => [line.label, line.value])).toEqual(
+      expect.arrayContaining([
+        ['Tax (25%)', '€ 120.00'],
+        ['Total', '€ 600.00'],
+      ])
+    )
+    expect(data.payment.some((pair) => pair.label.endsWith('No.'))).toBe(false)
+  })
+})

+ 3 - 1
src/__tests__/features/invoices/recurring-invoice-timezone.test.ts

@@ -17,7 +17,7 @@ vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },
     vehicle: { findFirst: vi.fn() },
-    appSetting: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
     recurringInvoice: { create: vi.fn() },
   },
 }))
@@ -38,6 +38,8 @@ function setupAuth() {
     customRole: null,
   } as any)
   vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+  // No tax settings: the template inherits an exclusive, single-rate default.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
 }
 
 beforeEach(() => {

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

@@ -256,6 +256,64 @@ describe('getTaxReport — taxableAmount formula', () => {
   })
 })
 
+describe('getTaxReport — split taxes', () => {
+  it('lists each component of a split tax under its own name', async () => {
+    setupAuth()
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      // Québec: 900 net, GST 45 and QST 89.78
+      {
+        serviceDate: new Date('2026-09-01'),
+        startDateTime: new Date('2026-09-01'),
+        subtotal: 900,
+        taxRate: 14.975,
+        taxAmount: 134.78,
+        taxInclusive: false,
+        taxComponents: [
+          { name: 'GST', rate: 5, amount: 45 },
+          { name: 'QST', rate: 9.975, amount: 89.78 },
+        ],
+        totalAmount: 1034.78,
+      },
+      // A second job at the same split: the rows add up, and count both.
+      {
+        serviceDate: new Date('2026-09-02'),
+        startDateTime: new Date('2026-09-02'),
+        subtotal: 100,
+        taxRate: 14.975,
+        taxAmount: 14.98,
+        taxInclusive: false,
+        taxComponents: [
+          { name: 'GST', rate: 5, amount: 5 },
+          { name: 'QST', rate: 9.975, amount: 9.98 },
+        ],
+        totalAmount: 114.98,
+      },
+      // An older single-rate job keeps its own row.
+      {
+        serviceDate: new Date('2026-09-03'),
+        startDateTime: new Date('2026-09-03'),
+        subtotal: 100,
+        taxRate: 5,
+        taxAmount: 5,
+        taxInclusive: false,
+        taxComponents: null,
+        totalAmount: 105,
+      },
+    ] as any)
+
+    const result = await getTaxReport({})
+
+    expect(result.data?.byRate).toEqual([
+      { name: 'GST', taxRate: 5, taxCollected: 50, invoiceCount: 2 },
+      { name: 'QST', taxRate: 9.975, taxCollected: 99.76, invoiceCount: 2 },
+      { taxRate: 5, taxCollected: 5, invoiceCount: 1 },
+    ])
+    // The combined figures are untouched by the split.
+    expect(result.data?.summary.totalTaxCollected).toBeCloseTo(154.76)
+    expect(result.data?.summary.totalInvoices).toBe(3)
+  })
+})
+
 // ---------------------------------------------------------------------------
 // getPartsUsageReport
 // ---------------------------------------------------------------------------

+ 201 - 0
src/__tests__/features/settings/split-tax-print.test.ts

@@ -0,0 +1,201 @@
+import { describe, expect, it } from 'vitest'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuotePrint'
+import { buildLayoutFromPreset, layoutPresets } from '@/features/settings/Schema/layoutPresets'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+
+/**
+ * A Québec invoice: GST and QST each on their own line, each registration
+ * number under the business details, and no combined "Tax" line to confuse
+ * the two. The same job printed through every template preset, because the
+ * totals box is laid out per preset and a second tax row must fit them all.
+ */
+
+const components = [
+  { name: 'GST', rate: 5, amount: 45, registrationNumber: '123456789 RT0001' },
+  { name: 'QST', rate: 9.975, amount: 89.78, registrationNumber: '1234567890 TQ0001' },
+]
+
+const invoice: InvoiceData = {
+  id: 'svc_1',
+  title: 'Brakes',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-09-10'),
+  invoiceDate: new Date('2026-09-10'),
+  invoiceDueDate: new Date('2026-09-24'),
+  shopName: 'Garage Tremblay',
+  techName: 'Jamie',
+  mileage: 1000,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 900,
+  taxRate: 14.975,
+  taxAmount: 134.78,
+  taxInclusive: false,
+  taxComponents: components,
+  totalAmount: 1034.78,
+  cost: 1034.78,
+  invoiceNumber: 'INV-1',
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  partItems: [{ partNumber: 'P1', name: 'Pads', quantity: 2, unitPrice: 150, total: 300 }],
+  laborItems: [{ description: 'Fit', hours: 1.5, rate: 400, total: 600 }],
+  customFields: [],
+  findings: [],
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyExpiresAt: null,
+  warrantyNotes: null,
+  customer: {
+    name: 'Alex',
+    email: null,
+    phone: null,
+    address: null,
+    company: null,
+    customerNumber: null,
+  },
+  vehicle: {
+    make: 'Volvo',
+    model: 'V60',
+    year: 2021,
+    vin: null,
+    licensePlate: 'AB 12345',
+    mileage: 1000,
+    customer: null,
+  },
+} as unknown as InvoiceData
+
+const labels = {
+  subtotal: 'Subtotal',
+  tax: 'Tax ({rate}%)',
+  taxIncluded: 'Includes tax ({rate}%)',
+  taxIncludedNamed: 'Includes {name} ({rate}%)',
+  taxRegistrationLabel: '{name} No.',
+  orgNumberLabel: 'Org. Number',
+  total: 'Total',
+}
+
+function totalsOf(spec: DocumentSpec): string[] {
+  const seen: string[] = []
+  const walk = (node: unknown) => {
+    if (Array.isArray(node)) return node.forEach(walk)
+    if (node && typeof node === 'object') {
+      for (const [key, value] of Object.entries(node)) {
+        if (key === 'text' && typeof value === 'string') seen.push(value)
+        else walk(value)
+      }
+    }
+  }
+  walk(spec)
+  return seen
+}
+
+describe('an invoice with a split tax', () => {
+  it('prints one line per tax and no combined tax line', () => {
+    const text = totalsOf(
+      buildInvoicePrintSpec({
+        data: invoice,
+        labels,
+        invoiceSettings: { currencyCode: 'CAD', currencyFormat: 'code' },
+      })
+    )
+    expect(text).toContain('GST (5%)')
+    expect(text).toContain('QST (9.975%)')
+    expect(text.some((s) => /^Tax \(/.test(s))).toBe(false)
+    expect(text.find((s) => s.includes('45.00'))).toBeTruthy()
+    expect(text.find((s) => s.includes('89.78'))).toBeTruthy()
+    expect(text.find((s) => s.includes('1,034.78'))).toBeTruthy()
+  })
+
+  it('prints each registration number under the business details', () => {
+    const text = totalsOf(
+      buildInvoicePrintSpec({
+        data: invoice,
+        labels,
+        invoiceSettings: { currencyCode: 'CAD', orgNumber: 'NEQ 1234567890' },
+      })
+    )
+    expect(text).toContain('GST No.')
+    expect(text).toContain('123456789 RT0001')
+    expect(text).toContain('QST No.')
+    expect(text).toContain('1234567890 TQ0001')
+    expect(text).toContain('NEQ 1234567890')
+  })
+
+  it('names the tax in each line when lines are printed with tax in them', () => {
+    const text = totalsOf(
+      buildInvoicePrintSpec({
+        data: invoice,
+        labels,
+        invoiceSettings: { currencyCode: 'CAD', lineItemsInclTax: true },
+      })
+    )
+    expect(text).toContain('Includes GST (5%)')
+    expect(text).toContain('Includes QST (9.975%)')
+  })
+
+  it('prints a single-rate invoice exactly as before', () => {
+    const text = totalsOf(
+      buildInvoicePrintSpec({
+        data: { ...invoice, taxComponents: null, taxRate: 25, taxAmount: 225, totalAmount: 1125 },
+        labels,
+        invoiceSettings: { currencyCode: 'CAD' },
+      })
+    )
+    expect(text).toContain('Tax (25%)')
+    expect(text.some((s) => s.includes('No.'))).toBe(false)
+  })
+
+  it('fits both lines in every template preset', () => {
+    for (const preset of layoutPresets) {
+      const spec = buildInvoicePrintSpec({
+        data: invoice,
+        labels,
+        invoiceSettings: { currencyCode: 'CAD' },
+        template: { ...preset.template, layoutConfig: buildLayoutFromPreset(preset) },
+      })
+      const text = totalsOf(spec)
+      expect(text, preset.id).toContain('GST (5%)')
+      expect(text, preset.id).toContain('QST (9.975%)')
+    }
+  })
+
+  it('does the same on a quote', () => {
+    const quote = {
+      id: 'q1',
+      quoteNumber: 'Q-1',
+      title: 'Brakes',
+      description: null,
+      status: 'sent',
+      validUntil: null,
+      createdAt: new Date('2026-09-10'),
+      subtotal: 900,
+      taxRate: 14.975,
+      taxAmount: 134.78,
+      taxInclusive: false,
+      taxComponents: components,
+      discountType: null,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 1034.78,
+      partItems: [{ partNumber: 'P1', name: 'Pads', quantity: 2, unitPrice: 150, total: 300 }],
+      laborItems: [{ description: 'Fit', hours: 1.5, rate: 400, total: 600 }],
+      customer: { name: 'Alex', email: null, phone: null, address: null },
+      vehicle: { make: 'Volvo', model: 'V60', year: 2021, licensePlate: 'AB 12345', vin: null },
+      notes: null,
+    }
+    const text = totalsOf(
+      buildQuotePrintSpec({
+        data: quote as unknown as Parameters<typeof buildQuotePrintSpec>[0]['data'],
+        labels,
+        currencyCode: 'CAD',
+      })
+    )
+    expect(text).toContain('GST (5%)')
+    expect(text).toContain('QST (9.975%)')
+    expect(text.some((s) => /^Tax \(/.test(s))).toBe(false)
+  })
+})

+ 289 - 0
src/__tests__/lib/tax-components.test.ts

@@ -0,0 +1,289 @@
+import { describe, expect, it } from 'vitest'
+import { calculateTotals, combinedTaxRate, splitTaxAmount } from '@/lib/tax'
+import {
+  parseTaxComponentDefinitions,
+  parseTaxComponents,
+  serializeTaxComponentDefinitions,
+  TAX_COMPONENT_PRESETS,
+  taxComponentLabel,
+  taxComponentsJson,
+} from '@/lib/tax-components'
+import {
+  documentTotals,
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+} from '@/features/settings/Lib/workshopTax'
+
+/**
+ * A document taxed in more than one part: Québec's GST and QST are the
+ * worked example throughout, because the combined figure (14.975%) and the
+ * half-cent cases (900 at 9.975% is 89.775) are where a naive split goes
+ * wrong.
+ */
+
+const QUEBEC = [
+  { name: 'GST', rate: 5 },
+  { name: 'QST', rate: 9.975 },
+]
+
+describe('combinedTaxRate', () => {
+  it('adds non-compounding rates exactly', () => {
+    expect(combinedTaxRate(QUEBEC)).toBe(14.975)
+    expect(
+      combinedTaxRate([
+        { name: 'CGST', rate: 9 },
+        { name: 'SGST', rate: 9 },
+      ])
+    ).toBe(18)
+  })
+
+  it('gives the effective rate when a component compounds on the ones before it', () => {
+    // 5% then 9.975% on the price plus the 5%: 1.05 × 1.09975 = 1.1547375
+    expect(
+      combinedTaxRate([
+        { name: 'GST', rate: 5 },
+        { name: 'QST', rate: 9.975, compound: true },
+      ])
+    ).toBeCloseTo(15.47375, 5)
+  })
+
+  it('is zero with no components', () => {
+    expect(combinedTaxRate([])).toBe(0)
+  })
+})
+
+describe('calculateTotals with components', () => {
+  it('charges each component on the net base and sums them (exclusive)', () => {
+    const r = calculateTotals({
+      subtotal: 900,
+      discountAmount: 0,
+      taxRate: 14.975,
+      taxInclusive: false,
+      components: QUEBEC,
+    })
+    expect(r.components).toEqual([
+      { name: 'GST', rate: 5, amount: 45 },
+      { name: 'QST', rate: 9.975, amount: 89.78 },
+    ])
+    // The lines are what the customer adds up, so the tax is their sum.
+    expect(r.taxAmount).toBe(134.78)
+    expect(r.totalAmount).toBe(1034.78)
+  })
+
+  it('takes the discount off before either tax', () => {
+    const r = calculateTotals({
+      subtotal: 900,
+      discountAmount: 90,
+      taxRate: 14.975,
+      taxInclusive: false,
+      components: QUEBEC,
+    })
+    expect(r.components?.map((c) => c.amount)).toEqual([40.5, 80.8])
+    expect(r.totalAmount).toBe(931.3)
+  })
+
+  it('reads the rate off the components rather than the argument', () => {
+    // A stale combined rate on the row cannot change what the split charges.
+    const r = calculateTotals({
+      subtotal: 100,
+      discountAmount: 0,
+      taxRate: 99,
+      taxInclusive: false,
+      components: QUEBEC,
+    })
+    expect(r.taxAmount).toBeCloseTo(14.98, 2)
+  })
+
+  it('backs the combined tax out of a gross price and shares it by rate (inclusive)', () => {
+    // 1034.78 gross at 14.975% is 900.00 net and 134.78 tax
+    const r = calculateTotals({
+      subtotal: 1034.78,
+      discountAmount: 0,
+      taxRate: 14.975,
+      taxInclusive: true,
+      components: QUEBEC,
+    })
+    expect(r.totalAmount).toBe(1034.78)
+    expect(r.taxAmount).toBeCloseTo(134.78, 2)
+    const [gst, qst] = r.components!
+    expect(gst.amount).toBe(45)
+    expect(qst.amount).toBe(89.78)
+    // Whatever the rounding, the lines add up to the tax on the sheet.
+    expect(gst.amount + qst.amount).toBeCloseTo(Math.round(r.taxAmount * 100) / 100, 2)
+  })
+
+  it('gives the last component the rounding remainder', () => {
+    // Three equal thirds of 1.00 cannot each be a whole cent.
+    const split = splitTaxAmount({
+      base: 100,
+      taxAmount: 1,
+      taxInclusive: true,
+      components: [
+        { name: 'A', rate: 1 },
+        { name: 'B', rate: 1 },
+        { name: 'C', rate: 1 },
+      ],
+    })
+    expect(split.map((c) => c.amount)).toEqual([0.33, 0.33, 0.34])
+  })
+
+  it('compounds when told to (exclusive)', () => {
+    const r = calculateTotals({
+      subtotal: 100,
+      discountAmount: 0,
+      taxRate: 0,
+      taxInclusive: false,
+      components: [
+        { name: 'GST', rate: 5 },
+        { name: 'QST', rate: 9.975, compound: true },
+      ],
+    })
+    expect(r.components?.map((c) => c.amount)).toEqual([5, 10.47])
+    expect(r.totalAmount).toBe(115.47)
+  })
+
+  it('is the single-rate calculation when there are no components', () => {
+    const plain = calculateTotals({
+      subtotal: 900,
+      discountAmount: 0,
+      taxRate: 25,
+      taxInclusive: false,
+    })
+    expect(plain).toEqual({ taxAmount: 225, totalAmount: 1125, components: null })
+    const empty = calculateTotals({
+      subtotal: 900,
+      discountAmount: 0,
+      taxRate: 25,
+      taxInclusive: false,
+      components: [],
+    })
+    expect(empty.components).toBeNull()
+    expect(empty.taxAmount).toBe(225)
+  })
+
+  it('carries zero-amount components on a zero-rate inclusive document', () => {
+    const r = calculateTotals({
+      subtotal: 100,
+      discountAmount: 0,
+      taxRate: 0,
+      taxInclusive: true,
+      components: [{ name: 'Exempt', rate: 0 }],
+    })
+    expect(r.taxAmount).toBe(0)
+    expect(r.components).toEqual([{ name: 'Exempt', rate: 0, amount: 0 }])
+  })
+})
+
+describe('stored shape', () => {
+  it('reads a stored breakdown and drops what is not one', () => {
+    const stored = [{ name: 'GST', rate: 5, amount: 45, registrationNumber: '123456789 RT0001' }]
+    expect(parseTaxComponents(stored)).toEqual(stored)
+    expect(parseTaxComponents(JSON.stringify(stored))).toEqual(stored)
+    expect(parseTaxComponents(null)).toBeNull()
+    expect(parseTaxComponents([])).toBeNull()
+    expect(parseTaxComponents('not json')).toBeNull()
+    expect(parseTaxComponents({ name: 'GST' })).toBeNull()
+    expect(parseTaxComponents([{ name: '', rate: 5, amount: 1 }])).toBeNull()
+  })
+
+  it('reads the settings definition without amounts', () => {
+    expect(parseTaxComponentDefinitions('[{"name":"GST","rate":"5"}]')).toEqual([
+      { name: 'GST', rate: 5 },
+    ])
+    expect(parseTaxComponentDefinitions('')).toBeNull()
+    expect(parseTaxComponentDefinitions(undefined)).toBeNull()
+  })
+
+  it('strips empty optional fields on the way in and out', () => {
+    const parsed = parseTaxComponents([
+      { name: 'GST', rate: 5, amount: 1, registrationNumber: '', compound: false },
+    ])
+    expect(parsed).toEqual([{ name: 'GST', rate: 5, amount: 1 }])
+    expect(
+      taxComponentsJson([{ name: 'QST', rate: 9.975, amount: 2, registrationNumber: '' }])
+    ).toEqual([{ name: 'QST', rate: 9.975, amount: 2 }])
+    expect(serializeTaxComponentDefinitions([{ name: 'GST', rate: 5, compound: false }])).toBe(
+      '[{"name":"GST","rate":5}]'
+    )
+  })
+
+  it('labels a component the way the sheet prints it', () => {
+    expect(taxComponentLabel({ name: 'QST', rate: 9.975 })).toBe('QST (9.975%)')
+  })
+
+  it('ships presets that add up to the published combined rates', () => {
+    const byId = Object.fromEntries(TAX_COMPONENT_PRESETS.map((p) => [p.id, p]))
+    expect(combinedTaxRate(byId['ca-qc'].components)).toBe(14.975)
+    expect(combinedTaxRate(byId['ca-bc'].components)).toBe(12)
+    expect(combinedTaxRate(byId['ca-sk'].components)).toBe(11)
+    expect(combinedTaxRate(byId['ca-mb'].components)).toBe(12)
+    expect(combinedTaxRate(byId['in-intra'].components)).toBe(18)
+    for (const preset of TAX_COMPONENT_PRESETS) {
+      for (const c of preset.components) expect(c.registrationNumber).toBeUndefined()
+    }
+  })
+})
+
+describe('workshop tax settings', () => {
+  const split = {
+    'workshop.taxEnabled': 'true',
+    'workshop.defaultTaxRate': '14.975',
+    'workshop.taxInclusive': 'false',
+    'workshop.taxMode': 'split',
+    'workshop.taxComponents': JSON.stringify([
+      { name: 'GST', rate: 5, registrationNumber: '123456789 RT0001' },
+      { name: 'QST', rate: 9.975, registrationNumber: '1234567890 TQ0001' },
+    ]),
+  }
+
+  it('reads components only in split mode', () => {
+    expect(readWorkshopTax(split).components).toHaveLength(2)
+    expect(readWorkshopTax({ ...split, 'workshop.taxMode': 'single' }).components).toBeNull()
+    expect(readWorkshopTax({ ...split, 'workshop.taxMode': '' }).components).toBeNull()
+    expect(readWorkshopTax({ ...split, 'workshop.taxEnabled': 'false' })).toEqual({
+      enabled: false,
+      rate: 0,
+      inclusive: false,
+      components: null,
+    })
+  })
+
+  it('starts a new document with the split at zero, and none for an exempt customer', () => {
+    const fields = taxFieldsForNewDocument(readWorkshopTax(split))
+    expect(fields.taxRate).toBe(14.975)
+    expect(fields.taxComponents).toEqual([
+      { name: 'GST', rate: 5, amount: 0, registrationNumber: '123456789 RT0001' },
+      { name: 'QST', rate: 9.975, amount: 0, registrationNumber: '1234567890 TQ0001' },
+    ])
+    const exempt = taxFieldsForNewDocument(readWorkshopTax(split), { customerExempt: true })
+    expect(exempt).toEqual({ taxRate: 0, taxInclusive: false, taxComponents: undefined })
+  })
+
+  it('re-totals a document from the split stored on it', () => {
+    const fields = taxFieldsForNewDocument(readWorkshopTax(split))
+    const totals = documentTotals({
+      subtotal: 900,
+      discountAmount: 0,
+      taxRate: 14.975,
+      taxInclusive: false,
+      taxComponents: fields.taxComponents,
+    })
+    expect(totals.taxAmount).toBe(134.78)
+    expect(totals.totalAmount).toBe(1034.78)
+    expect(totals.taxComponents).toEqual([
+      { name: 'GST', rate: 5, amount: 45, registrationNumber: '123456789 RT0001' },
+      { name: 'QST', rate: 9.975, amount: 89.78, registrationNumber: '1234567890 TQ0001' },
+    ])
+  })
+
+  it('leaves a single-rate document exactly as before', () => {
+    const totals = documentTotals({
+      subtotal: 900,
+      discountAmount: 0,
+      taxRate: 25,
+      taxInclusive: false,
+      taxComponents: null,
+    })
+    expect(totals).toEqual({ taxAmount: 225, totalAmount: 1125, taxComponents: undefined })
+  })
+})

+ 6 - 2
src/app/(authenticated)/reports/reports-client.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { taxRateLabel } from '@/features/reports/Lib/taxRateLabel'
+
 import { interactiveRow } from '@/lib/interactive-row'
 import { formatQuantity } from '@/lib/format-quantity'
 import { useState, useEffect, useCallback } from 'react'
@@ -1500,8 +1502,10 @@ export default function ReportsClient({
                       </TableHeader>
                       <TableBody>
                         {taxData.byRate.map((row) => (
-                          <TableRow key={row.taxRate}>
-                            <TableCell className="text-sm font-medium">{row.taxRate}%</TableCell>
+                          <TableRow key={`${row.name ?? ''}|${row.taxRate}`}>
+                            <TableCell className="text-sm font-medium">
+                              {taxRateLabel(row)}
+                            </TableCell>
                             <TableCell className="text-right text-sm">
                               {fmtCurrency(row.taxCollected)}
                             </TableCell>

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

@@ -10,6 +10,8 @@ export default async function TaxSettingsPage() {
       SETTING_KEYS.DEFAULT_TAX_RATE,
       SETTING_KEYS.TAX_INCLUSIVE,
       SETTING_KEYS.TAX_LABEL,
+      SETTING_KEYS.TAX_MODE,
+      SETTING_KEYS.TAX_COMPONENTS,
       SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX,
     ]),
     getTaxBackfillCounts(),

+ 215 - 8
src/app/(authenticated)/settings/tax/tax-settings.tsx

@@ -9,15 +9,60 @@ import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
 import { Switch } from '@/components/ui/switch'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import { toast } from 'sonner'
-import { Loader2, Percent, Save } from 'lucide-react'
+import { Loader2, Percent, Plus, Save, X } from 'lucide-react'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { applyTaxRateToExisting } from '@/features/settings/Actions/applyTaxRateToExisting'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { useConfirm } from '@/components/confirm-dialog'
 import { cn } from '@/lib/utils'
+import { combinedTaxRate } from '@/lib/tax'
+import {
+  MAX_TAX_COMPONENTS,
+  parseTaxComponentDefinitions,
+  serializeTaxComponentDefinitions,
+  TAX_COMPONENT_PRESETS,
+} from '@/lib/tax-components'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 
+/** A component as it sits in the form: the rate stays text until it is saved. */
+interface ComponentRow {
+  name: string
+  rate: string
+  registrationNumber: string
+}
+
+const EMPTY_ROW: ComponentRow = { name: '', rate: '', registrationNumber: '' }
+
+function rowsFromSettings(value: string | undefined): ComponentRow[] {
+  const parsed = parseTaxComponentDefinitions(value)
+  if (!parsed) return [EMPTY_ROW, EMPTY_ROW]
+  return parsed.map((c) => ({
+    name: c.name,
+    rate: String(c.rate),
+    registrationNumber: c.registrationNumber ?? '',
+  }))
+}
+
+/** The rows as definitions, or null when one is not filled in yet. */
+function definitionsOf(rows: ComponentRow[]) {
+  const defs = rows.map((row) => ({
+    name: row.name.trim(),
+    rate: Number(row.rate),
+    registrationNumber: row.registrationNumber.trim() || undefined,
+  }))
+  if (defs.length === 0) return null
+  if (defs.some((d) => !d.name || !Number.isFinite(d.rate) || d.rate < 0)) return null
+  return defs
+}
+
 export function TaxSettings({
   settings,
   taxBackfillCounts,
@@ -40,15 +85,62 @@ export function TaxSettings({
   const [lineItemsInclTax, setLineItemsInclTax] = useState(
     settings[SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX] === 'true'
   )
+  // Off for every workshop that has not asked for it: the page then reads
+  // exactly as it did before the split existed.
+  const [splitTax, setSplitTax] = useState(settings[SETTING_KEYS.TAX_MODE] === 'split')
+  const [components, setComponents] = useState<ComponentRow[]>(() =>
+    rowsFromSettings(settings[SETTING_KEYS.TAX_COMPONENTS])
+  )
+
+  const splitDefinitions = definitionsOf(components)
+  const combinedRate = splitDefinitions ? combinedTaxRate(splitDefinitions) : null
+  // What the worked examples and the backfill quote: the combined rate when
+  // the tax is split, the typed rate otherwise.
+  const effectiveRate = splitTax ? String(combinedRate ?? 0) : defaultTaxRate
+
+  const updateRow = (index: number, patch: Partial<ComponentRow>) =>
+    setComponents((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
+  const removeRow = (index: number) =>
+    setComponents((rows) => (rows.length > 1 ? rows.filter((_, i) => i !== index) : rows))
+  const addRow = () =>
+    setComponents((rows) => (rows.length < MAX_TAX_COMPONENTS ? [...rows, EMPTY_ROW] : rows))
+  const applyPreset = (id: string) => {
+    const preset = TAX_COMPONENT_PRESETS.find((p) => p.id === id)
+    if (!preset) return
+    // Registration numbers are the workshop's own: a preset never touches
+    // one already typed, and otherwise leaves the field for the user.
+    setComponents(
+      preset.components.map((c, i) => ({
+        name: c.name,
+        rate: String(c.rate),
+        registrationNumber: components[i]?.registrationNumber ?? '',
+      }))
+    )
+  }
+  const toggleSplit = (on: boolean) => {
+    // Leaving the split keeps the combined figure as the single rate, so the
+    // workshop's default does not silently drop to zero.
+    if (!on && combinedRate != null) setDefaultTaxRate(String(combinedRate))
+    setSplitTax(on)
+  }
 
   const handleSave = async () => {
+    if (taxEnabled && splitTax && !splitDefinitions) {
+      toast.error(t('tax.splitIncomplete'))
+      return
+    }
     setSaving(true)
     await setSettings({
       [SETTING_KEYS.TAX_ENABLED]: String(taxEnabled),
-      [SETTING_KEYS.DEFAULT_TAX_RATE]: taxEnabled ? defaultTaxRate : '0',
+      [SETTING_KEYS.DEFAULT_TAX_RATE]: taxEnabled ? effectiveRate : '0',
       [SETTING_KEYS.TAX_INCLUSIVE]: String(taxInclusive),
       [SETTING_KEYS.TAX_LABEL]: taxLabel.trim(),
       [SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX]: String(lineItemsInclTax),
+      [SETTING_KEYS.TAX_MODE]: taxEnabled && splitTax ? 'split' : 'single',
+      [SETTING_KEYS.TAX_COMPONENTS]:
+        taxEnabled && splitTax && splitDefinitions
+          ? serializeTaxComponentDefinitions(splitDefinitions)
+          : '',
     })
     setSaving(false)
     router.refresh()
@@ -64,7 +156,7 @@ export function TaxSettings({
     const ok = await confirm({
       title: t('currency.applyTaxConfirmTitle'),
       description: t('currency.applyTaxConfirmDescription', {
-        rate: defaultTaxRate,
+        rate: effectiveRate,
         serviceRecords: taxBackfillCounts.serviceRecords,
         quotes: taxBackfillCounts.quotes,
       }),
@@ -106,7 +198,7 @@ export function TaxSettings({
               <Switch checked={taxEnabled} onCheckedChange={setTaxEnabled} />
             </div>
 
-            {taxEnabled && (
+            {taxEnabled && !splitTax && (
               <div className="flex items-start justify-between gap-4">
                 <div className="space-y-1">
                   <Label htmlFor="defaultTaxRate">{t('currency.defaultTaxRate')}</Label>
@@ -132,7 +224,7 @@ export function TaxSettings({
               </div>
             )}
 
-            {taxEnabled && (
+            {taxEnabled && !splitTax && (
               <div className="flex items-start justify-between gap-4">
                 <div className="space-y-1">
                   <Label htmlFor="taxLabel">{t('tax.labelLabel')}</Label>
@@ -149,6 +241,121 @@ export function TaxSettings({
               </div>
             )}
 
+            {/* Some places charge two taxes on the same invoice and want each
+                on its own line with its own registration: Québec's GST and
+                QST, India's CGST and SGST. Off, nothing below exists. */}
+            {taxEnabled && (
+              <div className="flex items-start justify-between gap-4">
+                <div className="space-y-1">
+                  <Label htmlFor="taxSplit">{t('tax.splitLabel')}</Label>
+                  <p className="text-xs text-muted-foreground">{t('tax.splitHint')}</p>
+                </div>
+                <Switch id="taxSplit" checked={splitTax} onCheckedChange={toggleSplit} />
+              </div>
+            )}
+
+            {taxEnabled && splitTax && (
+              <div className="space-y-4 rounded-lg border bg-muted/30 p-4">
+                <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+                  <Label htmlFor="taxPreset">{t('tax.presetLabel')}</Label>
+                  <Select onValueChange={applyPreset}>
+                    <SelectTrigger id="taxPreset" className="w-full sm:w-72">
+                      <SelectValue placeholder={t('tax.presetPlaceholder')} />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {TAX_COMPONENT_PRESETS.map((preset) => (
+                        <SelectItem key={preset.id} value={preset.id}>
+                          {t(`tax.presets.${preset.labelKey}`)}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                </div>
+
+                <div className="space-y-2">
+                  <div className="hidden grid-cols-[1fr_7rem_1fr_2rem] gap-2 text-xs text-muted-foreground sm:grid">
+                    <span>{t('tax.componentName')}</span>
+                    <span>{t('tax.componentRate')}</span>
+                    <span>{t('tax.componentRegistration')}</span>
+                    <span />
+                  </div>
+                  {components.map((row, index) => (
+                    <div
+                      key={index}
+                      data-testid="tax-component-row"
+                      className="grid grid-cols-[1fr_5rem_2rem] gap-2 sm:grid-cols-[1fr_7rem_1fr_2rem]"
+                    >
+                      <Input
+                        id={`taxComponentName-${index}`}
+                        aria-label={t('tax.componentName')}
+                        placeholder={t('tax.componentNamePlaceholder')}
+                        value={row.name}
+                        onChange={(e) => updateRow(index, { name: e.target.value })}
+                      />
+                      <div className="relative">
+                        <Input
+                          id={`taxComponentRate-${index}`}
+                          aria-label={t('tax.componentRate')}
+                          type="number"
+                          min="0"
+                          step="0.001"
+                          placeholder="0"
+                          value={row.rate}
+                          onChange={(e) => updateRow(index, { rate: e.target.value })}
+                          className="pr-7 text-right"
+                        />
+                        <span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-sm text-muted-foreground">
+                          %
+                        </span>
+                      </div>
+                      <Input
+                        id={`taxComponentRegistration-${index}`}
+                        aria-label={t('tax.componentRegistration')}
+                        placeholder={t('tax.componentRegistrationPlaceholder')}
+                        value={row.registrationNumber}
+                        onChange={(e) => updateRow(index, { registrationNumber: e.target.value })}
+                        className="col-span-3 sm:col-span-1"
+                      />
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="icon"
+                        aria-label={t('tax.componentRemove')}
+                        onClick={() => removeRow(index)}
+                        disabled={components.length <= 1}
+                        className="col-start-3 row-start-1 sm:col-start-auto sm:row-start-auto"
+                      >
+                        <X className="h-4 w-4" />
+                      </Button>
+                    </div>
+                  ))}
+                  <p className="text-xs text-muted-foreground">{t('tax.registrationHint')}</p>
+                </div>
+
+                <div className="flex items-center justify-between gap-4">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    onClick={addRow}
+                    disabled={components.length >= MAX_TAX_COMPONENTS}
+                  >
+                    <Plus className="mr-1.5 h-4 w-4" />
+                    {t('tax.addComponent')}
+                  </Button>
+                  <div className="text-right">
+                    <p className="text-sm">
+                      <span className="text-muted-foreground">{t('tax.combinedRate')}: </span>
+                      <span className="font-semibold" data-testid="tax-combined-rate">
+                        {combinedRate ?? '–'}%
+                      </span>
+                    </p>
+                    <p className="text-xs text-muted-foreground">{t('tax.combinedRateHint')}</p>
+                  </div>
+                </div>
+              </div>
+            )}
+
             {taxEnabled && (
               <div className="space-y-2">
                 <Label>{t('tax.modeLabel')}</Label>
@@ -182,10 +389,10 @@ export function TaxSettings({
                 <p className="text-xs text-muted-foreground">
                   {taxInclusive
                     ? t('tax.modeInclusiveExample', {
-                        rate: defaultTaxRate || '0',
+                        rate: effectiveRate || '0',
                       })
                     : t('tax.modeExclusiveExample', {
-                        rate: defaultTaxRate || '0',
+                        rate: effectiveRate || '0',
                       })}
                 </p>
               </div>
@@ -214,7 +421,7 @@ export function TaxSettings({
                   Re-enable once the backfill skips paid records. */}
             {false &&
               taxEnabled &&
-              Number(defaultTaxRate) > 0 &&
+              Number(effectiveRate) > 0 &&
               taxBackfillCounts.serviceRecords + taxBackfillCounts.quotes > 0 && (
                 <div className="rounded-lg border bg-muted/30 p-4">
                   <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">

+ 4 - 0
src/app/(authenticated)/settings/templates/page.tsx

@@ -1,4 +1,5 @@
 import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { readWorkshopTax, WORKSHOP_TAX_SETTING_KEYS } from '@/features/settings/Lib/workshopTax'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { TemplateSettings } from './template-settings'
 import { getLayoutData } from '@/lib/get-layout-data'
@@ -60,6 +61,7 @@ export default async function TemplatePage() {
       SETTING_KEYS.QUOTE_FONT_FAMILY,
       SETTING_KEYS.QUOTE_HEADER_STYLE,
       SETTING_KEYS.QUOTE_LOGO_SIZE,
+      ...WORKSHOP_TAX_SETTING_KEYS,
       SETTING_KEYS.COMPANY_LOGO,
       SETTING_KEYS.INVOICE_ACTIVE_DESIGN,
       SETTING_KEYS.QUOTE_ACTIVE_DESIGN,
@@ -114,6 +116,8 @@ export default async function TemplatePage() {
     phone: settings[SETTING_KEYS.WORKSHOP_PHONE],
     email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
     slogan: settings[SETTING_KEYS.WORKSHOP_SLOGAN],
+    // Its own taxes too: a split-tax workshop's cards show its GST and QST lines.
+    taxComponents: readWorkshopTax(settings).components,
   }
 
   const smsDefaultMap: Record<string, string> = {

+ 3 - 0
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import type { TaxComponentDefinition } from '@/lib/tax'
+
 import { useState, useCallback } from 'react'
 import Link from 'next/link'
 import { useRouter, useSearchParams } from 'next/navigation'
@@ -45,6 +47,7 @@ interface WorkshopPreviewInfo {
   phone?: string
   email?: string
   slogan?: string
+  taxComponents?: TaxComponentDefinition[] | null
 }
 
 const fontMap: Record<string, string> = {

+ 2 - 0
src/app/(designer)/invoice-designer/page.tsx

@@ -4,6 +4,7 @@ import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures } from '@/lib/features'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { readWorkshopTax } from '@/features/settings/Lib/workshopTax'
 import {
   getInvoiceLayoutConfig,
   getQuoteLayoutConfig,
@@ -126,6 +127,7 @@ export default async function InvoiceDesignerPage({
           orgNumberLabel: settings[SETTING_KEYS.ORG_NUMBER_LABEL] || '',
           paymentTerms: settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] || '',
           logoUrl: settings[SETTING_KEYS.COMPANY_LOGO] || '',
+          taxComponents: readWorkshopTax(settings).components,
         }}
         customFields={
           customFieldsResult.success && customFieldsResult.data

+ 3 - 0
src/app/api/protected/backup/import/route.ts

@@ -7,6 +7,7 @@ import { isDemoMode } from '@/lib/demo'
 import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { columnsOf } from '@/lib/backup/rows'
 import { toSafeDate } from '@/lib/invoice-utils'
+import { taxComponentsForCopy } from '@/features/settings/Lib/workshopTax'
 import { atZonedTime } from '@/lib/timezone'
 import { resolveWorkshopTimeZone } from '@/lib/workshop-timezone'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -139,6 +140,7 @@ async function importServiceRecordTree(
       taxRate: (sr.taxRate as number) || 0,
       taxAmount: (sr.taxAmount as number) || 0,
       taxInclusive: (sr.taxInclusive as boolean) ?? false,
+      taxComponents: taxComponentsForCopy(sr.taxComponents),
       totalAmount: (sr.totalAmount as number) || 0,
       invoiceNumber: (sr.invoiceNumber as string) || null,
       discountType: (sr.discountType as string) || null,
@@ -976,6 +978,7 @@ export async function POST(request: NextRequest) {
               taxRate: (q.taxRate as number) || 0,
               taxAmount: (q.taxAmount as number) || 0,
               taxInclusive: (q.taxInclusive as boolean) ?? false,
+              taxComponents: taxComponentsForCopy(q.taxComponents),
               discountType: (q.discountType as string) || null,
               discountValue: (q.discountValue as number) || 0,
               discountAmount: (q.discountAmount as number) || 0,

+ 5 - 3
src/app/api/v1/tech/jobs/[id]/labor/route.ts

@@ -1,6 +1,6 @@
 import { z } from 'zod'
 import { db } from '@/lib/db'
-import { calculateTotals } from '@/lib/tax'
+import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { roundMoney } from '@/features/inventory/Lib/partPricing'
@@ -49,6 +49,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
           title: true,
           taxRate: true,
           taxInclusive: true,
+          taxComponents: true,
           discountType: true,
           discountValue: true,
         },
@@ -91,16 +92,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
             : job.discountType === 'fixed'
               ? Math.min(job.discountValue ?? 0, subtotal)
               : 0
-        const { taxAmount, totalAmount } = calculateTotals({
+        const { taxAmount, totalAmount, taxComponents } = documentTotals({
           subtotal,
           discountAmount,
           taxRate: job.taxRate,
           taxInclusive: job.taxInclusive,
+          taxComponents: job.taxComponents,
         })
 
         await tx.serviceRecord.update({
           where: { id: job.id },
-          data: { subtotal, taxAmount, totalAmount },
+          data: { subtotal, taxAmount, totalAmount, taxComponents },
         })
 
         return created

+ 25 - 7
src/features/billing/Actions/recurringInvoiceActions.ts

@@ -11,7 +11,12 @@ import {
   type UpdateRecurringInvoiceInput,
 } from '../Schema/recurringInvoiceSchema'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
-import { calculateTotals } from '@/lib/tax'
+import {
+  documentTotals,
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '@/features/settings/Lib/workshopTax'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
 import { calculateNextRunDate } from '@/lib/cron/recurring-invoices'
 import { endOfWorkshopDay, toSafeWorkshopDate } from '@/lib/workshop-datetime'
@@ -57,14 +62,24 @@ export async function createRecurringInvoice(input: CreateRecurringInvoiceInput)
 
       // If the caller didn't explicitly set taxInclusive, inherit the org's
       // current default so new templates match the org's tax mode setting.
+      const taxSettings = await db.appSetting.findMany({
+        where: { organizationId, key: { in: [...WORKSHOP_TAX_SETTING_KEYS] } },
+        select: { key: true, value: true },
+      })
+      const workshopTax = readWorkshopTax(
+        Object.fromEntries(taxSettings.map((s) => [s.key, s.value]))
+      )
       let taxInclusive = parsed.taxInclusive
       if (input && typeof input === 'object' && !('taxInclusive' in input)) {
-        const setting = await db.appSetting.findUnique({
-          where: { organizationId_key: { organizationId, key: 'workshop.taxInclusive' } },
-          select: { value: true },
-        })
-        taxInclusive = setting?.value === 'true'
+        taxInclusive = workshopTax.inclusive
       }
+      // A template at the workshop's own rate carries its components too, so
+      // every invoice it generates prints the split; a hand-set rate is one
+      // figure and stays one.
+      const taxComponents =
+        parsed.taxRate > 0 && parsed.taxRate === workshopTax.rate
+          ? taxFieldsForNewDocument(workshopTax).taxComponents
+          : undefined
 
       // The dates are workshop days: the first run is that day's midnight
       // on its clock, and the end date covers the whole of its last day
@@ -81,6 +96,7 @@ export async function createRecurringInvoice(input: CreateRecurringInvoiceInput)
           cost: parsed.cost,
           taxRate: parsed.taxRate,
           taxInclusive,
+          taxComponents,
           invoiceNotes: parsed.invoiceNotes,
           templateParts: {
             create: parsed.templateParts.map((p) => ({
@@ -318,11 +334,12 @@ export async function processRecurringInvoices() {
         const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0)
         const laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0)
         const subtotal = ri.cost + partsSubtotal + laborSubtotal
-        const { taxAmount, totalAmount } = calculateTotals({
+        const { taxAmount, totalAmount, taxComponents } = documentTotals({
           subtotal,
           discountAmount: 0,
           taxRate: ri.taxRate,
           taxInclusive: ri.taxInclusive,
+          taxComponents: ri.taxComponents,
         })
 
         const serviceRecord = await db.$transaction(async (tx) => {
@@ -340,6 +357,7 @@ export async function processRecurringInvoices() {
               subtotal,
               taxRate: ri.taxRate,
               taxInclusive: ri.taxInclusive,
+              taxComponents,
               taxAmount,
               totalAmount,
               invoiceNumber,

+ 8 - 6
src/features/inspections/Actions/inspectionActions.ts

@@ -1,5 +1,10 @@
 'use server'
 
+import {
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '@/features/settings/Lib/workshopTax'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import {
@@ -677,9 +682,7 @@ export async function createWorkOrderFromInspection(id: string) {
               in: [
                 'workshop.invoicePrefix',
                 'workshop.defaultLaborRate',
-                'workshop.defaultTaxRate',
-                'workshop.taxEnabled',
-                'workshop.taxInclusive',
+                ...WORKSHOP_TAX_SETTING_KEYS,
               ],
             },
           },
@@ -708,7 +711,7 @@ export async function createWorkOrderFromInspection(id: string) {
         if (match) nextNum = parseInt(match[1], 10) + 1
       }
 
-      const taxEnabled = settingsMap['workshop.taxEnabled'] !== 'false'
+      const taxFields = taxFieldsForNewDocument(readWorkshopTax(settingsMap))
       const laborRate = Number(settingsMap['workshop.defaultLaborRate']) || 0
       const vehicleName = `${inspection.vehicle.year} ${inspection.vehicle.make} ${inspection.vehicle.model}`
 
@@ -729,8 +732,7 @@ export async function createWorkOrderFromInspection(id: string) {
             mileage: inspection.mileage,
             // Hours and totals stay at zero: the point is to get the job on the
             // board immediately, and the workshop prices it as it works.
-            taxRate: taxEnabled ? Number(settingsMap['workshop.defaultTaxRate']) || 0 : 0,
-            taxInclusive: settingsMap['workshop.taxInclusive'] === 'true',
+            ...taxFields,
             serviceDate: startOfZonedDay(now, timeZone),
             startDateTime: now,
           },

+ 4 - 0
src/features/invoice-designer/Components/PresetPreview.tsx

@@ -3,6 +3,7 @@
 import { useMemo } from 'react'
 import { useMessages, useTranslations } from 'next-intl'
 import type { LayoutPreset } from '@/features/settings/Schema/layoutPresets'
+import type { TaxComponentDefinition } from '@/lib/tax'
 import { SpecThumbnail } from '../Render/SpecThumbnail'
 import { buildSampleData, type PrintLabels } from './sample'
 import { specForDesign, specForPreset } from './presetSpec'
@@ -20,6 +21,8 @@ interface PreviewWorkshop {
   phone?: string
   email?: string
   slogan?: string
+  /** The workshop's split tax, so a card shows its GST and QST lines. */
+  taxComponents?: TaxComponentDefinition[] | null
 }
 
 /** The sample document on this workshop's own details, for a card's picture. */
@@ -46,6 +49,7 @@ function useSampleData(docType: DocumentType, workshop?: PreviewWorkshop, logoUr
         orgNumber: '',
         paymentTerms: '',
         logoUrl: logoUrl ?? '',
+        taxComponents: workshop?.taxComponents ?? null,
       },
       [],
       t,

+ 66 - 9
src/features/invoice-designer/Components/sample.ts

@@ -1,5 +1,7 @@
 import { toCustomFieldId } from '@/features/settings/Schema/invoiceLayoutSchema'
-import type { DocumentData } from '../Spec/buildSpec'
+import { calculateTotals } from '@/lib/tax'
+import { taxComponentLabel } from '@/lib/tax-components'
+import type { DocumentData, PaymentPair, TotalLine } from '../Spec/buildSpec'
 import type { DesignerWorkshop, DocumentType } from './types'
 
 /**
@@ -173,6 +175,62 @@ export function fieldValues(
   }
 }
 
+/** The sample's money, written the way its other figures are. */
+function sampleMoney(value: number): string {
+  return `€ ${value.toFixed(2)}`
+}
+
+/**
+ * The tax rows of the sample, and the registration numbers they bring: the
+ * workshop's own split when it has one, taxed on the sample's net subtotal
+ * so the lines add up on the canvas as they will on paper; otherwise the
+ * single 25% line every workshop has always seen here.
+ */
+function sampleTax(
+  workshop: DesignerWorkshop,
+  sample: SampleTables,
+  labels: PrintLabels
+): { lines: TotalLine[]; total: string; registrations: PaymentPair[] } {
+  const L = (key: string, fallback: string) => labels[key] || fallback
+  const components = workshop.taxComponents ?? null
+  if (!components || components.length === 0) {
+    return {
+      lines: [
+        {
+          // The tax label carries the rate, the way the printed sheet does.
+          label: fillTemplate(L('tax', 'Tax ({rate}%)'), { rate: '25' }),
+          value: sample.tax,
+          kind: 'line',
+        },
+      ],
+      total: sample.total,
+      registrations: [],
+    }
+  }
+  const subtotal = Number(sample.subtotal.replace(/[^\d.]/g, ''))
+  const totals = calculateTotals({
+    subtotal,
+    discountAmount: 0,
+    taxRate: 0,
+    taxInclusive: false,
+    components,
+  })
+  return {
+    lines: (totals.components ?? []).map((component) => ({
+      label: taxComponentLabel(component),
+      value: sampleMoney(component.amount),
+      kind: 'line' as const,
+    })),
+    total: sampleMoney(totals.totalAmount),
+    registrations: components
+      .filter((component) => component.registrationNumber)
+      .map((component) => ({
+        label: fillTemplate(L('taxRegistrationLabel', '{name} No.'), { name: component.name }),
+        value: component.registrationNumber as string,
+      })),
+  }
+}
+
 /**
  * The whole sample document: what a workshop's own sheet says, with the
  * sample standing in for a job. One builder, so the designer's canvas and the
@@ -188,6 +246,7 @@ export function buildSampleData(
   const L = (key: string, fallback: string) => labels[key] || fallback
   const sample = sampleTables(t, labels)
   const values = fieldValues(workshop, t, labels)
+  const tax = sampleTax(workshop, sample, labels)
   return {
     fields: {
       ...values,
@@ -237,13 +296,8 @@ export function buildSampleData(
     findings: sample.findings,
     totals: [
       { label: L('subtotal', 'Subtotal'), value: sample.subtotal, kind: 'line' as const },
-      {
-        // The tax label carries the rate, the way the printed sheet does.
-        label: fillTemplate(L('tax', 'Tax ({rate}%)'), { rate: '25' }),
-        value: sample.tax,
-        kind: 'line' as const,
-      },
-      { label: L('total', 'Total'), value: sample.total, kind: 'total' as const },
+      ...tax.lines,
+      { label: L('total', 'Total'), value: tax.total, kind: 'total' as const },
       // A settled invoice, so the payment line and the paid stamp can be
       // seen and styled. Quotes never carry payments, so theirs ends at the
       // total.
@@ -251,7 +305,7 @@ export function buildSampleData(
         ? [
             {
               label: `${sample.date} (Visa)`,
-              value: `-${sample.total}`,
+              value: `-${tax.total}`,
               kind: 'payment' as const,
             },
             { label: L('paidInFull', 'PAID IN FULL'), value: '', kind: 'paid' as const },
@@ -284,6 +338,9 @@ export function buildSampleData(
         value: workshop.paymentTerms?.trim() || t('sample.paymentTerms'),
       },
       { id: 'due_date', label: L('dueDateLabel', 'Due Date'), value: sample.due },
+      // The workshop's registration for each of its taxes, where it has one;
+      // the printed sheet carries these the same way, after the rows above.
+      ...tax.registrations,
     ],
     // A stand-in link, so the canvas shows the portal line the printed sheet
     // carries and the footer's switch for it has something to switch.

+ 8 - 0
src/features/invoice-designer/Components/types.ts

@@ -1,3 +1,4 @@
+import type { TaxComponentDefinition } from '@/lib/tax'
 import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
 import type { DesignAutoRule } from '../Lib/designRules'
 
@@ -52,6 +53,13 @@ export interface DesignerWorkshop {
   /** Payment terms from payment settings. Empty prints nothing on the sheet. */
   paymentTerms: string
   logoUrl: string
+  /**
+   * The taxes the workshop splits its tax into, when it does (GST and QST).
+   * The sample job is then taxed the workshop's way, one line per tax, with
+   * each registration number under the business details, so the canvas
+   * shows the sheet the workshop will actually print.
+   */
+  taxComponents?: TaxComponentDefinition[] | null
 }
 
 export interface DesignerState {

+ 23 - 11
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -1,6 +1,8 @@
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { formatQuantity } from '@/lib/format-quantity'
 import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { parseTaxComponents } from '@/lib/tax-components'
+import { taxLines } from './taxLines'
 import {
   getDefaultInvoiceLayout,
   isCustomFieldId,
@@ -327,18 +329,17 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       kind: 'discount',
     })
   }
-  if (taxRate > 0) {
-    const rate = { rate: String(taxRate) }
-    totals.push({
-      label: linesInclTax
-        ? fillTemplate(L('taxIncluded', 'Includes tax ({rate}%)'), rate)
-        : labels.tax
-          ? fillTemplate(labels.tax, rate)
-          : `Tax (${taxRate}%)`,
-      value: money(data.taxAmount),
-      kind: 'line',
+  const taxComponents = parseTaxComponents(data.taxComponents)
+  totals.push(
+    ...taxLines({
+      taxRate,
+      taxAmount: data.taxAmount,
+      components: taxComponents,
+      linesInclTax,
+      labels,
+      money,
     })
-  }
+  )
   totals.push({ label: L('total', 'Total'), value: money(displayTotal), kind: 'total' })
   if (paymentSummary && paymentSummary.payments.length > 0) {
     for (const payment of paymentSummary.payments) {
@@ -399,6 +400,17 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       value: invoiceSettings.orgNumber,
     })
   }
+  // A workshop registered for each of its taxes prints each registration:
+  // Québec expects the GST and the QST numbers on the invoice, and a business
+  // customer needs both to claim the tax back. They come from the job's own
+  // snapshot, so a number changed later does not rewrite an issued invoice.
+  for (const component of taxComponents ?? []) {
+    if (!component.registrationNumber) continue
+    payment.push({
+      label: fillTemplate(L('taxRegistrationLabel', '{name} No.'), { name: component.name }),
+      value: component.registrationNumber,
+    })
+  }
   if (paymentTermsText) {
     payment.push({
       id: 'payment_terms',

+ 13 - 11
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -1,6 +1,8 @@
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { formatQuantity } from '@/lib/format-quantity'
 import { calculateTotals, netLineTotal } from '@/lib/tax'
+import { parseTaxComponents } from '@/lib/tax-components'
+import { taxLines } from './taxLines'
 import {
   getDefaultInvoiceLayout,
   isCustomFieldId,
@@ -38,6 +40,8 @@ export interface QuotePrintData {
   taxRate: number
   taxAmount: number
   taxInclusive?: boolean
+  /** The stored split, when the quote has one; see src/lib/tax-components.ts. */
+  taxComponents?: unknown
   discountType: string | null
   discountValue: number
   discountAmount: number
@@ -296,18 +300,16 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       kind: 'discount',
     })
   }
-  if (taxRate > 0) {
-    const rate = { rate: String(taxRate) }
-    totals.push({
-      label: linesInclTax
-        ? fillTemplate(L('taxIncluded', 'Includes tax ({rate}%)'), rate)
-        : labels.tax
-          ? fillTemplate(labels.tax, rate)
-          : `Tax (${taxRate}%)`,
-      value: money(taxAmount),
-      kind: 'line',
+  totals.push(
+    ...taxLines({
+      taxRate,
+      taxAmount,
+      components: parseTaxComponents(data.taxComponents),
+      linesInclTax,
+      labels,
+      money,
     })
-  }
+  )
   totals.push({ label: L('total', 'Total'), value: money(totalAmount), kind: 'total' })
 
   const attachedDocuments = [

+ 55 - 0
src/features/invoice-designer/Pdf/taxLines.ts

@@ -0,0 +1,55 @@
+import type { TaxComponent } from '@/lib/tax'
+import { taxComponentLabel } from '@/lib/tax-components'
+import type { TotalLine } from '../Spec/buildSpec'
+
+function fillTemplate(template: string, values: Record<string, string>): string {
+  return Object.entries(values).reduce((str, [key, val]) => str.replace(`{${key}}`, val), template)
+}
+
+/**
+ * The tax rows of a totals box: one line per component when the document
+ * carries a split (GST and QST each on their own line, as Québec expects),
+ * otherwise the single line every document printed before. A document with
+ * no tax prints none.
+ *
+ * Shared by the invoice and the quote so the two sheets cannot drift.
+ */
+export function taxLines(args: {
+  taxRate: number
+  taxAmount: number
+  components: TaxComponent[] | null
+  /** The lines are printed with tax in them, so the row says how much of it is tax. */
+  linesInclTax: boolean
+  labels: Record<string, string>
+  money: (value: number) => string
+}): TotalLine[] {
+  const { taxRate, taxAmount, components, linesInclTax, labels, money } = args
+  const L = (key: string, fallback: string) => labels[key] || fallback
+
+  if (components && components.length > 0) {
+    return components.map((component) => ({
+      label: linesInclTax
+        ? fillTemplate(L('taxIncludedNamed', 'Includes {name} ({rate}%)'), {
+            name: component.name,
+            rate: String(component.rate),
+          })
+        : taxComponentLabel(component),
+      value: money(component.amount),
+      kind: 'line' as const,
+    }))
+  }
+
+  if (taxRate <= 0) return []
+  const rate = { rate: String(taxRate) }
+  return [
+    {
+      label: linesInclTax
+        ? fillTemplate(L('taxIncluded', 'Includes tax ({rate}%)'), rate)
+        : labels.tax
+          ? fillTemplate(labels.tax, rate)
+          : `Tax (${taxRate}%)`,
+      value: money(taxAmount),
+      kind: 'line',
+    },
+  ]
+}

+ 57 - 11
src/features/quotes/Actions/quoteActions.ts

@@ -3,6 +3,14 @@
 import { assertQuoteEditable, getDocumentLockSettings } from '@/lib/document-lock.server'
 import { DocumentLockedError, quoteLockState } from '@/lib/document-lock'
 import { db } from '@/lib/db'
+import { parseTaxComponentDefinitions } from '@/lib/tax-components'
+import {
+  documentTotals,
+  readWorkshopTax,
+  taxComponentsForCopy,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '@/features/settings/Lib/workshopTax'
 import { withAuth } from '@/lib/with-auth'
 import { createQuoteSchema, quoteStatusSchema, updateQuoteSchema } from '../Schema/quoteSchema'
 import { revalidatePath } from 'next/cache'
@@ -199,13 +207,7 @@ export async function createQuote(input: unknown) {
         where: {
           organizationId,
           key: {
-            in: [
-              'workshop.quotePrefix',
-              'workshop.quoteValidDays',
-              'workshop.defaultTaxRate',
-              'workshop.taxEnabled',
-              'workshop.taxInclusive',
-            ],
+            in: ['workshop.quotePrefix', 'workshop.quoteValidDays', ...WORKSHOP_TAX_SETTING_KEYS],
           },
         },
       })
@@ -215,23 +217,40 @@ export async function createQuote(input: unknown) {
 
       // Apply default tax rate from settings when the caller hasn't set one.
       // All current call sites send taxRate: 0 at creation, so 0 means "unset".
-      const taxEnabled = settingsMap['workshop.taxEnabled'] !== 'false'
-      let defaultTaxRate = taxEnabled ? Number(settingsMap['workshop.defaultTaxRate']) || 0 : 0
-      const taxInclusive = settingsMap['workshop.taxInclusive'] === 'true'
+      const workshopTax = readWorkshopTax(settingsMap)
+      let defaultTaxRate = workshopTax.rate
+      const taxInclusive = workshopTax.inclusive
 
       // Tax-exempt customer: force the rate to 0 regardless of org default.
+      let customerExempt = false
       if (data.customerId) {
         const customer = await db.customer.findFirst({
           where: { id: data.customerId, organizationId },
           select: { taxExempt: true },
         })
         if (customer?.taxExempt) {
+          customerExempt = true
           defaultTaxRate = 0
           data.taxRate = 0
           data.taxAmount = 0
         }
       }
 
+      // A quote at the workshop's own rate carries its tax components, so a
+      // split-tax workshop's quote prints GST and QST apart from the start.
+      // A rate the caller set by hand is one figure and stays one.
+      const taxRate = data.taxRate > 0 ? data.taxRate : defaultTaxRate
+      const splitTax =
+        taxRate > 0 && taxRate === workshopTax.rate && !customerExempt
+          ? documentTotals({
+              subtotal: data.subtotal,
+              discountAmount: data.discountAmount,
+              taxRate,
+              taxInclusive,
+              taxComponents: taxFieldsForNewDocument(workshopTax).taxComponents,
+            })
+          : null
+
       const lastQuote = await db.quote.findFirst({
         where: { organizationId },
         orderBy: { createdAt: 'desc' },
@@ -253,8 +272,15 @@ export async function createQuote(input: unknown) {
             quoteNumber,
             userId,
             organizationId,
-            taxRate: quoteData.taxRate > 0 ? quoteData.taxRate : defaultTaxRate,
+            taxRate,
             taxInclusive,
+            ...(splitTax?.taxComponents
+              ? {
+                  taxComponents: splitTax.taxComponents,
+                  taxAmount: splitTax.taxAmount,
+                  totalAmount: splitTax.totalAmount,
+                }
+              : {}),
             validUntil:
               toSafeWorkshopDate(quoteData.validUntil, await workshopTimeZone(organizationId)) ??
               defaultValidUntil(settingsMap['workshop.quoteValidDays']),
@@ -305,6 +331,24 @@ export async function updateQuote(input: unknown) {
 
       const { id, partItems, laborItems, ...quoteData } = data
 
+      // Same as the work order: a quote with tax components has its split
+      // recomputed from the row, since the editor sends one combined figure.
+      const splitTax =
+        quoteData.subtotal !== undefined && parseTaxComponentDefinitions(existing.taxComponents)
+          ? documentTotals({
+              subtotal: quoteData.subtotal,
+              discountAmount: quoteData.discountAmount ?? existing.discountAmount,
+              taxRate: existing.taxRate,
+              taxInclusive: existing.taxInclusive,
+              taxComponents: existing.taxComponents,
+            })
+          : null
+      if (splitTax) {
+        quoteData.taxRate = existing.taxRate
+        quoteData.taxAmount = splitTax.taxAmount
+        quoteData.totalAmount = splitTax.totalAmount
+      }
+
       const quote = await db.$transaction(async (tx) => {
         const updated = await tx.quote.update({
           where: { id },
@@ -312,6 +356,7 @@ export async function updateQuote(input: unknown) {
           // cleared.
           data: {
             ...quoteData,
+            taxComponents: splitTax?.taxComponents,
             description: clearedToNull(quoteData.description),
             notes: clearedToNull(quoteData.notes),
             customerId: clearedToNull(quoteData.customerId),
@@ -488,6 +533,7 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
             shopName: org?.name || undefined,
             invoiceNumber,
             subtotal: quote.subtotal,
+            taxComponents: taxComponentsForCopy(quote.taxComponents),
             taxRate: quote.taxRate,
             taxAmount: quote.taxAmount,
             taxInclusive: quote.taxInclusive,

+ 14 - 2
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -26,6 +26,7 @@ import {
 import { SharedLinkCard } from '@/components/shared-link-card'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { netLineTotal } from '@/lib/tax'
+import { taxComponentLabel } from '@/lib/tax-components'
 import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
 import type { QuoteFormState } from './useQuoteFormState'
 import type { QuoteRecord } from './quote-page-types'
@@ -412,7 +413,18 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
                   </span>
                 )}
               </div>
-              {state.taxEnabled && (
+              {state.taxEnabled && state.taxComponents && state.taxComponents.length > 0 ? (
+                state.taxComponents.map((component) => (
+                  <div
+                    key={component.name}
+                    className="flex items-center justify-between text-sm"
+                    data-testid="tax-component-row"
+                  >
+                    <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
+                    <span>{formatCurrency(component.amount, currencyCode)}</span>
+                  </div>
+                ))
+              ) : state.taxEnabled ? (
                 <div className="flex items-center justify-between text-sm">
                   <div className="flex items-center gap-2">
                     <span className="text-muted-foreground">{t('totals.tax')}</span>
@@ -431,7 +443,7 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
                   </div>
                   <span>{formatCurrency(state.taxAmount, currencyCode)}</span>
                 </div>
-              )}
+              ) : null}
               <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
                 <span>{t('totals.total')}</span>
                 <span>{formatCurrency(state.totalAmount, currencyCode)}</span>

+ 2 - 0
src/features/quotes/Components/quote-page-types.ts

@@ -26,6 +26,8 @@ export interface QuoteRecord {
   taxRate: number
   taxAmount: number
   taxInclusive: boolean
+  /** The stored split, read leniently; see src/lib/tax-components.ts. */
+  taxComponents?: unknown
   discountType: string | null
   discountValue: number
   discountAmount: number

+ 11 - 1
src/features/quotes/Components/useQuoteFormState.ts

@@ -12,6 +12,7 @@ import {
 } from '@/features/quotes/Actions/quoteActions'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
 import { calculateTotals } from '@/lib/tax'
+import { parseTaxComponents } from '@/lib/tax-components'
 import { useDeferredCommit } from '@/hooks/use-deferred-commit'
 import { isPriceOverridden, lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from './quote-page-types'
@@ -99,6 +100,9 @@ export function useQuoteFormState({
   )
   const [taxRate, setTaxRate] = useState(quote.taxRate ?? defaultTaxRate)
   const [taxInclusive] = useState<boolean>(quote.taxInclusive ?? false)
+  // The split the quote was created with; the server re-derives the
+  // amounts from it on every save.
+  const [taxComponentDefinitions] = useState(() => parseTaxComponents(quote.taxComponents))
   const [discountType, setDiscountType] = useState<string>(quote.discountType || 'none')
   const [discountValue, setDiscountValue] = useState(quote.discountValue ?? 0)
   const [noteType, setNoteType] = useState<'public' | 'internal'>('public')
@@ -207,11 +211,16 @@ export function useQuoteFormState({
       : discountType === 'fixed'
         ? Math.min(discountValue, subtotal)
         : 0
-  const { taxAmount, totalAmount } = calculateTotals({
+  const {
+    taxAmount,
+    totalAmount,
+    components: taxComponents,
+  } = calculateTotals({
     subtotal,
     discountAmount,
     taxRate,
     taxInclusive,
+    components: taxComponentDefinitions,
   })
 
   // Cost, markup and price stay consistent with each other; repricePartRow
@@ -491,6 +500,7 @@ export function useQuoteFormState({
     setTaxRate,
     taxEnabled,
     taxInclusive,
+    taxComponents,
     discountType,
     setDiscountType,
     discountValue,

+ 5 - 5
src/features/reports/Actions/reportActions.ts

@@ -5,6 +5,7 @@ import { listOrgEntries } from '@/features/time-tracking/Lib/timeEntries'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { netLineTotal } from '@/lib/tax'
+import { TaxByRateTable } from '../Lib/taxByRate'
 import { zonedDate, zonedDayKey, zonedParts } from '@/lib/timezone'
 import { workshopDayRange, workshopMonthKey } from '@/lib/workshop-datetime'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
@@ -980,6 +981,7 @@ export async function getTaxReport(params: { startDate?: string; endDate?: strin
           taxRate: true,
           taxAmount: true,
           taxInclusive: true,
+          taxComponents: true,
           totalAmount: true,
         },
         orderBy: [{ startDateTime: { sort: 'asc', nulls: 'last' } }, { serviceDate: 'asc' }],
@@ -989,7 +991,7 @@ export async function getTaxReport(params: { startDate?: string; endDate?: strin
         string,
         { taxCollected: number; invoiceCount: number; taxableAmount: number }
       > = {}
-      const byRate: Record<number, { taxCollected: number; invoiceCount: number }> = {}
+      const byRate = new TaxByRateTable()
       let totalTaxCollected = 0
       let totalTaxableAmount = 0
       let totalInvoices = 0
@@ -1010,9 +1012,7 @@ export async function getTaxReport(params: { startDate?: string; endDate?: strin
         monthly[month].invoiceCount += 1
         monthly[month].taxableAmount += taxableBase
 
-        if (!byRate[r.taxRate]) byRate[r.taxRate] = { taxCollected: 0, invoiceCount: 0 }
-        byRate[r.taxRate].taxCollected += r.taxAmount
-        byRate[r.taxRate].invoiceCount += 1
+        byRate.add(r)
 
         totalTaxCollected += r.taxAmount
         totalTaxableAmount += taxableBase
@@ -1021,7 +1021,7 @@ export async function getTaxReport(params: { startDate?: string; endDate?: strin
 
       return {
         monthly: Object.entries(monthly).map(([month, data]) => ({ month, ...data })),
-        byRate: Object.entries(byRate).map(([rate, data]) => ({ taxRate: Number(rate), ...data })),
+        byRate: byRate.list(),
         summary: { totalTaxCollected, totalTaxableAmount, totalInvoices },
       }
     },

+ 2 - 1
src/features/reports/Components/ReportPDF.tsx

@@ -1,4 +1,5 @@
 import { formatQuantity } from '@/lib/format-quantity'
+import { taxRateLabel } from '../Lib/taxRateLabel'
 import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer'
 import type {
   RevenueReport,
@@ -457,7 +458,7 @@ export function ReportPDF({
                 ]}
                 widths={[40, 35, 25]}
                 rows={taxData.byRate.map((r) => [
-                  `${r.taxRate}%`,
+                  taxRateLabel(r),
                   fmt(r.taxCollected),
                   String(r.invoiceCount),
                 ])}

+ 40 - 0
src/features/reports/Lib/taxByRate.ts

@@ -0,0 +1,40 @@
+import { parseTaxComponents } from '@/lib/tax-components'
+import type { TaxByRate } from '../Schema/reportTypes'
+
+/**
+ * The "tax by rate" table of the tax report, built one invoice at a time.
+ *
+ * A single-rate invoice lands under its rate, as it always has. An invoice
+ * that carries tax components lands under each of them by name, so a Québec
+ * workshop reads its GST total and its QST total straight off the report at
+ * filing time, which is the figure each return asks for. Two rates with the
+ * same name and rate share a row; "GST (5%)" and a plain 5% rate do not,
+ * because they are declared on different returns.
+ */
+export class TaxByRateTable {
+  private readonly rows = new Map<string, TaxByRate>()
+
+  add(record: { taxRate: number; taxAmount: number; taxComponents?: unknown }): void {
+    const components = parseTaxComponents(record.taxComponents)
+    if (components) {
+      for (const component of components) {
+        this.bump(`${component.name}|${component.rate}`, component.rate, component.amount, {
+          name: component.name,
+        })
+      }
+      return
+    }
+    this.bump(String(record.taxRate), record.taxRate, record.taxAmount)
+  }
+
+  private bump(key: string, taxRate: number, amount: number, extra: { name?: string } = {}) {
+    const row = this.rows.get(key) ?? { taxRate, taxCollected: 0, invoiceCount: 0, ...extra }
+    row.taxCollected += amount
+    row.invoiceCount += 1
+    this.rows.set(key, row)
+  }
+
+  list(): TaxByRate[] {
+    return [...this.rows.values()]
+  }
+}

+ 6 - 0
src/features/reports/Lib/taxRateLabel.ts

@@ -0,0 +1,6 @@
+import type { TaxByRate } from '../Schema/reportTypes'
+
+/** "GST (5%)" for a split tax's component, "25%" for a plain rate. */
+export function taxRateLabel(row: Pick<TaxByRate, 'taxRate' | 'name'>): string {
+  return row.name ? `${row.name} (${row.taxRate}%)` : `${row.taxRate}%`
+}

+ 2 - 0
src/features/reports/Schema/reportTypes.ts

@@ -219,6 +219,8 @@ export interface TaxByRate {
   taxRate: number
   taxCollected: number
   invoiceCount: number
+  /** Set when the row is one component of a split tax ("GST", "QST"). */
+  name?: string
 }
 
 export interface TaxReport {

+ 34 - 9
src/features/settings/Actions/applyTaxRateToExisting.ts

@@ -5,7 +5,12 @@ import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
 import { SETTING_KEYS } from '../Schema/settingsSchema'
-import { calculateTotals } from '@/lib/tax'
+import {
+  documentTotals,
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '../Lib/workshopTax'
 import { getDocumentLockSettings } from '@/lib/document-lock.server'
 import { invoiceLockState, quoteLockState } from '@/lib/document-lock'
 
@@ -25,14 +30,18 @@ export async function applyTaxRateToExisting() {
       const settings = await db.appSetting.findMany({
         where: {
           organizationId,
-          key: { in: [SETTING_KEYS.TAX_ENABLED, SETTING_KEYS.DEFAULT_TAX_RATE] },
+          key: { in: [...WORKSHOP_TAX_SETTING_KEYS] },
         },
       })
       const settingsMap: Record<string, string> = {}
       for (const s of settings) settingsMap[s.key] = s.value
 
-      const taxEnabled = settingsMap[SETTING_KEYS.TAX_ENABLED] !== 'false'
+      const workshopTax = readWorkshopTax(settingsMap)
+      const taxEnabled = workshopTax.enabled
       const defaultTaxRate = Number(settingsMap[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0
+      // The split, when the workshop has one, goes onto each record with the
+      // rate, so a backfilled invoice prints the same lines as a new one.
+      const defaultComponents = taxFieldsForNewDocument(workshopTax).taxComponents
 
       if (!taxEnabled || defaultTaxRate <= 0) {
         throw new Error('Tax must be enabled and the default tax rate must be greater than 0.')
@@ -91,11 +100,12 @@ export async function applyTaxRateToExisting() {
 
       await db.$transaction(async (tx) => {
         for (const r of serviceRecords) {
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal: r.subtotal,
             discountAmount: r.discountAmount,
             taxRate: defaultTaxRate,
             taxInclusive: r.taxInclusive,
+            taxComponents: defaultComponents,
           })
           await tx.serviceRecord.update({
             where: { id: r.id },
@@ -103,17 +113,19 @@ export async function applyTaxRateToExisting() {
               taxRate: defaultTaxRate,
               taxAmount,
               totalAmount,
+              taxComponents,
             },
           })
           serviceRecordsUpdated++
         }
 
         for (const q of quotes) {
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal: q.subtotal,
             discountAmount: q.discountAmount,
             taxRate: defaultTaxRate,
             taxInclusive: q.taxInclusive,
+            taxComponents: defaultComponents,
           })
           await tx.quote.update({
             where: { id: q.id },
@@ -121,6 +133,7 @@ export async function applyTaxRateToExisting() {
               taxRate: defaultTaxRate,
               taxAmount,
               totalAmount,
+              taxComponents,
             },
           })
           quotesUpdated++
@@ -268,6 +281,7 @@ export async function convertRecordsToInclusive() {
           discountValue: true,
           discountAmount: true,
           taxRate: true,
+          taxComponents: true,
           partItems: { select: { id: true, unitPrice: true, total: true } },
           laborItems: { select: { id: true, rate: true, total: true } },
         },
@@ -286,6 +300,7 @@ export async function convertRecordsToInclusive() {
           discountValue: true,
           discountAmount: true,
           taxRate: true,
+          taxComponents: true,
           partItems: { select: { id: true, unitPrice: true, total: true } },
           laborItems: { select: { id: true, rate: true, total: true } },
         },
@@ -303,11 +318,12 @@ export async function convertRecordsToInclusive() {
           const newDiscountValue =
             r.discountType === 'fixed' ? r.discountValue * factor : r.discountValue
 
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal: newSubtotal,
             discountAmount: newDiscountAmount,
             taxRate: r.taxRate,
             taxInclusive: true,
+            taxComponents: r.taxComponents,
           })
 
           if (r.taxRate > 0) {
@@ -334,6 +350,7 @@ export async function convertRecordsToInclusive() {
               discountAmount: newDiscountAmount,
               taxAmount,
               totalAmount,
+              taxComponents,
             },
           })
           serviceRecordsUpdated++
@@ -346,11 +363,12 @@ export async function convertRecordsToInclusive() {
           const newDiscountValue =
             q.discountType === 'fixed' ? q.discountValue * factor : q.discountValue
 
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal: newSubtotal,
             discountAmount: newDiscountAmount,
             taxRate: q.taxRate,
             taxInclusive: true,
+            taxComponents: q.taxComponents,
           })
 
           if (q.taxRate > 0) {
@@ -377,6 +395,7 @@ export async function convertRecordsToInclusive() {
               discountAmount: newDiscountAmount,
               taxAmount,
               totalAmount,
+              taxComponents,
             },
           })
           quotesUpdated++
@@ -440,6 +459,7 @@ export async function convertRecordsToExclusive() {
           discountValue: true,
           discountAmount: true,
           taxRate: true,
+          taxComponents: true,
           partItems: { select: { id: true, unitPrice: true, total: true } },
           laborItems: { select: { id: true, rate: true, total: true } },
         },
@@ -457,6 +477,7 @@ export async function convertRecordsToExclusive() {
           discountValue: true,
           discountAmount: true,
           taxRate: true,
+          taxComponents: true,
           partItems: { select: { id: true, unitPrice: true, total: true } },
           laborItems: { select: { id: true, rate: true, total: true } },
         },
@@ -473,11 +494,12 @@ export async function convertRecordsToExclusive() {
           const newDiscountValue =
             r.discountType === 'fixed' ? r.discountValue / factor : r.discountValue
 
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal: newSubtotal,
             discountAmount: newDiscountAmount,
             taxRate: r.taxRate,
             taxInclusive: false,
+            taxComponents: r.taxComponents,
           })
 
           if (r.taxRate > 0) {
@@ -504,6 +526,7 @@ export async function convertRecordsToExclusive() {
               discountAmount: newDiscountAmount,
               taxAmount,
               totalAmount,
+              taxComponents,
             },
           })
           serviceRecordsUpdated++
@@ -516,11 +539,12 @@ export async function convertRecordsToExclusive() {
           const newDiscountValue =
             q.discountType === 'fixed' ? q.discountValue / factor : q.discountValue
 
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal: newSubtotal,
             discountAmount: newDiscountAmount,
             taxRate: q.taxRate,
             taxInclusive: false,
+            taxComponents: q.taxComponents,
           })
 
           if (q.taxRate > 0) {
@@ -547,6 +571,7 @@ export async function convertRecordsToExclusive() {
               discountAmount: newDiscountAmount,
               taxAmount,
               totalAmount,
+              taxComponents,
             },
           })
           quotesUpdated++

+ 112 - 0
src/features/settings/Lib/workshopTax.ts

@@ -0,0 +1,112 @@
+import { calculateTotals, type TaxComponentDefinition } from '@/lib/tax'
+import {
+  parseTaxComponentDefinitions,
+  parseTaxComponents,
+  type StoredTaxComponent,
+  taxComponentsJson,
+} from '@/lib/tax-components'
+import { SETTING_KEYS } from '../Schema/settingsSchema'
+
+/**
+ * The workshop's tax as settings describe it, read in one place.
+ *
+ * Every path that opens a new document (a work order, a quote, a tire job,
+ * a recurring template) used to read the three tax keys for itself. With
+ * components in the picture that is one more thing to forget, so the keys
+ * are listed here and read here, and a new document asks for its tax fields
+ * rather than assembling them.
+ */
+
+export const WORKSHOP_TAX_SETTING_KEYS = [
+  SETTING_KEYS.TAX_ENABLED,
+  SETTING_KEYS.DEFAULT_TAX_RATE,
+  SETTING_KEYS.TAX_INCLUSIVE,
+  SETTING_KEYS.TAX_MODE,
+  SETTING_KEYS.TAX_COMPONENTS,
+] as const
+
+export interface WorkshopTax {
+  enabled: boolean
+  /** The default rate; the components' combined rate when the tax is split. */
+  rate: number
+  inclusive: boolean
+  /** Set only when the workshop splits its tax and has defined the parts. */
+  components: TaxComponentDefinition[] | null
+}
+
+export function readWorkshopTax(settings: Record<string, string | undefined>): WorkshopTax {
+  const enabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
+  const inclusive = settings[SETTING_KEYS.TAX_INCLUSIVE] === 'true'
+  const split = settings[SETTING_KEYS.TAX_MODE] === 'split'
+  const components = split
+    ? parseTaxComponentDefinitions(settings[SETTING_KEYS.TAX_COMPONENTS])
+    : null
+  const rate = enabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
+  return { enabled, rate, inclusive, components: enabled ? components : null }
+}
+
+/**
+ * The tax fields a new document starts with. A tax-exempt customer gets no
+ * tax at all, components included: their invoice must not print a GST line
+ * at zero.
+ */
+export function taxFieldsForNewDocument(
+  tax: WorkshopTax,
+  opts: { customerExempt?: boolean } = {}
+): {
+  taxRate: number
+  taxInclusive: boolean
+  taxComponents: StoredTaxComponent[] | undefined
+} {
+  const charged = tax.enabled && !opts.customerExempt
+  const components = charged ? tax.components : null
+  return {
+    taxRate: charged ? tax.rate : 0,
+    taxInclusive: tax.inclusive,
+    // Amounts start at zero; the first re-total writes the real split.
+    taxComponents: components
+      ? taxComponentsJson(components.map((component) => ({ ...component, amount: 0 })))
+      : undefined,
+  }
+}
+
+/**
+ * Totals for a document that may carry components, ready to write back:
+ * the combined figures plus the split, or no split when the document has
+ * none. Takes the stored Json as-is so callers do not each parse it.
+ */
+export function documentTotals(args: {
+  subtotal: number
+  discountAmount: number
+  taxRate: number
+  taxInclusive: boolean
+  taxComponents: unknown
+}): {
+  taxAmount: number
+  totalAmount: number
+  taxComponents: StoredTaxComponent[] | undefined
+} {
+  const definitions = parseTaxComponentDefinitions(args.taxComponents)
+  const result = calculateTotals({
+    subtotal: args.subtotal,
+    discountAmount: args.discountAmount,
+    taxRate: args.taxRate,
+    taxInclusive: args.taxInclusive,
+    components: definitions,
+  })
+  return {
+    taxAmount: result.taxAmount,
+    totalAmount: result.totalAmount,
+    taxComponents: result.components ? taxComponentsJson(result.components) : undefined,
+  }
+}
+
+/**
+ * A stored breakdown copied from one document to another (a quote becoming
+ * a job, a template becoming an invoice, a backup being restored). Read
+ * leniently and written clean; nothing when the source has none.
+ */
+export function taxComponentsForCopy(value: unknown): StoredTaxComponent[] | undefined {
+  const components = parseTaxComponents(value)
+  return components ? taxComponentsJson(components) : undefined
+}

+ 8 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -12,6 +12,14 @@ export const SETTING_KEYS = {
   TAX_ENABLED: 'workshop.taxEnabled',
   TAX_INCLUSIVE: 'workshop.taxInclusive',
   TAX_LABEL: 'workshop.taxLabel',
+  /**
+   * `single` (the default) or `split`. Split means the workshop charges more
+   * than one tax on a document and lists them in TAX_COMPONENTS; the default
+   * rate is then their sum, kept in step by the settings page.
+   */
+  TAX_MODE: 'workshop.taxMode',
+  /** JSON array of tax components; see src/lib/tax-components.ts. */
+  TAX_COMPONENTS: 'workshop.taxComponents',
   ORG_NUMBER_LABEL: 'workshop.orgNumberLabel',
   INVOICE_PREFIX: 'workshop.invoicePrefix',
   INVOICE_START_NUMBER: 'workshop.invoiceStartNumber',

+ 16 - 15
src/features/tire-hotel/Actions/tireJobActions.ts

@@ -1,11 +1,16 @@
 'use server'
 
+import {
+  documentTotals,
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '@/features/settings/Lib/workshopTax'
 import { revalidatePath } from 'next/cache'
 import { z } from 'zod'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { calculateTotals } from '@/lib/tax'
 import { createDraftRecord } from '@/features/vehicles/Lib/createDraftRecord'
 import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
@@ -375,22 +380,16 @@ export async function createQuoteFromTireSet(input: unknown) {
         where: {
           organizationId,
           key: {
-            in: [
-              'workshop.quotePrefix',
-              'workshop.quoteValidDays',
-              'workshop.defaultTaxRate',
-              'workshop.taxEnabled',
-              'workshop.taxInclusive',
-            ],
+            in: ['workshop.quotePrefix', 'workshop.quoteValidDays', ...WORKSHOP_TAX_SETTING_KEYS],
           },
         },
       })
       const map = new Map(settings.map((s) => [s.key, s.value]))
 
-      const taxEnabled = map.get('workshop.taxEnabled') !== 'false'
-      const taxInclusive = map.get('workshop.taxInclusive') === 'true'
-      const taxRate =
-        taxEnabled && !set.customer?.taxExempt ? Number(map.get('workshop.defaultTaxRate')) || 0 : 0
+      const { taxRate, taxInclusive, taxComponents } = taxFieldsForNewDocument(
+        readWorkshopTax(Object.fromEntries(map)),
+        { customerExempt: set.customer?.taxExempt }
+      )
 
       const prefix = resolveInvoicePrefix(map.get('workshop.quotePrefix') ?? 'QT-')
       const lastQuote = await db.quote.findFirst({
@@ -412,11 +411,12 @@ export async function createQuoteFromTireSet(input: unknown) {
       if (data.includeStorage) labor.push(await storageLine(set, data))
       const laborTotal = labor.reduce((sum, line) => sum + line.total, 0)
       const subtotal = Math.round((lineTotal + laborTotal) * 100) / 100
-      const { taxAmount, totalAmount } = calculateTotals({
+      const totals = documentTotals({
         subtotal,
         discountAmount: 0,
         taxRate,
         taxInclusive,
+        taxComponents,
       })
 
       const quote = await db.quote.create({
@@ -427,9 +427,10 @@ export async function createQuoteFromTireSet(input: unknown) {
           validUntil,
           subtotal,
           taxRate,
-          taxAmount,
+          taxAmount: totals.taxAmount,
           taxInclusive,
-          totalAmount,
+          taxComponents: totals.taxComponents,
+          totalAmount: totals.totalAmount,
           customerId: set.customerId,
           vehicleId: set.vehicleId,
           tireSetId: set.id,

+ 53 - 2
src/features/vehicles/Actions/serviceActions.ts

@@ -1,6 +1,13 @@
 'use server'
 
 import { db } from '@/lib/db'
+import {
+  documentTotals,
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '@/features/settings/Lib/workshopTax'
+import { parseTaxComponentDefinitions } from '@/lib/tax-components'
 import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { withAuth } from '@/lib/with-auth'
 import { createServiceSchema, updateServiceSchema } from '../Schema/serviceSchema'
@@ -319,7 +326,7 @@ export async function createServiceRecord(input: unknown) {
               in: [
                 'workshop.invoicePrefix',
                 'workshop.invoiceStartNumber',
-                'workshop.taxInclusive',
+                ...WORKSHOP_TAX_SETTING_KEYS,
               ],
             },
           },
@@ -345,11 +352,27 @@ export async function createServiceRecord(input: unknown) {
           : settingsMap['workshop.taxInclusive'] === 'true'
 
       // Tax-exempt customer: force taxRate to 0 (overrides whatever the caller sent).
-      if (vehicle?.customer?.taxExempt || directCustomer?.taxExempt) {
+      const customerExempt = Boolean(vehicle?.customer?.taxExempt || directCustomer?.taxExempt)
+      if (customerExempt) {
         data.taxRate = 0
         data.taxAmount = 0
       }
 
+      // A job at the workshop's own rate carries its tax components, so a
+      // split-tax workshop's invoice prints GST and QST apart from the start.
+      // A rate the caller set by hand is one figure and stays one.
+      const workshopTax = readWorkshopTax(settingsMap)
+      const splitTax =
+        !customerExempt && data.taxRate > 0 && data.taxRate === workshopTax.rate
+          ? documentTotals({
+              subtotal: data.subtotal,
+              discountAmount: data.discountAmount,
+              taxRate: data.taxRate,
+              taxInclusive,
+              taxComponents: taxFieldsForNewDocument(workshopTax).taxComponents,
+            })
+          : null
+
       // Generate sequential invoice number
       const startNumber = parseInt(settingsMap['workshop.invoiceStartNumber'] || '0', 10)
       const lastRecord = await db.serviceRecord.findFirst({
@@ -398,6 +421,13 @@ export async function createServiceRecord(input: unknown) {
             // records always resolve their customer through the vehicle.
             customerId: data.vehicleId ? null : data.customerId,
             taxInclusive,
+            ...(splitTax?.taxComponents
+              ? {
+                  taxComponents: splitTax.taxComponents,
+                  taxAmount: splitTax.taxAmount,
+                  totalAmount: splitTax.totalAmount,
+                }
+              : {}),
             shopName,
             invoiceNumber,
             serviceDate: toSafeWorkshopDate(serviceDate, timeZone) ?? new Date(),
@@ -576,6 +606,26 @@ export async function updateServiceRecord(input: unknown) {
         ...recordData
       } = data
 
+      // A job totalled with tax components keeps its split in step with the
+      // lines the client sent: the amounts are recomputed here from the
+      // components on the row, because the editor only knows the combined
+      // rate and must not leave the printed GST and QST lines stale.
+      const splitTax =
+        recordData.subtotal !== undefined && parseTaxComponentDefinitions(existing.taxComponents)
+          ? documentTotals({
+              subtotal: recordData.subtotal,
+              discountAmount: recordData.discountAmount ?? existing.discountAmount,
+              taxRate: existing.taxRate,
+              taxInclusive: existing.taxInclusive,
+              taxComponents: existing.taxComponents,
+            })
+          : null
+      if (splitTax) {
+        recordData.taxRate = existing.taxRate
+        recordData.taxAmount = splitTax.taxAmount
+        recordData.totalAmount = splitTax.totalAmount
+      }
+
       // A null vehicleId from the client means "no vehicle" (counter sale) —
       // treat it as no-change rather than detaching an existing vehicle.
       if (recordData.vehicleId == null) {
@@ -609,6 +659,7 @@ export async function updateServiceRecord(input: unknown) {
           where: { id },
           data: {
             ...recordData,
+            taxComponents: splitTax?.taxComponents,
             // Attaching a vehicle to a counter sale: the direct customer link is
             // cleared so the invoice follows the vehicle's customer again.
             customerId:

+ 2 - 0
src/features/vehicles/Components/invoice-pdf/types.ts

@@ -53,6 +53,8 @@ export interface InvoiceData {
   taxRate: number
   taxAmount: number
   taxInclusive?: boolean
+  /** The stored split, when the job has one; see src/lib/tax-components.ts. */
+  taxComponents?: unknown
   totalAmount: number
   cost: number
   invoiceNumber: string | null

+ 13 - 3
src/features/vehicles/Components/service-detail/InvoiceSummary.tsx

@@ -3,7 +3,8 @@
 import { useTranslations } from 'next-intl'
 import { Separator } from '@/components/ui/separator'
 import { useFormatCurrency } from '@/components/currency-settings-context'
-import { netLineTotal } from '@/lib/tax'
+import { netLineTotal, type TaxComponent } from '@/lib/tax'
+import { taxComponentLabel } from '@/lib/tax-components'
 
 interface InvoiceSummaryProps {
   hasPartItems: boolean
@@ -17,6 +18,7 @@ interface InvoiceSummaryProps {
   taxRate: number
   taxAmount: number
   taxInclusive: boolean
+  taxComponents?: TaxComponent[] | null
   displayTotal: number
   totalPaid: number
   balanceDue: number
@@ -36,6 +38,7 @@ export function InvoiceSummary({
   taxRate,
   taxAmount,
   taxInclusive,
+  taxComponents,
   displayTotal,
   totalPaid,
   balanceDue,
@@ -86,12 +89,19 @@ export function InvoiceSummary({
             </span>
           </div>
         )}
-        {taxRate > 0 && (
+        {taxComponents && taxComponents.length > 0 ? (
+          taxComponents.map((component) => (
+            <div key={component.name} className="flex justify-between text-sm">
+              <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
+              <span>{formatCurrency(component.amount, currencyCode)}</span>
+            </div>
+          ))
+        ) : taxRate > 0 ? (
           <div className="flex justify-between text-sm">
             <span className="text-muted-foreground">{t('tax', { rate: taxRate })}</span>
             <span>{formatCurrency(taxAmount, currencyCode)}</span>
           </div>
-        )}
+        ) : null}
         <Separator />
         <div className="flex justify-between font-bold">
           <span>{t('total')}</span>

+ 21 - 3
src/features/vehicles/Components/service-edit/TotalsSection.tsx

@@ -10,7 +10,8 @@ import {
   SelectValue,
 } from '@/components/ui/select'
 import { useFormatCurrency } from '@/components/currency-settings-context'
-import { netLineTotal } from '@/lib/tax'
+import { netLineTotal, type TaxComponent } from '@/lib/tax'
+import { taxComponentLabel } from '@/lib/tax-components'
 
 interface TotalsSectionProps {
   partsSubtotal: number
@@ -28,6 +29,8 @@ interface TotalsSectionProps {
   setTaxRate: (rate: number) => void
   taxAmount: number
   taxInclusive: boolean
+  /** One row per tax when the job splits its tax; the rate is then not editable here. */
+  taxComponents?: TaxComponent[] | null
   totalAmount: number
   currencyCode: string
 }
@@ -47,6 +50,7 @@ export function TotalsSection({
   setTaxRate,
   taxAmount,
   taxInclusive,
+  taxComponents,
   totalAmount,
   currencyCode,
 }: TotalsSectionProps) {
@@ -118,7 +122,21 @@ export function TotalsSection({
           )}
         </div>
 
-        {taxEnabled && (
+        {taxEnabled && taxComponents && taxComponents.length > 0 ? (
+          // A split tax is the workshop's, set once in settings: each part
+          // gets its own line here, as it does on the invoice, and the
+          // rate is not a field on the job.
+          taxComponents.map((component) => (
+            <div
+              key={component.name}
+              className="flex items-center justify-between text-sm"
+              data-testid="tax-component-row"
+            >
+              <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
+              <span>{formatCurrency(component.amount, currencyCode)}</span>
+            </div>
+          ))
+        ) : taxEnabled ? (
           <div className="flex items-center justify-between text-sm">
             <div className="flex items-center gap-2">
               <span className="text-muted-foreground">{t('tax')}</span>
@@ -134,7 +152,7 @@ export function TotalsSection({
             </div>
             <span>{formatCurrency(taxAmount, currencyCode)}</span>
           </div>
-        )}
+        ) : null}
 
         <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
           <span>{t('total')}</span>

+ 3 - 0
src/features/vehicles/Components/service-edit/form-types.ts

@@ -1,3 +1,4 @@
+import type { TaxComponent } from '@/lib/tax'
 import type {
   ServiceConcernInput,
   ServicePartInput,
@@ -43,6 +44,8 @@ export interface InitialData {
   taxRate: number
   taxAmount: number
   taxInclusive?: boolean
+  /** The job's stored tax split, when it has one; amounts are recomputed live. */
+  taxComponents?: TaxComponent[] | null
   totalAmount: number
   discountType?: string
   discountValue?: number

+ 1 - 0
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -233,6 +233,7 @@ export function DetailsLeftColumn({
           taxRate={formState.taxRate}
           taxAmount={formState.taxAmount}
           taxInclusive={formState.taxInclusive}
+          taxComponents={formState.taxComponents}
           displayTotal={formState.displayTotal}
           totalPaid={formState.totalPaid}
           balanceDue={formState.balanceDue}

+ 1 - 0
src/features/vehicles/Components/service-page/DetailsRightColumn.tsx

@@ -157,6 +157,7 @@ export function DetailsRightColumn({
         setTaxRate={formState.dirtySetTaxRate}
         taxAmount={formState.taxAmount}
         taxInclusive={formState.taxInclusive}
+        taxComponents={formState.taxComponents}
         totalAmount={formState.totalAmount}
         currencyCode={currencyCode}
       />

+ 2 - 0
src/features/vehicles/Components/service-page/ServiceRecordPage.tsx

@@ -1,3 +1,4 @@
+import { parseTaxComponents } from '@/lib/tax-components'
 import { getServiceRecord } from '@/features/vehicles/Actions/serviceActions'
 import { getServiceVideoCall } from '@/features/integrations/Actions/integrationActions'
 import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
@@ -253,6 +254,7 @@ export async function ServiceRecordPage({
     taxRate: record.taxRate,
     taxAmount: record.taxAmount,
     taxInclusive: record.taxInclusive,
+    taxComponents: parseTaxComponents(record.taxComponents),
     totalAmount: record.totalAmount,
     discountType: record.discountType || undefined,
     discountValue: record.discountValue,

+ 10 - 1
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -35,6 +35,9 @@ export function useServiceFormState({
   const [laborItems, setLaborItems] = useState<ServiceLaborInput[]>(initialData.laborItems || [])
   const [taxRate, setTaxRate] = useState(initialData.taxRate ?? defaultTaxRate)
   const [taxInclusive] = useState<boolean>(initialData.taxInclusive ?? false)
+  // The split the job was created with. Fixed for the job's life: the
+  // server re-derives the amounts from it on every save.
+  const [taxComponentDefinitions] = useState(initialData.taxComponents ?? null)
   const [discountType, setDiscountType] = useState<string>(initialData.discountType || 'none')
   const [discountValue, setDiscountValue] = useState(initialData.discountValue ?? 0)
   const [showInventoryPicker, setShowInventoryPicker] = useState(false)
@@ -155,11 +158,16 @@ export function useServiceFormState({
       : discountType === 'fixed'
         ? Math.min(discountValue, subtotal)
         : 0
-  const { taxAmount, totalAmount } = calculateTotals({
+  const {
+    taxAmount,
+    totalAmount,
+    components: taxComponents,
+  } = calculateTotals({
     subtotal,
     discountAmount,
     taxRate,
     taxInclusive,
+    components: taxComponentDefinitions,
   })
 
   const [localManuallyPaid, setLocalManuallyPaid] = useState(record.manuallyPaid)
@@ -347,6 +355,7 @@ export function useServiceFormState({
     laborItems,
     taxRate,
     taxInclusive,
+    taxComponents,
     discountType,
     discountValue,
     showInventoryPicker,

+ 5 - 3
src/features/vehicles/Lib/addPart.ts

@@ -1,5 +1,5 @@
 import { db } from '@/lib/db'
-import { calculateTotals } from '@/lib/tax'
+import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
 
@@ -56,6 +56,7 @@ export async function addPart(args: {
       subtotal: true,
       taxRate: true,
       taxInclusive: true,
+      taxComponents: true,
       discountType: true,
       discountValue: true,
       title: true,
@@ -97,16 +98,17 @@ export async function addPart(args: {
         : record.discountType === 'fixed'
           ? Math.min(record.discountValue ?? 0, subtotal)
           : 0
-    const { taxAmount, totalAmount } = calculateTotals({
+    const { taxAmount, totalAmount, taxComponents } = documentTotals({
       subtotal,
       discountAmount,
       taxRate: record.taxRate,
       taxInclusive: record.taxInclusive,
+      taxComponents: record.taxComponents,
     })
 
     await tx.serviceRecord.update({
       where: { id: record.id },
-      data: { subtotal, taxAmount, totalAmount },
+      data: { subtotal, taxAmount, totalAmount, taxComponents },
     })
 
     // Deduct stock for the newly added line (delta from empty).

+ 11 - 10
src/features/vehicles/Lib/createDraftRecord.ts

@@ -1,4 +1,9 @@
 import { db } from '@/lib/db'
+import {
+  readWorkshopTax,
+  taxFieldsForNewDocument,
+  WORKSHOP_TAX_SETTING_KEYS,
+} from '@/features/settings/Lib/workshopTax'
 import { nextAvailableSlot } from '@/features/workboard/Lib/availability'
 import { loadBookingContext } from '@/features/workboard/Lib/bookings'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
@@ -36,9 +41,7 @@ export async function createDraftRecord(
             'workshop.invoiceStartNumber',
             'workshop.defaultTechnician',
             'workshop.defaultTechnicianId',
-            'workshop.defaultTaxRate',
-            'workshop.taxEnabled',
-            'workshop.taxInclusive',
+            ...WORKSHOP_TAX_SETTING_KEYS,
             'workboard.workDayStart',
           ],
         },
@@ -126,12 +129,11 @@ export async function createDraftRecord(
     })
   }
 
-  // Apply default tax rate from settings (if tax is enabled).
+  // The workshop's default tax, components included when it splits its tax.
   // Tax-exempt customers always get a 0% rate regardless of org default.
-  const taxEnabled = settingsMap['workshop.taxEnabled'] !== 'false'
-  const defaultTaxRate =
-    taxEnabled && !opts.customerExempt ? Number(settingsMap['workshop.defaultTaxRate']) || 0 : 0
-  const taxInclusive = settingsMap['workshop.taxInclusive'] === 'true'
+  const taxFields = taxFieldsForNewDocument(readWorkshopTax(settingsMap), {
+    customerExempt: opts.customerExempt,
+  })
 
   /**
    * When a job with no stated time gets booked in.
@@ -188,8 +190,7 @@ export async function createDraftRecord(
       technicianId: resolvedTechId || undefined,
       workBayId: opts.workBayId || undefined,
       invoiceNumber,
-      taxRate: defaultTaxRate,
-      taxInclusive,
+      ...taxFields,
       serviceDate,
       invoiceDate: serviceDate,
       startDateTime: defaultStart,

+ 11 - 4
src/features/vehicles/Lib/retotalServiceRecord.ts

@@ -1,6 +1,6 @@
 import { db } from '@/lib/db'
 import type { Prisma } from '@/generated/prisma/client'
-import { calculateTotals } from '@/lib/tax'
+import { documentTotals } from '@/features/settings/Lib/workshopTax'
 
 /**
  * Re-totals a service record from its line items.
@@ -19,7 +19,13 @@ export async function retotalServiceRecord(
 ): Promise<void> {
   const record = await tx.serviceRecord.findUnique({
     where: { id: serviceRecordId },
-    select: { discountType: true, discountValue: true, taxRate: true, taxInclusive: true },
+    select: {
+      discountType: true,
+      discountValue: true,
+      taxRate: true,
+      taxInclusive: true,
+      taxComponents: true,
+    },
   })
   if (!record) return
 
@@ -36,15 +42,16 @@ export async function retotalServiceRecord(
         ? Math.min(record.discountValue ?? 0, subtotal)
         : 0
 
-  const { taxAmount, totalAmount } = calculateTotals({
+  const { taxAmount, totalAmount, taxComponents } = documentTotals({
     subtotal,
     discountAmount,
     taxRate: record.taxRate,
     taxInclusive: record.taxInclusive,
+    taxComponents: record.taxComponents,
   })
 
   await tx.serviceRecord.update({
     where: { id: serviceRecordId },
-    data: { subtotal, taxAmount, totalAmount },
+    data: { subtotal, taxAmount, totalAmount, taxComponents },
   })
 }

+ 4 - 2
src/lib/cron/recurring-invoices.ts

@@ -1,7 +1,7 @@
 import { CronJob } from 'cron'
 import { db } from '@/lib/db'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
-import { calculateTotals } from '@/lib/tax'
+import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
 import { shiftWorkshopTime } from '@/lib/workshop-datetime'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
@@ -93,11 +93,12 @@ export function processRecurringInvoices() {
           const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0)
           const laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0)
           const subtotal = ri.cost + partsSubtotal + laborSubtotal
-          const { taxAmount, totalAmount } = calculateTotals({
+          const { taxAmount, totalAmount, taxComponents } = documentTotals({
             subtotal,
             discountAmount: 0,
             taxRate: ri.taxRate,
             taxInclusive: ri.taxInclusive,
+            taxComponents: ri.taxComponents,
           })
 
           await db.$transaction(async (tx) => {
@@ -115,6 +116,7 @@ export function processRecurringInvoices() {
                 subtotal,
                 taxRate: ri.taxRate,
                 taxInclusive: ri.taxInclusive,
+                taxComponents,
                 taxAmount,
                 totalAmount,
                 invoiceNumber,

+ 5 - 5
src/lib/cron/report-schedules.ts

@@ -7,6 +7,7 @@ import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { ReportPDF } from '@/features/reports/Components/ReportPDF'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { netLineTotal } from '@/lib/tax'
+import { TaxByRateTable } from '@/features/reports/Lib/taxByRate'
 import {
   addZonedDays,
   atZonedTime,
@@ -607,6 +608,7 @@ async function fetchTax(orgId: string, start: Date, end: Date, timeZone: string)
       taxRate: true,
       taxAmount: true,
       taxInclusive: true,
+      taxComponents: true,
       totalAmount: true,
     },
     orderBy: [{ startDateTime: { sort: 'asc', nulls: 'last' } }, { serviceDate: 'asc' }],
@@ -615,7 +617,7 @@ async function fetchTax(orgId: string, start: Date, end: Date, timeZone: string)
     string,
     { taxCollected: number; invoiceCount: number; taxableAmount: number }
   > = {}
-  const byRate: Record<number, { taxCollected: number; invoiceCount: number }> = {}
+  const byRate = new TaxByRateTable()
   let totalTaxCollected = 0,
     totalTaxableAmount = 0,
     totalInvoices = 0
@@ -629,16 +631,14 @@ async function fetchTax(orgId: string, start: Date, end: Date, timeZone: string)
     monthly[month].taxCollected += r.taxAmount
     monthly[month].invoiceCount += 1
     monthly[month].taxableAmount += taxableBase
-    if (!byRate[r.taxRate]) byRate[r.taxRate] = { taxCollected: 0, invoiceCount: 0 }
-    byRate[r.taxRate].taxCollected += r.taxAmount
-    byRate[r.taxRate].invoiceCount += 1
+    byRate.add(r)
     totalTaxCollected += r.taxAmount
     totalTaxableAmount += taxableBase
     totalInvoices += 1
   }
   return {
     monthly: Object.entries(monthly).map(([month, d]) => ({ month, ...d })),
-    byRate: Object.entries(byRate).map(([rate, d]) => ({ taxRate: Number(rate), ...d })),
+    byRate: byRate.list(),
     summary: { totalTaxCollected, totalTaxableAmount, totalInvoices },
   }
 }

+ 160 - 0
src/lib/tax-components.ts

@@ -0,0 +1,160 @@
+import { z } from 'zod'
+import { combinedTaxRate, type TaxComponent, type TaxComponentDefinition } from './tax'
+
+/**
+ * The stored shape of a document's tax components, and the workshop's
+ * definition of them in settings.
+ *
+ * Documents keep a snapshot, not a reference: the components a job was
+ * totalled with are copied onto it, registration numbers included, so a
+ * later change in settings never rewrites an invoice that has gone out.
+ * Both are read leniently, because a row written by an older build, or
+ * edited by hand, must render rather than crash the page.
+ */
+
+export const MAX_TAX_COMPONENTS = 6
+
+export const taxComponentDefinitionSchema = z.object({
+  name: z.string().trim().min(1).max(40),
+  rate: z.coerce.number().min(0).max(100),
+  registrationNumber: z.string().trim().max(60).optional(),
+  compound: z.boolean().optional(),
+})
+
+export const taxComponentSchema = taxComponentDefinitionSchema.extend({
+  amount: z.coerce.number(),
+})
+
+const definitionsSchema = z.array(taxComponentDefinitionSchema).max(MAX_TAX_COMPONENTS)
+const componentsSchema = z.array(taxComponentSchema).max(MAX_TAX_COMPONENTS)
+
+/** A stored breakdown, or null when the row has none or holds something else. */
+export function parseTaxComponents(value: unknown): TaxComponent[] | null {
+  if (value == null) return null
+  const parsed = componentsSchema.safeParse(typeof value === 'string' ? tryJson(value) : value)
+  if (!parsed.success || parsed.data.length === 0) return null
+  return parsed.data.map(tidy)
+}
+
+/** The workshop's definition from settings, or null when it has none. */
+export function parseTaxComponentDefinitions(value: unknown): TaxComponentDefinition[] | null {
+  if (value == null || value === '') return null
+  const parsed = definitionsSchema.safeParse(typeof value === 'string' ? tryJson(value) : value)
+  if (!parsed.success || parsed.data.length === 0) return null
+  return parsed.data.map(tidy)
+}
+
+function tryJson(text: string): unknown {
+  try {
+    return JSON.parse(text)
+  } catch {
+    return null
+  }
+}
+
+/** Drops the optional fields that are empty, so a snapshot holds only what prints. */
+function tidy<T extends TaxComponentDefinition>(component: T): T {
+  const clean = { ...component }
+  if (!clean.registrationNumber) delete clean.registrationNumber
+  if (!clean.compound) delete clean.compound
+  return clean
+}
+
+/**
+ * A component as it sits in the Json column: plain data with no undefined
+ * fields, the shape Prisma's Json input accepts and the client reads back.
+ */
+export type StoredTaxComponent = {
+  name: string
+  rate: number
+  amount: number
+  registrationNumber?: string
+  compound?: boolean
+}
+
+/** The value to store in a Json column. */
+export function taxComponentsJson(components: TaxComponent[]): StoredTaxComponent[] {
+  return components.map((component) => ({
+    name: component.name,
+    rate: component.rate,
+    amount: component.amount,
+    ...(component.registrationNumber ? { registrationNumber: component.registrationNumber } : {}),
+    ...(component.compound ? { compound: true } : {}),
+  }))
+}
+
+export function serializeTaxComponentDefinitions(components: TaxComponentDefinition[]): string {
+  return JSON.stringify(
+    components.map((component) => ({
+      name: component.name,
+      rate: component.rate,
+      ...(component.registrationNumber ? { registrationNumber: component.registrationNumber } : {}),
+      ...(component.compound ? { compound: true } : {}),
+    }))
+  )
+}
+
+/** How a component is named on a totals line: "GST (5%)". */
+export function taxComponentLabel(component: TaxComponentDefinition): string {
+  return `${component.name} (${component.rate}%)`
+}
+
+/**
+ * Markets whose tax is more than one rate, ready to pick in settings. Only
+ * markets a workshop has asked for: each was checked against the regulator
+ * in September 2026, and the rates are what the workshop reviews on screen
+ * before saving, not something applied behind its back.
+ *
+ * Registration numbers are the workshop's own and are never preset.
+ */
+export interface TaxComponentPreset {
+  id: string
+  /** Translation key under settings.tax.presets. */
+  labelKey: string
+  components: TaxComponentDefinition[]
+}
+
+export const TAX_COMPONENT_PRESETS: TaxComponentPreset[] = [
+  {
+    id: 'ca-qc',
+    labelKey: 'caQc',
+    components: [
+      { name: 'GST', rate: 5 },
+      { name: 'QST', rate: 9.975 },
+    ],
+  },
+  {
+    id: 'ca-bc',
+    labelKey: 'caBc',
+    components: [
+      { name: 'GST', rate: 5 },
+      { name: 'PST', rate: 7 },
+    ],
+  },
+  {
+    id: 'ca-sk',
+    labelKey: 'caSk',
+    components: [
+      { name: 'GST', rate: 5 },
+      { name: 'PST', rate: 6 },
+    ],
+  },
+  {
+    id: 'ca-mb',
+    labelKey: 'caMb',
+    components: [
+      { name: 'GST', rate: 5 },
+      { name: 'RST', rate: 7 },
+    ],
+  },
+  {
+    id: 'in-intra',
+    labelKey: 'inIntra',
+    components: [
+      { name: 'CGST', rate: 9 },
+      { name: 'SGST', rate: 9 },
+    ],
+  },
+]
+
+export { combinedTaxRate }

+ 147 - 7
src/lib/tax.ts

@@ -21,29 +21,169 @@
  * In both modes, `subtotal` and `totalAmount` are stored as-displayed-to-user
  * (net in exclusive mode, gross in inclusive mode), so existing PDFs and DB
  * rows remain consistent.
+ *
+ * A document may carry more than one tax, the way a Québec invoice carries
+ * GST and QST, or an Indian one CGST and SGST. Those are `components`: the
+ * combined `taxRate` and `taxAmount` keep their meaning, and the components
+ * say how the combined tax splits, one printed line and one report line
+ * each. A document with no components is a single-rate document and every
+ * formula above applies unchanged.
+ */
+
+/** One tax on a document, as the workshop defines it in settings. */
+export interface TaxComponentDefinition {
+  /** Printed on the invoice and used to group the tax report: "GST", "QST". */
+  name: string
+  /** Percent, as typed: 5, 9.975. */
+  rate: number
+  /** The workshop's registration for this tax, printed under the business details. */
+  registrationNumber?: string
+  /**
+   * Charged on the price plus the taxes listed before it, instead of on the
+   * price alone. Off for every market shipped so far; Québec stopped
+   * compounding QST on GST in 2013. Kept in the shape so a row written today
+   * still reads correctly if a compounding market is added.
+   */
+  compound?: boolean
+}
+
+/** A component as stored on a document: the definition plus its share of the tax. */
+export interface TaxComponent extends TaxComponentDefinition {
+  amount: number
+}
+
+/**
+ * Money is kept to the cent per component, because that is how each tax is
+ * declared and how each line prints. The nudge keeps a value that sits
+ * exactly on a half-cent in binary (89.775 is really 89.77499...) rounding
+ * the way a person expects.
+ */
+function roundCents(value: number): number {
+  return Math.round((value + Number.EPSILON) * 100) / 100
+}
+
+/**
+ * Each component's tax on a net base, in exclusive terms, before rounding.
+ * A compounding component takes the base plus everything charged before it.
  */
+function exclusiveShares(base: number, components: TaxComponentDefinition[]): number[] {
+  const shares: number[] = []
+  let charged = 0
+  for (const component of components) {
+    const on = component.compound ? base + charged : base
+    const amount = on * (component.rate / 100)
+    shares.push(amount)
+    charged += amount
+  }
+  return shares
+}
+
+/**
+ * The rate a document's components add up to: the single figure `taxRate`
+ * holds, so nothing that reads one rate needs to know about the split.
+ * Plain sums for non-compounding components (5 + 9.975 = 14.975); the
+ * effective rate when one compounds. Trimmed of float noise, not rounded.
+ */
+export function combinedTaxRate(components: TaxComponentDefinition[]): number {
+  const total = exclusiveShares(100, components).reduce((sum, share) => sum + share, 0)
+  return Math.round(total * 1e6) / 1e6
+}
+
+/**
+ * Splits a document's tax across its components.
+ *
+ * Exclusive mode charges each component on the net base and rounds it to
+ * the cent, the way each tax is assessed; the combined tax is then their
+ * sum, so the printed lines always add up to the total.
+ *
+ * Inclusive mode already knows the combined tax (backed out of the gross);
+ * it is shared out in proportion to what each component would have charged,
+ * and the last component takes the rounding remainder, so again the lines
+ * add up to the tax the customer was told.
+ */
+export function splitTaxAmount(args: {
+  base: number
+  taxAmount: number
+  taxInclusive: boolean
+  components: TaxComponentDefinition[]
+}): TaxComponent[] {
+  const { base, taxAmount, taxInclusive, components } = args
+  if (components.length === 0) return []
+
+  if (!taxInclusive) {
+    const shares = exclusiveShares(base, components)
+    return components.map((component, index) => ({
+      ...component,
+      amount: roundCents(shares[index]),
+    }))
+  }
+
+  const target = roundCents(taxAmount)
+  const shares = exclusiveShares(1, components)
+  const total = shares.reduce((sum, share) => sum + share, 0)
+  const split: TaxComponent[] = []
+  let allocated = 0
+  components.forEach((component, index) => {
+    const last = index === components.length - 1
+    const amount = last
+      ? roundCents(target - allocated)
+      : total > 0
+        ? roundCents(target * (shares[index] / total))
+        : 0
+    allocated += amount
+    split.push({ ...component, amount })
+  })
+  return split
+}
+
 export function calculateTotals({
   subtotal,
   discountAmount,
   taxRate,
   taxInclusive,
+  components,
 }: {
   subtotal: number
   discountAmount: number
   taxRate: number
   taxInclusive: boolean
-}): { taxAmount: number; totalAmount: number } {
+  /**
+   * The document's tax components, when it has them. Their rates decide the
+   * tax; `taxRate` is only read when there are none, since a document with
+   * components stores their combined rate there anyway.
+   */
+  components?: TaxComponentDefinition[] | null
+}): { taxAmount: number; totalAmount: number; components: TaxComponent[] | null } {
   const base = Math.max(0, subtotal - discountAmount)
+  const split = components && components.length > 0 ? components : null
+  const rate = split ? combinedTaxRate(split) : taxRate
+
   if (taxInclusive) {
-    if (taxRate <= 0) {
-      return { taxAmount: 0, totalAmount: base }
+    if (rate <= 0) {
+      return { taxAmount: 0, totalAmount: base, components: split ? withZero(split) : null }
     }
-    const net = base / (1 + taxRate / 100)
+    const net = base / (1 + rate / 100)
     const taxAmount = base - net
-    return { taxAmount, totalAmount: base }
+    return {
+      taxAmount,
+      totalAmount: base,
+      components: split
+        ? splitTaxAmount({ base, taxAmount, taxInclusive: true, components: split })
+        : null,
+    }
   }
-  const taxAmount = base * (taxRate / 100)
-  return { taxAmount, totalAmount: base + taxAmount }
+
+  if (split) {
+    const parts = splitTaxAmount({ base, taxAmount: 0, taxInclusive: false, components: split })
+    const taxAmount = parts.reduce((sum, part) => sum + part.amount, 0)
+    return { taxAmount, totalAmount: base + taxAmount, components: parts }
+  }
+  const taxAmount = base * (rate / 100)
+  return { taxAmount, totalAmount: base + taxAmount, components: null }
+}
+
+function withZero(components: TaxComponentDefinition[]): TaxComponent[] {
+  return components.map((component) => ({ ...component, amount: 0 }))
 }
 
 /**