瀏覽代碼

Tax only some charges, and a cleaner totals card (#458)

* add options to set tax idividually

* Align the totals card on three columns and share it with quotes

* Fix a lint error in the tax offer test

* Tax a shop fee on a recurring invoice by the rule for the fee

* Confirm the save before applying the workshop tax, and preview recurring totals by the generator's rule
Bernt Christian Egeland 2 周之前
父節點
當前提交
adb50a6e7d
共有 100 個文件被更改,包括 4449 次插入 和 104 次删除
  1. 9 3
      e2e/specs/quotes/add-to-work-order-conflicts.spec.ts
  2. 272 0
      e2e/specs/work-orders/parts-only-tax.spec.ts
  3. 12 0
      e2e/support/settings.ts
  4. 5 5
      e2e/support/work-order.ts
  5. 2 0
      messages/de/pdf.json
  6. 1 0
      messages/de/quotes.json
  7. 17 0
      messages/de/service.json
  8. 7 0
      messages/de/settings.json
  9. 2 0
      messages/en/pdf.json
  10. 1 0
      messages/en/quotes.json
  11. 17 0
      messages/en/service.json
  12. 7 0
      messages/en/settings.json
  13. 2 0
      messages/es/pdf.json
  14. 1 0
      messages/es/quotes.json
  15. 17 0
      messages/es/service.json
  16. 7 0
      messages/es/settings.json
  17. 2 0
      messages/fr/pdf.json
  18. 1 0
      messages/fr/quotes.json
  19. 17 0
      messages/fr/service.json
  20. 7 0
      messages/fr/settings.json
  21. 2 0
      messages/it/pdf.json
  22. 1 0
      messages/it/quotes.json
  23. 17 0
      messages/it/service.json
  24. 7 0
      messages/it/settings.json
  25. 2 0
      messages/lt/pdf.json
  26. 1 0
      messages/lt/quotes.json
  27. 17 0
      messages/lt/service.json
  28. 7 0
      messages/lt/settings.json
  29. 2 0
      messages/nb/pdf.json
  30. 1 0
      messages/nb/quotes.json
  31. 17 0
      messages/nb/service.json
  32. 7 0
      messages/nb/settings.json
  33. 2 0
      messages/nl/pdf.json
  34. 1 0
      messages/nl/quotes.json
  35. 17 0
      messages/nl/service.json
  36. 7 0
      messages/nl/settings.json
  37. 2 0
      messages/pl/pdf.json
  38. 1 0
      messages/pl/quotes.json
  39. 17 0
      messages/pl/service.json
  40. 7 0
      messages/pl/settings.json
  41. 2 0
      messages/pt-BR/pdf.json
  42. 1 0
      messages/pt-BR/quotes.json
  43. 17 0
      messages/pt-BR/service.json
  44. 7 0
      messages/pt-BR/settings.json
  45. 2 0
      messages/pt-PT/pdf.json
  46. 1 0
      messages/pt-PT/quotes.json
  47. 17 0
      messages/pt-PT/service.json
  48. 7 0
      messages/pt-PT/settings.json
  49. 2 0
      messages/ru/pdf.json
  50. 1 0
      messages/ru/quotes.json
  51. 17 0
      messages/ru/service.json
  52. 7 0
      messages/ru/settings.json
  53. 2 0
      messages/tr/pdf.json
  54. 1 0
      messages/tr/quotes.json
  55. 17 0
      messages/tr/service.json
  56. 7 0
      messages/tr/settings.json
  57. 25 0
      prisma/migrations/20261013120000_taxable_lines/migration.sql
  58. 6 0
      prisma/schema/quotes.prisma
  59. 11 0
      prisma/schema/service-records.prisma
  60. 5 1
      src/__tests__/api/tech-labor-live.test.ts
  61. 21 0
      src/__tests__/features/backup/part-restore-fields.test.ts
  62. 2 0
      src/__tests__/features/inspections/link-work-order.test.ts
  63. 22 0
      src/__tests__/features/integrations/fiken-server.test.ts
  64. 3 0
      src/__tests__/features/integrations/fiken.test.ts
  65. 3 0
      src/__tests__/features/integrations/quickbooks-server.test.ts
  66. 4 0
      src/__tests__/features/integrations/quickbooks.test.ts
  67. 4 1
      src/__tests__/features/settings/shop-fee.test.ts
  68. 268 0
      src/__tests__/features/tax/taxable-lines-backfill.test.ts
  69. 665 0
      src/__tests__/features/tax/taxable-lines-documents.test.ts
  70. 374 0
      src/__tests__/features/tax/taxable-lines-editor.test.tsx
  71. 314 0
      src/__tests__/features/tax/taxable-lines-retotal.test.ts
  72. 545 0
      src/__tests__/features/tax/taxable-lines-save.test.ts
  73. 98 0
      src/__tests__/features/tax/workshop-tax-offer-save.test.tsx
  74. 393 0
      src/__tests__/features/tax/workshop-tax-offer.test.tsx
  75. 7 1
      src/__tests__/features/tire-hotel/add-tire-line.test.ts
  76. 22 3
      src/__tests__/features/tire-hotel/check-in-job-lines.test.ts
  77. 8 1
      src/__tests__/features/workorders/create-workorder.test.ts
  78. 15 2
      src/__tests__/lib/tax-components.test.ts
  79. 3 0
      src/__tests__/lib/tax.test.ts
  80. 616 0
      src/__tests__/lib/taxable-lines.test.ts
  81. 3 1
      src/app/(authenticated)/billing/recurring/page.tsx
  82. 5 0
      src/app/(authenticated)/quotes/[id]/page.tsx
  83. 4 0
      src/app/(authenticated)/settings/tax/page.tsx
  84. 57 0
      src/app/(authenticated)/settings/tax/tax-settings.tsx
  85. 3 0
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  86. 1 0
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  87. 3 0
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  88. 24 0
      src/app/api/protected/backup/import/route.ts
  89. 3 0
      src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts
  90. 13 8
      src/app/api/v1/tech/jobs/[id]/labor/route.ts
  91. 15 1
      src/features/ai/tools/workshop-columns.ts
  92. 14 2
      src/features/billing/Actions/recurringInvoiceActions.ts
  93. 14 0
      src/features/billing/Components/RecurringInvoicesClient.tsx
  94. 7 1
      src/features/inspections/Actions/inspectionActions.ts
  95. 17 1
      src/features/integrations/Lib/accounting-sync.ts
  96. 29 22
      src/features/invoice-designer/Pdf/buildInvoicePrint.ts
  97. 37 24
      src/features/invoice-designer/Pdf/buildQuotePrint.ts
  98. 32 20
      src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts
  99. 18 7
      src/features/invoice-designer/Pdf/lineGroups.ts
  100. 67 0
      src/features/invoice-designer/Pdf/printFigures.ts

+ 9 - 3
e2e/specs/quotes/add-to-work-order-conflicts.spec.ts

@@ -290,7 +290,9 @@ test.describe('nothing to decide', () => {
       Tax: '$258.00',
       Tax: '$258.00',
       Total: '$1,290.00',
       Total: '$1,290.00',
     })
     })
-    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Fixed')
+    await expect(
+      totalsRow(page, 'Discount').getByRole('radio', { name: 'Fixed', exact: true })
+    ).toBeChecked()
     await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('68')
     await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('68')
     await expectQuotePartArrived(page, scenario)
     await expectQuotePartArrived(page, scenario)
 
 
@@ -344,7 +346,9 @@ test.describe('a percentage discount on the job and a discount on the quote', ()
       Tax: '$248.00',
       Tax: '$248.00',
       Total: '$1,240.00',
       Total: '$1,240.00',
     })
     })
-    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Fixed')
+    await expect(
+      totalsRow(page, 'Discount').getByRole('radio', { name: 'Fixed', exact: true })
+    ).toBeChecked()
     await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('108')
     await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('108')
     await expectQuotePartArrived(page, scenario)
     await expectQuotePartArrived(page, scenario)
 
 
@@ -378,7 +382,9 @@ test.describe('a percentage discount on the job and a discount on the quote', ()
       Tax: '$247.50',
       Tax: '$247.50',
       Total: '$1,237.50',
       Total: '$1,237.50',
     })
     })
-    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Percentage')
+    await expect(
+      totalsRow(page, 'Discount').getByRole('radio', { name: 'Percentage', exact: true })
+    ).toBeChecked()
     await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('10')
     await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('10')
     await expectQuotePartArrived(page, scenario)
     await expectQuotePartArrived(page, scenario)
 
 

+ 272 - 0
e2e/specs/work-orders/parts-only-tax.spec.ts

@@ -0,0 +1,272 @@
+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 where the sales tax is on parts and not on labor, which is the
+ * rule in a good many US states and the question a workshop wrote in with.
+ *
+ * The job is the one the pricing spec prices: parts 2 x 150 = 300, labor
+ * 1.5 x 400 = 600, so 900 before tax. At 8% on the parts alone the tax is
+ * 24.00 and the total 924.00, where taxing everything would say 72.00 and
+ * 972.00. With the 10% discount the 90 comes off both in proportion: the
+ * parts are a third of the job, so the taxable amount is 270.00, the tax
+ * 21.60 and the total 831.60.
+ *
+ * Serial: the tax settings are the workshop's, every job here is created
+ * after they change, and the last test puts them back.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+let vehicleUrl = ''
+let job = ''
+
+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)
+  }
+}
+
+/** The switch on each line, top to bottom, as "true" or "false". */
+async function lineTaxStates(page: Page): Promise<string[]> {
+  const toggles = page.getByTestId('line-tax-toggle')
+  return toggles.evaluateAll((nodes) =>
+    nodes.map((node) => node.getAttribute('data-taxable') ?? '')
+  )
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  await page.close()
+})
+
+test('a workshop that taxes everything sees no tax switch on its lines', async ({ page }) => {
+  await setTax(page, { enabled: true, rate: 8, inclusive: false, label: '' })
+  await priceTheJob(page, `E2E taxed in full ${stamp}`)
+  await expect(page.getByTestId('line-tax-toggle')).toHaveCount(0)
+  await expect(page.getByTestId('taxable-amount-row')).toHaveCount(0)
+  await expectTotals(page, { Subtotal: '$900.00', Tax: '$72.00', Total: '$972.00' })
+})
+
+test('the settings page takes "parts only" and remembers it', async ({ page }) => {
+  await page.goto('/settings/tax')
+  // The three answers are not on the page until the workshop asks for them.
+  await expect(page.getByTestId('tax-applies')).toHaveCount(0)
+
+  await setTax(page, {
+    enabled: true,
+    rate: 8,
+    inclusive: false,
+    label: '',
+    appliesTo: { parts: true, labor: false, shopFee: false },
+  })
+
+  await page.reload()
+  await settle(page)
+  await expect(page.locator('#taxSelective')).toHaveAttribute('aria-checked', 'true')
+  await expect(page.locator('#taxOnParts')).toHaveAttribute('aria-checked', 'true')
+  await expect(page.locator('#taxOnLabor')).toHaveAttribute('aria-checked', 'false')
+  await expect(page.locator('#taxOnShopFee')).toHaveAttribute('aria-checked', 'false')
+})
+
+test('a new job charges the tax on its parts and not on its labor', async ({ page }) => {
+  job = await priceTheJob(page, `E2E parts only ${stamp}`)
+  await expectTotals(page, {
+    Parts: '$300.00',
+    Labor: '$600.00',
+    Subtotal: '$900.00',
+    'Taxable amount': '$300.00',
+    Tax: '$24.00',
+    Total: '$924.00',
+  })
+  // Labor is listed above parts on the page; whichever comes first, there is
+  // one line of each and exactly one of them is taxed.
+  expect((await lineTaxStates(page)).sort()).toEqual(['false', 'true'])
+
+  // What was stored is what was shown: the server worked the tax out itself.
+  await page.reload()
+  await settle(page)
+  await expectTotals(page, { 'Taxable amount': '$300.00', Tax: '$24.00', Total: '$924.00' })
+  expect((await lineTaxStates(page)).sort()).toEqual(['false', 'true'])
+})
+
+test('a discount comes off the taxed and the untaxed lines in proportion', async ({ page }) => {
+  await page.goto(job)
+  await setDiscount(page, 'Percentage', 10)
+  await saveWorkOrder(page)
+  await expectTotals(page, {
+    Discount: '-$90.00',
+    'Taxable amount': '$270.00',
+    Tax: '$21.60',
+    Total: '$831.60',
+  })
+
+  await page.reload()
+  await settle(page)
+  await expectTotals(page, { 'Taxable amount': '$270.00', Tax: '$21.60', Total: '$831.60' })
+})
+
+test('the customer copy and the PDF say what the tax was charged on', 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 ['Taxable amount', '$270.00', '$21.60', '$831.60']) {
+    await expect(
+      page.getByText(text).filter({ visible: true }).first(),
+      `${text} on the shared invoice`
+    ).toBeVisible()
+  }
+  // Tax on everything would have printed these.
+  await expect(page.getByText('$64.80').filter({ visible: true })).toHaveCount(0)
+  await expect(page.getByText('$874.80').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 ['Taxable amount', '270.00', 'Tax (8%)', '21.60', '831.60']) {
+    expect(pdf.flat, `${text} in the PDF`).toContain(text)
+  }
+  expect(pdf.flat).not.toContain('64.80')
+  expect(pdf.flat).not.toContain('874.80')
+})
+
+test('a line can be taxed or left out by hand, and the choice survives a save', async ({
+  page,
+}) => {
+  await page.goto(job)
+  await settle(page)
+  // Tax the labor too: the whole job is taxed again and the row is gone.
+  const untaxed = page.locator('[data-testid="line-tax-toggle"][data-taxable="false"]')
+  await expect(async () => {
+    if ((await untaxed.count()) > 0) await untaxed.first().click()
+    await expect(untaxed).toHaveCount(0, { timeout: 2_000 })
+  }).toPass()
+  await expectTotals(page, { Tax: '$64.80', Total: '$874.80' })
+  await expect(page.getByTestId('taxable-amount-row')).toHaveCount(0)
+  await saveWorkOrder(page)
+
+  await page.reload()
+  await settle(page)
+  expect(await lineTaxStates(page)).toEqual(['true', 'true'])
+  await expectTotals(page, { Tax: '$64.80', Total: '$874.80' })
+
+  // And back: leave every line out, and no tax is charged at all.
+  const taxed = page.locator('[data-testid="line-tax-toggle"][data-taxable="true"]')
+  await expect(async () => {
+    if ((await taxed.count()) > 0) await taxed.first().click()
+    await expect(taxed).toHaveCount(0, { timeout: 2_000 })
+  }).toPass()
+  await expectTotals(page, { 'Taxable amount': '$0.00', Tax: '$0.00', Total: '$810.00' })
+  await saveWorkOrder(page)
+
+  await page.reload()
+  await settle(page)
+  expect(await lineTaxStates(page)).toEqual(['false', 'false'])
+  await expectTotals(page, { Tax: '$0.00', Total: '$810.00' })
+})
+
+test('a quote is taxed the same way', async ({ page }) => {
+  await newQuote(page, `E2E parts only 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 row = page.getByTestId('taxable-amount-row')
+  await expect(row).toContainText('$300.00')
+  await expect(page.getByText('$924.00').filter({ visible: true }).first()).toBeVisible()
+  expect((await lineTaxStates(page)).sort()).toEqual(['false', 'true'])
+
+  await page.reload()
+  await settle(page)
+  await expect(page.getByTestId('taxable-amount-row')).toContainText('$300.00')
+  expect((await lineTaxStates(page)).sort()).toEqual(['false', 'true'])
+})
+
+test('the tax report counts only what was taxed', async ({ page }) => {
+  // Put the first job's lines back to the workshop rule, so it reads 270 taxed.
+  await page.goto(job)
+  await settle(page)
+  const toggles = page.getByTestId('line-tax-toggle')
+  await expect(toggles).toHaveCount(2)
+  // The part row is the one with a part number field beside it.
+  const partToggle = page
+    .getByPlaceholder('Part #')
+    .first()
+    .locator('xpath=ancestor::div[.//*[@data-testid="line-tax-toggle"]][1]')
+    .getByTestId('line-tax-toggle')
+  await expect(async () => {
+    if ((await partToggle.getAttribute('data-taxable')) !== 'true') await partToggle.click()
+    await expect(partToggle).toHaveAttribute('data-taxable', 'true', { timeout: 2_000 })
+  }).toPass()
+  await expectTotals(page, { 'Taxable amount': '$270.00', Tax: '$21.60', Total: '$831.60' })
+  await saveWorkOrder(page)
+
+  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()
+  // Other runs leave jobs in the period, so the row's presence is what is
+  // pinned here; the amount it was charged on is pinned in the unit tests.
+  await expect(
+    table
+      .getByRole('row')
+      .filter({ hasText: /(^|\D)8%/ })
+      .first()
+  ).toBeVisible()
+})
+
+test('switching it off taxes a new job in full and leaves the old one alone', async ({ page }) => {
+  // The seed's own tax, which the pricing and parity specs are pinned to.
+  await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
+  await page.goto('/settings/tax')
+  await expect(page.locator('#taxSelective')).toHaveAttribute('aria-checked', 'false')
+  await expect(page.getByTestId('tax-applies')).toHaveCount(0)
+
+  await priceTheJob(page, `E2E taxed in full again ${stamp}`)
+  await expectTotals(page, { Tax: '$225.00', Total: '$1,125.00' })
+  await expect(page.getByTestId('line-tax-toggle')).toHaveCount(0)
+  await expect(page.getByTestId('taxable-amount-row')).toHaveCount(0)
+
+  // The parts-only job keeps what it was made with, and its untaxed line
+  // still shows its switch, so it can be seen and changed.
+  await page.goto(job)
+  await settle(page)
+  await expectTotals(page, { 'Taxable amount': '$270.00', Tax: '$21.60', Total: '$831.60' })
+  await expect(page.getByTestId('line-tax-toggle')).toHaveCount(1)
+  await expect(page.getByTestId('line-tax-toggle')).toHaveAttribute('data-taxable', 'false')
+})

+ 12 - 0
e2e/support/settings.ts

@@ -28,6 +28,12 @@ export interface TaxSetup {
    * switch is turned off and the workshop is back to one rate.
    * switch is turned off and the workshop is back to one rate.
    */
    */
   components?: TaxComponentSetup[]
   components?: TaxComponentSetup[]
+  /**
+   * Tax on only some kinds of charge (parts and not labor). Given, "Tax only
+   * some charges" is turned on with these three answers; left out, it is
+   * turned off and the workshop is back to taxing everything.
+   */
+  appliesTo?: { parts: boolean; labor: boolean; shopFee: boolean }
 }
 }
 
 
 /** Flips a switch to the wanted state, retried because a click before hydration does nothing. */
 /** Flips a switch to the wanted state, retried because a click before hydration does nothing. */
@@ -66,6 +72,12 @@ export async function setTax(page: Page, tax: TaxSetup): Promise<void> {
       await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
       await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
       await page.locator('#taxLabel').fill(tax.label ?? '')
       await page.locator('#taxLabel').fill(tax.label ?? '')
     }
     }
+    await setSwitch(page.locator('#taxSelective'), Boolean(tax.appliesTo))
+    if (tax.appliesTo) {
+      await setSwitch(page.locator('#taxOnParts'), tax.appliesTo.parts)
+      await setSwitch(page.locator('#taxOnLabor'), tax.appliesTo.labor)
+      await setSwitch(page.locator('#taxOnShopFee'), tax.appliesTo.shopFee)
+    }
     await page
     await page
       .getByRole('button', { name: tax.inclusive ? 'Inclusive' : 'Exclusive', exact: true })
       .getByRole('button', { name: tax.inclusive ? 'Inclusive' : 'Exclusive', exact: true })
       .click()
       .click()

+ 5 - 5
e2e/support/work-order.ts

@@ -167,12 +167,11 @@ export async function saveWorkOrder(page: Page): Promise<void> {
 export function totalsRow(page: Page, label: string): Locator {
 export function totalsRow(page: Page, label: string): Locator {
   // Scoped to the totals rows: "Parts" and "Labor" are also section headings.
   // Scoped to the totals rows: "Parts" and "Labor" are also section headings.
   const panel = page.getByTestId('totals')
   const panel = page.getByTestId('totals')
-  // The row is the nearest box that spreads label and figure apart; the tax
-  // label sits one level deeper, beside its percentage input.
+  // Every line of the card, the total included, is marked as a row.
   return panel
   return panel
     .getByText(label, { exact: true })
     .getByText(label, { exact: true })
     .first()
     .first()
-    .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
+    .locator('xpath=ancestor::div[@data-totals-row][1]')
 }
 }
 
 
 /** Sets the discount on the open work order. */
 /** Sets the discount on the open work order. */
@@ -182,8 +181,9 @@ export async function setDiscount(
   value?: number
   value?: number
 ): Promise<void> {
 ): Promise<void> {
   const row = totalsRow(page, 'Discount')
   const row = totalsRow(page, 'Discount')
-  await row.getByRole('combobox').click()
-  await page.getByRole('option', { name: kind, exact: true }).click()
+  // Off, a percentage or an amount: a three-way switch named as the old
+  // dropdown's options were.
+  await row.getByRole('radio', { name: kind, exact: true }).click()
   if (value !== undefined) await row.locator('input[type="number"]').fill(String(value))
   if (value !== undefined) await row.locator('input[type="number"]').fill(String(value))
 }
 }
 
 

+ 2 - 0
messages/de/pdf.json

@@ -46,6 +46,7 @@
     "tax": "MwSt. ({rate}%)",
     "tax": "MwSt. ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
     "taxIncludedNamed": "Inkl. {name} ({rate}%)",
     "taxIncludedNamed": "Inkl. {name} ({rate}%)",
+    "taxableAmount": "Steuerpflichtiger Betrag",
     "paymentsReceived": "Eingegangene Zahlungen",
     "paymentsReceived": "Eingegangene Zahlungen",
     "paidInFull": "VOLLSTÄNDIG BEZAHLT",
     "paidInFull": "VOLLSTÄNDIG BEZAHLT",
     "amountDue": "Fälliger Betrag",
     "amountDue": "Fälliger Betrag",
@@ -114,6 +115,7 @@
     "tax": "MwSt. ({rate}%)",
     "tax": "MwSt. ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
     "taxIncludedNamed": "Inkl. {name} ({rate}%)",
     "taxIncludedNamed": "Inkl. {name} ({rate}%)",
+    "taxableAmount": "Steuerpflichtiger Betrag",
     "validUntil": "Gültig bis: {date}",
     "validUntil": "Gültig bis: {date}",
     "attachedDocuments": "Beigefügte Dokumente",
     "attachedDocuments": "Beigefügte Dokumente",
     "attached": "{name} (beigefügt)",
     "attached": "{name} (beigefügt)",

+ 1 - 0
messages/de/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Festbetrag",
     "discountFixed": "Festbetrag",
     "tax": "Steuer",
     "tax": "Steuer",
     "taxIncluded": "Steuer (inkl.)",
     "taxIncluded": "Steuer (inkl.)",
+    "taxableAmount": "Steuerpflichtiger Betrag",
     "total": "Gesamt",
     "total": "Gesamt",
     "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer."
     "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer."
   },
   },

+ 17 - 0
messages/de/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "subtotalInclTax": "Zwischensumme (inkl. Steuer)",
     "discount": "Rabatt",
     "discount": "Rabatt",
     "discountNone": "Keine",
     "discountNone": "Keine",
+    "discountOff": "Aus",
     "discountPercentage": "Prozentsatz",
     "discountPercentage": "Prozentsatz",
     "discountFixed": "Festbetrag",
     "discountFixed": "Festbetrag",
     "tax": "Steuern",
     "tax": "Steuern",
     "taxIncluded": "Steuer (inkl.)",
     "taxIncluded": "Steuer (inkl.)",
+    "taxableAmount": "Steuerpflichtiger Betrag",
     "total": "Gesamt",
     "total": "Gesamt",
     "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer.",
     "inclusiveModeHint": "Zeilenpreise werden inklusive Steuer eingegeben. Die Summen zeigen die Aufteilung netto + Steuer.",
     "partsCost": "Teilekosten (intern)"
     "partsCost": "Teilekosten (intern)"
   },
   },
+  "lineTax": {
+    "label": "Steuer auf diese Position",
+    "taxedTag": "STEUER",
+    "untaxedTag": "OHNE ST.",
+    "taxedHint": "Auf diese Position wird Steuer berechnet. Klicken, um sie von der Steuer auszunehmen.",
+    "untaxedHint": "Auf diese Position wird keine Steuer berechnet. Klicken, um sie zu besteuern."
+  },
+  "taxOffer": {
+    "noTax": "Dies wurde ohne Steuer erstellt. Die Werkstatt berechnet jetzt {rate} %.",
+    "otherRate": "Dies steht auf {current} %. Die Werkstatt berechnet jetzt {rate} %.",
+    "apply": "{rate} % Steuer anwenden",
+    "failed": "Die Steuer konnte nicht aktualisiert werden",
+    "saveFirst": "Speichern Sie zuerst Ihre Änderungen und wenden Sie dann die Steuer an."
+  },
   "notes": {
   "notes": {
     "title": "Notizen",
     "title": "Notizen",
     "public": "Öffentlich",
     "public": "Öffentlich",
@@ -227,6 +243,7 @@
     "discount": "Rabatt",
     "discount": "Rabatt",
     "tax": "Steuern ({rate}%)",
     "tax": "Steuern ({rate}%)",
     "taxIncluded": "Steuer (inkl., {rate}%)",
     "taxIncluded": "Steuer (inkl., {rate}%)",
+    "taxableAmount": "Steuerpflichtiger Betrag",
     "total": "Gesamt",
     "total": "Gesamt",
     "paid": "Bezahlt",
     "paid": "Bezahlt",
     "balanceDue": "Ausstehend",
     "balanceDue": "Ausstehend",

+ 7 - 0
messages/de/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "MwSt.",
     "labelPlaceholder": "MwSt.",
     "splitLabel": "Steuer in mehrere Sätze aufteilen",
     "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.",
     "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.",
+    "selectiveLabel": "Nur einen Teil der Posten besteuern",
+    "selectiveHint": "Für Orte, an denen Teile besteuert werden und Arbeit nicht, oder an denen eine Pauschale steuerfrei bleibt. Ausgeschaltet wird auf alles Steuer berechnet.",
+    "appliesTitle": "Steuer berechnen auf",
+    "appliesParts": "Teile",
+    "appliesLabor": "Arbeit",
+    "appliesShopFee": "Werkstattpauschale",
+    "appliesHint": "Neue Positionen beginnen mit diesen Angaben, und jede Position lässt sich im Auftrag oder Angebot ändern. Bereits geschriebene Aufträge und Angebote behalten, was sie haben.",
     "splitIncomplete": "Geben Sie jeder Steuer vor dem Speichern einen Namen und einen Satz.",
     "splitIncomplete": "Geben Sie jeder Steuer vor dem Speichern einen Namen und einen Satz.",
     "presetLabel": "Mit einer Vorlage beginnen",
     "presetLabel": "Mit einer Vorlage beginnen",
     "presetPlaceholder": "Region wählen",
     "presetPlaceholder": "Region wählen",

+ 2 - 0
messages/en/pdf.json

@@ -47,6 +47,7 @@
     "tax": "Tax ({rate}%)",
     "tax": "Tax ({rate}%)",
     "taxIncluded": "Includes tax ({rate}%)",
     "taxIncluded": "Includes tax ({rate}%)",
     "taxIncludedNamed": "Includes {name} ({rate}%)",
     "taxIncludedNamed": "Includes {name} ({rate}%)",
+    "taxableAmount": "Taxable amount",
     "paymentsReceived": "Payments Received",
     "paymentsReceived": "Payments Received",
     "paidInFull": "PAID IN FULL",
     "paidInFull": "PAID IN FULL",
     "amountDue": "Amount Due",
     "amountDue": "Amount Due",
@@ -115,6 +116,7 @@
     "tax": "Tax ({rate}%)",
     "tax": "Tax ({rate}%)",
     "taxIncluded": "Includes tax ({rate}%)",
     "taxIncluded": "Includes tax ({rate}%)",
     "taxIncludedNamed": "Includes {name} ({rate}%)",
     "taxIncludedNamed": "Includes {name} ({rate}%)",
+    "taxableAmount": "Taxable amount",
     "validUntil": "Valid until: {date}",
     "validUntil": "Valid until: {date}",
     "attachedDocuments": "Attached Documents",
     "attachedDocuments": "Attached Documents",
     "attached": "{name} (attached)",
     "attached": "{name} (attached)",

+ 1 - 0
messages/en/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fixed",
     "discountFixed": "Fixed",
     "tax": "Tax",
     "tax": "Tax",
     "taxIncluded": "Tax (incl.)",
     "taxIncluded": "Tax (incl.)",
+    "taxableAmount": "Taxable amount",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown."
     "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown."
   },
   },

+ 17 - 0
messages/en/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Subtotal (incl. tax)",
     "subtotalInclTax": "Subtotal (incl. tax)",
     "discount": "Discount",
     "discount": "Discount",
     "discountNone": "None",
     "discountNone": "None",
+    "discountOff": "Off",
     "discountPercentage": "Percentage",
     "discountPercentage": "Percentage",
     "discountFixed": "Fixed",
     "discountFixed": "Fixed",
     "tax": "Tax",
     "tax": "Tax",
     "taxIncluded": "Tax (incl.)",
     "taxIncluded": "Tax (incl.)",
+    "taxableAmount": "Taxable amount",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown.",
     "inclusiveModeHint": "Line item prices are entered with tax included. Totals show the net + tax breakdown.",
     "partsCost": "Parts cost (internal)"
     "partsCost": "Parts cost (internal)"
   },
   },
+  "lineTax": {
+    "label": "Tax on this line",
+    "taxedTag": "TAX",
+    "untaxedTag": "NO TAX",
+    "taxedHint": "Tax is charged on this line. Click to leave it out of the tax.",
+    "untaxedHint": "No tax is charged on this line. Click to tax it."
+  },
+  "taxOffer": {
+    "noTax": "This was created without tax. The workshop now charges {rate}%.",
+    "otherRate": "This is at {current}%. The workshop now charges {rate}%.",
+    "apply": "Apply {rate}% tax",
+    "failed": "Could not update the tax",
+    "saveFirst": "Save your changes first, then apply the tax."
+  },
   "notes": {
   "notes": {
     "title": "Notes",
     "title": "Notes",
     "public": "Public",
     "public": "Public",
@@ -227,6 +243,7 @@
     "discount": "Discount",
     "discount": "Discount",
     "tax": "Tax ({rate}%)",
     "tax": "Tax ({rate}%)",
     "taxIncluded": "Tax (incl., {rate}%)",
     "taxIncluded": "Tax (incl., {rate}%)",
+    "taxableAmount": "Taxable amount",
     "total": "Total",
     "total": "Total",
     "paid": "Paid",
     "paid": "Paid",
     "balanceDue": "Balance Due",
     "balanceDue": "Balance Due",

+ 7 - 0
messages/en/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "VAT",
     "labelPlaceholder": "VAT",
     "splitLabel": "Split the tax into several rates",
     "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.",
     "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.",
+    "selectiveLabel": "Tax only some charges",
+    "selectiveHint": "For places that tax parts and not labor, or leave a fee out of the tax. Off, tax is charged on everything.",
+    "appliesTitle": "Charge tax on",
+    "appliesParts": "Parts",
+    "appliesLabor": "Labor",
+    "appliesShopFee": "Shop fee",
+    "appliesHint": "New lines start from these answers, and any line can be changed on the work order or quote. Work orders and quotes already written keep what they have.",
     "splitIncomplete": "Give every tax a name and a rate before saving.",
     "splitIncomplete": "Give every tax a name and a rate before saving.",
     "presetLabel": "Start from a preset",
     "presetLabel": "Start from a preset",
     "presetPlaceholder": "Choose a region",
     "presetPlaceholder": "Choose a region",

+ 2 - 0
messages/es/pdf.json

@@ -46,6 +46,7 @@
     "tax": "Impuesto ({rate}%)",
     "tax": "Impuesto ({rate}%)",
     "taxIncluded": "Con impuesto ({rate}%)",
     "taxIncluded": "Con impuesto ({rate}%)",
     "taxIncludedNamed": "Con {name} ({rate}%)",
     "taxIncludedNamed": "Con {name} ({rate}%)",
+    "taxableAmount": "Base imponible",
     "paymentsReceived": "Pagos recibidos",
     "paymentsReceived": "Pagos recibidos",
     "paidInFull": "PAGADO EN SU TOTALIDAD",
     "paidInFull": "PAGADO EN SU TOTALIDAD",
     "amountDue": "Monto adeudado",
     "amountDue": "Monto adeudado",
@@ -114,6 +115,7 @@
     "tax": "Impuesto ({rate}%)",
     "tax": "Impuesto ({rate}%)",
     "taxIncluded": "Con impuesto ({rate}%)",
     "taxIncluded": "Con impuesto ({rate}%)",
     "taxIncludedNamed": "Con {name} ({rate}%)",
     "taxIncludedNamed": "Con {name} ({rate}%)",
+    "taxableAmount": "Base imponible",
     "validUntil": "Válido hasta: {date}",
     "validUntil": "Válido hasta: {date}",
     "attachedDocuments": "Documentos adjuntos",
     "attachedDocuments": "Documentos adjuntos",
     "attached": "{name} (adjunto)",
     "attached": "{name} (adjunto)",

+ 1 - 0
messages/es/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fijo",
     "discountFixed": "Fijo",
     "tax": "Impuesto",
     "tax": "Impuesto",
     "taxIncluded": "Impuesto (incl.)",
     "taxIncluded": "Impuesto (incl.)",
+    "taxableAmount": "Base imponible",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto."
     "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto."
   },
   },

+ 17 - 0
messages/es/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Subtotal (con impuesto)",
     "subtotalInclTax": "Subtotal (con impuesto)",
     "discount": "Descuento",
     "discount": "Descuento",
     "discountNone": "Ninguno",
     "discountNone": "Ninguno",
+    "discountOff": "No",
     "discountPercentage": "Porcentaje",
     "discountPercentage": "Porcentaje",
     "discountFixed": "Fijo",
     "discountFixed": "Fijo",
     "tax": "Impuesto",
     "tax": "Impuesto",
     "taxIncluded": "Impuesto (incl.)",
     "taxIncluded": "Impuesto (incl.)",
+    "taxableAmount": "Base imponible",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto.",
     "inclusiveModeHint": "Los precios por línea se introducen con impuesto incluido. Los totales muestran el desglose neto + impuesto.",
     "partsCost": "Costo de repuestos (interno)"
     "partsCost": "Costo de repuestos (interno)"
   },
   },
+  "lineTax": {
+    "label": "Impuesto en esta línea",
+    "taxedTag": "IMP.",
+    "untaxedTag": "SIN IMP.",
+    "taxedHint": "Esta línea lleva impuesto. Haz clic para dejarla fuera del impuesto.",
+    "untaxedHint": "Esta línea no lleva impuesto. Haz clic para aplicarlo."
+  },
+  "taxOffer": {
+    "noTax": "Esto se creó sin impuesto. El taller cobra ahora un {rate} %.",
+    "otherRate": "Esto está al {current} %. El taller cobra ahora un {rate} %.",
+    "apply": "Aplicar {rate} % de impuesto",
+    "failed": "No se pudo actualizar el impuesto",
+    "saveFirst": "Guarda primero los cambios y después aplica el impuesto."
+  },
   "notes": {
   "notes": {
     "title": "Notas",
     "title": "Notas",
     "public": "Público",
     "public": "Público",
@@ -227,6 +243,7 @@
     "discount": "Descuento",
     "discount": "Descuento",
     "tax": "Impuesto ({rate}%)",
     "tax": "Impuesto ({rate}%)",
     "taxIncluded": "Impuesto (incl., {rate}%)",
     "taxIncluded": "Impuesto (incl., {rate}%)",
+    "taxableAmount": "Base imponible",
     "total": "Total",
     "total": "Total",
     "paid": "Pagado",
     "paid": "Pagado",
     "balanceDue": "Saldo Pendiente",
     "balanceDue": "Saldo Pendiente",

+ 7 - 0
messages/es/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "IVA",
     "labelPlaceholder": "IVA",
     "splitLabel": "Dividir el impuesto en varias tasas",
     "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.",
     "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.",
+    "selectiveLabel": "Aplicar impuesto solo a algunos cargos",
+    "selectiveHint": "Para lugares donde se gravan las piezas y no la mano de obra, o donde un cargo queda fuera del impuesto. Desactivado, el impuesto se aplica a todo.",
+    "appliesTitle": "Aplicar impuesto a",
+    "appliesParts": "Piezas",
+    "appliesLabor": "Mano de obra",
+    "appliesShopFee": "Cargo de taller",
+    "appliesHint": "Las líneas nuevas parten de estas respuestas, y cualquier línea se puede cambiar en la orden de trabajo o el presupuesto. Las órdenes y los presupuestos ya escritos conservan lo que tienen.",
     "splitIncomplete": "Asigne un nombre y una tasa a cada impuesto antes de guardar.",
     "splitIncomplete": "Asigne un nombre y una tasa a cada impuesto antes de guardar.",
     "presetLabel": "Empezar desde una plantilla",
     "presetLabel": "Empezar desde una plantilla",
     "presetPlaceholder": "Elija una región",
     "presetPlaceholder": "Elija una región",

+ 2 - 0
messages/fr/pdf.json

@@ -46,6 +46,7 @@
     "tax": "Taxe ({rate}%)",
     "tax": "Taxe ({rate}%)",
     "taxIncluded": "TTC ({rate}%)",
     "taxIncluded": "TTC ({rate}%)",
     "taxIncludedNamed": "{name} incluse ({rate}%)",
     "taxIncludedNamed": "{name} incluse ({rate}%)",
+    "taxableAmount": "Montant taxable",
     "paymentsReceived": "Paiements reçus",
     "paymentsReceived": "Paiements reçus",
     "paidInFull": "ENTIÈREMENT PAYÉ",
     "paidInFull": "ENTIÈREMENT PAYÉ",
     "amountDue": "Montant dû",
     "amountDue": "Montant dû",
@@ -114,6 +115,7 @@
     "tax": "Taxe ({rate}%)",
     "tax": "Taxe ({rate}%)",
     "taxIncluded": "TTC ({rate}%)",
     "taxIncluded": "TTC ({rate}%)",
     "taxIncludedNamed": "{name} incluse ({rate}%)",
     "taxIncludedNamed": "{name} incluse ({rate}%)",
+    "taxableAmount": "Montant taxable",
     "validUntil": "Valable jusqu'au : {date}",
     "validUntil": "Valable jusqu'au : {date}",
     "attachedDocuments": "Documents joints",
     "attachedDocuments": "Documents joints",
     "attached": "{name} (joint)",
     "attached": "{name} (joint)",

+ 1 - 0
messages/fr/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fixe",
     "discountFixed": "Fixe",
     "tax": "Taxe",
     "tax": "Taxe",
     "taxIncluded": "Taxe (incl.)",
     "taxIncluded": "Taxe (incl.)",
+    "taxableAmount": "Montant taxable",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe."
     "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe."
   },
   },

+ 17 - 0
messages/fr/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Sous-total (TTC)",
     "subtotalInclTax": "Sous-total (TTC)",
     "discount": "Remise",
     "discount": "Remise",
     "discountNone": "Aucune",
     "discountNone": "Aucune",
+    "discountOff": "Non",
     "discountPercentage": "Pourcentage",
     "discountPercentage": "Pourcentage",
     "discountFixed": "Fixe",
     "discountFixed": "Fixe",
     "tax": "Taxe",
     "tax": "Taxe",
     "taxIncluded": "Taxe (incl.)",
     "taxIncluded": "Taxe (incl.)",
+    "taxableAmount": "Montant taxable",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe.",
     "inclusiveModeHint": "Les prix de ligne sont saisis taxe incluse. Les totaux affichent la répartition net + taxe.",
     "partsCost": "Coût des pièces (interne)"
     "partsCost": "Coût des pièces (interne)"
   },
   },
+  "lineTax": {
+    "label": "Taxe sur cette ligne",
+    "taxedTag": "TAXE",
+    "untaxedTag": "SANS TAXE",
+    "taxedHint": "Cette ligne est taxée. Cliquez pour l'exclure de la taxe.",
+    "untaxedHint": "Cette ligne n'est pas taxée. Cliquez pour la taxer."
+  },
+  "taxOffer": {
+    "noTax": "Ce document a été créé sans taxe. L'atelier applique maintenant {rate} %.",
+    "otherRate": "Ce document est à {current} %. L'atelier applique maintenant {rate} %.",
+    "apply": "Appliquer {rate} % de taxe",
+    "failed": "Impossible de mettre à jour la taxe",
+    "saveFirst": "Enregistrez d'abord vos modifications, puis appliquez la taxe."
+  },
   "notes": {
   "notes": {
     "title": "Notes",
     "title": "Notes",
     "public": "Public",
     "public": "Public",
@@ -227,6 +243,7 @@
     "discount": "Remise",
     "discount": "Remise",
     "tax": "Taxe ({rate}%)",
     "tax": "Taxe ({rate}%)",
     "taxIncluded": "Taxe (incl., {rate}%)",
     "taxIncluded": "Taxe (incl., {rate}%)",
+    "taxableAmount": "Montant taxable",
     "total": "Total",
     "total": "Total",
     "paid": "Payé",
     "paid": "Payé",
     "balanceDue": "Solde Dû",
     "balanceDue": "Solde Dû",

+ 7 - 0
messages/fr/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "TVA",
     "labelPlaceholder": "TVA",
     "splitLabel": "Répartir la taxe en plusieurs taux",
     "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.",
     "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.",
+    "selectiveLabel": "Ne taxer que certains frais",
+    "selectiveHint": "Pour les endroits où les pièces sont taxées et pas la main-d'œuvre, ou où des frais restent hors taxe. Désactivé, la taxe s'applique à tout.",
+    "appliesTitle": "Appliquer la taxe sur",
+    "appliesParts": "Pièces",
+    "appliesLabor": "Main-d'œuvre",
+    "appliesShopFee": "Frais d'atelier",
+    "appliesHint": "Les nouvelles lignes partent de ces réponses, et chaque ligne peut être modifiée sur l'ordre de travail ou le devis. Les ordres de travail et les devis déjà rédigés gardent ce qu'ils ont.",
     "splitIncomplete": "Donnez un nom et un taux à chaque taxe avant d'enregistrer.",
     "splitIncomplete": "Donnez un nom et un taux à chaque taxe avant d'enregistrer.",
     "presetLabel": "Partir d'un modèle",
     "presetLabel": "Partir d'un modèle",
     "presetPlaceholder": "Choisir une région",
     "presetPlaceholder": "Choisir une région",

+ 2 - 0
messages/it/pdf.json

@@ -46,6 +46,7 @@
     "tax": "IVA ({rate}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA inclusa ({rate}%)",
     "taxIncluded": "IVA inclusa ({rate}%)",
     "taxIncludedNamed": "{name} inclusa ({rate}%)",
     "taxIncludedNamed": "{name} inclusa ({rate}%)",
+    "taxableAmount": "Imponibile",
     "paymentsReceived": "Pagamenti Ricevuti",
     "paymentsReceived": "Pagamenti Ricevuti",
     "paidInFull": "SALDATO",
     "paidInFull": "SALDATO",
     "amountDue": "Importo Dovuto",
     "amountDue": "Importo Dovuto",
@@ -114,6 +115,7 @@
     "tax": "IVA ({rate}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA inclusa ({rate}%)",
     "taxIncluded": "IVA inclusa ({rate}%)",
     "taxIncludedNamed": "{name} inclusa ({rate}%)",
     "taxIncludedNamed": "{name} inclusa ({rate}%)",
+    "taxableAmount": "Imponibile",
     "validUntil": "Valido fino al: {date}",
     "validUntil": "Valido fino al: {date}",
     "attachedDocuments": "Documenti Allegati",
     "attachedDocuments": "Documenti Allegati",
     "attached": "{name} (allegato)",
     "attached": "{name} (allegato)",

+ 1 - 0
messages/it/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fisso",
     "discountFixed": "Fisso",
     "tax": "IVA",
     "tax": "IVA",
     "taxIncluded": "IVA (incl.)",
     "taxIncluded": "IVA (incl.)",
+    "taxableAmount": "Imponibile",
     "total": "Totale",
     "total": "Totale",
     "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta."
     "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta."
   },
   },

+ 17 - 0
messages/it/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Subtotale (incl. imposta)",
     "subtotalInclTax": "Subtotale (incl. imposta)",
     "discount": "Sconto",
     "discount": "Sconto",
     "discountNone": "Nessuno",
     "discountNone": "Nessuno",
+    "discountOff": "No",
     "discountPercentage": "Percentuale",
     "discountPercentage": "Percentuale",
     "discountFixed": "Fisso",
     "discountFixed": "Fisso",
     "tax": "Imposta",
     "tax": "Imposta",
     "taxIncluded": "Imposta (incl.)",
     "taxIncluded": "Imposta (incl.)",
+    "taxableAmount": "Imponibile",
     "total": "Totale",
     "total": "Totale",
     "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta.",
     "inclusiveModeHint": "I prezzi delle righe sono inseriti con imposta inclusa. I totali mostrano la suddivisione netto + imposta.",
     "partsCost": "Costo ricambi (interno)"
     "partsCost": "Costo ricambi (interno)"
   },
   },
+  "lineTax": {
+    "label": "Imposta su questa riga",
+    "taxedTag": "IMP.",
+    "untaxedTag": "NO IMP.",
+    "taxedHint": "Su questa riga si applica l'imposta. Fai clic per escluderla dall'imposta.",
+    "untaxedHint": "Su questa riga non si applica l'imposta. Fai clic per applicarla."
+  },
+  "taxOffer": {
+    "noTax": "Questo documento è stato creato senza imposta. L'officina ora applica il {rate}%.",
+    "otherRate": "Questo documento è al {current}%. L'officina ora applica il {rate}%.",
+    "apply": "Applica il {rate}% di imposta",
+    "failed": "Impossibile aggiornare l'imposta",
+    "saveFirst": "Salva prima le modifiche, poi applica l'imposta."
+  },
   "notes": {
   "notes": {
     "title": "Note",
     "title": "Note",
     "public": "Pubblico",
     "public": "Pubblico",
@@ -227,6 +243,7 @@
     "discount": "Sconto",
     "discount": "Sconto",
     "tax": "Imposta ({rate}%)",
     "tax": "Imposta ({rate}%)",
     "taxIncluded": "Imposta (incl., {rate}%)",
     "taxIncluded": "Imposta (incl., {rate}%)",
+    "taxableAmount": "Imponibile",
     "total": "Totale",
     "total": "Totale",
     "paid": "Pagato",
     "paid": "Pagato",
     "balanceDue": "Importo Dovuto",
     "balanceDue": "Importo Dovuto",

+ 7 - 0
messages/it/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "IVA",
     "labelPlaceholder": "IVA",
     "splitLabel": "Suddividi l'imposta in più aliquote",
     "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.",
     "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.",
+    "selectiveLabel": "Applica l'imposta solo ad alcune voci",
+    "selectiveHint": "Per i luoghi in cui si tassano i pezzi e non la manodopera, o in cui un contributo resta fuori dall'imposta. Disattivato, l'imposta si applica a tutto.",
+    "appliesTitle": "Applica l'imposta a",
+    "appliesParts": "Pezzi",
+    "appliesLabor": "Manodopera",
+    "appliesShopFee": "Contributo officina",
+    "appliesHint": "Le nuove righe partono da queste risposte, e ogni riga può essere cambiata nell'ordine di lavoro o nel preventivo. Gli ordini e i preventivi già scritti mantengono ciò che hanno.",
     "splitIncomplete": "Assegna un nome e un'aliquota a ogni imposta prima di salvare.",
     "splitIncomplete": "Assegna un nome e un'aliquota a ogni imposta prima di salvare.",
     "presetLabel": "Parti da un modello",
     "presetLabel": "Parti da un modello",
     "presetPlaceholder": "Scegli una regione",
     "presetPlaceholder": "Scegli una regione",

+ 2 - 0
messages/lt/pdf.json

@@ -47,6 +47,7 @@
     "tax": "Mokestis ({rate}%)",
     "tax": "Mokestis ({rate}%)",
     "taxIncluded": "Su mokesčiu ({rate}%)",
     "taxIncluded": "Su mokesčiu ({rate}%)",
     "taxIncludedNamed": "Su {name} ({rate}%)",
     "taxIncludedNamed": "Su {name} ({rate}%)",
+    "taxableAmount": "Apmokestinama suma",
     "paymentsReceived": "Gauti mokėjimai",
     "paymentsReceived": "Gauti mokėjimai",
     "paidInFull": "PILNAI APMOKĖTA",
     "paidInFull": "PILNAI APMOKĖTA",
     "amountDue": "Mokėtina suma",
     "amountDue": "Mokėtina suma",
@@ -115,6 +116,7 @@
     "tax": "Mokestis ({rate}%)",
     "tax": "Mokestis ({rate}%)",
     "taxIncluded": "Su mokesčiu ({rate}%)",
     "taxIncluded": "Su mokesčiu ({rate}%)",
     "taxIncludedNamed": "Su {name} ({rate}%)",
     "taxIncludedNamed": "Su {name} ({rate}%)",
+    "taxableAmount": "Apmokestinama suma",
     "validUntil": "Galioja iki: {date}",
     "validUntil": "Galioja iki: {date}",
     "attachedDocuments": "Pridėti dokumentai",
     "attachedDocuments": "Pridėti dokumentai",
     "attached": "{name} (pridėtas)",
     "attached": "{name} (pridėtas)",

+ 1 - 0
messages/lt/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fiksuota",
     "discountFixed": "Fiksuota",
     "tax": "Mokestis",
     "tax": "Mokestis",
     "taxIncluded": "Mokestis (įsk.)",
     "taxIncluded": "Mokestis (įsk.)",
+    "taxableAmount": "Apmokestinama suma",
     "total": "Iš viso",
     "total": "Iš viso",
     "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis."
     "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis."
   },
   },

+ 17 - 0
messages/lt/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "subtotalInclTax": "Tarpinė suma (su mokesčiu)",
     "discount": "Nuolaida",
     "discount": "Nuolaida",
     "discountNone": "Nėra",
     "discountNone": "Nėra",
+    "discountOff": "Ne",
     "discountPercentage": "Procentinė",
     "discountPercentage": "Procentinė",
     "discountFixed": "Fiksuota",
     "discountFixed": "Fiksuota",
     "tax": "Mokestis",
     "tax": "Mokestis",
     "taxIncluded": "Mokestis (įsk.)",
     "taxIncluded": "Mokestis (įsk.)",
+    "taxableAmount": "Apmokestinama suma",
     "total": "Iš viso",
     "total": "Iš viso",
     "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis.",
     "inclusiveModeHint": "Eilučių kainos įvedamos su mokesčiu. Sumos rodo paskirstymą be mokesčio + mokestis.",
     "partsCost": "Dalių savikaina (vidinė)"
     "partsCost": "Dalių savikaina (vidinė)"
   },
   },
+  "lineTax": {
+    "label": "Mokestis šiai eilutei",
+    "taxedTag": "MOK.",
+    "untaxedTag": "BE MOK.",
+    "taxedHint": "Šiai eilutei taikomas mokestis. Spustelėkite, kad jo netaikytumėte.",
+    "untaxedHint": "Šiai eilutei mokestis netaikomas. Spustelėkite, kad taikytumėte."
+  },
+  "taxOffer": {
+    "noTax": "Tai sukurta be mokesčio. Dirbtuvės dabar taiko {rate} %.",
+    "otherRate": "Čia taikoma {current} %. Dirbtuvės dabar taiko {rate} %.",
+    "apply": "Taikyti {rate} % mokestį",
+    "failed": "Nepavyko atnaujinti mokesčio",
+    "saveFirst": "Pirmiausia išsaugokite pakeitimus, tada taikykite mokestį."
+  },
   "notes": {
   "notes": {
     "title": "Pastabos",
     "title": "Pastabos",
     "public": "Viešos",
     "public": "Viešos",
@@ -227,6 +243,7 @@
     "discount": "Nuolaida",
     "discount": "Nuolaida",
     "tax": "Mokestis ({rate}%)",
     "tax": "Mokestis ({rate}%)",
     "taxIncluded": "Mokestis (įsk., {rate}%)",
     "taxIncluded": "Mokestis (įsk., {rate}%)",
+    "taxableAmount": "Apmokestinama suma",
     "total": "Iš viso",
     "total": "Iš viso",
     "paid": "Apmokėta",
     "paid": "Apmokėta",
     "balanceDue": "Mokėtina suma",
     "balanceDue": "Mokėtina suma",

+ 7 - 0
messages/lt/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "PVM",
     "labelPlaceholder": "PVM",
     "splitLabel": "Padalyti mokestį į kelis tarifus",
     "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.",
     "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.",
+    "selectiveLabel": "Apmokestinti tik dalį sumų",
+    "selectiveHint": "Vietoms, kur apmokestinamos dalys, bet ne darbai, arba kur kuris nors mokestis lieka neapmokestintas. Išjungus mokestis taikomas viskam.",
+    "appliesTitle": "Taikyti mokestį",
+    "appliesParts": "Dalims",
+    "appliesLabor": "Darbams",
+    "appliesShopFee": "Dirbtuvių mokesčiui",
+    "appliesHint": "Naujos eilutės pradedamos nuo šių atsakymų, o bet kurią eilutę galima pakeisti darbo užsakyme ar pasiūlyme. Jau parašyti užsakymai ir pasiūlymai išlieka tokie, kokie yra.",
     "splitIncomplete": "Prieš išsaugodami kiekvienam mokesčiui nurodykite pavadinimą ir tarifą.",
     "splitIncomplete": "Prieš išsaugodami kiekvienam mokesčiui nurodykite pavadinimą ir tarifą.",
     "presetLabel": "Pradėti nuo šablono",
     "presetLabel": "Pradėti nuo šablono",
     "presetPlaceholder": "Pasirinkite regioną",
     "presetPlaceholder": "Pasirinkite regioną",

+ 2 - 0
messages/nb/pdf.json

@@ -46,6 +46,7 @@
     "tax": "Mva. ({rate}%)",
     "tax": "Mva. ({rate}%)",
     "taxIncluded": "Inkluderer mva ({rate}%)",
     "taxIncluded": "Inkluderer mva ({rate}%)",
     "taxIncludedNamed": "Inkluderer {name} ({rate}%)",
     "taxIncludedNamed": "Inkluderer {name} ({rate}%)",
+    "taxableAmount": "Avgiftspliktig beløp",
     "paymentsReceived": "Mottatte betalinger",
     "paymentsReceived": "Mottatte betalinger",
     "paidInFull": "FULLT BETALT",
     "paidInFull": "FULLT BETALT",
     "amountDue": "Beløp å betale",
     "amountDue": "Beløp å betale",
@@ -114,6 +115,7 @@
     "tax": "Mva. ({rate}%)",
     "tax": "Mva. ({rate}%)",
     "taxIncluded": "Inkluderer mva ({rate}%)",
     "taxIncluded": "Inkluderer mva ({rate}%)",
     "taxIncludedNamed": "Inkluderer {name} ({rate}%)",
     "taxIncludedNamed": "Inkluderer {name} ({rate}%)",
+    "taxableAmount": "Avgiftspliktig beløp",
     "validUntil": "Gyldig til: {date}",
     "validUntil": "Gyldig til: {date}",
     "attachedDocuments": "Vedlagte dokumenter",
     "attachedDocuments": "Vedlagte dokumenter",
     "attached": "{name} (vedlagt)",
     "attached": "{name} (vedlagt)",

+ 1 - 0
messages/nb/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fast beløp",
     "discountFixed": "Fast beløp",
     "tax": "Mva",
     "tax": "Mva",
     "taxIncluded": "Mva (inkl.)",
     "taxIncluded": "Mva (inkl.)",
+    "taxableAmount": "Avgiftspliktig beløp",
     "total": "Totalt",
     "total": "Totalt",
     "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift."
     "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift."
   },
   },

+ 17 - 0
messages/nb/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Delsum (inkl. avgift)",
     "subtotalInclTax": "Delsum (inkl. avgift)",
     "discount": "Rabatt",
     "discount": "Rabatt",
     "discountNone": "Ingen",
     "discountNone": "Ingen",
+    "discountOff": "Av",
     "discountPercentage": "Prosent",
     "discountPercentage": "Prosent",
     "discountFixed": "Fast",
     "discountFixed": "Fast",
     "tax": "Skatt",
     "tax": "Skatt",
     "taxIncluded": "Avgift (inkl.)",
     "taxIncluded": "Avgift (inkl.)",
+    "taxableAmount": "Avgiftspliktig beløp",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift.",
     "inclusiveModeHint": "Linjepriser legges inn med avgift inkludert. Totalene viser fordelingen netto + avgift.",
     "partsCost": "Delekostnad (intern)"
     "partsCost": "Delekostnad (intern)"
   },
   },
+  "lineTax": {
+    "label": "Avgift på denne linjen",
+    "taxedTag": "AVG.",
+    "untaxedTag": "UTEN AVG.",
+    "taxedHint": "Det beregnes avgift på denne linjen. Klikk for å holde den utenfor avgiften.",
+    "untaxedHint": "Det beregnes ikke avgift på denne linjen. Klikk for å legge avgift på den."
+  },
+  "taxOffer": {
+    "noTax": "Denne ble opprettet uten avgift. Verkstedet beregner nå {rate} %.",
+    "otherRate": "Denne står på {current} %. Verkstedet beregner nå {rate} %.",
+    "apply": "Bruk {rate} % avgift",
+    "failed": "Kunne ikke oppdatere avgiften",
+    "saveFirst": "Lagre endringene først, og bruk deretter avgiften."
+  },
   "notes": {
   "notes": {
     "title": "Notater",
     "title": "Notater",
     "public": "Offentlig",
     "public": "Offentlig",
@@ -227,6 +243,7 @@
     "discount": "Rabatt",
     "discount": "Rabatt",
     "tax": "Skatt ({rate}%)",
     "tax": "Skatt ({rate}%)",
     "taxIncluded": "Mva. inkl. ({rate}%)",
     "taxIncluded": "Mva. inkl. ({rate}%)",
+    "taxableAmount": "Avgiftspliktig beløp",
     "total": "Total",
     "total": "Total",
     "paid": "Betalt",
     "paid": "Betalt",
     "balanceDue": "Skyld beløp",
     "balanceDue": "Skyld beløp",

+ 7 - 0
messages/nb/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "MVA",
     "labelPlaceholder": "MVA",
     "splitLabel": "Del avgiften opp i flere satser",
     "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.",
     "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.",
+    "selectiveLabel": "Avgift bare på noe av det som faktureres",
+    "selectiveHint": "For steder der deler er avgiftspliktige og arbeid ikke er det, eller der et gebyr holdes utenfor avgiften. Av betyr avgift på alt.",
+    "appliesTitle": "Beregn avgift på",
+    "appliesParts": "Deler",
+    "appliesLabor": "Arbeid",
+    "appliesShopFee": "Verkstedgebyr",
+    "appliesHint": "Nye linjer starter fra disse svarene, og hver linje kan endres på arbeidsordren eller tilbudet. Arbeidsordrer og tilbud som allerede er skrevet, beholder det de har.",
     "splitIncomplete": "Gi hver avgift et navn og en sats før du lagrer.",
     "splitIncomplete": "Gi hver avgift et navn og en sats før du lagrer.",
     "presetLabel": "Start fra en mal",
     "presetLabel": "Start fra en mal",
     "presetPlaceholder": "Velg en region",
     "presetPlaceholder": "Velg en region",

+ 2 - 0
messages/nl/pdf.json

@@ -46,6 +46,7 @@
     "tax": "BTW ({rate}%)",
     "tax": "BTW ({rate}%)",
     "taxIncluded": "Incl. btw ({rate}%)",
     "taxIncluded": "Incl. btw ({rate}%)",
     "taxIncludedNamed": "Incl. {name} ({rate}%)",
     "taxIncludedNamed": "Incl. {name} ({rate}%)",
+    "taxableAmount": "Belastbaar bedrag",
     "paymentsReceived": "Ontvangen betalingen",
     "paymentsReceived": "Ontvangen betalingen",
     "paidInFull": "VOLLEDIG BETAALD",
     "paidInFull": "VOLLEDIG BETAALD",
     "amountDue": "Te betalen bedrag",
     "amountDue": "Te betalen bedrag",
@@ -114,6 +115,7 @@
     "tax": "BTW ({rate}%)",
     "tax": "BTW ({rate}%)",
     "taxIncluded": "Incl. btw ({rate}%)",
     "taxIncluded": "Incl. btw ({rate}%)",
     "taxIncludedNamed": "Incl. {name} ({rate}%)",
     "taxIncludedNamed": "Incl. {name} ({rate}%)",
+    "taxableAmount": "Belastbaar bedrag",
     "validUntil": "Geldig tot: {date}",
     "validUntil": "Geldig tot: {date}",
     "attachedDocuments": "Bijgevoegde documenten",
     "attachedDocuments": "Bijgevoegde documenten",
     "attached": "{name} (bijgevoegd)",
     "attached": "{name} (bijgevoegd)",

+ 1 - 0
messages/nl/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Vast bedrag",
     "discountFixed": "Vast bedrag",
     "tax": "Btw",
     "tax": "Btw",
     "taxIncluded": "Btw (incl.)",
     "taxIncluded": "Btw (incl.)",
+    "taxableAmount": "Belastbaar bedrag",
     "total": "Totaal",
     "total": "Totaal",
     "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting."
     "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting."
   },
   },

+ 17 - 0
messages/nl/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Subtotaal (incl. btw)",
     "subtotalInclTax": "Subtotaal (incl. btw)",
     "discount": "Korting",
     "discount": "Korting",
     "discountNone": "Geen",
     "discountNone": "Geen",
+    "discountOff": "Uit",
     "discountPercentage": "Percentage",
     "discountPercentage": "Percentage",
     "discountFixed": "Vast",
     "discountFixed": "Vast",
     "tax": "Belasting",
     "tax": "Belasting",
     "taxIncluded": "Belasting (incl.)",
     "taxIncluded": "Belasting (incl.)",
+    "taxableAmount": "Belastbaar bedrag",
     "total": "Totaal",
     "total": "Totaal",
     "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting.",
     "inclusiveModeHint": "Regelprijzen worden inclusief belasting ingevoerd. De totalen tonen de uitsplitsing netto + belasting.",
     "partsCost": "Onderdelenkosten (intern)"
     "partsCost": "Onderdelenkosten (intern)"
   },
   },
+  "lineTax": {
+    "label": "Belasting op deze regel",
+    "taxedTag": "BEL.",
+    "untaxedTag": "GEEN BEL.",
+    "taxedHint": "Over deze regel wordt belasting berekend. Klik om de regel buiten de belasting te houden.",
+    "untaxedHint": "Over deze regel wordt geen belasting berekend. Klik om de regel te belasten."
+  },
+  "taxOffer": {
+    "noTax": "Dit is zonder belasting aangemaakt. De werkplaats rekent nu {rate}%.",
+    "otherRate": "Dit staat op {current}%. De werkplaats rekent nu {rate}%.",
+    "apply": "{rate}% belasting toepassen",
+    "failed": "De belasting kon niet worden bijgewerkt",
+    "saveFirst": "Sla eerst je wijzigingen op en pas daarna de belasting toe."
+  },
   "notes": {
   "notes": {
     "title": "Opmerkingen",
     "title": "Opmerkingen",
     "public": "Openbaar",
     "public": "Openbaar",
@@ -227,6 +243,7 @@
     "discount": "Korting",
     "discount": "Korting",
     "tax": "Belasting ({rate}%)",
     "tax": "Belasting ({rate}%)",
     "taxIncluded": "Btw (incl., {rate}%)",
     "taxIncluded": "Btw (incl., {rate}%)",
+    "taxableAmount": "Belastbaar bedrag",
     "total": "Totaal",
     "total": "Totaal",
     "paid": "Betaald",
     "paid": "Betaald",
     "balanceDue": "Verschuldigd bedrag",
     "balanceDue": "Verschuldigd bedrag",

+ 7 - 0
messages/nl/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "Btw",
     "labelPlaceholder": "Btw",
     "splitLabel": "Belasting opsplitsen in meerdere tarieven",
     "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.",
     "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.",
+    "selectiveLabel": "Alleen sommige kosten belasten",
+    "selectiveHint": "Voor plaatsen waar onderdelen worden belast en arbeid niet, of waar een toeslag buiten de belasting blijft. Uit betekent belasting over alles.",
+    "appliesTitle": "Belasting berekenen over",
+    "appliesParts": "Onderdelen",
+    "appliesLabor": "Arbeid",
+    "appliesShopFee": "Werkplaatstoeslag",
+    "appliesHint": "Nieuwe regels beginnen met deze antwoorden, en elke regel kan op de werkorder of offerte worden gewijzigd. Werkorders en offertes die al zijn geschreven, houden wat ze hebben.",
     "splitIncomplete": "Geef elke belasting een naam en een tarief voordat u opslaat.",
     "splitIncomplete": "Geef elke belasting een naam en een tarief voordat u opslaat.",
     "presetLabel": "Beginnen met een voorinstelling",
     "presetLabel": "Beginnen met een voorinstelling",
     "presetPlaceholder": "Kies een regio",
     "presetPlaceholder": "Kies een regio",

+ 2 - 0
messages/pl/pdf.json

@@ -46,6 +46,7 @@
     "tax": "Podatek ({rate}%)",
     "tax": "Podatek ({rate}%)",
     "taxIncluded": "Podatek wliczony ({rate}%)",
     "taxIncluded": "Podatek wliczony ({rate}%)",
     "taxIncludedNamed": "Zawiera {name} ({rate}%)",
     "taxIncludedNamed": "Zawiera {name} ({rate}%)",
+    "taxableAmount": "Podstawa opodatkowania",
     "paymentsReceived": "Otrzymane płatności",
     "paymentsReceived": "Otrzymane płatności",
     "paidInFull": "ZAPŁACONO W CAŁOŚCI",
     "paidInFull": "ZAPŁACONO W CAŁOŚCI",
     "amountDue": "Kwota do zapłaty",
     "amountDue": "Kwota do zapłaty",
@@ -114,6 +115,7 @@
     "tax": "Podatek ({rate}%)",
     "tax": "Podatek ({rate}%)",
     "taxIncluded": "Podatek wliczony ({rate}%)",
     "taxIncluded": "Podatek wliczony ({rate}%)",
     "taxIncludedNamed": "Zawiera {name} ({rate}%)",
     "taxIncludedNamed": "Zawiera {name} ({rate}%)",
+    "taxableAmount": "Podstawa opodatkowania",
     "validUntil": "Ważne do: {date}",
     "validUntil": "Ważne do: {date}",
     "attachedDocuments": "Załączone dokumenty",
     "attachedDocuments": "Załączone dokumenty",
     "attached": "{name} (załączono)",
     "attached": "{name} (załączono)",

+ 1 - 0
messages/pl/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Stały",
     "discountFixed": "Stały",
     "tax": "Podatek",
     "tax": "Podatek",
     "taxIncluded": "Podatek (zaw.)",
     "taxIncluded": "Podatek (zaw.)",
+    "taxableAmount": "Podstawa opodatkowania",
     "total": "Suma",
     "total": "Suma",
     "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek."
     "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek."
   },
   },

+ 17 - 0
messages/pl/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Razem Częściowe (z podatkiem)",
     "subtotalInclTax": "Razem Częściowe (z podatkiem)",
     "discount": "Rabat",
     "discount": "Rabat",
     "discountNone": "Brak",
     "discountNone": "Brak",
+    "discountOff": "Brak",
     "discountPercentage": "Procent",
     "discountPercentage": "Procent",
     "discountFixed": "Stały",
     "discountFixed": "Stały",
     "tax": "Podatek",
     "tax": "Podatek",
     "taxIncluded": "Podatek (zaw.)",
     "taxIncluded": "Podatek (zaw.)",
+    "taxableAmount": "Podstawa opodatkowania",
     "total": "Razem",
     "total": "Razem",
     "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek.",
     "inclusiveModeHint": "Ceny pozycji są wprowadzane z podatkiem. Sumy pokazują podział netto + podatek.",
     "partsCost": "Koszt części (wewnętrzny)"
     "partsCost": "Koszt części (wewnętrzny)"
   },
   },
+  "lineTax": {
+    "label": "Podatek od tej pozycji",
+    "taxedTag": "POD.",
+    "untaxedTag": "BEZ POD.",
+    "taxedHint": "Od tej pozycji naliczany jest podatek. Kliknij, aby wyłączyć ją z podatku.",
+    "untaxedHint": "Od tej pozycji nie jest naliczany podatek. Kliknij, aby go naliczyć."
+  },
+  "taxOffer": {
+    "noTax": "Ten dokument utworzono bez podatku. Warsztat nalicza teraz {rate}%.",
+    "otherRate": "Ten dokument ma stawkę {current}%. Warsztat nalicza teraz {rate}%.",
+    "apply": "Zastosuj podatek {rate}%",
+    "failed": "Nie udało się zaktualizować podatku",
+    "saveFirst": "Najpierw zapisz zmiany, a potem zastosuj podatek."
+  },
   "notes": {
   "notes": {
     "title": "Notatki",
     "title": "Notatki",
     "public": "Publiczne",
     "public": "Publiczne",
@@ -227,6 +243,7 @@
     "discount": "Rabat",
     "discount": "Rabat",
     "tax": "Podatek ({rate}%)",
     "tax": "Podatek ({rate}%)",
     "taxIncluded": "Podatek (zaw., {rate}%)",
     "taxIncluded": "Podatek (zaw., {rate}%)",
+    "taxableAmount": "Podstawa opodatkowania",
     "total": "Razem",
     "total": "Razem",
     "paid": "Zapłacone",
     "paid": "Zapłacone",
     "balanceDue": "Pozostało do Zapłaty",
     "balanceDue": "Pozostało do Zapłaty",

+ 7 - 0
messages/pl/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "VAT",
     "labelPlaceholder": "VAT",
     "splitLabel": "Podziel podatek na kilka stawek",
     "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.",
     "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.",
+    "selectiveLabel": "Opodatkuj tylko część opłat",
+    "selectiveHint": "Dla miejsc, gdzie opodatkowane są części, a praca nie, albo gdzie jakaś opłata pozostaje poza podatkiem. Po wyłączeniu podatek jest naliczany od wszystkiego.",
+    "appliesTitle": "Naliczaj podatek od",
+    "appliesParts": "Części",
+    "appliesLabor": "Pracy",
+    "appliesShopFee": "Opłaty warsztatowej",
+    "appliesHint": "Nowe pozycje zaczynają od tych odpowiedzi, a każdą pozycję można zmienić w zleceniu lub ofercie. Zlecenia i oferty już zapisane zachowują to, co mają.",
     "splitIncomplete": "Przed zapisaniem nadaj każdemu podatkowi nazwę i stawkę.",
     "splitIncomplete": "Przed zapisaniem nadaj każdemu podatkowi nazwę i stawkę.",
     "presetLabel": "Zacznij od szablonu",
     "presetLabel": "Zacznij od szablonu",
     "presetPlaceholder": "Wybierz region",
     "presetPlaceholder": "Wybierz region",

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

@@ -46,6 +46,7 @@
     "tax": "Imposto ({rate}%)",
     "tax": "Imposto ({rate}%)",
     "taxIncluded": "Imposto incluído ({rate}%)",
     "taxIncluded": "Imposto incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
+    "taxableAmount": "Base tributável",
     "paymentsReceived": "Pagamentos Recebidos",
     "paymentsReceived": "Pagamentos Recebidos",
     "paidInFull": "PAGO INTEGRALMENTE",
     "paidInFull": "PAGO INTEGRALMENTE",
     "amountDue": "Valor a Pagar",
     "amountDue": "Valor a Pagar",
@@ -114,6 +115,7 @@
     "tax": "Imposto ({rate}%)",
     "tax": "Imposto ({rate}%)",
     "taxIncluded": "Imposto incluído ({rate}%)",
     "taxIncluded": "Imposto incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
+    "taxableAmount": "Base tributável",
     "validUntil": "Válido até: {date}",
     "validUntil": "Válido até: {date}",
     "attachedDocuments": "Documentos Anexados",
     "attachedDocuments": "Documentos Anexados",
     "attached": "{name} (anexado)",
     "attached": "{name} (anexado)",

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

@@ -192,6 +192,7 @@
     "discountFixed": "Fixo",
     "discountFixed": "Fixo",
     "tax": "Imposto",
     "tax": "Imposto",
     "taxIncluded": "Imposto (incl.)",
     "taxIncluded": "Imposto (incl.)",
+    "taxableAmount": "Base tributável",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto."
     "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto."
   },
   },

+ 17 - 0
messages/pt-BR/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Subtotal (com imposto)",
     "subtotalInclTax": "Subtotal (com imposto)",
     "discount": "Desconto",
     "discount": "Desconto",
     "discountNone": "Nenhum",
     "discountNone": "Nenhum",
+    "discountOff": "Não",
     "discountPercentage": "Percentual",
     "discountPercentage": "Percentual",
     "discountFixed": "Fixo",
     "discountFixed": "Fixo",
     "tax": "Imposto",
     "tax": "Imposto",
     "taxIncluded": "Imposto (incl.)",
     "taxIncluded": "Imposto (incl.)",
+    "taxableAmount": "Base tributável",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto.",
     "inclusiveModeHint": "Os preços das linhas são inseridos com imposto incluído. Os totais mostram a divisão líquido + imposto.",
     "partsCost": "Custo das peças (interno)"
     "partsCost": "Custo das peças (interno)"
   },
   },
+  "lineTax": {
+    "label": "Imposto nesta linha",
+    "taxedTag": "IMP.",
+    "untaxedTag": "SEM IMP.",
+    "taxedHint": "Esta linha tem imposto. Clique para deixá-la fora do imposto.",
+    "untaxedHint": "Esta linha não tem imposto. Clique para aplicar."
+  },
+  "taxOffer": {
+    "noTax": "Isto foi criado sem imposto. A oficina agora cobra {rate}%.",
+    "otherRate": "Isto está em {current}%. A oficina agora cobra {rate}%.",
+    "apply": "Aplicar {rate}% de imposto",
+    "failed": "Não foi possível atualizar o imposto",
+    "saveFirst": "Salve as alterações primeiro e depois aplique o imposto."
+  },
   "notes": {
   "notes": {
     "title": "Notas",
     "title": "Notas",
     "public": "Público",
     "public": "Público",
@@ -227,6 +243,7 @@
     "discount": "Desconto",
     "discount": "Desconto",
     "tax": "Imposto ({rate}%)",
     "tax": "Imposto ({rate}%)",
     "taxIncluded": "Imposto (incl., {rate}%)",
     "taxIncluded": "Imposto (incl., {rate}%)",
+    "taxableAmount": "Base tributável",
     "total": "Total",
     "total": "Total",
     "paid": "Pago",
     "paid": "Pago",
     "balanceDue": "Saldo Devedor",
     "balanceDue": "Saldo Devedor",

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

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "Imposto",
     "labelPlaceholder": "Imposto",
     "splitLabel": "Dividir o imposto em várias taxas",
     "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.",
     "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.",
+    "selectiveLabel": "Cobrar imposto só de algumas cobranças",
+    "selectiveHint": "Para lugares que tributam as peças e não a mão de obra, ou que deixam uma taxa fora do imposto. Desativado, o imposto incide sobre tudo.",
+    "appliesTitle": "Cobrar imposto sobre",
+    "appliesParts": "Peças",
+    "appliesLabor": "Mão de obra",
+    "appliesShopFee": "Taxa da oficina",
+    "appliesHint": "As novas linhas partem destas respostas, e qualquer linha pode ser alterada na ordem de serviço ou no orçamento. Ordens e orçamentos já escritos mantêm o que têm.",
     "splitIncomplete": "Dê um nome e uma taxa a cada imposto antes de salvar.",
     "splitIncomplete": "Dê um nome e uma taxa a cada imposto antes de salvar.",
     "presetLabel": "Começar a partir de um modelo",
     "presetLabel": "Começar a partir de um modelo",
     "presetPlaceholder": "Escolha uma região",
     "presetPlaceholder": "Escolha uma região",

+ 2 - 0
messages/pt-PT/pdf.json

@@ -46,6 +46,7 @@
     "tax": "IVA ({rate}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA incluído ({rate}%)",
     "taxIncluded": "IVA incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
+    "taxableAmount": "Valor tributável",
     "paymentsReceived": "Pagamentos Recebidos",
     "paymentsReceived": "Pagamentos Recebidos",
     "paidInFull": "PAGO NA TOTALIDADE",
     "paidInFull": "PAGO NA TOTALIDADE",
     "amountDue": "Valor a Pagar",
     "amountDue": "Valor a Pagar",
@@ -114,6 +115,7 @@
     "tax": "IVA ({rate}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA incluído ({rate}%)",
     "taxIncluded": "IVA incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
     "taxIncludedNamed": "{name} incluído ({rate}%)",
+    "taxableAmount": "Valor tributável",
     "validUntil": "Válido até: {date}",
     "validUntil": "Válido até: {date}",
     "attachedDocuments": "Documentos Anexos",
     "attachedDocuments": "Documentos Anexos",
     "attached": "{name} (em anexo)",
     "attached": "{name} (em anexo)",

+ 1 - 0
messages/pt-PT/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Fixo",
     "discountFixed": "Fixo",
     "tax": "IVA",
     "tax": "IVA",
     "taxIncluded": "IVA (incluído)",
     "taxIncluded": "IVA (incluído)",
+    "taxableAmount": "Valor tributável",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Os preços das linhas são introduzidos com IVA incluído. Os totais mostram a divisão entre valor líquido e IVA."
     "inclusiveModeHint": "Os preços das linhas são introduzidos com IVA incluído. Os totais mostram a divisão entre valor líquido e IVA."
   },
   },

+ 17 - 0
messages/pt-PT/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Subtotal (com IVA)",
     "subtotalInclTax": "Subtotal (com IVA)",
     "discount": "Desconto",
     "discount": "Desconto",
     "discountNone": "Nenhum",
     "discountNone": "Nenhum",
+    "discountOff": "Não",
     "discountPercentage": "Percentagem",
     "discountPercentage": "Percentagem",
     "discountFixed": "Fixo",
     "discountFixed": "Fixo",
     "tax": "IVA",
     "tax": "IVA",
     "taxIncluded": "IVA (incl.)",
     "taxIncluded": "IVA (incl.)",
+    "taxableAmount": "Valor tributável",
     "total": "Total",
     "total": "Total",
     "inclusiveModeHint": "Os preços das linhas são introduzidos com IVA incluído. Os totais mostram a divisão entre valor líquido e IVA.",
     "inclusiveModeHint": "Os preços das linhas são introduzidos com IVA incluído. Os totais mostram a divisão entre valor líquido e IVA.",
     "partsCost": "Custo das peças (interno)"
     "partsCost": "Custo das peças (interno)"
   },
   },
+  "lineTax": {
+    "label": "Imposto nesta linha",
+    "taxedTag": "IMP.",
+    "untaxedTag": "SEM IMP.",
+    "taxedHint": "Esta linha tem imposto. Clique para a deixar fora do imposto.",
+    "untaxedHint": "Esta linha não tem imposto. Clique para o aplicar."
+  },
+  "taxOffer": {
+    "noTax": "Isto foi criado sem imposto. A oficina cobra agora {rate}%.",
+    "otherRate": "Isto está a {current}%. A oficina cobra agora {rate}%.",
+    "apply": "Aplicar {rate}% de imposto",
+    "failed": "Não foi possível atualizar o imposto",
+    "saveFirst": "Guarde primeiro as alterações e depois aplique o imposto."
+  },
   "notes": {
   "notes": {
     "title": "Notas",
     "title": "Notas",
     "public": "Públicas",
     "public": "Públicas",
@@ -227,6 +243,7 @@
     "discount": "Desconto",
     "discount": "Desconto",
     "tax": "IVA ({rate}%)",
     "tax": "IVA ({rate}%)",
     "taxIncluded": "IVA (incl., {rate}%)",
     "taxIncluded": "IVA (incl., {rate}%)",
+    "taxableAmount": "Valor tributável",
     "total": "Total",
     "total": "Total",
     "paid": "Pago",
     "paid": "Pago",
     "balanceDue": "Valor em dívida",
     "balanceDue": "Valor em dívida",

+ 7 - 0
messages/pt-PT/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "IVA",
     "labelPlaceholder": "IVA",
     "splitLabel": "Dividir o imposto em várias taxas",
     "splitLabel": "Dividir o imposto em várias taxas",
     "splitHint": "Para locais que cobram mais do que um imposto na mesma fatura, como GST e QST no Quebeque ou CGST e SGST na Índia. Cada imposto é impresso numa linha própria com o seu número de registo, e o relatório de impostos mantém-nos separados.",
     "splitHint": "Para locais que cobram mais do que um imposto na mesma fatura, como GST e QST no Quebeque ou CGST e SGST na Índia. Cada imposto é impresso numa linha própria com o seu número de registo, e o relatório de impostos mantém-nos separados.",
+    "selectiveLabel": "Aplicar imposto só a alguns encargos",
+    "selectiveHint": "Para locais que tributam as peças e não a mão de obra, ou que deixam uma taxa fora do imposto. Desativado, o imposto incide sobre tudo.",
+    "appliesTitle": "Aplicar imposto a",
+    "appliesParts": "Peças",
+    "appliesLabor": "Mão de obra",
+    "appliesShopFee": "Taxa da oficina",
+    "appliesHint": "As novas linhas partem destas respostas, e qualquer linha pode ser alterada na ordem de trabalho ou no orçamento. As ordens e os orçamentos já escritos mantêm o que têm.",
     "splitIncomplete": "Dê um nome e uma taxa a cada imposto antes de guardar.",
     "splitIncomplete": "Dê um nome e uma taxa a cada imposto antes de guardar.",
     "presetLabel": "Começar a partir de uma predefinição",
     "presetLabel": "Começar a partir de uma predefinição",
     "presetPlaceholder": "Escolha uma região",
     "presetPlaceholder": "Escolha uma região",

+ 2 - 0
messages/ru/pdf.json

@@ -46,6 +46,7 @@
     "tax": "Налог ({rate}%)",
     "tax": "Налог ({rate}%)",
     "taxIncluded": "С НДС ({rate}%)",
     "taxIncluded": "С НДС ({rate}%)",
     "taxIncludedNamed": "С {name} ({rate}%)",
     "taxIncludedNamed": "С {name} ({rate}%)",
+    "taxableAmount": "Налогооблагаемая сумма",
     "paymentsReceived": "Полученные платежи",
     "paymentsReceived": "Полученные платежи",
     "paidInFull": "ПОЛНОСТЬЮ ОПЛАЧЕНО",
     "paidInFull": "ПОЛНОСТЬЮ ОПЛАЧЕНО",
     "amountDue": "К оплате",
     "amountDue": "К оплате",
@@ -114,6 +115,7 @@
     "tax": "Налог ({rate}%)",
     "tax": "Налог ({rate}%)",
     "taxIncluded": "С НДС ({rate}%)",
     "taxIncluded": "С НДС ({rate}%)",
     "taxIncludedNamed": "С {name} ({rate}%)",
     "taxIncludedNamed": "С {name} ({rate}%)",
+    "taxableAmount": "Налогооблагаемая сумма",
     "validUntil": "Действительно до: {date}",
     "validUntil": "Действительно до: {date}",
     "attachedDocuments": "Прикреплённые документы",
     "attachedDocuments": "Прикреплённые документы",
     "attached": "{name} (прикреплено)",
     "attached": "{name} (прикреплено)",

+ 1 - 0
messages/ru/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Фиксированная",
     "discountFixed": "Фиксированная",
     "tax": "Налог",
     "tax": "Налог",
     "taxIncluded": "Налог (вкл.)",
     "taxIncluded": "Налог (вкл.)",
+    "taxableAmount": "Налогооблагаемая сумма",
     "total": "Итого",
     "total": "Итого",
     "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС."
     "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС."
   },
   },

+ 17 - 0
messages/ru/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Подитог (с НДС)",
     "subtotalInclTax": "Подитог (с НДС)",
     "discount": "Скидка",
     "discount": "Скидка",
     "discountNone": "Нет",
     "discountNone": "Нет",
+    "discountOff": "Нет",
     "discountPercentage": "Процент",
     "discountPercentage": "Процент",
     "discountFixed": "Фиксированная",
     "discountFixed": "Фиксированная",
     "tax": "Налог",
     "tax": "Налог",
     "taxIncluded": "Налог (вкл.)",
     "taxIncluded": "Налог (вкл.)",
+    "taxableAmount": "Налогооблагаемая сумма",
     "total": "Итого",
     "total": "Итого",
     "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС.",
     "inclusiveModeHint": "Цены позиций вводятся с НДС. Итоги показывают разбивку: без НДС + НДС.",
     "partsCost": "Себестоимость запчастей (внутр.)"
     "partsCost": "Себестоимость запчастей (внутр.)"
   },
   },
+  "lineTax": {
+    "label": "Налог на эту строку",
+    "taxedTag": "НАЛОГ",
+    "untaxedTag": "БЕЗ НАЛОГА",
+    "taxedHint": "На эту строку начисляется налог. Нажмите, чтобы исключить её из налога.",
+    "untaxedHint": "На эту строку налог не начисляется. Нажмите, чтобы начислить."
+  },
+  "taxOffer": {
+    "noTax": "Документ создан без налога. Мастерская теперь начисляет {rate} %.",
+    "otherRate": "В документе ставка {current} %. Мастерская теперь начисляет {rate} %.",
+    "apply": "Применить налог {rate} %",
+    "failed": "Не удалось обновить налог",
+    "saveFirst": "Сначала сохраните изменения, затем примените налог."
+  },
   "notes": {
   "notes": {
     "title": "Заметки",
     "title": "Заметки",
     "public": "Публичные",
     "public": "Публичные",
@@ -227,6 +243,7 @@
     "discount": "Скидка",
     "discount": "Скидка",
     "tax": "Налог ({rate}%)",
     "tax": "Налог ({rate}%)",
     "taxIncluded": "Налог (вкл., {rate}%)",
     "taxIncluded": "Налог (вкл., {rate}%)",
+    "taxableAmount": "Налогооблагаемая сумма",
     "total": "Итого",
     "total": "Итого",
     "paid": "Оплачено",
     "paid": "Оплачено",
     "balanceDue": "К оплате",
     "balanceDue": "К оплате",

+ 7 - 0
messages/ru/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "НДС",
     "labelPlaceholder": "НДС",
     "splitLabel": "Разделить налог на несколько ставок",
     "splitLabel": "Разделить налог на несколько ставок",
     "splitHint": "Для регионов, где в одном счёте начисляется больше одного налога, например GST и QST в Квебеке или CGST и SGST в Индии. Каждый налог печатается отдельной строкой со своим регистрационным номером, а в налоговом отчёте они учитываются раздельно.",
     "splitHint": "Для регионов, где в одном счёте начисляется больше одного налога, например GST и QST в Квебеке или CGST и SGST в Индии. Каждый налог печатается отдельной строкой со своим регистрационным номером, а в налоговом отчёте они учитываются раздельно.",
+    "selectiveLabel": "Облагать налогом только часть начислений",
+    "selectiveHint": "Для мест, где налогом облагаются запчасти, но не работа, или где какой-то сбор остаётся вне налога. Если выключено, налог начисляется на всё.",
+    "appliesTitle": "Начислять налог на",
+    "appliesParts": "Запчасти",
+    "appliesLabor": "Работу",
+    "appliesShopFee": "Сбор мастерской",
+    "appliesHint": "Новые строки начинают с этих ответов, а любую строку можно изменить в заказ-наряде или предложении. Уже созданные заказ-наряды и предложения сохраняют то, что в них есть.",
     "splitIncomplete": "Перед сохранением укажите название и ставку для каждого налога.",
     "splitIncomplete": "Перед сохранением укажите название и ставку для каждого налога.",
     "presetLabel": "Начать с шаблона",
     "presetLabel": "Начать с шаблона",
     "presetPlaceholder": "Выберите регион",
     "presetPlaceholder": "Выберите регион",

+ 2 - 0
messages/tr/pdf.json

@@ -46,6 +46,7 @@
     "tax": "Vergi ({rate}%)",
     "tax": "Vergi ({rate}%)",
     "taxIncluded": "KDV dahil (%{rate})",
     "taxIncluded": "KDV dahil (%{rate})",
     "taxIncludedNamed": "{name} dahil (%{rate})",
     "taxIncludedNamed": "{name} dahil (%{rate})",
+    "taxableAmount": "Vergiye tabi tutar",
     "paymentsReceived": "Alınan Ödemeler",
     "paymentsReceived": "Alınan Ödemeler",
     "paidInFull": "TAMAMEN ÖDENDİ",
     "paidInFull": "TAMAMEN ÖDENDİ",
     "amountDue": "Ödenecek Tutar",
     "amountDue": "Ödenecek Tutar",
@@ -114,6 +115,7 @@
     "tax": "Vergi ({rate}%)",
     "tax": "Vergi ({rate}%)",
     "taxIncluded": "KDV dahil (%{rate})",
     "taxIncluded": "KDV dahil (%{rate})",
     "taxIncludedNamed": "{name} dahil (%{rate})",
     "taxIncludedNamed": "{name} dahil (%{rate})",
+    "taxableAmount": "Vergiye tabi tutar",
     "validUntil": "Geçerlilik tarihi: {date}",
     "validUntil": "Geçerlilik tarihi: {date}",
     "attachedDocuments": "Ekli Belgeler",
     "attachedDocuments": "Ekli Belgeler",
     "attached": "{name} (ekli)",
     "attached": "{name} (ekli)",

+ 1 - 0
messages/tr/quotes.json

@@ -192,6 +192,7 @@
     "discountFixed": "Sabit",
     "discountFixed": "Sabit",
     "tax": "Vergi",
     "tax": "Vergi",
     "taxIncluded": "Vergi (dahil)",
     "taxIncluded": "Vergi (dahil)",
+    "taxableAmount": "Vergiye tabi tutar",
     "total": "Toplam",
     "total": "Toplam",
     "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir."
     "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir."
   },
   },

+ 17 - 0
messages/tr/service.json

@@ -188,14 +188,30 @@
     "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "subtotalInclTax": "Ara Toplam (KDV dahil)",
     "discount": "İndirim",
     "discount": "İndirim",
     "discountNone": "Yok",
     "discountNone": "Yok",
+    "discountOff": "Yok",
     "discountPercentage": "Yüzde",
     "discountPercentage": "Yüzde",
     "discountFixed": "Sabit",
     "discountFixed": "Sabit",
     "tax": "Vergi",
     "tax": "Vergi",
     "taxIncluded": "Vergi (dahil)",
     "taxIncluded": "Vergi (dahil)",
+    "taxableAmount": "Vergiye tabi tutar",
     "total": "Toplam",
     "total": "Toplam",
     "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir.",
     "inclusiveModeHint": "Satır fiyatları KDV dahil girilir. Toplamlar net + vergi dağılımını gösterir.",
     "partsCost": "Parça maliyeti (dahili)"
     "partsCost": "Parça maliyeti (dahili)"
   },
   },
+  "lineTax": {
+    "label": "Bu satırda vergi",
+    "taxedTag": "VERGİ",
+    "untaxedTag": "VERGİSİZ",
+    "taxedHint": "Bu satıra vergi uygulanıyor. Vergi dışında bırakmak için tıklayın.",
+    "untaxedHint": "Bu satıra vergi uygulanmıyor. Vergilendirmek için tıklayın."
+  },
+  "taxOffer": {
+    "noTax": "Bu, vergisiz oluşturuldu. Atölye artık %{rate} uyguluyor.",
+    "otherRate": "Bu, %{current} oranında. Atölye artık %{rate} uyguluyor.",
+    "apply": "%{rate} vergi uygula",
+    "failed": "Vergi güncellenemedi",
+    "saveFirst": "Önce değişikliklerinizi kaydedin, sonra vergiyi uygulayın."
+  },
   "notes": {
   "notes": {
     "title": "Notlar",
     "title": "Notlar",
     "public": "Genel",
     "public": "Genel",
@@ -227,6 +243,7 @@
     "discount": "İndirim",
     "discount": "İndirim",
     "tax": "Vergi ({rate}%)",
     "tax": "Vergi ({rate}%)",
     "taxIncluded": "Vergi (dahil, %{rate})",
     "taxIncluded": "Vergi (dahil, %{rate})",
+    "taxableAmount": "Vergiye tabi tutar",
     "total": "Toplam",
     "total": "Toplam",
     "paid": "Ödendi",
     "paid": "Ödendi",
     "balanceDue": "Kalan Bakiye",
     "balanceDue": "Kalan Bakiye",

+ 7 - 0
messages/tr/settings.json

@@ -1027,6 +1027,13 @@
     "labelPlaceholder": "KDV",
     "labelPlaceholder": "KDV",
     "splitLabel": "Vergiyi birden fazla orana böl",
     "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.",
     "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.",
+    "selectiveLabel": "Yalnızca bazı kalemleri vergilendir",
+    "selectiveHint": "Parçaların vergilendirildiği ama işçiliğin vergilendirilmediği veya bir ücretin vergi dışında kaldığı yerler için. Kapalıyken vergi her şeye uygulanır.",
+    "appliesTitle": "Vergi uygulanacaklar",
+    "appliesParts": "Parçalar",
+    "appliesLabor": "İşçilik",
+    "appliesShopFee": "Atölye ücreti",
+    "appliesHint": "Yeni satırlar bu yanıtlarla başlar ve her satır iş emrinde veya teklifte değiştirilebilir. Daha önce yazılmış iş emirleri ve teklifler sahip olduklarını korur.",
     "splitIncomplete": "Kaydetmeden önce her vergiye bir ad ve oran verin.",
     "splitIncomplete": "Kaydetmeden önce her vergiye bir ad ve oran verin.",
     "presetLabel": "Bir şablondan başla",
     "presetLabel": "Bir şablondan başla",
     "presetPlaceholder": "Bölge seçin",
     "presetPlaceholder": "Bölge seçin",

+ 25 - 0
prisma/migrations/20261013120000_taxable_lines/migration.sql

@@ -0,0 +1,25 @@
+-- Lines say whether the document's tax is charged on them. Every existing
+-- line is taxable, which is what every document so far was totalled as, so
+-- nothing already written changes. The columns are added with a constant
+-- default, which Postgres stores without rewriting the tables.
+
+-- AlterTable
+ALTER TABLE "service_parts" ADD COLUMN     "taxable" BOOLEAN NOT NULL DEFAULT true;
+
+-- AlterTable
+ALTER TABLE "service_labor" ADD COLUMN     "taxable" BOOLEAN NOT NULL DEFAULT true;
+
+-- AlterTable
+ALTER TABLE "quote_parts" ADD COLUMN     "taxable" BOOLEAN NOT NULL DEFAULT true;
+
+-- AlterTable
+ALTER TABLE "quote_labor" ADD COLUMN     "taxable" BOOLEAN NOT NULL DEFAULT true;
+
+-- The sum of a document's taxable lines. Null means all of them, so the
+-- existing rows need no backfill.
+
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "taxableSubtotal" DOUBLE PRECISION;
+
+-- AlterTable
+ALTER TABLE "quotes" ADD COLUMN     "taxableSubtotal" DOUBLE PRECISION;

+ 6 - 0
prisma/schema/quotes.prisma

@@ -11,6 +11,8 @@ model Quote {
   taxInclusive    Boolean   @default(false)
   taxInclusive    Boolean   @default(false)
   /// See ServiceRecord.taxComponents.
   /// See ServiceRecord.taxComponents.
   taxComponents   Json?
   taxComponents   Json?
+  /// See ServiceRecord.taxableSubtotal.
+  taxableSubtotal Float?
   discountType    String?
   discountType    String?
   discountValue   Float     @default(0)
   discountValue   Float     @default(0)
   discountAmount  Float     @default(0)
   discountAmount  Float     @default(0)
@@ -114,6 +116,8 @@ model QuotePart {
   unitPrice     Float   @default(0)
   unitPrice     Float   @default(0)
   total         Float   @default(0)
   total         Float   @default(0)
   excluded      Boolean @default(false)
   excluded      Boolean @default(false)
+  /// Carried into ServicePart on conversion. See there.
+  taxable       Boolean @default(true)
 
 
   /// Optional link to the stocked part this line was picked from. Carried into
   /// Optional link to the stocked part this line was picked from. Carried into
   /// ServicePart on conversion so the job deducts the right inventory item.
   /// ServicePart on conversion so the job deducts the right inventory item.
@@ -134,6 +138,8 @@ model QuoteLabor {
   total       Float   @default(0)
   total       Float   @default(0)
   pricingType String  @default("hourly")
   pricingType String  @default("hourly")
   excluded    Boolean @default(false)
   excluded    Boolean @default(false)
+  /// Carried into ServiceLabor on conversion. See there.
+  taxable     Boolean @default(true)
 
 
   quoteId String
   quoteId String
   quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)
   quote   Quote  @relation(fields: [quoteId], references: [id], onDelete: Cascade)

+ 11 - 0
prisma/schema/service-records.prisma

@@ -29,6 +29,11 @@ model ServiceRecord {
   /// no change. Shape in src/lib/tax-components.ts; read leniently so an old
   /// no change. Shape in src/lib/tax-components.ts; read leniently so an old
   /// row renders on new code.
   /// row renders on new code.
   taxComponents   Json?
   taxComponents   Json?
+  /// The sum of the lines the tax is charged on, before the discount and in
+  /// the same terms as subtotal. Null when every line is taxable, which is
+  /// every document written before a line could be left untaxed. Written
+  /// with the totals by documentTotals; see src/lib/taxable-lines.ts.
+  taxableSubtotal Float?
   totalAmount     Float     @default(0)
   totalAmount     Float     @default(0)
   invoiceNumber   String?
   invoiceNumber   String?
   invoiceDate     DateTime?
   invoiceDate     DateTime?
@@ -305,6 +310,9 @@ model ServicePart {
   unitCost        Float   @default(0)
   unitCost        Float   @default(0)
   markupPercent   Float   @default(0)
   markupPercent   Float   @default(0)
   inventoryPartId String?
   inventoryPartId String?
+  /// Whether the document's tax is charged on this line. Starts as the
+  /// workshop's answer for parts (Settings, Tax) and is then the line's own.
+  taxable         Boolean @default(true)
 
 
   serviceRecordId String
   serviceRecordId String
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
@@ -320,6 +328,9 @@ model ServiceLabor {
   rate        Float  @default(0)
   rate        Float  @default(0)
   total       Float  @default(0)
   total       Float  @default(0)
   pricingType String @default("hourly")
   pricingType String @default("hourly")
+  /// Whether the document's tax is charged on this line. Starts as the
+  /// workshop's answer for labor, or for the shop fee, and is then the line's own.
+  taxable     Boolean @default(true)
 
 
   serviceRecordId String
   serviceRecordId String
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)

+ 5 - 1
src/__tests__/api/tech-labor-live.test.ts

@@ -42,7 +42,11 @@ vi.mock('@/lib/db', () => {
   return {
   return {
     db: {
     db: {
       serviceRecord: { findFirst: vi.fn(async () => state.job) },
       serviceRecord: { findFirst: vi.fn(async () => state.job) },
-      appSetting: { findFirst: vi.fn(async () => ({ value: '900' })) },
+      appSetting: {
+        findFirst: vi.fn(async () => ({ value: '900' })),
+        // The workshop's tax rule for labor; none set, so the line is taxed.
+        findMany: vi.fn(async () => []),
+      },
       $transaction: vi.fn(async (run: (t: typeof tx) => Promise<unknown>) => {
       $transaction: vi.fn(async (run: (t: typeof tx) => Promise<unknown>) => {
         if (state.transactionThrows) throw new Error('rolled back')
         if (state.transactionThrows) throw new Error('rolled back')
         return run(tx)
         return run(tx)

+ 21 - 0
src/__tests__/features/backup/part-restore-fields.test.ts

@@ -48,6 +48,11 @@ function restoreMap(delegate: string): string {
 describe.each([
 describe.each([
   { model: 'ServicePart', delegate: 'servicePart' },
   { model: 'ServicePart', delegate: 'servicePart' },
   { model: 'QuotePart', delegate: 'quotePart' },
   { model: 'QuotePart', delegate: 'quotePart' },
+  // Labor lines too: a restore that dropped pricingType turned a shop fee
+  // into an hour of labor, and one that dropped excluded put a declined
+  // quote line back into the total.
+  { model: 'ServiceLabor', delegate: 'serviceLabor' },
+  { model: 'QuoteLabor', delegate: 'quoteLabor' },
 ])('$model restore', ({ model, delegate }) => {
 ])('$model restore', ({ model, delegate }) => {
   it('carries every schema column, so nothing is dropped on restore', () => {
   it('carries every schema column, so nothing is dropped on restore', () => {
     const map = restoreMap(delegate)
     const map = restoreMap(delegate)
@@ -73,3 +78,19 @@ describe('restore ordering', () => {
     expect(inventory).toBeLessThan(importSource.indexOf('tx.quotePart.createMany'))
     expect(inventory).toBeLessThan(importSource.indexOf('tx.quotePart.createMany'))
   })
   })
 })
 })
+
+describe('which lines were taxed', () => {
+  it('comes back with the document: the flag on every line and the taxable subtotal', () => {
+    // Two documents and four line tables carry it.
+    expect(importSource.match(/taxableSubtotal: storedTaxableSubtotalFromBackup\(/g)).toHaveLength(
+      2
+    )
+    expect(importSource.match(/taxable: [pl]\.taxable !== false/g)).toHaveLength(4)
+  })
+
+  it('reads a backup from before the flag as every line taxed', () => {
+    // `!== false` rather than `|| false`: a missing flag is a taxed line,
+    // which is what every line was when such a backup was taken.
+    expect(importSource).not.toMatch(/taxable: \([pl]\.taxable as boolean\) \|\| false/)
+  })
+})

+ 2 - 0
src/__tests__/features/inspections/link-work-order.test.ts

@@ -8,6 +8,8 @@ const { db, tx } = vi.hoisted(() => {
   const tx = {
   const tx = {
     serviceRecord: { update: vi.fn() },
     serviceRecord: { update: vi.fn() },
     serviceLabor: { createMany: vi.fn() },
     serviceLabor: { createMany: vi.fn() },
+    // The workshop's tax rule for labor; none set, so the lines are taxed.
+    appSetting: { findMany: vi.fn(async () => []) },
     inspection: { create: vi.fn() },
     inspection: { create: vi.fn() },
     inspectionItem: { createMany: vi.fn() },
     inspectionItem: { createMany: vi.fn() },
   }
   }

+ 22 - 0
src/__tests__/features/integrations/fiken-server.test.ts

@@ -189,6 +189,7 @@ const invoice: AccountingInvoice = {
   mileage: 84200,
   mileage: 84200,
   notes: null,
   notes: null,
   subtotal: 300,
   subtotal: 300,
+  taxableSubtotal: null,
   discountType: null,
   discountType: null,
   discountValue: 0,
   discountValue: 0,
   discountAmount: 0,
   discountAmount: 0,
@@ -207,6 +208,7 @@ const invoice: AccountingInvoice = {
       quantity: 2,
       quantity: 2,
       unitPrice: 100,
       unitPrice: 100,
       total: 200,
       total: 200,
+      taxable: true,
     },
     },
     {
     {
       kind: 'part',
       kind: 'part',
@@ -215,6 +217,7 @@ const invoice: AccountingInvoice = {
       quantity: 1,
       quantity: 1,
       unitPrice: 100,
       unitPrice: 100,
       total: 100,
       total: 100,
+      taxable: true,
     },
     },
   ],
   ],
   payments: [],
   payments: [],
@@ -750,6 +753,25 @@ describe('Fiken: what stays out', () => {
     expect(t.calls).toHaveLength(0)
     expect(t.calls).toHaveLength(0)
   })
   })
 
 
+  it('asks the same about one line left out of the VAT, and does not guess', async () => {
+    // 200 of labor with no VAT beside 100 of parts with it: the labor is a
+    // sale without VAT, and how that is booked is the bookkeeper's call.
+    const t = makeCtx({ answer: emptyCompany() })
+    loadInvoice.mockResolvedValue({
+      ...invoice,
+      taxableSubtotal: 100,
+      taxAmount: 25,
+      totalAmount: 325,
+      lines: [{ ...invoice.lines[0], taxable: false }, invoice.lines[1]],
+    })
+    expect((await push(t))?.summary).toBe(
+      'invoice INV-1001 not sent, VAT type for tax-free sales not chosen'
+    )
+    expect(t.logs[0]).toMatchObject({ level: 'warn' })
+    expect(t.logs[0].message).toContain('some of its lines carry no VAT')
+    expect(t.calls).toHaveLength(0)
+  })
+
   it('asks a VAT registered company how to book a sale without VAT, and does not guess', async () => {
   it('asks a VAT registered company how to book a sale without VAT, and does not guess', async () => {
     const t = makeCtx({ answer: emptyCompany() })
     const t = makeCtx({ answer: emptyCompany() })
     loadInvoice.mockResolvedValue({ ...invoice, taxAmount: 0, totalAmount: 300 })
     loadInvoice.mockResolvedValue({ ...invoice, taxAmount: 0, totalAmount: 300 })

+ 3 - 0
src/__tests__/features/integrations/fiken.test.ts

@@ -51,6 +51,7 @@ const invoice: AccountingInvoice = {
   mileage: 84200,
   mileage: 84200,
   notes: null,
   notes: null,
   subtotal: 1500,
   subtotal: 1500,
+  taxableSubtotal: null,
   discountType: null,
   discountType: null,
   discountValue: 0,
   discountValue: 0,
   discountAmount: 0,
   discountAmount: 0,
@@ -69,6 +70,7 @@ const invoice: AccountingInvoice = {
       quantity: 2,
       quantity: 2,
       unitPrice: 500,
       unitPrice: 500,
       total: 1000,
       total: 1000,
+      taxable: true,
     },
     },
     {
     {
       kind: 'part',
       kind: 'part',
@@ -77,6 +79,7 @@ const invoice: AccountingInvoice = {
       quantity: 1,
       quantity: 1,
       unitPrice: 500,
       unitPrice: 500,
       total: 500,
       total: 500,
+      taxable: true,
     },
     },
   ],
   ],
   payments: [],
   payments: [],

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

@@ -183,6 +183,7 @@ const invoice: AccountingInvoice = {
   mileage: 84200,
   mileage: 84200,
   notes: null,
   notes: null,
   subtotal: 300,
   subtotal: 300,
+  taxableSubtotal: null,
   discountType: null,
   discountType: null,
   discountValue: 0,
   discountValue: 0,
   discountAmount: 0,
   discountAmount: 0,
@@ -201,6 +202,7 @@ const invoice: AccountingInvoice = {
       quantity: 2,
       quantity: 2,
       unitPrice: 100,
       unitPrice: 100,
       total: 200,
       total: 200,
+      taxable: true,
     },
     },
     {
     {
       kind: 'part',
       kind: 'part',
@@ -209,6 +211,7 @@ const invoice: AccountingInvoice = {
       quantity: 1,
       quantity: 1,
       unitPrice: 100,
       unitPrice: 100,
       total: 100,
       total: 100,
+      taxable: true,
     },
     },
   ],
   ],
   payments: [],
   payments: [],

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

@@ -41,6 +41,7 @@ const invoice: AccountingInvoice = {
   mileage: 84200,
   mileage: 84200,
   notes: 'Thank you',
   notes: 'Thank you',
   subtotal: 1500,
   subtotal: 1500,
+  taxableSubtotal: null,
   discountType: 'percentage',
   discountType: 'percentage',
   discountValue: 10,
   discountValue: 10,
   discountAmount: 150,
   discountAmount: 150,
@@ -59,6 +60,7 @@ const invoice: AccountingInvoice = {
       quantity: 2,
       quantity: 2,
       unitPrice: 500,
       unitPrice: 500,
       total: 1000,
       total: 1000,
+      taxable: true,
     },
     },
     {
     {
       kind: 'part',
       kind: 'part',
@@ -67,6 +69,7 @@ const invoice: AccountingInvoice = {
       quantity: 2,
       quantity: 2,
       unitPrice: 250,
       unitPrice: 250,
       total: 500,
       total: 500,
+      taxable: true,
     },
     },
   ],
   ],
   payments: [],
   payments: [],
@@ -143,6 +146,7 @@ describe('QuickBooks invoice mapping', () => {
             quantity: 1.5,
             quantity: 1.5,
             unitPrice: 800,
             unitPrice: 800,
             total: 1000,
             total: 1000,
+            taxable: true,
           },
           },
         ],
         ],
       },
       },

+ 4 - 1
src/__tests__/features/settings/shop-fee.test.ts

@@ -77,13 +77,16 @@ describe('the fee on a job', () => {
   it('is the flat amount whatever the job holds', () => {
   it('is the flat amount whatever the job holds', () => {
     const flat = percent({ mode: 'flat', amount: 15 })
     const flat = percent({ mode: 'flat', amount: 15 })
     expect(shopFeeAmount(flat, { labor: 1000, parts: 500 })).toBe(15)
     expect(shopFeeAmount(flat, { labor: 1000, parts: 500 })).toBe(15)
-    expect(newShopFeeLine(flat)).toEqual({
+    expect(newShopFeeLine(flat, { labor: 0, parts: 0 }, true)).toEqual({
       description: 'Shop supplies',
       description: 'Shop supplies',
       hours: 1,
       hours: 1,
       rate: 15,
       rate: 15,
       total: 15,
       total: 15,
       pricingType: 'shopFee',
       pricingType: 'shopFee',
+      taxable: true,
     })
     })
+    // The workshop's answer for the fee is the line's own from the start.
+    expect(newShopFeeLine(flat, { labor: 0, parts: 0 }, false).taxable).toBe(false)
   })
   })
 })
 })
 
 

+ 268 - 0
src/__tests__/features/tax/taxable-lines-backfill.test.ts

@@ -0,0 +1,268 @@
+/**
+ * The paths that write a job or re-price one without anyone at an editor:
+ * an invoice generated from a recurring template, and the one-off switch of
+ * every stored document between tax-exclusive and tax-inclusive prices.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+vi.mock('@/lib/document-lock.server', () => ({
+  getDocumentLockSettings: vi.fn().mockResolvedValue({}),
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    recurringInvoice: { findMany: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), findMany: vi.fn() },
+    quote: { findMany: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { processRecurringInvoices } from '@/features/billing/Actions/recurringInvoiceActions'
+import {
+  convertRecordsToExclusive,
+  convertRecordsToInclusive,
+} from '@/features/settings/Actions/applyTaxRateToExisting'
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { recurringTaxableSubtotal } from '@/lib/cron/recurring-invoices'
+import { calculateTotals } from '@/lib/tax'
+
+function settings(rows: { key: string; value: string }[]) {
+  vi.mocked(db.appSetting.findMany).mockImplementation((async (args: {
+    where?: { key?: { in?: string[] } }
+  }) => {
+    const wanted = args?.where?.key?.in
+    return wanted ? rows.filter((r) => wanted.includes(r.key)) : rows
+  }) as never)
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'a@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org-1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+  settings([])
+})
+
+describe('an invoice generated from a recurring template', () => {
+  const template = {
+    id: 'ri-1',
+    title: 'Fleet service',
+    description: null,
+    frequency: 'monthly',
+    nextRunDate: new Date('2026-10-01T00:00:00Z'),
+    endDate: null,
+    type: 'maintenance',
+    cost: 0,
+    taxRate: 8,
+    taxInclusive: false,
+    taxComponents: null,
+    invoiceNotes: null,
+    vehicleId: 'veh-1',
+    vehicle: { organizationId: 'org-1' },
+    templateParts: [{ name: 'Oil', partNumber: null, quantity: 2, unitPrice: 100 }],
+    templateLabor: [{ description: 'Service', hours: 3, rate: 100, pricingType: 'hourly' }],
+  }
+
+  function generate() {
+    vi.mocked(db.recurringInvoice.findMany).mockResolvedValue([template] as never)
+    const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
+      id: 'sr-1',
+      ...data,
+    }))
+    vi.mocked(db.$transaction).mockImplementation((async (run: (tx: unknown) => unknown) =>
+      run({
+        serviceRecord: { create },
+        recurringInvoice: { update: vi.fn() },
+      })) as never)
+    return processRecurringInvoices().then((result) => ({
+      result,
+      data: create.mock.calls[0]?.[0].data as {
+        taxAmount: number
+        totalAmount: number
+        taxableSubtotal: number | null
+        partItems: { create: { taxable: boolean }[] }
+        laborItems: { create: { taxable: boolean }[] }
+      },
+    }))
+  }
+
+  it('is taxed in full where the workshop taxes everything', async () => {
+    const { result, data } = await generate()
+    expect(result.success).toBe(true)
+    expect(data).toMatchObject({ taxableSubtotal: null, taxAmount: 40, totalAmount: 540 })
+    expect(data.partItems.create.map((p) => p.taxable)).toEqual([true])
+    expect(data.laborItems.create.map((l) => l.taxable)).toEqual([true])
+  })
+
+  it('follows the workshop rule on the day it is generated', async () => {
+    settings([
+      { key: 'workshop.taxSelective', value: 'true' },
+      { key: 'workshop.taxAppliesToLabor', value: 'false' },
+    ])
+    const { data } = await generate()
+    expect(data).toMatchObject({ taxableSubtotal: 200, taxAmount: 16, totalAmount: 516 })
+    expect(data.partItems.create.map((p) => p.taxable)).toEqual([true])
+    expect(data.laborItems.create.map((l) => l.taxable)).toEqual([false])
+  })
+})
+
+describe('a recurring template that carries a shop fee', () => {
+  const template = {
+    cost: 0,
+    templateParts: [{ quantity: 2, unitPrice: 100 }],
+    templateLabor: [
+      { hours: 3, rate: 100, pricingType: 'hourly' },
+      { hours: 1, rate: 20, pricingType: 'shopFee' },
+    ],
+  }
+
+  it('taxes the fee by the rule for the fee, not the rule for labor', () => {
+    // Labor untaxed, fee taxed: 200 of parts and the 20 fee.
+    expect(recurringTaxableSubtotal(template, { parts: true, labor: false, shopFee: true })).toBe(
+      220
+    )
+    // The other way round: 200 of parts and the 300 of labor.
+    expect(recurringTaxableSubtotal(template, { parts: true, labor: true, shopFee: false })).toBe(
+      500
+    )
+    expect(recurringTaxableSubtotal(template, { parts: false, labor: false, shopFee: false })).toBe(
+      0
+    )
+  })
+
+  it('counts the template cost and every line when everything is taxed', () => {
+    expect(
+      recurringTaxableSubtotal(
+        { ...template, cost: 50 },
+        { parts: true, labor: true, shopFee: true }
+      )
+    ).toBe(570)
+  })
+})
+
+describe('switching every stored document between tax modes', () => {
+  /** A job with 200 of taxed parts, 300 of untaxed labor, 10% off, at 8%. */
+  const record = (taxInclusive: boolean) => {
+    const parts = taxInclusive ? 216 : 200
+    const subtotal = parts + 300
+    const discountAmount = subtotal / 10
+    return {
+      id: 'sr-1',
+      subtotal,
+      taxableSubtotal: parts,
+      discountType: 'percentage',
+      discountValue: 10,
+      discountAmount,
+      taxRate: 8,
+      taxComponents: null,
+      partItems: [{ id: 'p1', unitPrice: parts, total: parts, taxable: true }],
+      laborItems: [{ id: 'l1', rate: 100, total: 300, taxable: false }],
+    }
+  }
+
+  function run(action: () => Promise<{ success: boolean }>, rows: unknown[]) {
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue(rows as never)
+    vi.mocked(db.quote.findMany).mockResolvedValue([] as never)
+    const written = {
+      record: vi.fn(async () => ({})),
+      part: vi.fn(async () => ({})),
+      labor: vi.fn(async () => ({})),
+    }
+    vi.mocked(db.$transaction).mockImplementation((async (go: (tx: unknown) => unknown) =>
+      go({
+        serviceRecord: { update: written.record },
+        servicePart: { update: written.part },
+        serviceLabor: { update: written.labor },
+        quote: { update: vi.fn() },
+        quotePart: { update: vi.fn() },
+        quoteLabor: { update: vi.fn() },
+      })) as never)
+    return action().then((result) => ({ result, written }))
+  }
+
+  const totalBefore = (taxInclusive: boolean) => {
+    const r = record(taxInclusive)
+    return calculateTotals({
+      subtotal: r.subtotal,
+      discountAmount: r.discountAmount,
+      taxRate: 8,
+      taxInclusive,
+      taxableSubtotal: r.taxableSubtotal,
+    })
+  }
+
+  it('re-prices the taxed line, leaves the untaxed one, and keeps the total', async () => {
+    const { result, written } = await run(convertRecordsToInclusive, [record(false)])
+    expect(result.success).toBe(true)
+
+    // The parts gain their tax; the labor is not touched at all.
+    expect(written.part).toHaveBeenCalledTimes(1)
+    const part = (written.part.mock.calls[0] as unknown as [{ data: { total: number } }])[0]
+    expect(part.data.total).toBeCloseTo(216, 10)
+    expect(written.labor).not.toHaveBeenCalled()
+
+    const data = (
+      written.record.mock.calls[0] as unknown as [{ data: Record<string, number | boolean> }]
+    )[0].data
+    expect(data.taxInclusive).toBe(true)
+    expect(data.subtotal).toBeCloseTo(516, 10)
+    expect(data.taxableSubtotal).toBe(216)
+    // What the customer pays, and the tax inside it, are where they were.
+    const before = totalBefore(false)
+    expect(data.totalAmount).toBe(before.totalAmount)
+    expect(data.taxAmount).toBe(before.taxAmount)
+  })
+
+  it('does the same on the way back', async () => {
+    const { written } = await run(convertRecordsToExclusive, [record(true)])
+    const part = (written.part.mock.calls[0] as unknown as [{ data: { total: number } }])[0]
+    expect(part.data.total).toBeCloseTo(200, 10)
+    expect(written.labor).not.toHaveBeenCalled()
+
+    const data = (
+      written.record.mock.calls[0] as unknown as [{ data: Record<string, number | boolean> }]
+    )[0].data
+    expect(data.taxInclusive).toBe(false)
+    expect(data.subtotal).toBeCloseTo(500, 10)
+    expect(data.taxableSubtotal).toBe(200)
+    const before = totalBefore(true)
+    expect(data.totalAmount).toBe(before.totalAmount)
+    expect(data.taxAmount).toBe(before.taxAmount)
+  })
+
+  it('re-prices every line of a fully taxed job, as it always did', async () => {
+    const all = {
+      ...record(false),
+      taxableSubtotal: null,
+      laborItems: [{ id: 'l1', rate: 100, total: 300, taxable: true }],
+    }
+    const { written } = await run(convertRecordsToInclusive, [all])
+    expect(written.part).toHaveBeenCalledTimes(1)
+    expect(written.labor).toHaveBeenCalledTimes(1)
+    const data = (
+      written.record.mock.calls[0] as unknown as [{ data: Record<string, number | null> }]
+    )[0].data
+    expect(data.subtotal).toBe(500 * 1.08)
+    expect(data.taxableSubtotal).toBeNull()
+  })
+})

+ 665 - 0
src/__tests__/features/tax/taxable-lines-documents.test.ts

@@ -0,0 +1,665 @@
+/**
+ * What a document with untaxed lines looks like once it leaves the editor:
+ * on the printed invoice and quote, in QuickBooks, and in Fiken.
+ *
+ * And the two small pieces the save paths stand on: reading only what an
+ * update was sent, and the server's word on a saved document's tax.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import type { AccountingInvoice } from '@/features/integrations/Lib/accounting-sync'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { buildQuotePrintSpec } from '@/features/invoice-designer/Pdf/buildQuotePrint'
+import { lineGroupsFor } from '@/features/invoice-designer/Pdf/lineGroups'
+import { printFigures } from '@/features/invoice-designer/Pdf/printFigures'
+import { taxLines } from '@/features/invoice-designer/Pdf/taxLines'
+import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+import { updateQuoteSchema } from '@/features/quotes/Schema/quoteSchema'
+import {
+  readLineTaxSettings,
+  readTaxApplies,
+  readWorkshopTax,
+  savedDocumentTax,
+} from '@/features/settings/Lib/workshopTax'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+import { updateServiceSchema } from '@/features/vehicles/Schema/serviceSchema'
+import { buildSale, type SaleOptions } from '@/integrations/fiken/mapping'
+import { buildInvoice } from '@/integrations/quickbooks/mapping'
+import { parseSentFields } from '@/lib/parse-sent-fields'
+
+/* -------------------------------------------------------------------------- */
+
+describe('reading only what an update was sent', () => {
+  it('shows what the defaults did: a bare parse invents every field', () => {
+    // The reason parseSentFields exists. If this ever stops being true the
+    // helper is harmless, but the comment on it is out of date.
+    const bare = updateServiceSchema.parse({ id: 'sr-1' }) as Record<string, unknown>
+    expect(bare.status).toBe('pending')
+    expect(bare.subtotal).toBe(0)
+  })
+
+  it('returns the id and nothing else when nothing else was sent', () => {
+    expect(parseSentFields(updateServiceSchema, { id: 'sr-1' })).toEqual({ id: 'sr-1' })
+    expect(parseSentFields(updateQuoteSchema, { id: 'q-1' })).toEqual({ id: 'q-1' })
+  })
+
+  it('keeps what was sent, parsed and coerced as the schema says', () => {
+    const parsed = parseSentFields(updateServiceSchema, {
+      id: 'sr-1',
+      subtotal: '500',
+      taxRate: 8,
+      laborItems: [{ description: 'Fit', hours: '2', rate: 100, total: 200 }],
+    })
+    expect(parsed).toEqual({
+      id: 'sr-1',
+      subtotal: 500,
+      taxRate: 8,
+      // Inside a line that was sent the defaults still apply: it is a whole line.
+      laborItems: [{ description: 'Fit', hours: 2, rate: 100, total: 200, pricingType: 'hourly' }],
+    })
+  })
+
+  it('treats a field sent as undefined as not sent', () => {
+    expect(parseSentFields(updateServiceSchema, { id: 'sr-1', status: undefined })).toEqual({
+      id: 'sr-1',
+    })
+  })
+
+  it('keeps a value that was sent even when it equals the default', () => {
+    expect(
+      parseSentFields(updateServiceSchema, { id: 'sr-1', subtotal: 0, status: 'pending' })
+    ).toEqual({ id: 'sr-1', subtotal: 0, status: 'pending' })
+  })
+
+  it('still refuses what the schema refuses', () => {
+    expect(() => parseSentFields(updateServiceSchema, { id: 'sr-1', subtotal: -1 })).toThrow()
+    expect(() => parseSentFields(updateServiceSchema, {})).toThrow()
+  })
+
+  it('leaves a schema without defaults as it parses', () => {
+    const schema = z.object({ a: z.string(), b: z.number().optional() })
+    expect(parseSentFields(schema, { a: 'x' })).toEqual({ a: 'x' })
+  })
+})
+
+/* -------------------------------------------------------------------------- */
+
+describe('the workshop rule in settings', () => {
+  it('taxes everything unless the workshop taxes only some charges', () => {
+    expect(readTaxApplies({})).toEqual({ parts: true, labor: true, shopFee: true })
+    expect(readTaxApplies({ 'workshop.taxAppliesToLabor': 'false' })).toEqual({
+      parts: true,
+      labor: true,
+      shopFee: true,
+    })
+    expect(
+      readTaxApplies({ 'workshop.taxSelective': 'true', 'workshop.taxAppliesToLabor': 'false' })
+    ).toEqual({ parts: true, labor: false, shopFee: true })
+  })
+
+  it('tells the editors whether to offer the choice on each line', () => {
+    expect(readLineTaxSettings({}).selective).toBe(false)
+    expect(readLineTaxSettings({ 'workshop.taxSelective': 'true' })).toEqual({
+      selective: true,
+      applies: { parts: true, labor: true, shopFee: true },
+    })
+  })
+
+  it('carries the rule with the rest of the workshop tax', () => {
+    const tax = readWorkshopTax({
+      'workshop.defaultTaxRate': '8',
+      'workshop.taxSelective': 'true',
+      'workshop.taxAppliesToShopFee': 'false',
+    })
+    expect(tax.rate).toBe(8)
+    expect(tax.applies).toEqual({ parts: true, labor: true, shopFee: false })
+  })
+})
+
+describe("the server's word on a saved document's tax", () => {
+  const base = {
+    subtotal: 500,
+    discountAmount: 0,
+    taxRate: 8,
+    taxInclusive: false,
+    taxComponents: null,
+  }
+
+  it('lets the caller figures stand while every line is taxed and the tax is one rate', () => {
+    expect(
+      savedDocumentTax({
+        ...base,
+        parts: [{ total: 200, taxable: true }],
+        labor: [{ total: 300, taxable: true }],
+      })
+    ).toEqual({ taxableSubtotal: null, totals: null })
+  })
+
+  it('states the totals itself once a line is left out', () => {
+    expect(
+      savedDocumentTax({
+        ...base,
+        parts: [{ total: 200, taxable: true }],
+        labor: [{ total: 300, taxable: false }],
+      })
+    ).toEqual({
+      taxableSubtotal: 200,
+      totals: { taxAmount: 16, totalAmount: 516, taxComponents: undefined },
+    })
+  })
+
+  it('does not count an untaxed line of nothing as a reason to restate', () => {
+    expect(
+      savedDocumentTax({
+        ...base,
+        parts: [{ total: 500, taxable: true }],
+        labor: [{ total: 0, taxable: false }],
+      }).totals
+    ).toBeNull()
+  })
+
+  it('skips the lines a customer declined', () => {
+    expect(
+      savedDocumentTax({
+        ...base,
+        parts: [
+          { total: 200, taxable: true },
+          { total: 400, taxable: true, excluded: true },
+        ],
+        labor: [{ total: 300, taxable: false }],
+      }).taxableSubtotal
+    ).toBe(200)
+  })
+
+  it('splits the tax on the taxable part when the document has components', () => {
+    const result = savedDocumentTax({
+      ...base,
+      taxRate: 14.975,
+      taxComponents: [
+        { name: 'GST', rate: 5, amount: 0 },
+        { name: 'QST', rate: 9.975, amount: 0 },
+      ],
+      parts: [{ total: 200, taxable: true }],
+      labor: [{ total: 300, taxable: false }],
+    })
+    expect(result.taxableSubtotal).toBe(200)
+    expect(result.totals).toEqual({
+      taxAmount: 29.95,
+      totalAmount: 529.95,
+      taxComponents: [
+        { name: 'GST', rate: 5, amount: 10 },
+        { name: 'QST', rate: 9.975, amount: 19.95 },
+      ],
+    })
+  })
+})
+
+/* -------------------------------------------------------------------------- */
+
+const invoice = {
+  id: 'svc_1',
+  title: 'Brakes',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-09-10'),
+  invoiceDate: new Date('2026-09-10'),
+  invoiceDueDate: null,
+  shopName: 'Main Street Auto',
+  techName: null,
+  mileage: null,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 500,
+  taxRate: 8,
+  taxAmount: 16,
+  taxInclusive: false,
+  taxComponents: null,
+  taxableSubtotal: 200,
+  totalAmount: 516,
+  cost: 516,
+  invoiceNumber: 'INV-1',
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  partItems: [
+    { partNumber: 'P1', name: 'Pads', quantity: 1, unitPrice: 200, total: 200, taxable: true },
+  ],
+  laborItems: [{ description: 'Fit', hours: 3, rate: 100, total: 300, taxable: false }],
+  customFields: [],
+  findings: [],
+  customer: null,
+  vehicle: null,
+} as unknown as InvoiceData
+
+const labels = {
+  subtotal: 'Subtotal',
+  subtotalInclTax: 'Subtotal (incl. tax)',
+  tax: 'Tax ({rate}%)',
+  taxIncluded: 'Includes tax ({rate}%)',
+  taxableAmount: 'Taxable amount',
+  total: 'Total',
+  parts: 'Parts',
+  labor: 'Labor',
+}
+
+function textOf(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
+}
+
+/** The amount printed on the row after a label, as the totals box lays it out. */
+function amountAfter(text: string[], label: string): string | undefined {
+  const at = text.indexOf(label)
+  return at === -1 ? undefined : text[at + 1]
+}
+
+const settings = { currencyCode: 'USD', currencyFormat: 'code' as const }
+
+describe('a printed invoice with untaxed labor', () => {
+  it('says what the tax was charged on, above the tax', () => {
+    const text = textOf(buildInvoicePrintSpec({ data: invoice, labels, invoiceSettings: settings }))
+    expect(text).toContain('Taxable amount')
+    expect(amountAfter(text, 'Taxable amount')).toContain('200.00')
+    expect(amountAfter(text, 'Tax (8%)')).toContain('16.00')
+    expect(amountAfter(text, 'Subtotal')).toContain('500.00')
+    expect(amountAfter(text, 'Total')).toContain('516.00')
+    expect(text.indexOf('Taxable amount')).toBeLessThan(text.indexOf('Tax (8%)'))
+  })
+
+  it('does not print the row on an invoice whose every line is taxed', () => {
+    const all = {
+      ...invoice,
+      taxableSubtotal: null,
+      taxAmount: 40,
+      totalAmount: 540,
+      laborItems: [{ description: 'Fit', hours: 3, rate: 100, total: 300, taxable: true }],
+    } as unknown as InvoiceData
+    const text = textOf(buildInvoicePrintSpec({ data: all, labels, invoiceSettings: settings }))
+    expect(text).not.toContain('Taxable amount')
+    expect(amountAfter(text, 'Tax (8%)')).toContain('40.00')
+  })
+
+  it('does not print the row for lines that predate the flag', () => {
+    // Stored before a line could be untaxed: no flag at all means taxed.
+    const legacy = {
+      ...invoice,
+      taxableSubtotal: undefined,
+      taxAmount: 40,
+      totalAmount: 540,
+      partItems: [{ partNumber: 'P1', name: 'Pads', quantity: 1, unitPrice: 200, total: 200 }],
+      laborItems: [{ description: 'Fit', hours: 3, rate: 100, total: 300 }],
+    } as unknown as InvoiceData
+    const text = textOf(buildInvoicePrintSpec({ data: legacy, labels, invoiceSettings: settings }))
+    expect(text).not.toContain('Taxable amount')
+  })
+
+  it('adds tax to the taxed line only when lines are printed with tax in them', () => {
+    const text = textOf(
+      buildInvoicePrintSpec({
+        data: invoice,
+        labels,
+        invoiceSettings: { ...settings, lineItemsInclTax: true },
+      })
+    )
+    // The pads at 200 read 216 with their tax; the labor stays 300.
+    expect(text.some((s) => s.includes('216.00'))).toBe(true)
+    expect(text.some((s) => s.includes('300.00'))).toBe(true)
+    expect(text.some((s) => s.includes('324.00'))).toBe(false)
+    // And the subtotal is their sum, which is the total the customer pays.
+    expect(amountAfter(text, 'Subtotal (incl. tax)')).toContain('516.00')
+    expect(amountAfter(text, 'Total')).toContain('516.00')
+  })
+
+  it('takes tax out of the taxed line only when prices were entered with it', () => {
+    const inclusive = {
+      ...invoice,
+      subtotal: 516,
+      taxableSubtotal: 216,
+      taxInclusive: true,
+      partItems: [
+        { partNumber: 'P1', name: 'Pads', quantity: 1, unitPrice: 216, total: 216, taxable: true },
+      ],
+    } as unknown as InvoiceData
+    const text = textOf(
+      buildInvoicePrintSpec({ data: inclusive, labels, invoiceSettings: settings })
+    )
+    expect(amountAfter(text, 'Subtotal')).toContain('500.00')
+    expect(amountAfter(text, 'Taxable amount')).toContain('200.00')
+    expect(amountAfter(text, 'Tax (8%)')).toContain('16.00')
+    expect(amountAfter(text, 'Total')).toContain('516.00')
+    expect(text.some((s) => s.includes('277.78'))).toBe(false)
+  })
+})
+
+describe('a printed quote with untaxed labor', () => {
+  const quote = {
+    id: 'q1',
+    quoteNumber: 'QT-1',
+    title: 'Brakes',
+    description: null,
+    status: 'draft',
+    validUntil: null,
+    createdAt: new Date('2026-09-10'),
+    subtotal: 500,
+    taxRate: 8,
+    taxAmount: 16,
+    taxInclusive: false,
+    taxComponents: null,
+    discountType: null,
+    discountValue: 0,
+    discountAmount: 0,
+    totalAmount: 516,
+    notes: null,
+    partItems: [
+      { partNumber: 'P1', name: 'Pads', quantity: 1, unitPrice: 200, total: 200, taxable: true },
+      {
+        partNumber: 'P2',
+        name: 'Rotors',
+        quantity: 1,
+        unitPrice: 400,
+        total: 400,
+        taxable: true,
+        excluded: true,
+      },
+    ],
+    laborItems: [{ description: 'Fit', hours: 3, rate: 100, total: 300, taxable: false }],
+    customer: null,
+    vehicle: null,
+  }
+
+  it('works the tax out from the lines the customer kept, and only the taxed ones', () => {
+    const text = textOf(
+      buildQuotePrintSpec({
+        data: quote as never,
+        labels,
+        currencyCode: 'USD',
+        currencyFormat: 'code',
+      })
+    )
+    expect(amountAfter(text, 'Subtotal')).toContain('500.00')
+    expect(amountAfter(text, 'Taxable amount')).toContain('200.00')
+    expect(amountAfter(text, 'Tax (8%)')).toContain('16.00')
+    expect(amountAfter(text, 'Total')).toContain('516.00')
+  })
+})
+
+describe('the pieces the sheets share', () => {
+  it('converts each sum in its taxable part only', () => {
+    const figures = printFigures({
+      taxRate: 25,
+      taxInclusive: true,
+      linesInclTax: false,
+      parts: [{ total: 125 }, { total: 50, taxable: false }],
+      labor: [
+        { total: 300, taxable: false },
+        { total: 999, excluded: true },
+      ],
+    })
+    expect(figures.partsTotal).toBe(175)
+    expect(figures.laborTotal).toBe(300)
+    expect(figures.partsShown).toBe(150)
+    expect(figures.laborShown).toBe(300)
+    expect(figures.taxableSubtotal).toBe(125)
+    expect(figures.subtotalShown(475)).toBe(450)
+    expect(figures.shown(125)).toBe(100)
+    expect(figures.shown(50, false)).toBe(50)
+    expect(figures.taxableAmount(475, 0)).toBe(100)
+  })
+
+  it('has nothing to say about a taxable amount when every line is taxed, or none is', () => {
+    const all = printFigures({
+      taxRate: 25,
+      taxInclusive: false,
+      linesInclTax: false,
+      parts: [{ total: 100 }],
+      labor: [{ total: 300, taxable: null }],
+    })
+    expect(all.taxableSubtotal).toBeNull()
+    expect(all.taxableAmount(400, 0)).toBeNull()
+    const noRate = printFigures({
+      taxRate: 0,
+      taxInclusive: false,
+      linesInclTax: false,
+      parts: [{ total: 100 }],
+      labor: [{ total: 300, taxable: false }],
+    })
+    expect(noRate.taxableAmount(400, 0)).toBeNull()
+  })
+
+  it('puts the taxable amount above a single tax and above a split one', () => {
+    const money = (value: number) => value.toFixed(2)
+    expect(
+      taxLines({
+        taxRate: 8,
+        taxAmount: 16,
+        components: null,
+        linesInclTax: false,
+        labels,
+        money,
+        taxableAmount: 200,
+      }).map((line) => [line.label, line.value])
+    ).toEqual([
+      ['Taxable amount', '200.00'],
+      ['Tax (8%)', '16.00'],
+    ])
+    expect(
+      taxLines({
+        taxRate: 14.975,
+        taxAmount: 29.95,
+        components: [
+          { name: 'GST', rate: 5, amount: 10 },
+          { name: 'QST', rate: 9.975, amount: 19.95 },
+        ],
+        linesInclTax: false,
+        labels,
+        money,
+        taxableAmount: 200,
+      }).map((line) => line.label)
+    ).toEqual(['Taxable amount', 'GST (5%)', 'QST (9.975%)'])
+    // Without it, the rows every document printed before.
+    expect(
+      taxLines({ taxRate: 8, taxAmount: 40, components: null, linesInclTax: false, labels, money })
+    ).toHaveLength(1)
+  })
+
+  it('adds a group up line by line, so a mixed group is right', () => {
+    const display = (amount: number, taxable = true) => (taxable ? amount * 1.25 : amount)
+    const groups = lineGroupsFor({
+      parts: [
+        { category: 'Brakes', total: 100 },
+        { category: 'Brakes', total: 40, taxable: false },
+      ],
+      labor: [{ total: 300, taxable: false }],
+      labels: {},
+      shown: display,
+      money: (amount) => amount.toFixed(2),
+    })
+    expect(groups?.map((g) => [g.key, g.subtotal])).toEqual([
+      ['labor', '300.00'],
+      ['category:Brakes', '165.00'],
+    ])
+  })
+})
+
+/* -------------------------------------------------------------------------- */
+
+const accounting: AccountingInvoice = {
+  id: 'svc1',
+  vehicleId: 'veh1',
+  invoiceNumber: 'INV-1',
+  status: 'completed',
+  issuedAt: new Date('2026-09-04T10:00:00Z'),
+  invoiceDate: new Date('2026-09-04T10:00:00Z'),
+  serviceDate: new Date('2026-09-02T09:00:00Z'),
+  dueDate: null,
+  mileage: null,
+  notes: null,
+  subtotal: 500,
+  taxableSubtotal: 200,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 8,
+  taxAmount: 16,
+  taxInclusive: false,
+  totalAmount: 516,
+  manuallyPaid: false,
+  customer: null,
+  vehicle: null,
+  lines: [
+    {
+      kind: 'labor',
+      description: 'Fit pads',
+      partNumber: null,
+      quantity: 3,
+      unitPrice: 100,
+      total: 300,
+      taxable: false,
+    },
+    {
+      kind: 'part',
+      description: 'Brake pads',
+      partNumber: 'BP-100',
+      quantity: 1,
+      unitPrice: 200,
+      total: 200,
+      taxable: true,
+    },
+  ],
+  payments: [],
+}
+
+describe('QuickBooks, for an invoice with untaxed labor', () => {
+  const options = {
+    customerRef: '58',
+    customerEmail: null,
+    laborItemId: '7',
+    partsItemId: '8',
+    taxCodeId: 'TAX',
+    zeroTaxCodeId: 'NON',
+    globalTax: false,
+    currency: null,
+    timezone: 'America/New_York',
+    url: 'https://shop.example.com/x',
+    taxExempt: false,
+    includeVehicle: false,
+    customTxnNumbers: true,
+    txnTaxDetail: null,
+  }
+  type QboLine = { Amount: number; SalesItemLineDetail?: { TaxCodeRef?: { value: string } } }
+  const codes = (body: Record<string, unknown>) =>
+    (body.Line as QboLine[]).map((line) => line.SalesItemLineDetail?.TaxCodeRef?.value)
+
+  it('books the labor under the non-taxable code and the parts under the taxable one', () => {
+    expect(codes(buildInvoice(accounting, options))).toEqual(['NON', 'TAX'])
+  })
+
+  it('books every line as non-taxable for an exempt customer', () => {
+    expect(codes(buildInvoice(accounting, { ...options, taxExempt: true }))).toEqual(['NON', 'NON'])
+  })
+
+  it('takes the tax out of the taxed line only, for a US company with inclusive prices', () => {
+    const inclusive: AccountingInvoice = {
+      ...accounting,
+      subtotal: 516,
+      taxableSubtotal: 216,
+      taxInclusive: true,
+      discountType: 'fixed',
+      discountValue: 51.6,
+      discountAmount: 51.6,
+      lines: [accounting.lines[0], { ...accounting.lines[1], unitPrice: 216, total: 216 }],
+    }
+    const lines = buildInvoice(inclusive, options).Line as QboLine[]
+    expect(lines[0].Amount).toBe(300)
+    expect(lines[1].Amount).toBe(200)
+    // A tenth off: 50 of the 500 net, not 51.60 and not 47.78.
+    expect(lines[2].Amount).toBe(50)
+  })
+
+  it('is unchanged for an invoice whose every line is taxed', () => {
+    const all: AccountingInvoice = {
+      ...accounting,
+      taxableSubtotal: null,
+      lines: accounting.lines.map((line) => ({ ...line, taxable: true })),
+    }
+    expect(codes(buildInvoice(all, options))).toEqual(['TAX', 'TAX'])
+  })
+})
+
+describe('Fiken, for an invoice with untaxed labor', () => {
+  const options: SaleOptions = {
+    customerId: 501,
+    laborAccount: '3000',
+    partsAccount: '3010',
+    zeroAccount: '3100',
+    vatType: 'HIGH',
+    zeroVatType: 'NONE',
+    timezone: 'Europe/Oslo',
+  }
+  type Line = { netPrice: number; vat?: number; account: string; vatType: string }
+  const linesOf = (body: Record<string, unknown>) => body.lines as Line[]
+
+  it('puts the VAT on the taxed line alone, and files the other as a sale without VAT', () => {
+    const lines = linesOf(buildSale(accounting, options))
+    expect(lines).toEqual([
+      expect.objectContaining({ netPrice: 30000, account: '3100', vatType: 'NONE' }),
+      expect.objectContaining({ netPrice: 20000, vat: 1600, account: '3010', vatType: 'HIGH' }),
+    ])
+    expect(lines[0].vat).toBeUndefined()
+  })
+
+  it('keeps the sale to the øre with a discount shared over both lines', () => {
+    const discounted: AccountingInvoice = {
+      ...accounting,
+      discountType: 'percentage',
+      discountValue: 10,
+      discountAmount: 50,
+      taxAmount: 14.4,
+      totalAmount: 464.4,
+    }
+    const lines = linesOf(buildSale(discounted, options))
+    expect(lines.map((l) => l.netPrice)).toEqual([27000, 18000])
+    expect(lines.map((l) => l.vat ?? 0)).toEqual([0, 1440])
+    const gross = lines.reduce((sum, l) => sum + l.netPrice + (l.vat ?? 0), 0)
+    expect(gross).toBe(46440)
+  })
+
+  it('finds the VAT inside the taxed line when prices include it', () => {
+    const inclusive: AccountingInvoice = {
+      ...accounting,
+      subtotal: 516,
+      taxableSubtotal: 216,
+      taxInclusive: true,
+      lines: [accounting.lines[0], { ...accounting.lines[1], unitPrice: 216, total: 216 }],
+    }
+    const lines = linesOf(buildSale(inclusive, options))
+    expect(lines.map((l) => l.netPrice)).toEqual([30000, 20000])
+    expect(lines.map((l) => l.vat ?? 0)).toEqual([0, 1600])
+  })
+
+  it('is unchanged for an invoice whose every line is taxed', () => {
+    const all: AccountingInvoice = {
+      ...accounting,
+      taxableSubtotal: null,
+      taxAmount: 40,
+      totalAmount: 540,
+      lines: accounting.lines.map((line) => ({ ...line, taxable: true })),
+    }
+    const lines = linesOf(buildSale(all, options))
+    expect(lines.map((l) => [l.netPrice, l.vat, l.account, l.vatType])).toEqual([
+      [30000, 2400, '3000', 'HIGH'],
+      [20000, 1600, '3010', 'HIGH'],
+    ])
+  })
+})

+ 374 - 0
src/__tests__/features/tax/taxable-lines-editor.test.tsx

@@ -0,0 +1,374 @@
+/**
+ * The editor's half of untaxed lines: the totals a work order shows while it
+ * is typed, the switch on each line, and the row that says what the tax was
+ * charged on.
+ *
+ * The server has the last word on what is stored (see the save tests), but
+ * the screen has to agree with it before the save, or the desk reads one
+ * total and the invoice prints another.
+ */
+
+import { fireEvent, render, renderHook, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { LineTaxProvider } from '@/features/settings/Components/LineTaxContext'
+import { LineTaxToggle } from '@/features/settings/Components/LineTaxToggle'
+import { InvoiceSummary } from '@/features/vehicles/Components/service-detail/InvoiceSummary'
+import { LaborEditor } from '@/features/vehicles/Components/service-edit/LaborEditor'
+import { PartsEditor } from '@/features/vehicles/Components/service-edit/PartsEditor'
+import { TotalsSection } from '@/features/vehicles/Components/service-edit/TotalsSection'
+import { useServiceFormState } from '@/features/vehicles/Components/service-page/useServiceFormState'
+import { useQuoteFormState } from '@/features/quotes/Components/useQuoteFormState'
+import type { ServiceLaborInput, ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
+import type { TaxApplies } from '@/lib/taxable-lines'
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
+}))
+vi.mock('@/components/glass-modal', () => ({ useGlassModal: () => ({ open: vi.fn() }) }))
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => vi.fn() }))
+
+const PARTS_ONLY: TaxApplies = { parts: true, labor: false, shopFee: false }
+const ALL: TaxApplies = { parts: true, labor: true, shopFee: true }
+
+const PART = {
+  name: 'Pads',
+  quantity: 1,
+  unitPrice: 200,
+  total: 200,
+  unitCost: 0,
+  markupPercent: 0,
+}
+const LABOR = { description: 'Fit', hours: 3, rate: 100, total: 300, pricingType: 'hourly' }
+const FEE = { description: 'Supplies', hours: 1, rate: 20, total: 20, pricingType: 'shopFee' }
+
+function workOrder(opts: {
+  taxApplies?: TaxApplies
+  partItems?: unknown[]
+  laborItems?: unknown[]
+  taxInclusive?: boolean
+  discountType?: string
+  discountValue?: number
+}) {
+  return renderHook(() =>
+    useServiceFormState({
+      vehicleId: null,
+      initialData: {
+        id: 'rec-1',
+        title: 'Brakes',
+        description: '',
+        type: 'repair',
+        status: 'pending',
+        serviceDate: '2026-01-01',
+        concerns: [],
+        partItems: opts.partItems ?? [PART],
+        laborItems: opts.laborItems ?? [LABOR],
+        taxRate: 8,
+        taxInclusive: opts.taxInclusive ?? false,
+        discountType: opts.discountType,
+        discountValue: opts.discountValue,
+      } as never,
+      defaultTaxRate: 8,
+      currentUserName: 'Tester',
+      record: { id: 'rec-1', payments: [], manuallyPaid: false, attachments: [] } as never,
+      taxApplies: opts.taxApplies,
+    })
+  ).result.current
+}
+
+describe('the totals a work order shows while it is typed', () => {
+  it('taxes everything when the workshop has no rule', () => {
+    const form = workOrder({})
+    expect(form.taxAmount).toBe(40)
+    expect(form.totalAmount).toBe(540)
+    expect(form.taxableShare).toBeNull()
+  })
+
+  it('taxes the parts alone where the workshop taxes only parts', () => {
+    const form = workOrder({ taxApplies: PARTS_ONLY, laborItems: [LABOR, FEE] })
+    expect(form.subtotal).toBe(520)
+    expect(form.taxAmount).toBe(16)
+    expect(form.totalAmount).toBe(536)
+    expect(form.taxableShare).toEqual({
+      parts: 200,
+      labor: 0,
+      subtotal: 200,
+      taxableAmount: 200,
+    })
+  })
+
+  it('follows a choice made on a line over the workshop rule', () => {
+    const form = workOrder({
+      taxApplies: PARTS_ONLY,
+      partItems: [{ ...PART, taxable: false }],
+      laborItems: [{ ...LABOR, taxable: true }],
+    })
+    expect(form.taxAmount).toBe(24)
+    expect(form.totalAmount).toBe(524)
+    expect(form.taxableShare).toMatchObject({ parts: 0, labor: 300, subtotal: 300 })
+  })
+
+  it('shares a discount over taxed and untaxed lines', () => {
+    const form = workOrder({
+      taxApplies: PARTS_ONLY,
+      discountType: 'percentage',
+      discountValue: 10,
+    })
+    expect(form.discountAmount).toBe(50)
+    expect(form.taxAmount).toBe(14.4)
+    expect(form.totalAmount).toBe(464.4)
+    expect(form.taxableShare?.taxableAmount).toBe(180)
+  })
+
+  it('agrees with the server about a job saved with a stored choice', () => {
+    // The same job the save test stores: 8% of 200, total 516.
+    const form = workOrder({
+      taxApplies: ALL,
+      laborItems: [{ ...LABOR, taxable: false }],
+    })
+    expect(form.taxAmount).toBe(16)
+    expect(form.totalAmount).toBe(516)
+  })
+})
+
+describe('the totals a quote shows while it is typed', () => {
+  const quote = (lines: { partItems: unknown[]; laborItems: unknown[] }, taxApplies?: TaxApplies) =>
+    renderHook(() =>
+      useQuoteFormState({
+        quote: {
+          id: 'q-1',
+          status: 'draft',
+          taxRate: 8,
+          taxInclusive: false,
+          customer: null,
+          vehicle: null,
+          attachments: [],
+          ...lines,
+        } as never,
+        currencyCode: 'USD',
+        defaultTaxRate: 8,
+        taxEnabled: true,
+        defaultLaborRate: 100,
+        taxApplies,
+        t: (key: string) => key,
+      })
+    ).result.current
+
+  it('leaves out of the tax both the untaxed lines and the declined ones', () => {
+    const state = quote(
+      {
+        partItems: [
+          { id: 'p1', ...PART, partNumber: null },
+          { id: 'p2', ...PART, partNumber: null, name: 'Rotors', total: 400, excluded: true },
+        ],
+        laborItems: [{ id: 'l1', ...LABOR }],
+      },
+      PARTS_ONLY
+    )
+    expect(state.subtotal).toBe(500)
+    expect(state.taxAmount).toBe(16)
+    expect(state.totalAmount).toBe(516)
+    expect(state.taxableShare).toMatchObject({ parts: 200, labor: 0, subtotal: 200 })
+  })
+
+  it('keeps the choice stored on a line when the quote is opened', () => {
+    const state = quote({
+      partItems: [{ id: 'p1', ...PART, partNumber: null }],
+      laborItems: [{ id: 'l1', ...LABOR, taxable: false }],
+    })
+    expect(state.laborItems[0].taxable).toBe(false)
+    expect(state.partItems[0].taxable).toBeUndefined()
+    expect(state.taxAmount).toBe(16)
+  })
+
+  it('taxes everything when the workshop has no rule', () => {
+    const state = quote({
+      partItems: [{ id: 'p1', ...PART, partNumber: null }],
+      laborItems: [{ id: 'l1', ...LABOR }],
+    })
+    expect(state.taxAmount).toBe(40)
+    expect(state.taxableShare).toBeNull()
+  })
+})
+
+describe('the switch on a line', () => {
+  const toggle = (selective: boolean, taxable: boolean, onToggle = vi.fn()) =>
+    render(
+      <LineTaxProvider value={{ selective, applies: ALL }}>
+        <LineTaxToggle taxable={taxable} onToggle={onToggle} />
+      </LineTaxProvider>
+    )
+
+  it('is not there at all for a workshop that taxes everything', () => {
+    toggle(false, true)
+    expect(screen.queryByTestId('line-tax-toggle')).toBeNull()
+  })
+
+  it('is there on every line for a workshop that taxes only some charges', () => {
+    toggle(true, true)
+    const button = screen.getByTestId('line-tax-toggle')
+    expect(button).toHaveTextContent('TAX')
+    expect(button).toHaveAttribute('aria-checked', 'true')
+  })
+
+  it('stays visible on an untaxed line after the setting is switched off', () => {
+    // Otherwise a line left out of the tax could not be seen or undone.
+    toggle(false, false)
+    const button = screen.getByTestId('line-tax-toggle')
+    expect(button).toHaveTextContent('NO TAX')
+    expect(button).toHaveAttribute('aria-checked', 'false')
+  })
+
+  it('asks for the flip when clicked', () => {
+    const onToggle = vi.fn()
+    toggle(true, true, onToggle)
+    fireEvent.click(screen.getByTestId('line-tax-toggle'))
+    expect(onToggle).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('the line editors', () => {
+  function Labor({ initial, applies }: { initial: ServiceLaborInput[]; applies: TaxApplies }) {
+    const [items, setItems] = useState(initial)
+    return (
+      <TooltipProvider>
+        <LineTaxProvider value={{ selective: true, applies }}>
+          <LaborEditor
+            laborItems={items}
+            setLaborItems={setItems}
+            updateLabor={vi.fn()}
+            laborSubtotal={0}
+            currencyCode="USD"
+            defaultLaborRate={100}
+          />
+          <output data-testid="flags">{JSON.stringify(items.map((item) => item.taxable))}</output>
+        </LineTaxProvider>
+      </TooltipProvider>
+    )
+  }
+
+  function Parts({ initial, applies }: { initial: ServicePartInput[]; applies: TaxApplies }) {
+    const [items, setItems] = useState(initial)
+    return (
+      <TooltipProvider>
+        <LineTaxProvider value={{ selective: true, applies }}>
+          <PartsEditor
+            partItems={items}
+            setPartItems={setItems}
+            updatePart={vi.fn()}
+            partsSubtotal={0}
+            currencyCode="USD"
+            hasInventory={false}
+            onOpenInventory={vi.fn()}
+          />
+          <output data-testid="flags">{JSON.stringify(items.map((item) => item.taxable))}</output>
+        </LineTaxProvider>
+      </TooltipProvider>
+    )
+  }
+
+  it('shows a labor line as untaxed where the workshop does not tax labor, and flips it', () => {
+    render(<Labor initial={[LABOR as ServiceLaborInput]} applies={PARTS_ONLY} />)
+    const button = screen.getByTestId('line-tax-toggle')
+    expect(button).toHaveAttribute('data-taxable', 'false')
+    // Nothing chosen yet: the line follows the rule.
+    expect(screen.getByTestId('flags')).toHaveTextContent('[null]')
+
+    fireEvent.click(button)
+    expect(screen.getByTestId('flags')).toHaveTextContent('[true]')
+    expect(screen.getByTestId('line-tax-toggle')).toHaveAttribute('data-taxable', 'true')
+
+    fireEvent.click(screen.getByTestId('line-tax-toggle'))
+    expect(screen.getByTestId('flags')).toHaveTextContent('[false]')
+  })
+
+  it('shows a shop fee by the rule for the fee, not the rule for labor', () => {
+    render(
+      <Labor
+        initial={[LABOR as ServiceLaborInput, FEE as ServiceLaborInput]}
+        applies={{ parts: true, labor: false, shopFee: true }}
+      />
+    )
+    expect(
+      screen.getAllByTestId('line-tax-toggle').map((el) => el.getAttribute('data-taxable'))
+    ).toEqual(['false', 'true'])
+  })
+
+  it('shows a part as taxed where the workshop taxes parts, and flips it', () => {
+    render(<Parts initial={[PART as ServicePartInput]} applies={PARTS_ONLY} />)
+    const button = screen.getByTestId('line-tax-toggle')
+    expect(button).toHaveAttribute('data-taxable', 'true')
+
+    fireEvent.click(button)
+    expect(screen.getByTestId('flags')).toHaveTextContent('[false]')
+    expect(screen.getByTestId('line-tax-toggle')).toHaveAttribute('data-taxable', 'false')
+  })
+})
+
+describe('the row that says what the tax was charged on', () => {
+  const totals = (taxableShare: Parameters<typeof TotalsSection>[0]['taxableShare']) =>
+    render(
+      <TotalsSection
+        partsSubtotal={200}
+        partsCostSubtotal={0}
+        laborSubtotal={300}
+        subtotal={500}
+        discountType="none"
+        setDiscountType={vi.fn()}
+        discountValue={0}
+        setDiscountValue={vi.fn()}
+        discountAmount={0}
+        taxEnabled
+        taxRate={8}
+        setTaxRate={vi.fn()}
+        taxAmount={taxableShare ? 16 : 40}
+        taxInclusive={false}
+        taxableShare={taxableShare}
+        totalAmount={taxableShare ? 516 : 540}
+        currencyCode="USD"
+      />
+    )
+
+  it('is on the totals card when a line is left out of the tax', () => {
+    totals({ parts: 200, labor: 0, subtotal: 200, taxableAmount: 200 })
+    const row = screen.getByTestId('taxable-amount-row')
+    expect(row).toHaveTextContent('Taxable amount')
+    expect(row).toHaveTextContent('200.00')
+  })
+
+  it('is not there when every line is taxed', () => {
+    totals(null)
+    expect(screen.queryByTestId('taxable-amount-row')).toBeNull()
+  })
+
+  it('is on the invoice summary too, and the summary takes tax out of taxed lines only', () => {
+    render(
+      <InvoiceSummary
+        hasPartItems
+        hasLaborItems
+        // Prices entered with tax in them: 216 of taxed parts, 300 of untaxed labor.
+        partsSubtotal={216}
+        laborSubtotal={300}
+        subtotal={516}
+        discountAmount={0}
+        discountType={null}
+        discountValue={0}
+        taxRate={8}
+        taxAmount={16}
+        taxInclusive
+        taxableShare={{ parts: 216, labor: 0, subtotal: 216, taxableAmount: 200 }}
+        displayTotal={516}
+        totalPaid={0}
+        balanceDue={516}
+        hasPayments={false}
+        currencyCode="USD"
+      />
+    )
+    expect(screen.getByTestId('taxable-amount-row')).toHaveTextContent('200.00')
+    // Labor reads 300, not 277.78: there was never any tax in it.
+    expect(screen.getByText('Labor').parentElement).toHaveTextContent('300.00')
+    expect(screen.getByText('Parts').parentElement).toHaveTextContent('200.00')
+    expect(screen.getByText('Subtotal').parentElement).toHaveTextContent('500.00')
+  })
+})

+ 314 - 0
src/__tests__/features/tax/taxable-lines-retotal.test.ts

@@ -0,0 +1,314 @@
+/**
+ * @vitest-environment node
+ *
+ * Lines that reach a job from outside the editor: a part or a labor line
+ * booked from the technician app, and anything that re-totals a job from
+ * its stored lines.
+ *
+ * None of these paths is told whether the line is taxed, so each has to ask
+ * the workshop's rule, and each has to total the job on its taxable lines
+ * alone. A fake database holding real rows stands in for Prisma here, so
+ * the test reads the job back the way the next page load would.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+type Row = Record<string, unknown> & { serviceRecordId: string; total: number; taxable?: boolean }
+
+const store = {
+  settings: [] as { key: string; value: string }[],
+  record: {} as Record<string, unknown>,
+  parts: [] as Row[],
+  labor: [] as Row[],
+}
+
+function table(rows: () => Row[]) {
+  const matches = (where: Record<string, unknown> = {}) =>
+    rows().filter((row) => Object.entries(where).every(([key, value]) => row[key] === value))
+  return {
+    aggregate: vi.fn(async ({ where }: { where?: Record<string, unknown> }) => ({
+      _sum: { total: matches(where).reduce((sum, row) => sum + row.total, 0) || null },
+    })),
+    findMany: vi.fn(async ({ where }: { where?: Record<string, unknown> } = {}) => matches(where)),
+    create: vi.fn(async ({ data }: { data: Row }) => {
+      // The column's own default, which is what a path that forgets to say gets.
+      const row = { id: `row-${rows().length + 1}`, taxable: true, ...data }
+      rows().push(row)
+      return row
+    }),
+    update: vi.fn(async ({ where, data }: { where: { id: string }; data: Partial<Row> }) => {
+      const row = rows().find((r) => r.id === where.id)
+      if (row) Object.assign(row, data)
+      return row
+    }),
+  }
+}
+
+vi.mock('@/lib/db', () => {
+  const db: Record<string, unknown> = {
+    appSetting: {
+      findMany: vi.fn(async ({ where }: { where?: { key?: { in?: string[] } } } = {}) => {
+        const wanted = where?.key?.in
+        return wanted ? store.settings.filter((s) => wanted.includes(s.key)) : store.settings
+      }),
+      findFirst: vi.fn(
+        async ({ where }: { where: { key: string } }) =>
+          store.settings.find((s) => s.key === where.key) ?? null
+      ),
+    },
+    serviceRecord: {
+      findFirst: vi.fn(async () => store.record),
+      findUnique: vi.fn(async () => store.record),
+      update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
+        Object.assign(store.record, data)
+        return store.record
+      }),
+    },
+    servicePart: table(() => store.parts),
+    serviceLabor: table(() => store.labor),
+  }
+  db.$transaction = vi.fn(async (run: (tx: unknown) => unknown) => run(db))
+  return { db }
+})
+vi.mock('@/lib/document-lock.server', () => ({
+  assertInvoiceEditable: vi.fn(async () => undefined),
+}))
+vi.mock('@/features/inventory/Lib/reconcileStock', () => ({
+  reconcileInventoryForParts: vi.fn(async () => undefined),
+}))
+vi.mock('@/features/vehicles/Lib/jobEvents', () => ({ announceLaborAdded: vi.fn() }))
+vi.mock('@/lib/with-api-auth', async () => {
+  const actual = await vi.importActual<typeof import('@/lib/with-api-auth')>('@/lib/with-api-auth')
+  return {
+    ...actual,
+    withApiAuth: vi.fn(
+      async (
+        _request: Request,
+        handler: (ctx: {
+          organizationId: string
+          technicianIds: string[]
+          isAdmin: boolean
+        }) => Promise<Response>
+      ) => handler({ organizationId: 'org-1', technicianIds: ['tech-1'], isAdmin: true })
+    ),
+  }
+})
+
+import { POST as postLabor } from '@/app/api/v1/tech/jobs/[id]/labor/route'
+import { loadTaxApplies } from '@/features/settings/Lib/loadTaxApplies'
+import { addPart } from '@/features/vehicles/Lib/addPart'
+import { serviceRecordLineSums } from '@/features/vehicles/Lib/lineSums'
+import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
+import { db } from '@/lib/db'
+
+const PARTS_ONLY = [
+  { key: 'workshop.taxSelective', value: 'true' },
+  { key: 'workshop.taxAppliesToLabor', value: 'false' },
+  { key: 'workshop.taxAppliesToShopFee', value: 'false' },
+  { key: 'workshop.defaultLaborRate', value: '100' },
+]
+
+beforeEach(() => {
+  store.settings = []
+  store.parts = []
+  store.labor = []
+  store.record = {
+    id: 'sr-1',
+    organizationId: 'org-1',
+    vehicleId: 'veh-1',
+    title: 'Brakes',
+    invoiceNumber: 'INV-1',
+    subtotal: 0,
+    taxRate: 8,
+    taxAmount: 0,
+    taxInclusive: false,
+    taxComponents: null,
+    taxableSubtotal: null,
+    totalAmount: 0,
+    discountType: null,
+    discountValue: 0,
+  }
+})
+
+const part = (total: number, taxable = true): Row => ({
+  id: `p-${total}`,
+  serviceRecordId: 'sr-1',
+  total,
+  taxable,
+})
+const labor = (total: number, taxable = true, pricingType = 'hourly'): Row => ({
+  id: `l-${total}`,
+  serviceRecordId: 'sr-1',
+  total,
+  taxable,
+  pricingType,
+  hours: 1,
+  rate: total,
+})
+
+describe('the workshop rule, read from settings', () => {
+  it('taxes everything when nothing is set', async () => {
+    expect(await loadTaxApplies('org-1')).toEqual({ parts: true, labor: true, shopFee: true })
+  })
+
+  it('reads the three answers when the workshop taxes only some charges', async () => {
+    store.settings = PARTS_ONLY
+    expect(await loadTaxApplies('org-1')).toEqual({ parts: true, labor: false, shopFee: false })
+  })
+
+  it('taxes everything again when that is switched off, whatever the answers say', async () => {
+    store.settings = PARTS_ONLY.map((s) =>
+      s.key === 'workshop.taxSelective' ? { ...s, value: 'false' } : s
+    )
+    expect(await loadTaxApplies('org-1')).toEqual({ parts: true, labor: true, shopFee: true })
+  })
+})
+
+describe('re-totalling a job from its stored lines', () => {
+  it('adds up the lines and the taxable part of them', async () => {
+    store.parts = [part(200), part(10, false)]
+    store.labor = [labor(300, false), labor(50, true)]
+    expect(await serviceRecordLineSums(db, 'sr-1')).toEqual({
+      subtotal: 560,
+      taxableSubtotal: 250,
+    })
+  })
+
+  it('charges the tax on the taxable lines and stores what it was charged on', async () => {
+    store.parts = [part(200)]
+    store.labor = [labor(300, false)]
+    await retotalServiceRecord('sr-1')
+    expect(store.record).toMatchObject({
+      subtotal: 500,
+      taxableSubtotal: 200,
+      taxAmount: 16,
+      totalAmount: 516,
+    })
+  })
+
+  it('stores no taxable subtotal for a job whose every line is taxed', async () => {
+    store.record.taxableSubtotal = 200
+    store.parts = [part(200)]
+    store.labor = [labor(300)]
+    await retotalServiceRecord('sr-1')
+    expect(store.record).toMatchObject({
+      subtotal: 500,
+      taxableSubtotal: null,
+      taxAmount: 40,
+      totalAmount: 540,
+    })
+  })
+
+  it('shares a percentage discount over taxed and untaxed lines', async () => {
+    store.record.discountType = 'percentage'
+    store.record.discountValue = 10
+    store.parts = [part(200)]
+    store.labor = [labor(300, false)]
+    await retotalServiceRecord('sr-1')
+    expect(store.record).toMatchObject({
+      discountAmount: 50,
+      taxableSubtotal: 200,
+      taxAmount: 14.4,
+      totalAmount: 464.4,
+    })
+  })
+})
+
+describe('a labor line billed from the technician app', () => {
+  const post = (body: unknown) =>
+    postLabor(
+      new Request('https://app.test/api/v1/tech/jobs/sr-1/labor', {
+        method: 'POST',
+        body: JSON.stringify(body),
+      }),
+      { params: Promise.resolve({ id: 'sr-1' }) }
+    )
+
+  it('is left out of the tax where the workshop does not tax labor', async () => {
+    store.settings = PARTS_ONLY
+    store.parts = [part(200)]
+
+    const response = await post({ description: 'Fit pads', hours: 3 })
+
+    expect(response.status).toBe(201)
+    expect(store.labor).toHaveLength(1)
+    expect(store.labor[0]).toMatchObject({ total: 300, taxable: false })
+    // The parts are taxed, the three hours are not.
+    expect(store.record).toMatchObject({
+      subtotal: 500,
+      taxableSubtotal: 200,
+      taxAmount: 16,
+      totalAmount: 516,
+    })
+  })
+
+  it('is taxed like everything else where the workshop taxes everything', async () => {
+    store.settings = [{ key: 'workshop.defaultLaborRate', value: '100' }]
+    store.parts = [part(200)]
+
+    await post({ description: 'Fit pads', hours: 3 })
+
+    expect(store.labor[0]).toMatchObject({ taxable: true })
+    expect(store.record).toMatchObject({
+      subtotal: 500,
+      taxableSubtotal: null,
+      taxAmount: 40,
+      totalAmount: 540,
+    })
+  })
+})
+
+describe('a part booked onto a job', () => {
+  const input = {
+    serviceRecordId: 'sr-1',
+    name: 'Brake pads',
+    quantity: 1,
+    unitPrice: 200,
+    total: 200,
+    unitCost: 120,
+  }
+
+  it('is taxed where the workshop taxes parts, beside labor that is not', async () => {
+    store.settings = PARTS_ONLY
+    store.labor = [labor(300, false)]
+
+    await addPart({ organizationId: 'org-1', userId: 'user-1', input })
+
+    expect(store.parts[0]).toMatchObject({ taxable: true })
+    expect(store.record).toMatchObject({
+      subtotal: 500,
+      taxableSubtotal: 200,
+      taxAmount: 16,
+      totalAmount: 516,
+    })
+  })
+
+  it('is left out of the tax where the workshop does not tax parts', async () => {
+    store.settings = [
+      { key: 'workshop.taxSelective', value: 'true' },
+      { key: 'workshop.taxAppliesToParts', value: 'false' },
+    ]
+    store.labor = [labor(300)]
+
+    await addPart({ organizationId: 'org-1', userId: 'user-1', input })
+
+    expect(store.parts[0]).toMatchObject({ taxable: false })
+    expect(store.record).toMatchObject({
+      subtotal: 500,
+      taxableSubtotal: 300,
+      taxAmount: 24,
+      totalAmount: 524,
+    })
+  })
+
+  it('takes the caller at its word when it says', async () => {
+    store.settings = PARTS_ONLY
+    await addPart({
+      organizationId: 'org-1',
+      userId: 'user-1',
+      input: { ...input, taxable: false },
+    })
+    expect(store.parts[0]).toMatchObject({ taxable: false })
+    expect(store.record).toMatchObject({ taxableSubtotal: 0, taxAmount: 0, totalAmount: 200 })
+  })
+})

+ 545 - 0
src/__tests__/features/tax/taxable-lines-save.test.ts

@@ -0,0 +1,545 @@
+/**
+ * Saving a work order or a quote whose lines are not all taxed.
+ *
+ * The editors total a document as it is typed and send their figures with
+ * the lines. These tests pin what the server does with them: every stored
+ * line carries a plain yes or no, the tax is the server's own whenever a
+ * line is left out of it, and a document whose every line is taxed is
+ * stored exactly as it was sent, which is how it has always worked.
+ *
+ * They also pin the update that sends two dates and nothing else, which
+ * used to reset the job's status and write its totals as zero.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('server-only', () => ({}))
+vi.mock('@/lib/workshop-timezone', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/workshop-timezone')>()),
+  workshopTimeZone: async () => 'UTC',
+}))
+vi.mock('@/lib/resolve-upload-path', () => ({ resolveUploadPath: vi.fn((u: string) => u) }))
+vi.mock('@/lib/features', () => ({
+  getFeatures: vi.fn().mockResolvedValue({}),
+  requireFeature: vi.fn().mockResolvedValue(undefined),
+  FeatureGatedError: class FeatureGatedError extends Error {},
+}))
+vi.mock('@/lib/notification-bus', () => ({ notificationBus: { emit: vi.fn() } }))
+vi.mock('@/lib/document-lock.server', () => ({
+  assertQuoteEditable: vi.fn().mockResolvedValue(undefined),
+  assertInvoiceEditable: vi.fn().mockResolvedValue(undefined),
+  getDocumentLockSettings: vi.fn().mockResolvedValue({}),
+}))
+vi.mock('@/features/inventory/Lib/reconcileStock', () => ({
+  reconcileInventoryForParts: vi.fn().mockResolvedValue(undefined),
+}))
+vi.mock('@/features/inventory/Lib/onInventoryChanged', () => ({
+  onInventoryChanged: vi.fn().mockResolvedValue(undefined),
+}))
+vi.mock('@/lib/files/manager', () => ({ releaseFiles: vi.fn().mockResolvedValue(undefined) }))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn(), updateMany: vi.fn() },
+    organization: { findUnique: vi.fn() },
+    vehicle: { findFirst: vi.fn(), update: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    inspection: { findFirst: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn() },
+    servicePart: { findMany: vi.fn() },
+    serviceLabor: { findMany: vi.fn() },
+    quote: { findFirst: vi.fn() },
+    quotePart: { findMany: vi.fn() },
+    quoteLabor: { findMany: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { createQuote, updateQuote } from '@/features/quotes/Actions/quoteActions'
+import {
+  createServiceRecord,
+  updateServiceRecord,
+} from '@/features/vehicles/Actions/serviceActions'
+
+const ORG = 'org-1'
+
+/** Settings for a workshop that taxes parts at 8% and nothing else. */
+const PARTS_ONLY = [
+  { key: 'workshop.taxEnabled', value: 'true' },
+  { key: 'workshop.defaultTaxRate', value: '8' },
+  { key: 'workshop.taxSelective', value: 'true' },
+  { key: 'workshop.taxAppliesToParts', value: 'true' },
+  { key: 'workshop.taxAppliesToLabor', value: 'false' },
+  { key: 'workshop.taxAppliesToShopFee', value: 'false' },
+]
+
+/** The same three answers with the switch above them off: they decide nothing. */
+const SWITCHED_OFF = PARTS_ONLY.map((s) =>
+  s.key === 'workshop.taxSelective' ? { ...s, value: 'false' } : s
+)
+
+function settings(rows: { key: string; value: string }[]) {
+  vi.mocked(db.appSetting.findMany).mockImplementation((async (args: {
+    where?: { key?: { in?: string[] } }
+  }) => {
+    const wanted = args?.where?.key?.in
+    return wanted ? rows.filter((r) => wanted.includes(r.key)) : rows
+  }) as never)
+}
+
+/** A transaction client that records what each table was told to write. */
+function transaction(extra: Record<string, unknown> = {}) {
+  const written = {
+    record: vi.fn(async (args: { data: Record<string, unknown> }) => ({
+      id: 'sr-1',
+      invoiceNumber: 'INV-1',
+      title: 'Job',
+      status: 'pending',
+      ...args.data,
+    })),
+    parts: vi.fn().mockResolvedValue({ count: 0 }),
+    labor: vi.fn().mockResolvedValue({ count: 0 }),
+  }
+  const tx = {
+    serviceRecord: { create: written.record, update: written.record },
+    quote: { create: written.record, update: written.record },
+    servicePart: {
+      createMany: written.parts,
+      deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+      findMany: vi.fn().mockResolvedValue([]),
+    },
+    serviceLabor: {
+      createMany: written.labor,
+      deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+    },
+    quotePart: { createMany: written.parts, deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
+    quoteLabor: { createMany: written.labor, deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
+    serviceConcern: { createMany: vi.fn(), findMany: vi.fn().mockResolvedValue([]) },
+    serviceAttachment: { createMany: vi.fn(), deleteMany: vi.fn() },
+    inventoryPart: { findFirst: vi.fn().mockResolvedValue(null) },
+    ...extra,
+  }
+  vi.mocked(db.$transaction).mockImplementation((async (fn: (client: typeof tx) => unknown) =>
+    fn(tx)) as never)
+  return {
+    data: () => written.record.mock.calls[0][0].data,
+    parts: () =>
+      (written.parts.mock.calls[0]?.[0].data ?? []) as { name: string; taxable: boolean }[],
+    labor: () =>
+      (written.labor.mock.calls[0]?.[0].data ?? []) as {
+        description: string
+        taxable: boolean
+      }[],
+  }
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'a@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as never)
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+    id: 'veh-1',
+    mileage: 1,
+    customer: { taxExempt: false },
+  } as never)
+  vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+  vi.mocked(db.quote.findFirst).mockResolvedValue(null)
+  settings([])
+})
+
+const PART = { name: 'Brake pads', quantity: 1, unitPrice: 200, total: 200 }
+const LABOR = { description: 'Fit pads', hours: 3, rate: 100, total: 300, pricingType: 'hourly' }
+const FEE = { description: 'Shop supplies', hours: 1, rate: 20, total: 20, pricingType: 'shopFee' }
+
+/* -------------------------------------------------------------------------- */
+
+describe('creating a work order', () => {
+  it('stores it exactly as sent while every line is taxed', async () => {
+    const tx = transaction()
+    const result = await createServiceRecord({
+      vehicleId: 'veh-1',
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR],
+      subtotal: 500,
+      taxRate: 8,
+      // Deliberately not what the server would compute: with nothing left out
+      // of the tax the editor's figures stand, as they always have.
+      taxAmount: 40.01,
+      totalAmount: 540.01,
+    })
+    expect(result.success).toBe(true)
+    expect(tx.data()).toMatchObject({
+      subtotal: 500,
+      taxAmount: 40.01,
+      totalAmount: 540.01,
+      taxableSubtotal: null,
+    })
+    expect(tx.parts().map((p) => p.taxable)).toEqual([true])
+    expect(tx.labor().map((l) => l.taxable)).toEqual([true])
+  })
+
+  it('leaves labor and the shop fee out of the tax where the workshop says so', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    const result = await createServiceRecord({
+      vehicleId: 'veh-1',
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR, FEE],
+      subtotal: 520,
+      taxRate: 8,
+      // What a client that knows nothing of untaxed lines would send.
+      taxAmount: 41.6,
+      totalAmount: 561.6,
+    })
+    expect(result.success).toBe(true)
+    expect(tx.parts().map((p) => p.taxable)).toEqual([true])
+    expect(tx.labor().map((l) => l.taxable)).toEqual([false, false])
+    // 8% of the 200 of parts, and nothing else.
+    expect(tx.data()).toMatchObject({
+      subtotal: 520,
+      taxableSubtotal: 200,
+      taxAmount: 16,
+      totalAmount: 536,
+    })
+  })
+
+  it('takes a line at its word over the workshop rule', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    await createServiceRecord({
+      vehicleId: 'veh-1',
+      title: 'Brakes',
+      // A disposal fee among the parts, untaxed; fabrication labor, taxed.
+      partItems: [
+        PART,
+        { name: 'Disposal', quantity: 1, unitPrice: 10, total: 10, taxable: false },
+      ],
+      laborItems: [LABOR, { ...LABOR, description: 'Fabrication', total: 100, taxable: true }],
+      subtotal: 610,
+      taxRate: 8,
+      taxAmount: 0,
+      totalAmount: 610,
+    })
+    expect(tx.parts().map((p) => p.taxable)).toEqual([true, false])
+    expect(tx.labor().map((l) => l.taxable)).toEqual([false, true])
+    // 200 of pads and 100 of fabrication.
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 300, taxAmount: 24, totalAmount: 634 })
+  })
+
+  it('shares the discount over taxed and untaxed lines', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    await createServiceRecord({
+      vehicleId: 'veh-1',
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR],
+      subtotal: 500,
+      discountType: 'percentage',
+      discountValue: 10,
+      discountAmount: 50,
+      taxRate: 8,
+      taxAmount: 36,
+      totalAmount: 486,
+    })
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 200, taxAmount: 14.4, totalAmount: 464.4 })
+  })
+
+  it('ignores the three answers while the switch above them is off', async () => {
+    settings(SWITCHED_OFF)
+    const tx = transaction()
+    await createServiceRecord({
+      vehicleId: 'veh-1',
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR],
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 40,
+      totalAmount: 540,
+    })
+    expect(tx.labor().map((l) => l.taxable)).toEqual([true])
+    expect(tx.data()).toMatchObject({ taxableSubtotal: null, taxAmount: 40, totalAmount: 540 })
+  })
+
+  it('charges an exempt customer nothing, whatever the lines say', async () => {
+    settings(PARTS_ONLY)
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({
+      id: 'veh-1',
+      mileage: 1,
+      customer: { taxExempt: true },
+    } as never)
+    const tx = transaction()
+    await createServiceRecord({
+      vehicleId: 'veh-1',
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR],
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 16,
+      totalAmount: 516,
+    })
+    expect(tx.data()).toMatchObject({ taxRate: 0, taxAmount: 0, totalAmount: 500 })
+  })
+})
+
+/* -------------------------------------------------------------------------- */
+
+describe('saving an existing work order', () => {
+  const existing = {
+    id: 'sr-1',
+    organizationId: ORG,
+    vehicleId: 'veh-1',
+    customerId: null,
+    title: 'Brakes',
+    status: 'completed',
+    serviceDate: new Date('2026-01-10'),
+    subtotal: 500,
+    discountAmount: 0,
+    taxRate: 8,
+    taxAmount: 16,
+    taxInclusive: false,
+    taxComponents: null,
+    taxableSubtotal: 200,
+    totalAmount: 516,
+    warrantyStatus: null,
+    warrantyMonths: null,
+    warrantyMileage: null,
+    warrantyNotes: null,
+    attachments: [],
+    vehicle: { id: 'veh-1', mileage: 50000 },
+  }
+
+  beforeEach(() => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(existing as never)
+  })
+
+  it('restates the tax from the lines it is about to store', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    const result = await updateServiceRecord({
+      id: 'sr-1',
+      partItems: [{ ...PART, quantity: 2, total: 400 }],
+      laborItems: [LABOR],
+      subtotal: 700,
+      taxRate: 8,
+      // An old tab's arithmetic: tax on everything.
+      taxAmount: 56,
+      totalAmount: 756,
+    })
+    expect(result.success).toBe(true)
+    expect(tx.parts().map((p) => p.taxable)).toEqual([true])
+    expect(tx.labor().map((l) => l.taxable)).toEqual([false])
+    expect(tx.data()).toMatchObject({
+      subtotal: 700,
+      taxableSubtotal: 400,
+      taxAmount: 32,
+      totalAmount: 732,
+    })
+  })
+
+  it('keeps a choice made on a line through a save', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    await updateServiceRecord({
+      id: 'sr-1',
+      partItems: [{ ...PART, taxable: false }],
+      laborItems: [{ ...LABOR, taxable: true }],
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 0,
+      totalAmount: 500,
+    })
+    expect(tx.parts().map((p) => p.taxable)).toEqual([false])
+    expect(tx.labor().map((l) => l.taxable)).toEqual([true])
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 300, taxAmount: 24, totalAmount: 524 })
+  })
+
+  it('reads the lines already on the job when the save sends only a new rate', async () => {
+    vi.mocked(db.servicePart.findMany).mockResolvedValue([{ total: 200, taxable: true }] as never)
+    vi.mocked(db.serviceLabor.findMany).mockResolvedValue([
+      { total: 300, taxable: false, pricingType: 'hourly' },
+    ] as never)
+    const tx = transaction()
+    await updateServiceRecord({ id: 'sr-1', taxRate: 10 })
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 200, taxAmount: 20, totalAmount: 520 })
+  })
+
+  it('clears the taxable subtotal once every line is taxed again', async () => {
+    const tx = transaction()
+    await updateServiceRecord({
+      id: 'sr-1',
+      partItems: [PART],
+      laborItems: [{ ...LABOR, taxable: true }],
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 40,
+      totalAmount: 540,
+    })
+    expect(tx.data()).toMatchObject({ taxableSubtotal: null, taxAmount: 40, totalAmount: 540 })
+  })
+
+  it('leaves the status, the date and the money alone when only two dates are sent', async () => {
+    const tx = transaction()
+    const result = await updateServiceRecord({
+      id: 'sr-1',
+      invoiceDate: '2026-10-13',
+      invoiceDueDate: '2026-10-27',
+    })
+    expect(result.success).toBe(true)
+    const data = tx.data()
+    expect(data.invoiceDate).toEqual(new Date('2026-10-13T00:00:00.000Z'))
+    expect(data.invoiceDueDate).toEqual(new Date('2026-10-27T00:00:00.000Z'))
+    // None of these were sent, so none of them may be written. They used to
+    // arrive as the create form's defaults: pending, zero, today.
+    for (const field of [
+      'status',
+      'type',
+      'cost',
+      'subtotal',
+      'taxRate',
+      'taxAmount',
+      'taxInclusive',
+      'totalAmount',
+      'discountValue',
+      'discountAmount',
+      'serviceDate',
+      'taxableSubtotal',
+      'customStatusId',
+    ]) {
+      expect(data[field], field).toBeUndefined()
+    }
+    // And the lines were not touched or even read.
+    expect(db.servicePart.findMany).not.toHaveBeenCalled()
+    expect(tx.parts()).toEqual([])
+  })
+})
+
+/* -------------------------------------------------------------------------- */
+
+describe('creating a quote', () => {
+  it('stores it as sent while every line is taxed', async () => {
+    const tx = transaction()
+    const result = await createQuote({
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR],
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 40,
+      totalAmount: 540,
+    })
+    expect(result.success).toBe(true)
+    expect(tx.data()).toMatchObject({ taxableSubtotal: null, taxAmount: 40, totalAmount: 540 })
+    expect(tx.labor().map((l) => l.taxable)).toEqual([true])
+  })
+
+  it('taxes the parts alone where the workshop says so', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    await createQuote({
+      title: 'Brakes',
+      partItems: [PART],
+      laborItems: [LABOR],
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 40,
+      totalAmount: 540,
+    })
+    expect(tx.parts().map((p) => p.taxable)).toEqual([true])
+    expect(tx.labor().map((l) => l.taxable)).toEqual([false])
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 200, taxAmount: 16, totalAmount: 516 })
+  })
+
+  it('does not count a line the customer declined toward the tax', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    await createQuote({
+      title: 'Brakes',
+      partItems: [
+        PART,
+        { name: 'Rotors', quantity: 1, unitPrice: 400, total: 400, excluded: true },
+      ],
+      laborItems: [LABOR],
+      // The subtotal leaves the declined rotors out, as the editor's does.
+      subtotal: 500,
+      taxRate: 8,
+      taxAmount: 0,
+      totalAmount: 500,
+    })
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 200, taxAmount: 16, totalAmount: 516 })
+  })
+})
+
+describe('saving an existing quote', () => {
+  const existing = {
+    id: 'q-1',
+    organizationId: ORG,
+    status: 'draft',
+    subtotal: 500,
+    discountAmount: 0,
+    taxRate: 8,
+    taxAmount: 16,
+    taxInclusive: false,
+    taxComponents: null,
+    taxableSubtotal: 200,
+    totalAmount: 516,
+    warrantyStatus: null,
+    warrantyMonths: null,
+    warrantyMileage: null,
+    warrantyNotes: null,
+  }
+
+  beforeEach(() => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(existing as never)
+  })
+
+  it('restates the tax from the lines it is about to store', async () => {
+    settings(PARTS_ONLY)
+    const tx = transaction()
+    const result = await updateQuote({
+      id: 'q-1',
+      title: 'Brakes',
+      partItems: [PART, { name: 'Rotors', quantity: 1, unitPrice: 400, total: 400 }],
+      laborItems: [LABOR],
+      subtotal: 900,
+      taxRate: 8,
+      taxAmount: 72,
+      totalAmount: 972,
+    })
+    expect(result.success).toBe(true)
+    expect(tx.labor().map((l) => l.taxable)).toEqual([false])
+    expect(tx.data()).toMatchObject({ taxableSubtotal: 600, taxAmount: 48, totalAmount: 948 })
+  })
+
+  it('does not reset the status of a quote it was not told about', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ ...existing, status: 'accepted' } as never)
+    const tx = transaction()
+    await updateQuote({ id: 'q-1', title: 'Brakes, front' })
+    const data = tx.data()
+    expect(data.title).toBe('Brakes, front')
+    for (const field of ['status', 'subtotal', 'taxRate', 'taxAmount', 'totalAmount']) {
+      expect(data[field], field).toBeUndefined()
+    }
+  })
+})

+ 98 - 0
src/__tests__/features/tax/workshop-tax-offer-save.test.tsx

@@ -0,0 +1,98 @@
+/**
+ * Applying the workshop's tax ends in a page reload, and a reload over
+ * unsaved edits loses them. So the edits are saved first, and when that save
+ * does not go through (the form refuses a row, the document has locked)
+ * nothing is applied and nothing is reloaded.
+ */
+
+import { renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/features/settings/Actions/applyWorkshopTaxToDocument', () => ({
+  applyWorkshopTaxToDocument: vi.fn(),
+}))
+
+import { applyWorkshopTaxToDocument } from '@/features/settings/Actions/applyWorkshopTaxToDocument'
+import { useTaxOfferValue } from '@/features/settings/Components/WorkshopTaxOffer'
+import { recurringTaxableSubtotal } from '@/lib/taxable-lines'
+
+const reload = vi.fn()
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  Object.defineProperty(window, 'location', {
+    configurable: true,
+    value: { ...window.location, reload },
+  })
+})
+
+function offer(saveFirst: () => Promise<boolean>, taxRate = 0) {
+  return renderHook(() =>
+    useTaxOfferValue({
+      offer: { rate: 25, currentRate: 0 },
+      kind: 'workOrder',
+      id: 'sr-1',
+      locked: false,
+      finished: false,
+      taxRate,
+      saveFirst,
+      unsavedMessage: 'Save your changes first, then apply the tax.',
+    })
+  ).result.current
+}
+
+describe('taking the offer from the page', () => {
+  it('saves, applies and reloads when the save went through', async () => {
+    vi.mocked(applyWorkshopTaxToDocument).mockResolvedValue({ success: true } as never)
+    const saveFirst = vi.fn().mockResolvedValue(true)
+
+    await offer(saveFirst)?.apply()
+
+    expect(saveFirst).toHaveBeenCalledTimes(1)
+    expect(applyWorkshopTaxToDocument).toHaveBeenCalledWith({ kind: 'workOrder', id: 'sr-1' })
+    expect(reload).toHaveBeenCalledTimes(1)
+  })
+
+  it('applies nothing and keeps the page when the save did not go through', async () => {
+    const value = offer(vi.fn().mockResolvedValue(false))
+
+    await expect(value?.apply()).rejects.toThrow('Save your changes first')
+
+    expect(applyWorkshopTaxToDocument).not.toHaveBeenCalled()
+    expect(reload).not.toHaveBeenCalled()
+  })
+
+  it('keeps the page when the server refuses, and passes on its reason', async () => {
+    vi.mocked(applyWorkshopTaxToDocument).mockResolvedValue({
+      success: false,
+      error: 'This invoice is locked',
+    } as never)
+
+    await expect(offer(vi.fn().mockResolvedValue(true))?.apply()).rejects.toThrow(
+      'This invoice is locked'
+    )
+    expect(reload).not.toHaveBeenCalled()
+  })
+
+  it('is withdrawn once the rate on screen is already the workshop rate', () => {
+    expect(offer(vi.fn(), 25)).toBeNull()
+  })
+})
+
+describe('the recurring invoice list', () => {
+  it('previews a template with the rule its invoice is generated by', () => {
+    // The list and the generator call this one function, so a shop fee is
+    // taxed the same in the figure shown and in the invoice that follows.
+    const template = {
+      cost: 0,
+      templateParts: [{ quantity: 1, unitPrice: 100 }],
+      templateLabor: [
+        { hours: 2, rate: 50, pricingType: 'hourly' },
+        { hours: 1, rate: 10, pricingType: 'shopFee' },
+      ],
+    }
+    expect(recurringTaxableSubtotal(template, { parts: true, labor: false, shopFee: true })).toBe(
+      110
+    )
+  })
+})

+ 393 - 0
src/__tests__/features/tax/workshop-tax-offer.test.tsx

@@ -0,0 +1,393 @@
+/**
+ * Putting an older job on the workshop's tax.
+ *
+ * A work order or quote keeps the rate it was created with. One written
+ * before the workshop turned tax on shows no tax, and used to leave the desk
+ * with a job that could only be fixed by knowing where to type a rate, or by
+ * deleting it and starting again. These tests pin when the page offers the
+ * workshop's rate, what taking the offer writes, and that a job with no rate
+ * does not show a tax switch on its lines.
+ */
+
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/document-lock.server', () => ({
+  assertQuoteEditable: vi.fn().mockResolvedValue(undefined),
+  assertInvoiceEditable: vi.fn().mockResolvedValue(undefined),
+}))
+vi.mock('@/features/vehicles/Lib/retotalServiceRecord', () => ({
+  retotalServiceRecord: vi.fn().mockResolvedValue(undefined),
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    serviceRecord: { findFirst: vi.fn() },
+    quote: { findFirst: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { applyWorkshopTaxToDocument } from '@/features/settings/Actions/applyWorkshopTaxToDocument'
+import { LineTaxProvider } from '@/features/settings/Components/LineTaxContext'
+import { LineTaxToggle } from '@/features/settings/Components/LineTaxToggle'
+import {
+  WorkshopTaxOfferPrompt,
+  WorkshopTaxOfferProvider,
+} from '@/features/settings/Components/WorkshopTaxOffer'
+import { taxOfferFor, workshopTaxOffer } from '@/features/settings/Lib/workshopTaxOffer'
+import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { assertInvoiceEditable } from '@/lib/document-lock.server'
+import { DocumentLockedError } from '@/lib/document-lock'
+
+const TAX_ON = [
+  { key: 'workshop.taxEnabled', value: 'true' },
+  { key: 'workshop.defaultTaxRate', value: '25' },
+]
+const PARTS_ONLY = [
+  ...TAX_ON,
+  { key: 'workshop.taxSelective', value: 'true' },
+  { key: 'workshop.taxAppliesToLabor', value: 'false' },
+]
+
+function settings(rows: { key: string; value: string }[]) {
+  vi.mocked(db.appSetting.findMany).mockImplementation((async (args: {
+    where?: { key?: { in?: string[] } }
+  }) => {
+    const wanted = args?.where?.key?.in
+    return wanted ? rows.filter((r) => wanted.includes(r.key)) : rows
+  }) as never)
+}
+
+const openJob = {
+  taxRate: 0,
+  status: 'pending',
+  sentAt: null,
+  issuedAt: null,
+  customer: null,
+  vehicle: { customer: { taxExempt: false } },
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'a@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org-1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  settings(TAX_ON)
+})
+
+describe('when the offer is made', () => {
+  const tax = { enabled: true, rate: 25 }
+  const doc = { taxRate: 0, customerExempt: false, settled: false }
+
+  it('is made to an open job with no tax in a workshop that charges it', () => {
+    expect(taxOfferFor(tax, doc)).toEqual({ rate: 25, currentRate: 0 })
+  })
+
+  it('is made to a job at another rate than the workshop charges now', () => {
+    expect(taxOfferFor(tax, { ...doc, taxRate: 20 })).toEqual({ rate: 25, currentRate: 20 })
+  })
+
+  it('is not made when the job already carries the workshop rate', () => {
+    expect(taxOfferFor(tax, { ...doc, taxRate: 25 })).toBeNull()
+    expect(taxOfferFor(tax, { ...doc, taxRate: 25.0000001 })).toBeNull()
+  })
+
+  it('is not made by a workshop that charges no tax', () => {
+    expect(taxOfferFor({ enabled: false, rate: 25 }, doc)).toBeNull()
+    expect(taxOfferFor({ enabled: true, rate: 0 }, doc)).toBeNull()
+  })
+
+  it('is not made for an exempt customer, whose 0% is deliberate', () => {
+    expect(taxOfferFor(tax, { ...doc, customerExempt: true })).toBeNull()
+  })
+
+  it('is not made once the job is finished or has gone to the customer', () => {
+    expect(taxOfferFor(tax, { ...doc, settled: true })).toBeNull()
+  })
+})
+
+describe('the offer for a stored document', () => {
+  it('is made to an open, unsent work order', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(openJob as never)
+    expect(await workshopTaxOffer('org-1', 'workOrder', 'sr-1')).toEqual({
+      rate: 25,
+      currentRate: 0,
+    })
+  })
+
+  it.each([
+    ['completed', { status: 'completed' }],
+    ['sent to the customer', { sentAt: new Date() }],
+    ['issued', { issuedAt: new Date() }],
+    ['for an exempt customer', { vehicle: { customer: { taxExempt: true } } }],
+    ['a counter sale to an exempt customer', { vehicle: null, customer: { taxExempt: true } }],
+  ])('is not made to a work order that is %s', async (_name, change) => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({ ...openJob, ...change } as never)
+    expect(await workshopTaxOffer('org-1', 'workOrder', 'sr-1')).toBeNull()
+  })
+
+  it('is made to a draft quote and to no other', async () => {
+    const draft = { taxRate: 0, status: 'draft', sentAt: null, customer: null }
+    vi.mocked(db.quote.findFirst).mockResolvedValue(draft as never)
+    expect(await workshopTaxOffer('org-1', 'quote', 'q-1')).toEqual({ rate: 25, currentRate: 0 })
+    for (const status of ['sent', 'accepted', 'converted', 'rejected']) {
+      vi.mocked(db.quote.findFirst).mockResolvedValue({ ...draft, status } as never)
+      expect(await workshopTaxOffer('org-1', 'quote', 'q-1'), status).toBeNull()
+    }
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ ...draft, sentAt: new Date() } as never)
+    expect(await workshopTaxOffer('org-1', 'quote', 'q-1')).toBeNull()
+  })
+
+  it('is not made for a document that is not this workshop’s', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    expect(await workshopTaxOffer('org-1', 'workOrder', 'someone-elses')).toBeNull()
+    expect(db.serviceRecord.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'someone-elses', organizationId: 'org-1' } })
+    )
+  })
+})
+
+describe('taking the offer on a work order', () => {
+  function transaction() {
+    const tx = {
+      servicePart: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
+      serviceLabor: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
+      serviceRecord: { update: vi.fn().mockResolvedValue({}) },
+    }
+    vi.mocked(db.$transaction).mockImplementation((async (run: (t: typeof tx) => unknown) =>
+      run(tx)) as never)
+    return tx
+  }
+
+  it('writes the workshop rate and re-totals the job from its lines', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(openJob as never)
+    const tx = transaction()
+
+    const result = await applyWorkshopTaxToDocument({ kind: 'workOrder', id: 'sr-1' })
+
+    expect(result).toMatchObject({ success: true, data: { id: 'sr-1', rate: 25 } })
+    expect(tx.serviceRecord.update).toHaveBeenCalledWith({
+      where: { id: 'sr-1' },
+      data: expect.objectContaining({ taxRate: 25 }),
+    })
+    expect(retotalServiceRecord).toHaveBeenCalledWith('sr-1', tx)
+    // The rate is on the row before the job is totalled under it.
+    expect(tx.serviceRecord.update.mock.invocationCallOrder[0]).toBeLessThan(
+      vi.mocked(retotalServiceRecord).mock.invocationCallOrder[0]
+    )
+  })
+
+  it('puts the lines of a job that had no tax on the workshop rule for each kind', async () => {
+    settings(PARTS_ONLY)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(openJob as never)
+    const tx = transaction()
+
+    await applyWorkshopTaxToDocument({ kind: 'workOrder', id: 'sr-1' })
+
+    expect(tx.servicePart.updateMany).toHaveBeenCalledWith({
+      where: { serviceRecordId: 'sr-1' },
+      data: { taxable: true },
+    })
+    expect(tx.serviceLabor.updateMany).toHaveBeenCalledWith({
+      where: { serviceRecordId: 'sr-1', pricingType: { not: 'shopFee' } },
+      data: { taxable: false },
+    })
+    expect(tx.serviceLabor.updateMany).toHaveBeenCalledWith({
+      where: { serviceRecordId: 'sr-1', pricingType: 'shopFee' },
+      data: { taxable: true },
+    })
+  })
+
+  it('leaves the lines alone on a job moving from one rate to another', async () => {
+    settings(PARTS_ONLY)
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({ ...openJob, taxRate: 20 } as never)
+    const tx = transaction()
+
+    await applyWorkshopTaxToDocument({ kind: 'workOrder', id: 'sr-1' })
+
+    expect(tx.servicePart.updateMany).not.toHaveBeenCalled()
+    expect(tx.serviceLabor.updateMany).not.toHaveBeenCalled()
+    expect(tx.serviceRecord.update).toHaveBeenCalled()
+  })
+
+  it('gives the job the split when the workshop charges two taxes', async () => {
+    settings([
+      ...TAX_ON.filter((s) => s.key !== 'workshop.defaultTaxRate'),
+      { key: 'workshop.defaultTaxRate', value: '14.975' },
+      { key: 'workshop.taxMode', value: 'split' },
+      {
+        key: 'workshop.taxComponents',
+        value: JSON.stringify([
+          { name: 'GST', rate: 5 },
+          { name: 'QST', rate: 9.975 },
+        ]),
+      },
+    ])
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(openJob as never)
+    const tx = transaction()
+
+    await applyWorkshopTaxToDocument({ kind: 'workOrder', id: 'sr-1' })
+
+    const { data } = tx.serviceRecord.update.mock.calls[0][0]
+    expect(data.taxRate).toBe(14.975)
+    expect(data.taxComponents).toEqual([
+      { name: 'GST', rate: 5, amount: 0 },
+      { name: 'QST', rate: 9.975, amount: 0 },
+    ])
+  })
+
+  it('is refused for a job that was sent since the page was opened', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
+      ...openJob,
+      sentAt: new Date(),
+    } as never)
+    transaction()
+
+    const result = await applyWorkshopTaxToDocument({ kind: 'workOrder', id: 'sr-1' })
+
+    expect(result.success).toBe(false)
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+
+  it('is refused for a locked invoice before anything is read', async () => {
+    vi.mocked(assertInvoiceEditable).mockRejectedValue(new DocumentLockedError('sent'))
+    const result = await applyWorkshopTaxToDocument({ kind: 'workOrder', id: 'sr-1' })
+    expect(result.success).toBe(false)
+    expect(db.serviceRecord.findFirst).not.toHaveBeenCalled()
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+})
+
+describe('taking the offer on a quote', () => {
+  it('totals the quote under the workshop rate, on the lines the customer kept', async () => {
+    settings(PARTS_ONLY)
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      taxRate: 0,
+      status: 'draft',
+      sentAt: null,
+      customer: null,
+    } as never)
+    const update = vi.fn().mockResolvedValue({})
+    const tx = {
+      quotePart: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
+      quoteLabor: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
+      quote: {
+        update,
+        // As the lines stand after the rule was put on them: parts taxed, labor not.
+        findUniqueOrThrow: vi.fn().mockResolvedValue({
+          subtotal: 500,
+          discountAmount: 0,
+          taxInclusive: false,
+          partItems: [
+            { total: 200, taxable: true, excluded: false },
+            { total: 400, taxable: true, excluded: true },
+          ],
+          laborItems: [{ total: 300, taxable: false, excluded: false, pricingType: 'hourly' }],
+        }),
+      },
+    }
+    vi.mocked(db.$transaction).mockImplementation((async (run: (t: typeof tx) => unknown) =>
+      run(tx)) as never)
+
+    const result = await applyWorkshopTaxToDocument({ kind: 'quote', id: 'q-1' })
+
+    expect(result.success).toBe(true)
+    expect(tx.quoteLabor.updateMany).toHaveBeenCalledWith({
+      where: { quoteId: 'q-1', pricingType: { not: 'shopFee' } },
+      data: { taxable: false },
+    })
+    // 25% of the 200 of parts the customer kept.
+    expect(update.mock.calls[0][0].data).toMatchObject({
+      taxRate: 25,
+      taxableSubtotal: 200,
+      taxAmount: 50,
+      totalAmount: 550,
+    })
+  })
+})
+
+describe('the prompt on the totals card', () => {
+  const prompt = (value: Parameters<typeof WorkshopTaxOfferProvider>[0]['value']) =>
+    render(
+      <WorkshopTaxOfferProvider value={value}>
+        <WorkshopTaxOfferPrompt />
+      </WorkshopTaxOfferProvider>
+    )
+
+  it('is not there without an offer', () => {
+    prompt(null)
+    expect(screen.queryByTestId('workshop-tax-offer')).toBeNull()
+  })
+
+  it('says the job has no tax and offers the workshop rate', () => {
+    prompt({ rate: 25, currentRate: 0, apply: vi.fn() })
+    const box = screen.getByTestId('workshop-tax-offer')
+    expect(box).toHaveTextContent('created without tax')
+    expect(screen.getByRole('button', { name: 'Apply 25% tax' })).toBeEnabled()
+  })
+
+  it('names the rate the job is at when it has one', () => {
+    prompt({ rate: 25, currentRate: 20, apply: vi.fn() })
+    expect(screen.getByTestId('workshop-tax-offer')).toHaveTextContent('This is at 20%')
+  })
+
+  it('applies on click and cannot be clicked twice while it does', async () => {
+    // Held open until the test lets it finish.
+    const pending: { finish?: () => void } = {}
+    const apply = vi.fn(
+      () =>
+        new Promise<void>((resolve) => {
+          pending.finish = resolve
+        })
+    )
+    prompt({ rate: 25, currentRate: 0, apply })
+    const button = screen.getByRole('button', { name: 'Apply 25% tax' })
+
+    fireEvent.click(button)
+    await waitFor(() => expect(button).toBeDisabled())
+    fireEvent.click(button)
+    expect(apply).toHaveBeenCalledTimes(1)
+
+    pending.finish?.()
+    await waitFor(() => expect(button).toBeEnabled())
+  })
+})
+
+describe('the tax switch on a line of a job with no tax rate', () => {
+  const all = { parts: true, labor: true, shopFee: true }
+
+  it('is not shown, taxed or untaxed, because it would decide nothing', () => {
+    render(
+      <LineTaxProvider value={{ selective: true, applies: all, untaxedDocument: true }}>
+        <LineTaxToggle taxable onToggle={vi.fn()} />
+        <LineTaxToggle taxable={false} onToggle={vi.fn()} />
+      </LineTaxProvider>
+    )
+    expect(screen.queryByTestId('line-tax-toggle')).toBeNull()
+  })
+
+  it('is shown again once the job has a rate', () => {
+    render(
+      <LineTaxProvider value={{ selective: true, applies: all, untaxedDocument: false }}>
+        <LineTaxToggle taxable onToggle={vi.fn()} />
+      </LineTaxProvider>
+    )
+    expect(screen.getByTestId('line-tax-toggle')).toBeInTheDocument()
+  })
+})

+ 7 - 1
src/__tests__/features/tire-hotel/add-tire-line.test.ts

@@ -44,7 +44,13 @@ function makeTx(balance = 12) {
   })
   })
 
 
   // A stand-in for Prisma's client, narrowed at the call site.
   // A stand-in for Prisma's client, narrowed at the call site.
-  const tx = { servicePart: { create }, stockMovement: { createMany }, $queryRaw }
+  const tx = {
+    servicePart: { create },
+    stockMovement: { createMany },
+    $queryRaw,
+    // The workshop's tax rule for parts is read before the line is written.
+    appSetting: { findMany: vi.fn().mockResolvedValue([]) },
+  }
   return { tx, create, createMany, updates }
   return { tx, create, createMany, updates }
 }
 }
 
 

+ 22 - 3
src/__tests__/features/tire-hotel/check-in-job-lines.test.ts

@@ -11,7 +11,12 @@
 
 
 import { it, expect, vi, beforeEach } from 'vitest'
 import { it, expect, vi, beforeEach } from 'vitest'
 
 
-vi.mock('@/lib/db', () => ({ db: { appSetting: { findUnique: vi.fn() } } }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    // findMany is the workshop's tax rule for the lines; none set, so all taxed.
+    appSetting: { findUnique: vi.fn(), findMany: vi.fn().mockResolvedValue([]) },
+  },
+}))
 vi.mock('@/features/tire-hotel/Lib/serverMessages', () => ({
 vi.mock('@/features/tire-hotel/Lib/serverMessages', () => ({
   invoiceLineWords: vi.fn(async () => ({
   invoiceLineWords: vi.fn(async () => ({
     storage: 'Tire storage',
     storage: 'Tire storage',
@@ -49,8 +54,22 @@ it('prices prep from settings, as flat service lines', async () => {
   })
   })
 
 
   expect(lines).toEqual([
   expect(lines).toEqual([
-    { description: 'Wash tires', hours: 1, rate: 150, total: 150, pricingType: 'service' },
-    { description: 'Balancing', hours: 1, rate: 400, total: 400, pricingType: 'service' },
+    {
+      description: 'Wash tires',
+      hours: 1,
+      rate: 150,
+      total: 150,
+      pricingType: 'service',
+      taxable: true,
+    },
+    {
+      description: 'Balancing',
+      hours: 1,
+      rate: 400,
+      total: 400,
+      pricingType: 'service',
+      taxable: true,
+    },
   ])
   ])
 })
 })
 
 

+ 8 - 1
src/__tests__/features/workorders/create-workorder.test.ts

@@ -973,7 +973,14 @@ describe('createDraftServiceRecord — shop fee', () => {
 
 
     const data = vi.mocked(db.serviceRecord.create).mock.calls[0][0].data as any
     const data = vi.mocked(db.serviceRecord.create).mock.calls[0][0].data as any
     expect(data.laborItems.create).toEqual([
     expect(data.laborItems.create).toEqual([
-      { description: 'Shop supplies', hours: 1, rate: 12.5, total: 12.5, pricingType: 'shopFee' },
+      {
+        description: 'Shop supplies',
+        hours: 1,
+        rate: 12.5,
+        total: 12.5,
+        pricingType: 'shopFee',
+        taxable: true,
+      },
     ])
     ])
     expect(data.subtotal).toBe(12.5)
     expect(data.subtotal).toBe(12.5)
     // 25% of 12.50 is 3.125, which is 3.13 on the sheet, so the total is 15.63.
     // 25% of 12.50 is 3.125, which is 3.13 on the sheet, so the total is 15.63.

+ 15 - 2
src/__tests__/lib/tax-components.test.ts

@@ -149,7 +149,12 @@ describe('calculateTotals with components', () => {
       taxRate: 25,
       taxRate: 25,
       taxInclusive: false,
       taxInclusive: false,
     })
     })
-    expect(plain).toEqual({ taxAmount: 225, totalAmount: 1125, components: null })
+    expect(plain).toEqual({
+      taxAmount: 225,
+      totalAmount: 1125,
+      components: null,
+      taxableAmount: 900,
+    })
     const empty = calculateTotals({
     const empty = calculateTotals({
       subtotal: 900,
       subtotal: 900,
       discountAmount: 0,
       discountAmount: 0,
@@ -245,6 +250,7 @@ describe('workshop tax settings', () => {
       rate: 0,
       rate: 0,
       inclusive: false,
       inclusive: false,
       components: null,
       components: null,
+      applies: { parts: true, labor: true, shopFee: true },
     })
     })
   })
   })
 
 
@@ -267,6 +273,7 @@ describe('workshop tax settings', () => {
       taxRate: 14.975,
       taxRate: 14.975,
       taxInclusive: false,
       taxInclusive: false,
       taxComponents: fields.taxComponents,
       taxComponents: fields.taxComponents,
+      taxableSubtotal: null,
     })
     })
     expect(totals.taxAmount).toBe(134.78)
     expect(totals.taxAmount).toBe(134.78)
     expect(totals.totalAmount).toBe(1034.78)
     expect(totals.totalAmount).toBe(1034.78)
@@ -283,7 +290,13 @@ describe('workshop tax settings', () => {
       taxRate: 25,
       taxRate: 25,
       taxInclusive: false,
       taxInclusive: false,
       taxComponents: null,
       taxComponents: null,
+      taxableSubtotal: null,
+    })
+    expect(totals).toEqual({
+      taxAmount: 225,
+      totalAmount: 1125,
+      taxComponents: undefined,
+      taxableSubtotal: null,
     })
     })
-    expect(totals).toEqual({ taxAmount: 225, totalAmount: 1125, taxComponents: undefined })
   })
   })
 })
 })

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

@@ -485,6 +485,7 @@ describe('totals that add up to the cent', () => {
       taxAmount: 0.24,
       taxAmount: 0.24,
       totalAmount: 1.18,
       totalAmount: 1.18,
       components: null,
       components: null,
+      taxableAmount: 0.94,
     })
     })
     // A part at 149.90: 37.475 of VAT is 37.48, and the total follows.
     // A part at 149.90: 37.475 of VAT is 37.48, and the total follows.
     expect(
     expect(
@@ -493,6 +494,7 @@ describe('totals that add up to the cent', () => {
       taxAmount: 37.48,
       taxAmount: 37.48,
       totalAmount: 187.38,
       totalAmount: 187.38,
       components: null,
       components: null,
+      taxableAmount: 149.9,
     })
     })
   })
   })
 
 
@@ -503,6 +505,7 @@ describe('totals that add up to the cent', () => {
       taxAmount: 29.98,
       taxAmount: 29.98,
       totalAmount: 149.9,
       totalAmount: 149.9,
       components: null,
       components: null,
+      taxableAmount: 119.92,
     })
     })
   })
   })
 
 

+ 616 - 0
src/__tests__/lib/taxable-lines.test.ts

@@ -0,0 +1,616 @@
+/**
+ * Tax charged on some of a document's lines and not on others.
+ *
+ * The worked examples pin the rule in figures a person can check by hand.
+ * The randomised section then throws a few thousand documents at the same
+ * code and asks only for what must always hold: the sheet adds up, a fully
+ * taxed document is untouched by any of this, and the tax never exceeds
+ * what taxing everything would have charged.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { roundMoney } from '@/lib/money'
+import {
+  calculateTotals,
+  earnedLineTotal,
+  netLineTotal,
+  netMixedTotal,
+  storedTaxableSubtotal,
+  taxableBaseFor,
+  taxDisplay,
+  taxedNetAmountOf,
+} from '@/lib/tax'
+import {
+  hasUntaxedLines,
+  laborLineKind,
+  laborTaxable,
+  partTaxable,
+  resolveTaxable,
+  TAX_APPLIES_TO_ALL,
+  type TaxApplies,
+  taxableLaborTotal,
+  taxablePartsTotal,
+  taxableSubtotalOf,
+  withTaxableLabor,
+  withTaxableParts,
+} from '@/lib/taxable-lines'
+import { taxModeConversion } from '@/lib/tax-mode-conversion'
+
+const PARTS_ONLY: TaxApplies = { parts: true, labor: false, shopFee: false }
+
+describe('a workshop that taxes parts and not labor', () => {
+  // The support question this was built for: 200 of parts, 300 of labor, 8%.
+  const parts = [{ total: 200 }]
+  const labor = [{ total: 300, pricingType: 'hourly' }]
+
+  it('charges the tax on the parts alone', () => {
+    const taxableSubtotal = taxableSubtotalOf(parts, labor, PARTS_ONLY)
+    expect(taxableSubtotal).toBe(200)
+    const totals = calculateTotals({
+      subtotal: 500,
+      discountAmount: 0,
+      taxRate: 8,
+      taxInclusive: false,
+      taxableSubtotal,
+    })
+    expect(totals.taxAmount).toBe(16)
+    expect(totals.totalAmount).toBe(516)
+    expect(totals.taxableAmount).toBe(200)
+  })
+
+  it('shares a discount over the taxed and the untaxed lines in proportion', () => {
+    // 10% off 500 is 50: 20 of it off the parts, 30 off the labor.
+    const totals = calculateTotals({
+      subtotal: 500,
+      discountAmount: 50,
+      taxRate: 8,
+      taxInclusive: false,
+      taxableSubtotal: 200,
+    })
+    expect(totals.taxableAmount).toBe(180)
+    expect(totals.taxAmount).toBe(14.4)
+    expect(totals.totalAmount).toBe(464.4)
+  })
+
+  it('shares a fixed discount the same way', () => {
+    // 100 off 500 leaves 400, of which the parts are two fifths: 160.
+    const totals = calculateTotals({
+      subtotal: 500,
+      discountAmount: 100,
+      taxRate: 8,
+      taxInclusive: false,
+      taxableSubtotal: 200,
+    })
+    expect(totals.taxableAmount).toBe(160)
+    expect(totals.taxAmount).toBe(12.8)
+    expect(totals.totalAmount).toBe(412.8)
+  })
+
+  it('charges nothing when every line is left out', () => {
+    const totals = calculateTotals({
+      subtotal: 500,
+      discountAmount: 0,
+      taxRate: 8,
+      taxInclusive: false,
+      taxableSubtotal: 0,
+    })
+    expect(totals).toEqual({
+      taxAmount: 0,
+      totalAmount: 500,
+      components: null,
+      taxableAmount: 0,
+    })
+  })
+
+  it('backs the tax out of the taxable lines only when prices include it', () => {
+    // Parts entered at 216 with 8% in them, labor at 300 with none.
+    const totals = calculateTotals({
+      subtotal: 516,
+      discountAmount: 0,
+      taxRate: 8,
+      taxInclusive: true,
+      taxableSubtotal: 216,
+    })
+    expect(totals.taxAmount).toBe(16)
+    expect(totals.totalAmount).toBe(516)
+    expect(totals.taxableAmount).toBe(200)
+  })
+
+  it('splits the tax into its components on the taxable part alone', () => {
+    const components = [
+      { name: 'GST', rate: 5 },
+      { name: 'QST', rate: 9.975 },
+    ]
+    const totals = calculateTotals({
+      subtotal: 500,
+      discountAmount: 0,
+      taxRate: 14.975,
+      taxInclusive: false,
+      components,
+      taxableSubtotal: 200,
+    })
+    expect(totals.components).toEqual([
+      { name: 'GST', rate: 5, amount: 10 },
+      { name: 'QST', rate: 9.975, amount: 19.95 },
+    ])
+    expect(totals.taxAmount).toBe(29.95)
+    expect(totals.totalAmount).toBe(529.95)
+  })
+
+  it('splits an inclusive tax so the components add up to it', () => {
+    const totals = calculateTotals({
+      subtotal: 529.95,
+      discountAmount: 0,
+      taxRate: 14.975,
+      taxInclusive: true,
+      components: [
+        { name: 'GST', rate: 5 },
+        { name: 'QST', rate: 9.975 },
+      ],
+      taxableSubtotal: 229.95,
+    })
+    const sum = roundMoney((totals.components ?? []).reduce((s, c) => s + c.amount, 0))
+    expect(sum).toBe(totals.taxAmount)
+    expect(totals.taxAmount).toBe(29.95)
+    expect(totals.totalAmount).toBe(529.95)
+  })
+})
+
+describe('which lines are taxed', () => {
+  it('lets the line say, and falls back to the workshop rule for its kind', () => {
+    expect(resolveTaxable(true, 'labor', PARTS_ONLY)).toBe(true)
+    expect(resolveTaxable(false, 'parts', PARTS_ONLY)).toBe(false)
+    expect(resolveTaxable(undefined, 'labor', PARTS_ONLY)).toBe(false)
+    expect(resolveTaxable(null, 'parts', PARTS_ONLY)).toBe(true)
+    // No rule given: everything is taxed, as before the setting existed.
+    expect(resolveTaxable(undefined, 'labor')).toBe(true)
+    expect(resolveTaxable(undefined, 'shopFee')).toBe(true)
+  })
+
+  it('tells a shop fee from labor by how the line is priced', () => {
+    expect(laborLineKind({ pricingType: 'shopFee' })).toBe('shopFee')
+    expect(laborLineKind({ pricingType: 'hourly' })).toBe('labor')
+    expect(laborLineKind({ pricingType: 'service' })).toBe('labor')
+    expect(laborLineKind({})).toBe('labor')
+    const feeOnly: TaxApplies = { parts: false, labor: false, shopFee: true }
+    expect(laborTaxable({ pricingType: 'shopFee' }, feeOnly)).toBe(true)
+    expect(laborTaxable({ pricingType: 'hourly' }, feeOnly)).toBe(false)
+    expect(partTaxable({}, feeOnly)).toBe(false)
+  })
+
+  it('adds up only the taxable lines', () => {
+    const parts = [{ total: 100 }, { total: 40, taxable: false }, { total: 60, taxable: true }]
+    const labor = [
+      { total: 300, pricingType: 'hourly' },
+      { total: 25, pricingType: 'shopFee' },
+      { total: 80, pricingType: 'service', taxable: true },
+    ]
+    expect(taxablePartsTotal(parts, PARTS_ONLY)).toBe(160)
+    expect(taxableLaborTotal(labor, PARTS_ONLY)).toBe(80)
+    expect(taxableSubtotalOf(parts, labor, PARTS_ONLY)).toBe(240)
+    expect(taxableSubtotalOf(parts, labor)).toBe(565)
+    expect(hasUntaxedLines(parts, labor)).toBe(true)
+    expect(hasUntaxedLines([{ total: 1 }], [{ total: 2 }])).toBe(false)
+    expect(hasUntaxedLines([{ total: 1 }], [{ total: 2 }], PARTS_ONLY)).toBe(true)
+  })
+
+  it('treats a missing or broken total as nothing rather than as NaN', () => {
+    expect(taxableSubtotalOf([{ total: null }, {}], [{ total: Number.NaN }])).toBe(0)
+  })
+
+  it('settles the flag on every line before it is stored', () => {
+    expect(withTaxableParts([{ name: 'a' }, { name: 'b', taxable: false }], PARTS_ONLY)).toEqual([
+      { name: 'a', taxable: true },
+      { name: 'b', taxable: false },
+    ])
+    expect(
+      withTaxableLabor(
+        [
+          { d: 1, pricingType: 'hourly' },
+          { d: 2, pricingType: 'shopFee' },
+          { d: 3, pricingType: 'hourly', taxable: true },
+          { d: 4, pricingType: 'hourly', taxable: null },
+        ],
+        { parts: true, labor: false, shopFee: true }
+      ).map((line) => line.taxable)
+    ).toEqual([false, true, true, false])
+  })
+})
+
+describe('the stored taxable subtotal', () => {
+  it('is null for a document whose every line is taxed', () => {
+    expect(storedTaxableSubtotal(500, null)).toBeNull()
+    expect(storedTaxableSubtotal(500, undefined)).toBeNull()
+    expect(storedTaxableSubtotal(500, 500)).toBeNull()
+    // Float noise and a figure a hair over are still "all of it".
+    expect(storedTaxableSubtotal(0.3, 0.1 + 0.2)).toBeNull()
+    expect(storedTaxableSubtotal(500, 500.004)).toBeNull()
+    expect(storedTaxableSubtotal(500, 600)).toBeNull()
+  })
+
+  it('is the figure itself when some lines are not', () => {
+    expect(storedTaxableSubtotal(500, 200)).toBe(200)
+    expect(storedTaxableSubtotal(500, 0)).toBe(0)
+    expect(storedTaxableSubtotal(500, 499.99)).toBe(499.99)
+    expect(storedTaxableSubtotal(500, -5)).toBe(0)
+  })
+
+  it('gives the base itself when everything is taxed, never a recomputed figure', () => {
+    expect(taxableBaseFor(449.99, 500, null)).toBe(449.99)
+    expect(taxableBaseFor(449.99, 500, 500)).toBe(449.99)
+    expect(taxableBaseFor(449.99, 500, 0)).toBe(0)
+    expect(taxableBaseFor(0, 0, 0)).toBe(0)
+    expect(taxableBaseFor(400, 500, 200)).toBe(160)
+  })
+})
+
+describe('figures shown above the tax line', () => {
+  it('reads an untaxed line the same in every mode', () => {
+    for (const taxInclusive of [true, false]) {
+      for (const withTax of [true, false]) {
+        const display = taxDisplay({ taxRate: 25, taxInclusive, withTax })
+        expect(display.line(300, false)).toBe(300)
+      }
+    }
+    expect(netLineTotal(300, 25, true, false)).toBe(300)
+    expect(netLineTotal(125, 25, true, true)).toBe(100)
+    expect(netLineTotal(125, 25, true)).toBe(100)
+  })
+
+  it('converts a taxed line by the rate, in the direction the sheet asks for', () => {
+    expect(taxDisplay({ taxRate: 25, taxInclusive: true }).line(125)).toBe(100)
+    expect(taxDisplay({ taxRate: 25, taxInclusive: false }).line(100)).toBe(100)
+    expect(taxDisplay({ taxRate: 25, taxInclusive: false, withTax: true }).line(100)).toBe(125)
+    expect(taxDisplay({ taxRate: 25, taxInclusive: true, withTax: true }).line(125)).toBe(125)
+    expect(taxDisplay({ taxRate: 0, taxInclusive: true }).line(125)).toBe(125)
+  })
+
+  it('converts a sum in its taxable part only', () => {
+    const net = taxDisplay({ taxRate: 25, taxInclusive: true })
+    // 125 of taxed parts and 300 of untaxed labor, entered with tax in the parts.
+    expect(net.mixed(425, 125)).toBe(400)
+    expect(net.mixed(425, null)).toBe(340)
+    expect(net.mixed(425, undefined)).toBe(340)
+    expect(netMixedTotal(425, 125, 25, true)).toBe(400)
+    expect(netMixedTotal(425, null, 25, true)).toBe(340)
+    expect(netMixedTotal(425, 125, 25, false)).toBe(425)
+    const gross = taxDisplay({ taxRate: 25, taxInclusive: false, withTax: true })
+    expect(gross.mixed(400, 100)).toBe(425)
+  })
+
+  it('keeps the discount the same share of the subtotal it shows', () => {
+    const net = taxDisplay({ taxRate: 25, taxInclusive: true })
+    // 10% of 425 is 42.50; the shown subtotal is 400, so the shown discount is 40.
+    expect(net.discount(42.5, 425, 125)).toBeCloseTo(40, 10)
+    // Every line taxed: the discount converts like any other figure.
+    expect(net.discount(42.5, 425, null)).toBeCloseTo(34, 10)
+    expect(taxDisplay({ taxRate: 25, taxInclusive: false }).discount(40, 400, 100)).toBe(40)
+  })
+})
+
+describe('reports on a document with untaxed lines', () => {
+  it('counts an untaxed line as earned in full on an inclusive document', () => {
+    const record = { taxRate: 25, taxInclusive: true, subtotal: 425, discountAmount: 0 }
+    expect(earnedLineTotal(300, record, false)).toBe(300)
+    expect(earnedLineTotal(125, record, true)).toBe(100)
+    expect(earnedLineTotal(125, record)).toBe(100)
+  })
+
+  it('still takes the discount share off an untaxed line', () => {
+    const record = { taxRate: 25, taxInclusive: false, subtotal: 500, discountAmount: 50 }
+    expect(earnedLineTotal(300, record, false)).toBe(270)
+  })
+
+  it('reports the amount the tax was actually charged on', () => {
+    // Exclusive: 500 less 50 discount, two fifths taxed at 8%.
+    expect(
+      taxedNetAmountOf({
+        totalAmount: 464.4,
+        taxAmount: 14.4,
+        taxInclusive: false,
+        subtotal: 500,
+        taxableSubtotal: 200,
+      })
+    ).toBeCloseTo(180, 10)
+    // Inclusive: 216 of taxed parts inside a 516 total.
+    expect(
+      taxedNetAmountOf({
+        totalAmount: 516,
+        taxAmount: 16,
+        taxInclusive: true,
+        subtotal: 516,
+        taxableSubtotal: 216,
+      })
+    ).toBeCloseTo(200, 10)
+    // Every line taxed: the total less the tax, as it always was.
+    expect(
+      taxedNetAmountOf({ totalAmount: 540, taxAmount: 40, taxInclusive: false, subtotal: 500 })
+    ).toBe(500)
+    expect(
+      taxedNetAmountOf({
+        totalAmount: 540,
+        taxAmount: 40,
+        taxInclusive: true,
+        subtotal: 540,
+        taxableSubtotal: null,
+      })
+    ).toBe(500)
+  })
+})
+
+describe('moving a document between exclusive and inclusive prices', () => {
+  const doc = {
+    subtotal: 500,
+    taxableSubtotal: 200,
+    discountType: 'fixed',
+    discountValue: 100,
+    discountAmount: 100,
+    taxRate: 8,
+  }
+  const totalOf = (
+    d: { subtotal: number; discountAmount: number; taxableSubtotal: number | null },
+    taxInclusive: boolean
+  ) =>
+    calculateTotals({
+      subtotal: d.subtotal,
+      discountAmount: d.discountAmount,
+      taxRate: 8,
+      taxInclusive,
+      taxableSubtotal: d.taxableSubtotal,
+    })
+
+  it('re-prices only the taxable part and leaves the customer total where it was', () => {
+    const before = totalOf(doc, false)
+    const moved = taxModeConversion(doc, 'inclusive')
+    expect(moved.subtotal).toBeCloseTo(516, 10)
+    expect(moved.taxableSubtotal).toBeCloseTo(216, 10)
+    expect(moved.scaleLine(200)).toBeCloseTo(216, 10)
+    const after = totalOf(moved, true)
+    expect(after.totalAmount).toBe(before.totalAmount)
+    expect(after.taxAmount).toBe(before.taxAmount)
+  })
+
+  it('comes back to where it started', () => {
+    const there = taxModeConversion(doc, 'inclusive')
+    const back = taxModeConversion({ ...doc, ...there }, 'exclusive')
+    expect(back.subtotal).toBeCloseTo(500, 8)
+    expect(back.taxableSubtotal).toBeCloseTo(200, 8)
+    expect(back.discountAmount).toBeCloseTo(100, 8)
+    expect(back.discountValue).toBeCloseTo(100, 8)
+  })
+
+  it('scales everything by the tax factor when every line is taxed, as it always did', () => {
+    const all = { ...doc, taxableSubtotal: null }
+    const moved = taxModeConversion(all, 'inclusive')
+    expect(moved).toMatchObject({
+      subtotal: 500 * 1.08,
+      taxableSubtotal: null,
+      discountAmount: 100 * 1.08,
+      discountValue: 100 * 1.08,
+    })
+    const down = taxModeConversion(all, 'exclusive')
+    expect(down.subtotal).toBe(500 / 1.08)
+    expect(down.discountAmount).toBe(100 / 1.08)
+  })
+
+  it('leaves a percentage discount as the percentage it is', () => {
+    const moved = taxModeConversion(
+      { ...doc, discountType: 'percentage', discountValue: 20 },
+      'inclusive'
+    )
+    expect(moved.discountValue).toBe(20)
+  })
+})
+
+/* -------------------------------------------------------------------------- */
+/* Randomised documents                                                        */
+/* -------------------------------------------------------------------------- */
+
+/** A small seeded generator, so a failure names a document that can be replayed. */
+function prng(seed: number) {
+  let state = seed >>> 0
+  return () => {
+    state = (state + 0x6d2b79f5) >>> 0
+    let t = state
+    t = Math.imul(t ^ (t >>> 15), t | 1)
+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+  }
+}
+
+const RATES = [0, 5, 7.25, 8, 8.875, 9.975, 14.975, 19, 20, 21, 25]
+
+function randomDocument(next: () => number) {
+  const money = (max: number) => Math.round(next() * max * 100) / 100
+  const lineCount = 1 + Math.floor(next() * 6)
+  const lines = Array.from({ length: lineCount }, () => ({
+    total: next() < 0.1 ? 0 : money(2000),
+    taxable: next() < 0.55,
+  }))
+  const subtotal = roundMoney(lines.reduce((s, l) => s + l.total, 0))
+  const taxableSubtotal = roundMoney(lines.reduce((s, l) => (l.taxable ? s + l.total : s), 0))
+  const pick = next()
+  const discountAmount =
+    pick < 0.4
+      ? 0
+      : pick < 0.7
+        ? roundMoney(subtotal * (Math.floor(next() * 50) / 100))
+        : roundMoney(Math.min(money(500), subtotal))
+  const taxRate = RATES[Math.floor(next() * RATES.length)]
+  const taxInclusive = next() < 0.4
+  const split = next() < 0.25 && taxRate > 0
+  const components = split
+    ? [
+        { name: 'A', rate: Math.round((taxRate / 3) * 1000) / 1000 },
+        { name: 'B', rate: Math.round(((taxRate * 2) / 3) * 1000) / 1000 },
+      ]
+    : null
+  return { lines, subtotal, taxableSubtotal, discountAmount, taxRate, taxInclusive, components }
+}
+
+describe('any document at all', () => {
+  const RUNS = 4000
+
+  it('adds up to the cent, whichever lines are taxed', () => {
+    const next = prng(20261013)
+    for (let run = 0; run < RUNS; run++) {
+      const d = randomDocument(next)
+      const totals = calculateTotals(d)
+      const base = Math.max(0, roundMoney(d.subtotal - d.discountAmount))
+      const label = JSON.stringify(d)
+
+      // Every figure is held to the cent.
+      expect(roundMoney(totals.taxAmount), label).toBe(totals.taxAmount)
+      expect(roundMoney(totals.totalAmount), label).toBe(totals.totalAmount)
+      expect(roundMoney(totals.taxableAmount), label).toBe(totals.taxableAmount)
+
+      // The sheet adds up: tax on top when exclusive, inside when inclusive.
+      if (d.taxInclusive) expect(totals.totalAmount, label).toBe(base)
+      else expect(totals.totalAmount, label).toBe(roundMoney(base + totals.taxAmount))
+
+      // Nothing is negative, and the tax is charged on no more than the base.
+      expect(totals.taxAmount, label).toBeGreaterThanOrEqual(0)
+      expect(totals.taxableAmount, label).toBeGreaterThanOrEqual(0)
+      expect(totals.taxableAmount, label).toBeLessThanOrEqual(base)
+
+      // Never more tax than taxing every line would have charged.
+      const full = calculateTotals({ ...d, taxableSubtotal: null })
+      expect(totals.taxAmount, label).toBeLessThanOrEqual(full.taxAmount)
+
+      // The printed components are the printed tax.
+      if (totals.components) {
+        const sum = roundMoney(totals.components.reduce((s, c) => s + c.amount, 0))
+        expect(sum, label).toBe(totals.taxAmount)
+      }
+    }
+  })
+
+  it('is not changed in any figure by this feature while every line is taxed', () => {
+    const next = prng(4242)
+    for (let run = 0; run < RUNS; run++) {
+      const d = randomDocument(next)
+      const { taxableSubtotal: _ignored, lines: _lines, ...legacy } = d
+      const before = calculateTotals(legacy)
+      const label = JSON.stringify(d)
+      // Said three ways: not said, said as null, said as the whole subtotal.
+      expect(calculateTotals({ ...legacy, taxableSubtotal: undefined }), label).toEqual(before)
+      expect(calculateTotals({ ...legacy, taxableSubtotal: null }), label).toEqual(before)
+      expect(calculateTotals({ ...legacy, taxableSubtotal: d.subtotal }), label).toEqual(before)
+      // And the taxable amount of such a document is its whole net base.
+      const base = Math.max(0, roundMoney(d.subtotal - d.discountAmount))
+      expect(before.taxableAmount, label).toBe(
+        d.taxInclusive ? roundMoney(base - before.taxAmount) : base
+      )
+    }
+  })
+
+  it('charges no tax at all when no line is taxed', () => {
+    const next = prng(7)
+    for (let run = 0; run < 500; run++) {
+      const d = randomDocument(next)
+      const totals = calculateTotals({ ...d, taxableSubtotal: 0 })
+      const base = Math.max(0, roundMoney(d.subtotal - d.discountAmount))
+      expect(totals.taxAmount).toBe(0)
+      expect(totals.totalAmount).toBe(base)
+      expect(totals.taxableAmount).toBe(0)
+    }
+  })
+
+  it('charges the rate on the taxable amount, to within a cent of rounding', () => {
+    const next = prng(99)
+    for (let run = 0; run < RUNS; run++) {
+      const d = { ...randomDocument(next), components: null }
+      const totals = calculateTotals(d)
+      const expected = totals.taxableAmount * (d.taxRate / 100)
+      expect(Math.abs(totals.taxAmount - expected), JSON.stringify(d)).toBeLessThanOrEqual(0.0101)
+    }
+  })
+
+  it('shows figures that add back up to the total, net or with tax in them', () => {
+    const next = prng(31337)
+    for (let run = 0; run < RUNS; run++) {
+      const d = randomDocument(next)
+      if (d.subtotal <= 0) continue
+      const stored = storedTaxableSubtotal(d.subtotal, d.taxableSubtotal)
+      const totals = calculateTotals({ ...d, components: null, taxableSubtotal: stored })
+      const label = JSON.stringify(d)
+
+      // Net reading: subtotal less discount plus tax is the total.
+      const net = taxDisplay({ taxRate: d.taxRate, taxInclusive: d.taxInclusive })
+      const netSum =
+        net.mixed(d.subtotal, stored) -
+        net.discount(d.discountAmount, d.subtotal, stored) +
+        totals.taxAmount
+      expect(Math.abs(netSum - totals.totalAmount), label).toBeLessThanOrEqual(0.0101)
+
+      // With tax in the lines: subtotal less discount is the total already.
+      const gross = taxDisplay({ taxRate: d.taxRate, taxInclusive: d.taxInclusive, withTax: true })
+      const grossSum =
+        gross.mixed(d.subtotal, stored) - gross.discount(d.discountAmount, d.subtotal, stored)
+      expect(Math.abs(grossSum - totals.totalAmount), label).toBeLessThanOrEqual(0.0101)
+    }
+  })
+
+  it('reports a taxable amount that the rate turns back into the tax collected', () => {
+    const next = prng(555)
+    for (let run = 0; run < RUNS; run++) {
+      const d = randomDocument(next)
+      if (d.subtotal <= 0) continue
+      const stored = storedTaxableSubtotal(d.subtotal, d.taxableSubtotal)
+      const totals = calculateTotals({ ...d, components: null, taxableSubtotal: stored })
+      const taxed = taxedNetAmountOf({
+        totalAmount: totals.totalAmount,
+        taxAmount: totals.taxAmount,
+        taxInclusive: d.taxInclusive,
+        subtotal: d.subtotal,
+        taxableSubtotal: stored,
+      })
+      expect(taxed, JSON.stringify(d)).toBeGreaterThanOrEqual(0)
+      // Within a cent of rounding on each side of the multiplication.
+      expect(
+        Math.abs(taxed * (d.taxRate / 100) - totals.taxAmount),
+        JSON.stringify(d)
+      ).toBeLessThanOrEqual(0.021)
+    }
+  })
+
+  it('keeps the customer total through a change of tax mode, to the cent', () => {
+    const next = prng(8080)
+    for (let run = 0; run < RUNS; run++) {
+      const d = randomDocument(next)
+      if (d.taxRate <= 0 || d.subtotal <= 0) continue
+      const stored = storedTaxableSubtotal(d.subtotal, d.taxableSubtotal)
+      const from = calculateTotals({ ...d, components: null, taxableSubtotal: stored })
+      const to = d.taxInclusive ? 'exclusive' : 'inclusive'
+      const moved = taxModeConversion(
+        {
+          subtotal: d.subtotal,
+          taxableSubtotal: stored,
+          discountType: 'fixed',
+          discountValue: d.discountAmount,
+          discountAmount: d.discountAmount,
+          taxRate: d.taxRate,
+        },
+        to
+      )
+      const after = calculateTotals({
+        subtotal: moved.subtotal,
+        discountAmount: moved.discountAmount,
+        taxRate: d.taxRate,
+        taxInclusive: !d.taxInclusive,
+        taxableSubtotal: moved.taxableSubtotal,
+      })
+      // Each side rounds its own subtotal and discount, so a cent or two of
+      // drift is the most that re-pricing every line can cause.
+      expect(Math.abs(after.totalAmount - from.totalAmount), JSON.stringify(d)).toBeLessThanOrEqual(
+        0.03
+      )
+    }
+  })
+})
+
+describe('the default rule', () => {
+  it('taxes every kind', () => {
+    expect(TAX_APPLIES_TO_ALL).toEqual({ parts: true, labor: true, shopFee: true })
+  })
+})

+ 3 - 1
src/app/(authenticated)/billing/recurring/page.tsx

@@ -2,6 +2,7 @@ import { resolveCurrencyCode } from '@/lib/currencies'
 import { getRecurringInvoices } from '@/features/billing/Actions/recurringInvoiceActions'
 import { getRecurringInvoices } from '@/features/billing/Actions/recurringInvoiceActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { readTaxApplies, TAX_APPLIES_SETTING_KEYS } from '@/features/settings/Lib/workshopTax'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { ListPage } from '@/components/list-page'
 import { ListPage } from '@/components/list-page'
 import RecurringInvoicesClient from '@/features/billing/Components/RecurringInvoicesClient'
 import RecurringInvoicesClient from '@/features/billing/Components/RecurringInvoicesClient'
@@ -18,7 +19,7 @@ export default async function RecurringInvoicesPage() {
 
 
   const [result, settingsResult, vehicles] = await Promise.all([
   const [result, settingsResult, vehicles] = await Promise.all([
     getRecurringInvoices(),
     getRecurringInvoices(),
-    getDisplaySettings([SETTING_KEYS.CURRENCY_CODE]),
+    getDisplaySettings([SETTING_KEYS.CURRENCY_CODE, ...TAX_APPLIES_SETTING_KEYS]),
     db.vehicle.findMany({
     db.vehicle.findMany({
       where: { organizationId: membership.organizationId, isArchived: false },
       where: { organizationId: membership.organizationId, isArchived: false },
       select: {
       select: {
@@ -44,6 +45,7 @@ export default async function RecurringInvoicesPage() {
           invoices={invoices}
           invoices={invoices}
           vehicles={vehicles}
           vehicles={vehicles}
           currencyCode={currencyCode}
           currencyCode={currencyCode}
+          taxApplies={readTaxApplies(settings)}
         />
         />
       </ListPage>
       </ListPage>
     </>
     </>

+ 5 - 0
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -1,3 +1,4 @@
+import { workshopTaxOffer } from '@/features/settings/Lib/workshopTaxOffer'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { resolveCurrencyCode } from '@/lib/currencies'
 import { getQuote } from '@/features/quotes/Actions/quoteActions'
 import { getQuote } from '@/features/quotes/Actions/quoteActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
 import { getDisplaySettings } from '@/features/settings/Actions/settingsActions'
@@ -14,6 +15,7 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { getQuoteLockState } from '@/lib/document-lock.server'
 import { getQuoteLockState } from '@/lib/document-lock.server'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
+import { readLineTaxSettings, TAX_APPLIES_SETTING_KEYS } from '@/features/settings/Lib/workshopTax'
 import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
 import { QuotePageClient } from '@/features/quotes/Components/QuotePageClient'
 
 
 export default async function QuoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
 export default async function QuoteDetailPage({ params }: { params: Promise<{ id: string }> }) {
@@ -24,6 +26,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.CURRENCY_CODE,
       SETTING_KEYS.DEFAULT_TAX_RATE,
       SETTING_KEYS.DEFAULT_TAX_RATE,
       SETTING_KEYS.TAX_ENABLED,
       SETTING_KEYS.TAX_ENABLED,
+      ...TAX_APPLIES_SETTING_KEYS,
       SETTING_KEYS.DEFAULT_LABOR_RATE,
       SETTING_KEYS.DEFAULT_LABOR_RATE,
       SETTING_KEYS.UNIT_SYSTEM,
       SETTING_KEYS.UNIT_SYSTEM,
       ...WARRANTY_SETTING_KEYS,
       ...WARRANTY_SETTING_KEYS,
@@ -85,6 +88,8 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         taxEnabled={taxEnabled}
         taxEnabled={taxEnabled}
         defaultLaborRate={defaultLaborRate}
         defaultLaborRate={defaultLaborRate}
         shopFee={readShopFee(settings)}
         shopFee={readShopFee(settings)}
+        lineTax={readLineTaxSettings(settings)}
+        taxOffer={orgId ? await workshopTaxOffer(orgId, 'quote', id) : null}
         warrantyTexts={warrantyTextsOf(readWarrantyDefaults(settings))}
         warrantyTexts={warrantyTextsOf(readWarrantyDefaults(settings))}
         distanceUnit={settings[SETTING_KEYS.UNIT_SYSTEM] === 'metric' ? 'km' : 'mi'}
         distanceUnit={settings[SETTING_KEYS.UNIT_SYSTEM] === 'metric' ? 'km' : 'mi'}
         laborPresets={laborPresets}
         laborPresets={laborPresets}

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

@@ -12,6 +12,10 @@ export default async function TaxSettingsPage() {
       SETTING_KEYS.TAX_LABEL,
       SETTING_KEYS.TAX_LABEL,
       SETTING_KEYS.TAX_MODE,
       SETTING_KEYS.TAX_MODE,
       SETTING_KEYS.TAX_COMPONENTS,
       SETTING_KEYS.TAX_COMPONENTS,
+      SETTING_KEYS.TAX_SELECTIVE,
+      SETTING_KEYS.TAX_APPLIES_TO_PARTS,
+      SETTING_KEYS.TAX_APPLIES_TO_LABOR,
+      SETTING_KEYS.TAX_APPLIES_TO_SHOP_FEE,
       SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX,
       SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX,
     ]),
     ]),
     getTaxBackfillCounts(),
     getTaxBackfillCounts(),

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

@@ -88,6 +88,18 @@ export function TaxSettings({
   // Off for every workshop that has not asked for it: the page then reads
   // Off for every workshop that has not asked for it: the page then reads
   // exactly as it did before the split existed.
   // exactly as it did before the split existed.
   const [splitTax, setSplitTax] = useState(settings[SETTING_KEYS.TAX_MODE] === 'split')
   const [splitTax, setSplitTax] = useState(settings[SETTING_KEYS.TAX_MODE] === 'split')
+  // Which kinds of charge the tax is put on. The three answers are kept when
+  // the switch above them is turned off, so they are there if it comes back.
+  const [taxSelective, setTaxSelective] = useState(settings[SETTING_KEYS.TAX_SELECTIVE] === 'true')
+  const [taxOnParts, setTaxOnParts] = useState(
+    settings[SETTING_KEYS.TAX_APPLIES_TO_PARTS] !== 'false'
+  )
+  const [taxOnLabor, setTaxOnLabor] = useState(
+    settings[SETTING_KEYS.TAX_APPLIES_TO_LABOR] !== 'false'
+  )
+  const [taxOnShopFee, setTaxOnShopFee] = useState(
+    settings[SETTING_KEYS.TAX_APPLIES_TO_SHOP_FEE] !== 'false'
+  )
   const [components, setComponents] = useState<ComponentRow[]>(() =>
   const [components, setComponents] = useState<ComponentRow[]>(() =>
     rowsFromSettings(settings[SETTING_KEYS.TAX_COMPONENTS])
     rowsFromSettings(settings[SETTING_KEYS.TAX_COMPONENTS])
   )
   )
@@ -137,6 +149,10 @@ export function TaxSettings({
       [SETTING_KEYS.TAX_LABEL]: taxLabel.trim(),
       [SETTING_KEYS.TAX_LABEL]: taxLabel.trim(),
       [SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX]: String(lineItemsInclTax),
       [SETTING_KEYS.INVOICE_LINE_ITEMS_INCL_TAX]: String(lineItemsInclTax),
       [SETTING_KEYS.TAX_MODE]: taxEnabled && splitTax ? 'split' : 'single',
       [SETTING_KEYS.TAX_MODE]: taxEnabled && splitTax ? 'split' : 'single',
+      [SETTING_KEYS.TAX_SELECTIVE]: String(taxSelective),
+      [SETTING_KEYS.TAX_APPLIES_TO_PARTS]: String(taxOnParts),
+      [SETTING_KEYS.TAX_APPLIES_TO_LABOR]: String(taxOnLabor),
+      [SETTING_KEYS.TAX_APPLIES_TO_SHOP_FEE]: String(taxOnShopFee),
       [SETTING_KEYS.TAX_COMPONENTS]:
       [SETTING_KEYS.TAX_COMPONENTS]:
         taxEnabled && splitTax && splitDefinitions
         taxEnabled && splitTax && splitDefinitions
           ? serializeTaxComponentDefinitions(splitDefinitions)
           ? serializeTaxComponentDefinitions(splitDefinitions)
@@ -356,6 +372,47 @@ export function TaxSettings({
               </div>
               </div>
             )}
             )}
 
 
+            {/* Many places tax the parts and not the labor, or leave a fee
+                out. Off, the tax is on everything and nothing about it shows
+                on a work order or a quote. */}
+            {taxEnabled && (
+              <div className="flex items-start justify-between gap-4">
+                <div className="space-y-1">
+                  <Label htmlFor="taxSelective">{t('tax.selectiveLabel')}</Label>
+                  <p className="text-xs text-muted-foreground">{t('tax.selectiveHint')}</p>
+                </div>
+                <Switch
+                  id="taxSelective"
+                  checked={taxSelective}
+                  onCheckedChange={setTaxSelective}
+                />
+              </div>
+            )}
+
+            {taxEnabled && taxSelective && (
+              <div
+                className="space-y-3 rounded-lg border bg-muted/30 p-4"
+                data-testid="tax-applies"
+              >
+                <p className="text-sm font-medium">{t('tax.appliesTitle')}</p>
+                {(
+                  [
+                    ['taxOnParts', 'appliesParts', taxOnParts, setTaxOnParts],
+                    ['taxOnLabor', 'appliesLabor', taxOnLabor, setTaxOnLabor],
+                    ['taxOnShopFee', 'appliesShopFee', taxOnShopFee, setTaxOnShopFee],
+                  ] as const
+                ).map(([id, labelKey, checked, onChange]) => (
+                  <div key={id} className="flex items-center justify-between gap-4">
+                    <Label htmlFor={id} className="font-normal">
+                      {t(`tax.${labelKey}`)}
+                    </Label>
+                    <Switch id={id} checked={checked} onCheckedChange={onChange} />
+                  </div>
+                ))}
+                <p className="text-xs text-muted-foreground">{t('tax.appliesHint')}</p>
+              </div>
+            )}
+
             {taxEnabled && (
             {taxEnabled && (
               <div className="space-y-2">
               <div className="space-y-2">
                 <Label>{t('tax.modeLabel')}</Label>
                 <Label>{t('tax.modeLabel')}</Label>

+ 3 - 0
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -15,6 +15,7 @@ import {
   FileCheck2,
   FileCheck2,
 } from 'lucide-react'
 } from 'lucide-react'
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import { taxableSubtotalOf } from '@/lib/taxable-lines'
 import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
 import { useLocale, useTranslations } from 'next-intl'
 import { useLocale, useTranslations } from 'next-intl'
 import {
 import {
@@ -307,6 +308,8 @@ export function InvoiceView({
     discountAmount: computedDiscountStored,
     discountAmount: computedDiscountStored,
     taxRate: record.taxRate,
     taxRate: record.taxRate,
     taxInclusive: recordTaxInclusive,
     taxInclusive: recordTaxInclusive,
+    // The same lines the checkout totals, read the same way.
+    taxableSubtotal: taxableSubtotalOf(record.partItems, record.laborItems),
   })
   })
 
 
   // Universal display: net per-line/category in both modes (no-op for exclusive,
   // Universal display: net per-line/category in both modes (no-op for exclusive,

+ 1 - 0
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -105,6 +105,7 @@ export default async function PublicInvoicePage({
       category: p.category,
       category: p.category,
       unitPrice: p.unitPrice,
       unitPrice: p.unitPrice,
       total: p.total,
       total: p.total,
+      taxable: p.taxable,
     })),
     })),
     attachments: record.attachments
     attachments: record.attachments
       .filter((att) => att.includeInInvoice !== false)
       .filter((att) => att.includeInInvoice !== false)

+ 3 - 0
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -54,6 +54,9 @@ export default async function PublicQuotePage({
           unitPrice: true,
           unitPrice: true,
           total: true,
           total: true,
           excluded: true,
           excluded: true,
+          // The sheet works its tax out from the lines, so each has to say
+          // whether it is taxed; left out here, every line would be.
+          taxable: true,
         },
         },
       },
       },
       laborItems: true,
       laborItems: true,

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

@@ -75,6 +75,15 @@ async function parseBackup(
   return { backup, files: null }
   return { backup, files: null }
 }
 }
 
 
+/**
+ * A document's taxable subtotal as the backup holds it: the figure when some
+ * of its lines were untaxed, null (the whole subtotal) otherwise, which is
+ * also what every backup from before the column existed says by omission.
+ */
+function storedTaxableSubtotalFromBackup(value: unknown): number | null {
+  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
+}
+
 /** A foreign key from the backup, kept only when this import restored its target. */
 /** A foreign key from the backup, kept only when this import restored its target. */
 function keptReference(id: unknown, restored: ReadonlySet<string> | undefined): string | null {
 function keptReference(id: unknown, restored: ReadonlySet<string> | undefined): string | null {
   return typeof id === 'string' && id && restored?.has(id) ? id : null
   return typeof id === 'string' && id && restored?.has(id) ? id : null
@@ -153,6 +162,7 @@ async function importServiceRecordTree(
       taxAmount: (sr.taxAmount as number) || 0,
       taxAmount: (sr.taxAmount as number) || 0,
       taxInclusive: (sr.taxInclusive as boolean) ?? false,
       taxInclusive: (sr.taxInclusive as boolean) ?? false,
       taxComponents: taxComponentsForCopy(sr.taxComponents),
       taxComponents: taxComponentsForCopy(sr.taxComponents),
+      taxableSubtotal: storedTaxableSubtotalFromBackup(sr.taxableSubtotal),
       totalAmount: (sr.totalAmount as number) || 0,
       totalAmount: (sr.totalAmount as number) || 0,
       invoiceNumber: (sr.invoiceNumber as string) || null,
       invoiceNumber: (sr.invoiceNumber as string) || null,
       discountType: (sr.discountType as string) || null,
       discountType: (sr.discountType as string) || null,
@@ -210,6 +220,9 @@ async function importServiceRecordTree(
         // Inventory parts are restored earlier in this transaction with their
         // Inventory parts are restored earlier in this transaction with their
         // ids preserved, so the stock link survives a restore verbatim.
         // ids preserved, so the stock link survives a restore verbatim.
         inventoryPartId: (p.inventoryPartId as string) || null,
         inventoryPartId: (p.inventoryPartId as string) || null,
+        // A backup taken before lines could be untaxed has no flag: every
+        // line in it was taxed. One that says false is restored as it was.
+        taxable: p.taxable !== false,
         serviceRecordId: sr.id as string,
         serviceRecordId: sr.id as string,
       })),
       })),
     })
     })
@@ -246,6 +259,10 @@ async function importServiceRecordTree(
         hours: (l.hours as number) || 0,
         hours: (l.hours as number) || 0,
         rate: (l.rate as number) || 0,
         rate: (l.rate as number) || 0,
         total: (l.total as number) || 0,
         total: (l.total as number) || 0,
+        // How the line is priced is what makes a shop fee a shop fee; left
+        // out, a restored fee came back as an hour of labor.
+        pricingType: (l.pricingType as string) || 'hourly',
+        taxable: l.taxable !== false,
         serviceRecordId: sr.id as string,
         serviceRecordId: sr.id as string,
       })),
       })),
     })
     })
@@ -1107,6 +1124,7 @@ export async function POST(request: NextRequest) {
               taxAmount: (q.taxAmount as number) || 0,
               taxAmount: (q.taxAmount as number) || 0,
               taxInclusive: (q.taxInclusive as boolean) ?? false,
               taxInclusive: (q.taxInclusive as boolean) ?? false,
               taxComponents: taxComponentsForCopy(q.taxComponents),
               taxComponents: taxComponentsForCopy(q.taxComponents),
+              taxableSubtotal: storedTaxableSubtotalFromBackup(q.taxableSubtotal),
               discountType: (q.discountType as string) || null,
               discountType: (q.discountType as string) || null,
               discountValue: (q.discountValue as number) || 0,
               discountValue: (q.discountValue as number) || 0,
               discountAmount: (q.discountAmount as number) || 0,
               discountAmount: (q.discountAmount as number) || 0,
@@ -1142,6 +1160,7 @@ export async function POST(request: NextRequest) {
                 // Same-transaction inventory restore keeps ids, so the link
                 // Same-transaction inventory restore keeps ids, so the link
                 // carried into ServicePart on conversion stays intact.
                 // carried into ServicePart on conversion stays intact.
                 inventoryPartId: (p.inventoryPartId as string) || null,
                 inventoryPartId: (p.inventoryPartId as string) || null,
+                taxable: p.taxable !== false,
                 quoteId: q.id as string,
                 quoteId: q.id as string,
               })),
               })),
             })
             })
@@ -1157,6 +1176,11 @@ export async function POST(request: NextRequest) {
                 hours: (l.hours as number) || 0,
                 hours: (l.hours as number) || 0,
                 rate: (l.rate as number) || 0,
                 rate: (l.rate as number) || 0,
                 total: (l.total as number) || 0,
                 total: (l.total as number) || 0,
+                pricingType: (l.pricingType as string) || 'hourly',
+                // A line the customer declined stays declined; left out, it
+                // came back counted in a total that never included it.
+                excluded: (l.excluded as boolean) || false,
+                taxable: l.taxable !== false,
                 quoteId: q.id as string,
                 quoteId: q.id as string,
               })),
               })),
             })
             })

+ 3 - 0
src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts

@@ -10,6 +10,7 @@ import {
 import { writeLog } from '@/features/integrations/Lib/connections'
 import { writeLog } from '@/features/integrations/Lib/connections'
 import { rateLimit } from '@/lib/rate-limit'
 import { rateLimit } from '@/lib/rate-limit'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
+import { taxableSubtotalOf } from '@/lib/taxable-lines'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 
 
@@ -79,6 +80,8 @@ export async function POST(
       discountAmount: computedDiscount,
       discountAmount: computedDiscount,
       taxRate: record.taxRate,
       taxRate: record.taxRate,
       taxInclusive: record.taxInclusive ?? false,
       taxInclusive: record.taxInclusive ?? false,
+      // The stored lines carry a plain yes or no.
+      taxableSubtotal: taxableSubtotalOf(record.partItems, record.laborItems),
     })
     })
     const displayTotal =
     const displayTotal =
       record.totalAmount > 0 ? record.totalAmount : computedTotal > 0 ? computedTotal : record.cost
       record.totalAmount > 0 ? record.totalAmount : computedTotal > 0 ? computedTotal : record.cost

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

@@ -9,6 +9,8 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
 import { assertInvoiceEditable } from '@/lib/document-lock.server'
 import { announceLaborAdded } from '@/features/vehicles/Lib/jobEvents'
 import { announceLaborAdded } from '@/features/vehicles/Lib/jobEvents'
 import { refreshShopFeeLines } from '@/features/vehicles/Lib/shopFeeLines'
 import { refreshShopFeeLines } from '@/features/vehicles/Lib/shopFeeLines'
+import { serviceRecordLineSums } from '@/features/vehicles/Lib/lineSums'
+import { loadTaxApplies } from '@/features/settings/Lib/loadTaxApplies'
 
 
 /**
 /**
  * Adds a line of work to the job.
  * Adds a line of work to the job.
@@ -68,6 +70,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       // refusal. The work still happened, and the office can price it.
       // refusal. The work still happened, and the office can price it.
       const safeRate = Number.isFinite(rate) && rate > 0 ? rate : 0
       const safeRate = Number.isFinite(rate) && rate > 0 ? rate : 0
 
 
+      // Whether labor is taxed is the workshop's rule, like the rate: the
+      // phone does not say, and a line written without asking would be taxed
+      // in a workshop that taxes parts alone.
+      const taxApplies = await loadTaxApplies(ctx.organizationId)
+
       const line = await db.$transaction(async (tx) => {
       const line = await db.$transaction(async (tx) => {
         const created = await tx.serviceLabor.create({
         const created = await tx.serviceLabor.create({
           data: {
           data: {
@@ -76,6 +83,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
             rate: safeRate,
             rate: safeRate,
             total: roundMoney(safeRate * hours),
             total: roundMoney(safeRate * hours),
             pricingType: 'hourly',
             pricingType: 'hourly',
+            taxable: taxApplies.labor,
             serviceRecordId: job.id,
             serviceRecordId: job.id,
           },
           },
           select: { id: true, description: true, hours: true, rate: true, total: true },
           select: { id: true, description: true, hours: true, rate: true, total: true },
@@ -85,24 +93,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
         // the job's totals can never disagree with its lines.
         // the job's totals can never disagree with its lines.
         await refreshShopFeeLines(tx, job.id, ctx.organizationId)
         await refreshShopFeeLines(tx, job.id, ctx.organizationId)
 
 
-        const [partsAgg, laborAgg] = await Promise.all([
-          tx.servicePart.aggregate({ where: { serviceRecordId: job.id }, _sum: { total: true } }),
-          tx.serviceLabor.aggregate({ where: { serviceRecordId: job.id }, _sum: { total: true } }),
-        ])
-
-        const subtotal = (partsAgg._sum.total || 0) + (laborAgg._sum.total || 0)
+        const sums = await serviceRecordLineSums(tx, job.id)
+        const subtotal = sums.subtotal
         const discountAmount = discountAmountFor(subtotal, job.discountType, job.discountValue)
         const discountAmount = discountAmountFor(subtotal, job.discountType, job.discountValue)
-        const { taxAmount, totalAmount, taxComponents } = documentTotals({
+        const { taxAmount, totalAmount, taxComponents, taxableSubtotal } = documentTotals({
           subtotal,
           subtotal,
           discountAmount,
           discountAmount,
           taxRate: job.taxRate,
           taxRate: job.taxRate,
           taxInclusive: job.taxInclusive,
           taxInclusive: job.taxInclusive,
           taxComponents: job.taxComponents,
           taxComponents: job.taxComponents,
+          taxableSubtotal: sums.taxableSubtotal,
         })
         })
 
 
         await tx.serviceRecord.update({
         await tx.serviceRecord.update({
           where: { id: job.id },
           where: { id: job.id },
-          data: { subtotal, taxAmount, totalAmount, taxComponents },
+          data: { subtotal, taxAmount, totalAmount, taxComponents, taxableSubtotal },
         })
         })
 
 
         return created
         return created

+ 15 - 1
src/features/ai/tools/workshop-columns.ts

@@ -82,6 +82,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'taxAmount',
     'taxAmount',
     'taxInclusive',
     'taxInclusive',
     'taxComponents',
     'taxComponents',
+    'taxableSubtotal',
     'totalAmount',
     'totalAmount',
     'invoiceNumber',
     'invoiceNumber',
     'invoiceDate',
     'invoiceDate',
@@ -131,9 +132,19 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'unitCost',
     'unitCost',
     'markupPercent',
     'markupPercent',
     'inventoryPartId',
     'inventoryPartId',
+    'taxable',
+    'serviceRecordId',
+  ],
+  service_labor: [
+    'id',
+    'description',
+    'hours',
+    'rate',
+    'total',
+    'pricingType',
+    'taxable',
     'serviceRecordId',
     'serviceRecordId',
   ],
   ],
-  service_labor: ['id', 'description', 'hours', 'rate', 'total', 'pricingType', 'serviceRecordId'],
   service_attachments: [
   service_attachments: [
     'id',
     'id',
     'fileName',
     'fileName',
@@ -206,6 +217,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'taxAmount',
     'taxAmount',
     'taxInclusive',
     'taxInclusive',
     'taxComponents',
     'taxComponents',
+    'taxableSubtotal',
     'discountType',
     'discountType',
     'discountValue',
     'discountValue',
     'discountAmount',
     'discountAmount',
@@ -244,6 +256,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'unitPrice',
     'unitPrice',
     'total',
     'total',
     'excluded',
     'excluded',
+    'taxable',
     'inventoryPartId',
     'inventoryPartId',
     'quoteId',
     'quoteId',
   ],
   ],
@@ -255,6 +268,7 @@ export const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = {
     'total',
     'total',
     'pricingType',
     'pricingType',
     'excluded',
     'excluded',
+    'taxable',
     'quoteId',
     'quoteId',
   ],
   ],
   quote_attachments: [
   quote_attachments: [

+ 14 - 2
src/features/billing/Actions/recurringInvoiceActions.ts

@@ -2,6 +2,7 @@
 
 
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
+import { loadTaxApplies } from '@/features/settings/Lib/loadTaxApplies'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import {
 import {
@@ -18,7 +19,8 @@ import {
   WORKSHOP_TAX_SETTING_KEYS,
   WORKSHOP_TAX_SETTING_KEYS,
 } from '@/features/settings/Lib/workshopTax'
 } from '@/features/settings/Lib/workshopTax'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
-import { calculateNextRunDate } from '@/lib/cron/recurring-invoices'
+import { calculateNextRunDate, recurringTaxableSubtotal } from '@/lib/cron/recurring-invoices'
+import { laborTaxable } from '@/lib/taxable-lines'
 import { endOfWorkshopDay, toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { endOfWorkshopDay, toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 
@@ -326,6 +328,7 @@ export async function processRecurringInvoices() {
       })
       })
 
 
       const results: { id: string; serviceRecordId: string }[] = []
       const results: { id: string; serviceRecordId: string }[] = []
+      const taxApplies = await loadTaxApplies(organizationId)
 
 
       for (const ri of dueInvoices) {
       for (const ri of dueInvoices) {
         const invoiceNumber = await generateInvoiceNumber(organizationId)
         const invoiceNumber = await generateInvoiceNumber(organizationId)
@@ -334,12 +337,15 @@ export async function processRecurringInvoices() {
         const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0)
         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 laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0)
         const subtotal = ri.cost + partsSubtotal + laborSubtotal
         const subtotal = ri.cost + partsSubtotal + laborSubtotal
-        const { taxAmount, totalAmount, taxComponents } = documentTotals({
+        // A template's lines carry no tax choice of their own, so each
+        // invoice it generates follows the workshop's rule on the day.
+        const { taxAmount, totalAmount, taxComponents, taxableSubtotal } = documentTotals({
           subtotal,
           subtotal,
           discountAmount: 0,
           discountAmount: 0,
           taxRate: ri.taxRate,
           taxRate: ri.taxRate,
           taxInclusive: ri.taxInclusive,
           taxInclusive: ri.taxInclusive,
           taxComponents: ri.taxComponents,
           taxComponents: ri.taxComponents,
+          taxableSubtotal: recurringTaxableSubtotal(ri, taxApplies),
         })
         })
 
 
         const serviceRecord = await db.$transaction(async (tx) => {
         const serviceRecord = await db.$transaction(async (tx) => {
@@ -358,6 +364,7 @@ export async function processRecurringInvoices() {
               taxRate: ri.taxRate,
               taxRate: ri.taxRate,
               taxInclusive: ri.taxInclusive,
               taxInclusive: ri.taxInclusive,
               taxComponents,
               taxComponents,
+              taxableSubtotal,
               taxAmount,
               taxAmount,
               totalAmount,
               totalAmount,
               invoiceNumber,
               invoiceNumber,
@@ -369,6 +376,7 @@ export async function processRecurringInvoices() {
                   quantity: p.quantity,
                   quantity: p.quantity,
                   unitPrice: p.unitPrice,
                   unitPrice: p.unitPrice,
                   total: lineTotal(p.quantity, p.unitPrice),
                   total: lineTotal(p.quantity, p.unitPrice),
+                  taxable: taxApplies.parts,
                 })),
                 })),
               },
               },
               laborItems: {
               laborItems: {
@@ -377,6 +385,10 @@ export async function processRecurringInvoices() {
                   hours: l.hours,
                   hours: l.hours,
                   rate: l.rate,
                   rate: l.rate,
                   total: l.hours * l.rate,
                   total: l.hours * l.rate,
+                  // The kind goes with the line, as the hourly run copies it,
+                  // so a shop fee stays a shop fee and is taxed as one.
+                  pricingType: l.pricingType || 'hourly',
+                  taxable: laborTaxable(l, taxApplies),
                 })),
                 })),
               },
               },
             },
             },

+ 14 - 0
src/features/billing/Components/RecurringInvoicesClient.tsx

@@ -40,6 +40,7 @@ import { TableCellLink } from '@/components/table-cell-link'
 import { Car, Loader2, Plus, Pause, Play, Trash2, User, Zap, X } from 'lucide-react'
 import { Car, Loader2, Plus, Pause, Play, Trash2, User, Zap, X } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { calculateTotals } from '@/lib/tax'
 import { calculateTotals } from '@/lib/tax'
+import { recurringTaxableSubtotal, TAX_APPLIES_TO_ALL, type TaxApplies } from '@/lib/taxable-lines'
 import {
 import {
   createRecurringInvoice,
   createRecurringInvoice,
   toggleRecurringInvoice,
   toggleRecurringInvoice,
@@ -69,6 +70,8 @@ interface TemplateLabor {
   description: string
   description: string
   hours: number
   hours: number
   rate: number
   rate: number
+  /** A shop fee is a labor row told apart by this, and is taxed by its own rule. */
+  pricingType?: string | null
 }
 }
 
 
 interface RecurringInvoice {
 interface RecurringInvoice {
@@ -102,6 +105,12 @@ interface RecurringInvoicesClientProps {
   invoices: RecurringInvoice[]
   invoices: RecurringInvoice[]
   vehicles: Vehicle[]
   vehicles: Vehicle[]
   currencyCode: string
   currencyCode: string
+  /**
+   * Which kinds of line the workshop taxes. A template's lines carry no
+   * choice of their own, so the total shown is what the next invoice will
+   * come to under today's rule.
+   */
+  taxApplies?: TaxApplies
 }
 }
 
 
 const SERVICE_TYPES = [
 const SERVICE_TYPES = [
@@ -128,6 +137,7 @@ export default function RecurringInvoicesClient({
   invoices,
   invoices,
   vehicles,
   vehicles,
   currencyCode,
   currencyCode,
+  taxApplies = TAX_APPLIES_TO_ALL,
 }: RecurringInvoicesClientProps) {
 }: RecurringInvoicesClientProps) {
   const formatCurrency = useFormatCurrency()
   const formatCurrency = useFormatCurrency()
   const router = useRouter()
   const router = useRouter()
@@ -351,6 +361,8 @@ export default function RecurringInvoicesClient({
                   discountAmount: 0,
                   discountAmount: 0,
                   taxRate: inv.taxRate,
                   taxRate: inv.taxRate,
                   taxInclusive: inv.taxInclusive,
                   taxInclusive: inv.taxInclusive,
+                  // The same rule the generated invoice is totalled by.
+                  taxableSubtotal: recurringTaxableSubtotal(inv, taxApplies),
                 })
                 })
                 return (
                 return (
                   <div key={inv.id} className="rounded-lg border bg-card p-3">
                   <div key={inv.id} className="rounded-lg border bg-card p-3">
@@ -434,6 +446,8 @@ export default function RecurringInvoicesClient({
                       discountAmount: 0,
                       discountAmount: 0,
                       taxRate: inv.taxRate,
                       taxRate: inv.taxRate,
                       taxInclusive: inv.taxInclusive,
                       taxInclusive: inv.taxInclusive,
+                      // The same rule the generated invoice is totalled by.
+                      taxableSubtotal: recurringTaxableSubtotal(inv, taxApplies),
                     })
                     })
 
 
                     return (
                     return (

+ 7 - 1
src/features/inspections/Actions/inspectionActions.ts

@@ -15,6 +15,7 @@ import {
   memberSignatureDataUri,
   memberSignatureDataUri,
 } from '@/features/signatures/Lib/memberSignature.server'
 } from '@/features/signatures/Lib/memberSignature.server'
 import { liveCertificateDesign } from '../Pdf/certificateDesign'
 import { liveCertificateDesign } from '../Pdf/certificateDesign'
+import { loadTaxApplies } from '@/features/settings/Lib/loadTaxApplies'
 import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
 import { retotalServiceRecord } from '@/features/vehicles/Lib/retotalServiceRecord'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
 import { defectLineText, defectsWorstFirst } from '../Lib/conversion'
 import { defectLineText, defectsWorstFirst } from '../Lib/conversion'
@@ -796,13 +797,16 @@ export async function createWorkOrderFromInspection(
             mileage: inspection.mileage,
             mileage: inspection.mileage,
           },
           },
         })
         })
-        // Hours start at zero: the workshop prices the job as it works.
+        // Hours start at zero: the workshop prices the job as it works. The
+        // lines are labor, taxed or not as the workshop taxes labor.
+        const laborTaxable = (await loadTaxApplies(organizationId, tx)).labor
         await tx.serviceLabor.createMany({
         await tx.serviceLabor.createMany({
           data: [inspection.template.name, ...defects.map(defectLineText)].map((description) => ({
           data: [inspection.template.name, ...defects.map(defectLineText)].map((description) => ({
             description,
             description,
             hours: 0,
             hours: 0,
             rate: laborRate,
             rate: laborRate,
             total: 0,
             total: 0,
+            taxable: laborTaxable,
             serviceRecordId: draft.id,
             serviceRecordId: draft.id,
           })),
           })),
         })
         })
@@ -1007,12 +1011,14 @@ export async function linkWorkOrderToInspection(
           ...defects.map(defectLineText),
           ...defects.map(defectLineText),
         ]
         ]
         if (lines.length > 0) {
         if (lines.length > 0) {
+          const laborTaxable = (await loadTaxApplies(organizationId, tx)).labor
           await tx.serviceLabor.createMany({
           await tx.serviceLabor.createMany({
             data: lines.map((description) => ({
             data: lines.map((description) => ({
               description,
               description,
               hours: 0,
               hours: 0,
               rate: laborRate,
               rate: laborRate,
               total: 0,
               total: 0,
+              taxable: laborTaxable,
               serviceRecordId: job.id,
               serviceRecordId: job.id,
             })),
             })),
           })
           })

+ 17 - 1
src/features/integrations/Lib/accounting-sync.ts

@@ -37,6 +37,8 @@ export interface AccountingLine {
   quantity: number
   quantity: number
   unitPrice: number
   unitPrice: number
   total: number
   total: number
+  /** Whether the invoice's tax was charged on the line; a ledger books it under a code that says so. */
+  taxable: boolean
 }
 }
 
 
 export interface AccountingPayment {
 export interface AccountingPayment {
@@ -64,6 +66,8 @@ export interface AccountingInvoice {
   mileage: number | null
   mileage: number | null
   notes: string | null
   notes: string | null
   subtotal: number
   subtotal: number
+  /** The sum of the taxable lines, or null when every line is taxed. */
+  taxableSubtotal: number | null
   discountType: string | null
   discountType: string | null
   discountValue: number
   discountValue: number
   discountAmount: number
   discountAmount: number
@@ -124,6 +128,7 @@ export async function loadInvoiceForAccounting(
       mileage: true,
       mileage: true,
       invoiceNotes: true,
       invoiceNotes: true,
       subtotal: true,
       subtotal: true,
+      taxableSubtotal: true,
       discountType: true,
       discountType: true,
       discountValue: true,
       discountValue: true,
       discountAmount: true,
       discountAmount: true,
@@ -150,10 +155,18 @@ export async function loadInvoiceForAccounting(
           rate: true,
           rate: true,
           total: true,
           total: true,
           pricingType: true,
           pricingType: true,
+          taxable: true,
         },
         },
       },
       },
       partItems: {
       partItems: {
-        select: { partNumber: true, name: true, quantity: true, unitPrice: true, total: true },
+        select: {
+          partNumber: true,
+          name: true,
+          quantity: true,
+          unitPrice: true,
+          total: true,
+          taxable: true,
+        },
       },
       },
       payments: {
       payments: {
         select: {
         select: {
@@ -181,6 +194,7 @@ export async function loadInvoiceForAccounting(
         quantity: hourly ? l.hours : 1,
         quantity: hourly ? l.hours : 1,
         unitPrice: hourly ? l.rate : l.total,
         unitPrice: hourly ? l.rate : l.total,
         total: l.total,
         total: l.total,
+        taxable: l.taxable,
       }
       }
     }),
     }),
     ...r.partItems.map((p) => ({
     ...r.partItems.map((p) => ({
@@ -190,6 +204,7 @@ export async function loadInvoiceForAccounting(
       quantity: p.quantity > 0 ? p.quantity : 1,
       quantity: p.quantity > 0 ? p.quantity : 1,
       unitPrice: p.quantity > 0 ? p.unitPrice : p.total,
       unitPrice: p.quantity > 0 ? p.unitPrice : p.total,
       total: p.total,
       total: p.total,
+      taxable: p.taxable,
     })),
     })),
   ]
   ]
   return {
   return {
@@ -204,6 +219,7 @@ export async function loadInvoiceForAccounting(
     mileage: r.mileage,
     mileage: r.mileage,
     notes: r.invoiceNotes,
     notes: r.invoiceNotes,
     subtotal: r.subtotal,
     subtotal: r.subtotal,
+    taxableSubtotal: r.taxableSubtotal,
     discountType: r.discountType,
     discountType: r.discountType,
     discountValue: r.discountValue,
     discountValue: r.discountValue,
     discountAmount: r.discountAmount,
     discountAmount: r.discountAmount,

+ 29 - 22
src/features/invoice-designer/Pdf/buildInvoicePrint.ts

@@ -2,9 +2,10 @@ import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 import { formatQuantity } from '@/lib/format-quantity'
-import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
+import { printFigures } from './printFigures'
 import { LABOR_GROUP, lineGroupsFor, partGroupKey } from './lineGroups'
 import { LABOR_GROUP, lineGroupsFor, partGroupKey } from './lineGroups'
 import {
 import {
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
@@ -155,17 +156,21 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const money = (value: number) => formatCurrency(value, cc, cf)
   const money = (value: number) => formatCurrency(value, cc, cf)
   const taxRate = data.taxRate
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false
   const taxInclusive = data.taxInclusive ?? false
-  const net = (value: number) => netLineTotal(value, taxRate, taxInclusive)
   // A workshop may print each line with tax in it, the way a consumer
   // A workshop may print each line with tax in it, the way a consumer
   // receipt reads: the lines then sum to the subtotal, and the tax row says
   // receipt reads: the lines then sum to the subtotal, and the tax row says
-  // how much of that is tax rather than adding it on.
+  // how much of that is tax rather than adding it on. Either way a line left
+  // out of the tax prints as entered; see printFigures.
   const linesInclTax = invoiceSettings?.lineItemsInclTax === true && taxRate > 0
   const linesInclTax = invoiceSettings?.lineItemsInclTax === true && taxRate > 0
-  const gross = (value: number) => (taxInclusive ? value : value * (1 + taxRate / 100))
-  const shown = linesInclTax ? gross : net
+  const figures = printFigures({
+    taxRate,
+    taxInclusive,
+    linesInclTax,
+    parts: data.partItems,
+    labor: data.laborItems,
+  })
+  const shown = figures.shown
 
 
-  const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
-  const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
-  const computedSubtotal = partsSubtotal + laborSubtotal
+  const computedSubtotal = figures.partsTotal + figures.laborTotal
   const computedDiscount = discountAmountFor(
   const computedDiscount = discountAmountFor(
     computedSubtotal,
     computedSubtotal,
     data.discountType,
     data.discountType,
@@ -176,6 +181,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     discountAmount: computedDiscount,
     discountAmount: computedDiscount,
     taxRate,
     taxRate,
     taxInclusive,
     taxInclusive,
+    taxableSubtotal: figures.taxableSubtotal,
   })
   })
   const displayTotal =
   const displayTotal =
     data.totalAmount > 0 ? data.totalAmount : computedTotal > 0 ? computedTotal : data.cost
     data.totalAmount > 0 ? data.totalAmount : computedTotal > 0 ? computedTotal : data.cost
@@ -275,8 +281,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       qty: String(l.hours),
       qty: String(l.hours),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       desc: l.description,
-      price: money(shown(l.rate)),
-      total: money(shown(l.total)),
+      price: money(shown(l.rate, l.taxable !== false)),
+      total: money(shown(l.total, l.taxable !== false)),
       group: LABOR_GROUP,
       group: LABOR_GROUP,
     })),
     })),
     ...data.partItems.map((p, i) => ({
     ...data.partItems.map((p, i) => ({
@@ -285,8 +291,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       unit: p.unit || '',
       unit: p.unit || '',
       desc: p.name,
       desc: p.name,
       sub: p.partNumber || undefined,
       sub: p.partNumber || undefined,
-      price: money(shown(p.unitPrice)),
-      total: money(shown(p.total)),
+      price: money(shown(p.unitPrice, p.taxable !== false)),
+      total: money(shown(p.total, p.taxable !== false)),
       group: partGroupKey(p.category),
       group: partGroupKey(p.category),
     })),
     })),
   ]
   ]
@@ -295,8 +301,8 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
     ref: p.partNumber || '-',
     ref: p.partNumber || '-',
     desc: p.name,
     desc: p.name,
     qty: formatQuantity(p.quantity, p.unit),
     qty: formatQuantity(p.quantity, p.unit),
-    price: money(shown(p.unitPrice)),
-    total: money(shown(p.total)),
+    price: money(shown(p.unitPrice, p.taxable !== false)),
+    total: money(shown(p.total, p.taxable !== false)),
     group: partGroupKey(p.category),
     group: partGroupKey(p.category),
   }))
   }))
 
 
@@ -315,11 +321,11 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       desc: l.description,
       desc: l.description,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
       rate: isService
       rate: isService
-        ? money(shown(l.rate))
+        ? money(shown(l.rate, l.taxable !== false))
         : labels.ratePerHour
         : labels.ratePerHour
-          ? fillTemplate(labels.ratePerHour, { rate: money(shown(l.rate)) })
-          : `${money(shown(l.rate))}/hr`,
-      total: money(shown(l.total)),
+          ? fillTemplate(labels.ratePerHour, { rate: money(shown(l.rate, l.taxable !== false)) })
+          : `${money(shown(l.rate, l.taxable !== false))}/hr`,
+      total: money(shown(l.total, l.taxable !== false)),
     }
     }
   })
   })
 
 
@@ -347,12 +353,12 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
   const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
   const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
   const totals: TotalLine[] = []
   const totals: TotalLine[] = []
   if (!itemsTableVisible && data.partItems.length > 0) {
   if (!itemsTableVisible && data.partItems.length > 0) {
-    totals.push({ label: L('parts', 'Parts'), value: money(shown(partsSubtotal)), kind: 'line' })
+    totals.push({ label: L('parts', 'Parts'), value: money(figures.partsShown), kind: 'line' })
   }
   }
   if (!itemsTableVisible && data.laborItems.length > 0) {
   if (!itemsTableVisible && data.laborItems.length > 0) {
-    totals.push({ label: L('labor', 'Labor'), value: money(shown(laborSubtotal)), kind: 'line' })
+    totals.push({ label: L('labor', 'Labor'), value: money(figures.laborShown), kind: 'line' })
   }
   }
-  const displaySubtotal = shown(data.subtotal)
+  const displaySubtotal = figures.subtotalShown(data.subtotal)
   if (displaySubtotal > 0) {
   if (displaySubtotal > 0) {
     totals.push({
     totals.push({
       label: linesInclTax
       label: linesInclTax
@@ -362,7 +368,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       kind: 'line',
       kind: 'line',
     })
     })
   }
   }
-  const displayDiscount = shown(data.discountAmount ?? 0)
+  const displayDiscount = figures.discountShown(data.discountAmount ?? 0, data.subtotal)
   if (displayDiscount > 0) {
   if (displayDiscount > 0) {
     totals.push({
     totals.push({
       label:
       label:
@@ -384,6 +390,7 @@ export function buildInvoicePrintSpec(input: InvoicePrintInput): DocumentSpec {
       linesInclTax,
       linesInclTax,
       labels,
       labels,
       money,
       money,
+      taxableAmount: figures.taxableAmount(data.subtotal, data.discountAmount ?? 0),
     })
     })
   )
   )
   totals.push({ label: L('total', 'Total'), value: money(displayTotal), kind: 'total' })
   totals.push({ label: L('total', 'Total'), value: money(displayTotal), kind: 'total' })

+ 37 - 24
src/features/invoice-designer/Pdf/buildQuotePrint.ts

@@ -2,9 +2,10 @@ import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { DEFAULT_DATE_FORMAT, formatCurrency, formatDateForPdf } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 import { formatQuantity } from '@/lib/format-quantity'
-import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
+import { printFigures } from './printFigures'
 import { LABOR_GROUP, lineGroupsFor, partGroupKey } from './lineGroups'
 import { LABOR_GROUP, lineGroupsFor, partGroupKey } from './lineGroups'
 import {
 import {
   getDefaultInvoiceLayout,
   getDefaultInvoiceLayout,
@@ -75,6 +76,8 @@ export interface QuotePrintData {
     unitPrice: number
     unitPrice: number
     total: number
     total: number
     excluded?: boolean
     excluded?: boolean
+    /** False when the quote's tax is not charged on the line. */
+    taxable?: boolean | null
   }[]
   }[]
   laborItems: {
   laborItems: {
     description: string
     description: string
@@ -83,6 +86,8 @@ export interface QuotePrintData {
     total: number
     total: number
     pricingType?: string
     pricingType?: string
     excluded?: boolean
     excluded?: boolean
+    /** False when the quote's tax is not charged on the line. */
+    taxable?: boolean | null
   }[]
   }[]
   customer: {
   customer: {
     name: string
     name: string
@@ -180,12 +185,18 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
   const money = (value: number) => formatCurrency(value, cc, cf)
   const money = (value: number) => formatCurrency(value, cc, cf)
   const taxRate = data.taxRate
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false
   const taxInclusive = data.taxInclusive ?? false
-  const net = (value: number) => netLineTotal(value, taxRate, taxInclusive)
   // The same choice the invoice offers: lines with tax in them, the subtotal
   // The same choice the invoice offers: lines with tax in them, the subtotal
-  // following, and the tax row saying how much of it is tax.
+  // following, and the tax row saying how much of it is tax. Either way a
+  // line left out of the tax prints as entered; see printFigures.
   const linesInclTax = input.lineItemsInclTax === true && taxRate > 0
   const linesInclTax = input.lineItemsInclTax === true && taxRate > 0
-  const gross = (value: number) => (taxInclusive ? value : value * (1 + taxRate / 100))
-  const shown = linesInclTax ? gross : net
+  const figures = printFigures({
+    taxRate,
+    taxInclusive,
+    linesInclTax,
+    parts: data.partItems,
+    labor: data.laborItems,
+  })
+  const shown = figures.shown
 
 
   const df = input.dateFormat || DEFAULT_DATE_FORMAT
   const df = input.dateFormat || DEFAULT_DATE_FORMAT
   const tz = input.timezone || undefined
   const tz = input.timezone || undefined
@@ -250,8 +261,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       qty: String(l.hours),
       qty: String(l.hours),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       desc: l.description,
-      price: money(shown(l.rate)),
-      total: money(shown(l.total)),
+      price: money(shown(l.rate, l.taxable !== false)),
+      total: money(shown(l.total, l.taxable !== false)),
       excluded: l.excluded,
       excluded: l.excluded,
       group: LABOR_GROUP,
       group: LABOR_GROUP,
     })),
     })),
@@ -261,8 +272,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       unit: p.unit || '',
       unit: p.unit || '',
       desc: p.name,
       desc: p.name,
       sub: p.partNumber || undefined,
       sub: p.partNumber || undefined,
-      price: money(shown(p.unitPrice)),
-      total: money(shown(p.total)),
+      price: money(shown(p.unitPrice, p.taxable !== false)),
+      total: money(shown(p.total, p.taxable !== false)),
       excluded: p.excluded,
       excluded: p.excluded,
       group: partGroupKey(p.category),
       group: partGroupKey(p.category),
     })),
     })),
@@ -272,8 +283,8 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
     ref: p.partNumber || '-',
     ref: p.partNumber || '-',
     desc: p.name,
     desc: p.name,
     qty: formatQuantity(p.quantity, p.unit),
     qty: formatQuantity(p.quantity, p.unit),
-    price: money(shown(p.unitPrice)),
-    total: money(shown(p.total)),
+    price: money(shown(p.unitPrice, p.taxable !== false)),
+    total: money(shown(p.total, p.taxable !== false)),
     excluded: p.excluded,
     excluded: p.excluded,
     group: partGroupKey(p.category),
     group: partGroupKey(p.category),
   }))
   }))
@@ -293,45 +304,46 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       desc: l.description,
       desc: l.description,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
       rate: isService
       rate: isService
-        ? money(shown(l.rate))
+        ? money(shown(l.rate, l.taxable !== false))
         : labels.ratePerHour
         : labels.ratePerHour
-          ? fillTemplate(labels.ratePerHour, { rate: money(shown(l.rate)) })
-          : `${money(shown(l.rate))}/hr`,
-      total: money(shown(l.total)),
+          ? fillTemplate(labels.ratePerHour, { rate: money(shown(l.rate, l.taxable !== false)) })
+          : `${money(shown(l.rate, l.taxable !== false))}/hr`,
+      total: money(shown(l.total, l.taxable !== false)),
       excluded: l.excluded,
       excluded: l.excluded,
     }
     }
   })
   })
 
 
   // Totals from the lines the customer kept; excluded ones do not count.
   // Totals from the lines the customer kept; excluded ones do not count.
-  const laborTotal = data.laborItems.reduce((sum, l) => (l.excluded ? sum : sum + l.total), 0)
-  const partsTotal = data.partItems.reduce((sum, p) => (p.excluded ? sum : sum + p.total), 0)
-  const subtotal = laborTotal + partsTotal
+  const subtotal = figures.laborTotal + figures.partsTotal
   const discount = discountAmountFor(subtotal, data.discountType, data.discountValue)
   const discount = discountAmountFor(subtotal, data.discountType, data.discountValue)
   const { taxAmount, totalAmount } = calculateTotals({
   const { taxAmount, totalAmount } = calculateTotals({
     subtotal,
     subtotal,
     discountAmount: discount,
     discountAmount: discount,
     taxRate,
     taxRate,
     taxInclusive,
     taxInclusive,
+    taxableSubtotal: figures.taxableSubtotal,
   })
   })
 
 
   const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
   const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
   const totals: TotalLine[] = []
   const totals: TotalLine[] = []
   if (!itemsTableVisible && data.laborItems.length > 0) {
   if (!itemsTableVisible && data.laborItems.length > 0) {
-    totals.push({ label: L('labor', 'Labor'), value: money(shown(laborTotal)), kind: 'line' })
+    totals.push({ label: L('labor', 'Labor'), value: money(figures.laborShown), kind: 'line' })
   }
   }
   if (!itemsTableVisible && data.partItems.length > 0) {
   if (!itemsTableVisible && data.partItems.length > 0) {
-    totals.push({ label: L('parts', 'Parts'), value: money(shown(partsTotal)), kind: 'line' })
+    totals.push({ label: L('parts', 'Parts'), value: money(figures.partsShown), kind: 'line' })
   }
   }
-  if (shown(subtotal) > 0) {
+  const displaySubtotal = figures.subtotalShown(subtotal)
+  const displayDiscount = figures.discountShown(discount, subtotal)
+  if (displaySubtotal > 0) {
     totals.push({
     totals.push({
       label: linesInclTax
       label: linesInclTax
         ? L('subtotalInclTax', 'Subtotal (incl. tax)')
         ? L('subtotalInclTax', 'Subtotal (incl. tax)')
         : L('subtotal', 'Subtotal'),
         : L('subtotal', 'Subtotal'),
-      value: money(shown(subtotal)),
+      value: money(displaySubtotal),
       kind: 'line',
       kind: 'line',
     })
     })
   }
   }
-  if (shown(discount) > 0) {
+  if (displayDiscount > 0) {
     totals.push({
     totals.push({
       label:
       label:
         data.discountType === 'percentage'
         data.discountType === 'percentage'
@@ -339,7 +351,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
             ? fillTemplate(labels.discountPercent, { percent: String(data.discountValue) })
             ? fillTemplate(labels.discountPercent, { percent: String(data.discountValue) })
             : `Discount (${data.discountValue}%)`
             : `Discount (${data.discountValue}%)`
           : L('discount', 'Discount'),
           : L('discount', 'Discount'),
-      value: money(-shown(discount)),
+      value: money(-displayDiscount),
       kind: 'discount',
       kind: 'discount',
     })
     })
   }
   }
@@ -351,6 +363,7 @@ export function buildQuotePrintSpec(input: QuotePrintInput): DocumentSpec {
       linesInclTax,
       linesInclTax,
       labels,
       labels,
       money,
       money,
+      taxableAmount: figures.taxableAmount(subtotal, discount),
     })
     })
   )
   )
   totals.push({ label: L('total', 'Total'), value: money(totalAmount), kind: 'total' })
   totals.push({ label: L('total', 'Total'), value: money(totalAmount), kind: 'total' })

+ 32 - 20
src/features/invoice-designer/Pdf/buildWorkOrderPrint.ts

@@ -8,9 +8,10 @@ import {
 } from '@/lib/format'
 } from '@/lib/format'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { documentLaborLines, isShopFeeLine } from '@/features/settings/Lib/shopFee'
 import { formatQuantity } from '@/lib/format-quantity'
 import { formatQuantity } from '@/lib/format-quantity'
-import { calculateTotals, netLineTotal, discountAmountFor } from '@/lib/tax'
+import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { parseTaxComponents } from '@/lib/tax-components'
 import { taxLines } from './taxLines'
 import { taxLines } from './taxLines'
+import { printFigures } from './printFigures'
 import {
 import {
   getDefaultLayout,
   getDefaultLayout,
   isCustomFieldId,
   isCustomFieldId,
@@ -166,14 +167,21 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const money = (value: number) => formatCurrency(value, cc, cf)
   const money = (value: number) => formatCurrency(value, cc, cf)
   const taxRate = data.taxRate
   const taxRate = data.taxRate
   const taxInclusive = data.taxInclusive ?? false
   const taxInclusive = data.taxInclusive ?? false
-  const net = (value: number) => netLineTotal(value, taxRate, taxInclusive)
+  // A workshop may print each line with tax in it, the way a consumer
+  // receipt reads: the lines then sum to the subtotal, and the tax row says
+  // how much of that is tax rather than adding it on. Either way a line left
+  // out of the tax prints as entered; see printFigures.
   const linesInclTax = invoiceSettings?.lineItemsInclTax === true && taxRate > 0
   const linesInclTax = invoiceSettings?.lineItemsInclTax === true && taxRate > 0
-  const gross = (value: number) => (taxInclusive ? value : value * (1 + taxRate / 100))
-  const shown = linesInclTax ? gross : net
+  const figures = printFigures({
+    taxRate,
+    taxInclusive,
+    linesInclTax,
+    parts: data.partItems,
+    labor: data.laborItems,
+  })
+  const shown = figures.shown
 
 
-  const partsSubtotal = data.partItems.reduce((sum, p) => sum + p.total, 0)
-  const laborSubtotal = data.laborItems.reduce((sum, l) => sum + l.total, 0)
-  const computedSubtotal = partsSubtotal + laborSubtotal
+  const computedSubtotal = figures.partsTotal + figures.laborTotal
   const computedDiscount = discountAmountFor(
   const computedDiscount = discountAmountFor(
     computedSubtotal,
     computedSubtotal,
     data.discountType,
     data.discountType,
@@ -184,6 +192,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
     discountAmount: computedDiscount,
     discountAmount: computedDiscount,
     taxRate,
     taxRate,
     taxInclusive,
     taxInclusive,
+    taxableSubtotal: figures.taxableSubtotal,
   })
   })
   const displayTotal =
   const displayTotal =
     data.totalAmount > 0 ? data.totalAmount : computedTotal > 0 ? computedTotal : data.cost
     data.totalAmount > 0 ? data.totalAmount : computedTotal > 0 ? computedTotal : data.cost
@@ -267,8 +276,8 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       qty: String(l.hours),
       qty: String(l.hours),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       unit: l.pricingType === 'service' || isShopFeeLine(l) ? L('unit', 'unit') : L('hrs', 'hrs'),
       desc: l.description,
       desc: l.description,
-      price: money(shown(l.rate)),
-      total: money(shown(l.total)),
+      price: money(shown(l.rate, l.taxable !== false)),
+      total: money(shown(l.total, l.taxable !== false)),
     })),
     })),
     ...data.partItems.map((p, i) => ({
     ...data.partItems.map((p, i) => ({
       n: String(data.laborItems.length + i + 1),
       n: String(data.laborItems.length + i + 1),
@@ -276,16 +285,16 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       unit: p.unit || '',
       unit: p.unit || '',
       desc: p.name,
       desc: p.name,
       sub: p.partNumber || undefined,
       sub: p.partNumber || undefined,
-      price: money(shown(p.unitPrice)),
-      total: money(shown(p.total)),
+      price: money(shown(p.unitPrice, p.taxable !== false)),
+      total: money(shown(p.total, p.taxable !== false)),
     })),
     })),
   ]
   ]
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
   const parts: DocumentData['parts'] = data.partItems.map((p) => ({
     ref: p.partNumber || '-',
     ref: p.partNumber || '-',
     desc: p.name,
     desc: p.name,
     qty: formatQuantity(p.quantity, p.unit),
     qty: formatQuantity(p.quantity, p.unit),
-    price: money(shown(p.unitPrice)),
-    total: money(shown(p.total)),
+    price: money(shown(p.unitPrice, p.taxable !== false)),
+    total: money(shown(p.total, p.taxable !== false)),
   }))
   }))
   const labor: DocumentData['labor'] = data.laborItems.map((l) => {
   const labor: DocumentData['labor'] = data.laborItems.map((l) => {
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
     const isService = l.pricingType === 'service' || isShopFeeLine(l)
@@ -293,9 +302,11 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       desc: l.description,
       desc: l.description,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
       qty: isService ? `${l.hours} ${L('unit', 'unit')}` : `${l.hours} ${L('hrs', 'hrs')}`,
       rate: isService
       rate: isService
-        ? money(shown(l.rate))
-        : fillTemplate(L('ratePerHour', '{rate}/hr'), { rate: money(shown(l.rate)) }),
-      total: money(shown(l.total)),
+        ? money(shown(l.rate, l.taxable !== false))
+        : fillTemplate(L('ratePerHour', '{rate}/hr'), {
+            rate: money(shown(l.rate, l.taxable !== false)),
+          }),
+      total: money(shown(l.total, l.taxable !== false)),
     }
     }
   })
   })
 
 
@@ -335,12 +346,12 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
   const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
   const itemsTableVisible = layout.sections.some((s) => s.id === 'items_table' && s.visible)
   const totals: TotalLine[] = []
   const totals: TotalLine[] = []
   if (!itemsTableVisible && data.partItems.length > 0) {
   if (!itemsTableVisible && data.partItems.length > 0) {
-    totals.push({ label: L('parts', 'Parts'), value: money(shown(partsSubtotal)), kind: 'line' })
+    totals.push({ label: L('parts', 'Parts'), value: money(figures.partsShown), kind: 'line' })
   }
   }
   if (!itemsTableVisible && data.laborItems.length > 0) {
   if (!itemsTableVisible && data.laborItems.length > 0) {
-    totals.push({ label: L('labor', 'Labor'), value: money(shown(laborSubtotal)), kind: 'line' })
+    totals.push({ label: L('labor', 'Labor'), value: money(figures.laborShown), kind: 'line' })
   }
   }
-  const displaySubtotal = shown(data.subtotal)
+  const displaySubtotal = figures.subtotalShown(data.subtotal)
   if (displaySubtotal > 0) {
   if (displaySubtotal > 0) {
     totals.push({
     totals.push({
       label: linesInclTax
       label: linesInclTax
@@ -350,7 +361,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       kind: 'line',
       kind: 'line',
     })
     })
   }
   }
-  const displayDiscount = shown(data.discountAmount ?? 0)
+  const displayDiscount = figures.discountShown(data.discountAmount ?? 0, data.subtotal)
   if (displayDiscount > 0) {
   if (displayDiscount > 0) {
     totals.push({
     totals.push({
       label:
       label:
@@ -371,6 +382,7 @@ export function buildWorkOrderPrintSpec(input: WorkOrderPrintInput): DocumentSpe
       linesInclTax,
       linesInclTax,
       labels,
       labels,
       money,
       money,
+      taxableAmount: figures.taxableAmount(data.subtotal, data.discountAmount ?? 0),
     })
     })
   )
   )
   totals.push({ label: L('total', 'Total'), value: money(displayTotal), kind: 'total' })
   totals.push({ label: L('total', 'Total'), value: money(displayTotal), kind: 'total' })

+ 18 - 7
src/features/invoice-designer/Pdf/lineGroups.ts

@@ -20,11 +20,14 @@ export const OTHER_PARTS_GROUP = 'other'
 export const LABOR_GROUP = 'labor'
 export const LABOR_GROUP = 'labor'
 
 
 interface GroupInput {
 interface GroupInput {
-  parts: { category?: string | null; total: number; excluded?: boolean }[]
-  labor: { total: number; excluded?: boolean }[]
+  parts: { category?: string | null; total: number; excluded?: boolean; taxable?: boolean | null }[]
+  labor: { total: number; excluded?: boolean; taxable?: boolean | null }[]
   labels: Record<string, string>
   labels: Record<string, string>
-  /** A line total as the sheet shows it, before and after tax alike. */
-  shown: (amount: number) => number
+  /**
+   * A line total as the sheet shows it, before and after tax alike. A line
+   * left out of the tax is shown as entered, so the caller is told which.
+   */
+  shown: (amount: number, taxable?: boolean) => number
   money: (amount: number) => string
   money: (amount: number) => string
 }
 }
 
 
@@ -43,9 +46,17 @@ export function lineGroupsFor(input: GroupInput): LineGroup[] | undefined {
   const L = (key: string, fallback: string) => labels[key] || fallback
   const L = (key: string, fallback: string) => labels[key] || fallback
   if (!input.parts.some((part) => part.category?.trim())) return undefined
   if (!input.parts.some((part) => part.category?.trim())) return undefined
 
 
-  const subtotalOf = (lines: { total: number; excluded?: boolean }[]) =>
-    money(shown(lines.reduce((sum, line) => (line.excluded ? sum : sum + line.total), 0)))
-  const group = (key: string, title: string, lines: { total: number; excluded?: boolean }[]) => ({
+  type Line = { total: number; excluded?: boolean; taxable?: boolean | null }
+  // Each line converted on its own and then added, since a group can hold
+  // taxed and untaxed lines side by side.
+  const subtotalOf = (lines: Line[]) =>
+    money(
+      lines.reduce(
+        (sum, line) => (line.excluded ? sum : sum + shown(line.total, line.taxable !== false)),
+        0
+      )
+    )
+  const group = (key: string, title: string, lines: Line[]) => ({
     key,
     key,
     title,
     title,
     subtotalLabel: fill(L('groupSubtotal', '{group} subtotal'), { group: title }),
     subtotalLabel: fill(L('groupSubtotal', '{group} subtotal'), { group: title }),

+ 67 - 0
src/features/invoice-designer/Pdf/printFigures.ts

@@ -0,0 +1,67 @@
+import { calculateTotals, taxDisplay } from '@/lib/tax'
+
+type Line = { total: number; taxable?: boolean | null; excluded?: boolean }
+
+/**
+ * The figures a sheet prints above its tax line, worked out once for the
+ * invoice, the quote and the work order so the three cannot disagree.
+ *
+ * A sheet shows its amounts either before tax or with tax in them (the
+ * workshop's choice), and a line left out of the tax reads the same either
+ * way. So every amount is converted in its taxable part only: a line by its
+ * own flag, a sum of lines by how much of it is taxable.
+ *
+ * Lines the customer declined on a quote (`excluded`) count toward nothing.
+ * A line that does not say is taxable, which is every line stored before a
+ * line could be left out.
+ */
+export function printFigures(args: {
+  taxRate: number
+  taxInclusive: boolean
+  /** The sheet prints its lines with tax in them. */
+  linesInclTax: boolean
+  parts: readonly Line[]
+  labor: readonly Line[]
+}) {
+  const { taxRate, taxInclusive, linesInclTax } = args
+  const display = taxDisplay({ taxRate, taxInclusive, withTax: linesInclTax })
+
+  const isTaxable = (line: Line) => line.taxable !== false
+  const sumOf = (lines: readonly Line[]) => lines.reduce((sum, line) => sum + line.total, 0)
+  // Null when every line is taxable, so a fully taxed sum is converted as
+  // one figure, exactly as it was before lines could be untaxed.
+  const taxablePartOf = (lines: readonly Line[]): number | null =>
+    lines.every(isTaxable) ? null : sumOf(lines.filter(isTaxable))
+
+  const parts = args.parts.filter((line) => !line.excluded)
+  const labor = args.labor.filter((line) => !line.excluded)
+  const partsTotal = sumOf(parts)
+  const laborTotal = sumOf(labor)
+  const taxableSubtotal = taxablePartOf([...parts, ...labor])
+
+  return {
+    /** A line's own figure (its price, its total), by the line's flag. */
+    shown: (value: number, taxable: boolean = true) => display.line(value, taxable),
+    /** The counted lines added up as entered, before any conversion. */
+    partsTotal,
+    laborTotal,
+    /** The same sums as the sheet shows them. */
+    partsShown: display.mixed(partsTotal, taxablePartOf(parts)),
+    laborShown: display.mixed(laborTotal, taxablePartOf(labor)),
+    /** The sum of the taxable lines, or null when every line is taxed. */
+    taxableSubtotal,
+    subtotalShown: (subtotal: number) => display.mixed(subtotal, taxableSubtotal),
+    discountShown: (discountAmount: number, subtotal: number) =>
+      display.discount(discountAmount, subtotal, taxableSubtotal),
+    /**
+     * What the tax was charged on, for the "Taxable amount" row. Null when
+     * the whole document is taxed, or not taxed at all: the row would then
+     * repeat the subtotal or describe a tax that is not there.
+     */
+    taxableAmount: (subtotal: number, discountAmount: number): number | null =>
+      taxableSubtotal === null || taxRate <= 0
+        ? null
+        : calculateTotals({ subtotal, discountAmount, taxRate, taxInclusive, taxableSubtotal })
+            .taxableAmount,
+  }
+}

部分文件因文件數量過多而無法顯示