split-tax.spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { scheduleServiceRecordInThePast } from '../../support/db'
  3. import { settle } from '../../support/hydration'
  4. import { pdfContent } from '../../support/pdf'
  5. import { addQuoteLabor, addQuotePart, newQuote, saveQuote } from '../../support/quote'
  6. import { setTax } from '../../support/settings'
  7. import {
  8. addLabor,
  9. addPart,
  10. newWorkOrder,
  11. saveWorkOrder,
  12. seededVehicleUrl,
  13. setDiscount,
  14. shareLink,
  15. totalsRow,
  16. } from '../../support/work-order'
  17. /**
  18. * A workshop in Québec: two taxes on every job, each on its own line, each
  19. * with its own registration number, and each totalled apart in the report.
  20. *
  21. * The job is the one the pricing spec prices, 900 before tax, so the
  22. * figures here follow from those: GST at 5% is 45.00 and QST at 9.975% is
  23. * 89.775, which the sheet must print as 89.78 and add to 1,034.78. With the
  24. * 10% discount the base is 810, GST 40.50, QST 80.7975 printed as 80.80,
  25. * and the total 931.30. The split is opt-in, so the last test turns it off
  26. * again and shows a new job is back to one tax line.
  27. *
  28. * Serial: the tax settings are the workshop's, and every job here is created
  29. * after they change.
  30. */
  31. test.describe.configure({ mode: 'serial' })
  32. const stamp = Date.now()
  33. const GST_NUMBER = '123456789 RT0001'
  34. const QST_NUMBER = '1234567890 TQ0001'
  35. let vehicleUrl = ''
  36. let job = ''
  37. /** The standard job: parts 2 × 150 = 300, labour 1.5 × 400 = 600, before tax 900. */
  38. async function priceTheJob(page: Page, title: string): Promise<string> {
  39. const url = await newWorkOrder(page, vehicleUrl, title)
  40. await addPart(page, {
  41. name: `E2E brake pads ${stamp}`,
  42. quantity: 2,
  43. cost: 100,
  44. markupPercent: 50,
  45. })
  46. await addLabor(page, { description: 'Replace front brake pads', hours: 1.5, rate: 400 })
  47. await saveWorkOrder(page)
  48. return url
  49. }
  50. async function expectTotals(page: Page, lines: Record<string, string>) {
  51. for (const [label, figure] of Object.entries(lines)) {
  52. await expect(totalsRow(page, label), `${label} on the work order`).toContainText(figure)
  53. }
  54. }
  55. test.beforeAll(async ({ browser }) => {
  56. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  57. vehicleUrl = await seededVehicleUrl(page)
  58. await page.close()
  59. })
  60. test('the settings page takes the Québec preset and remembers the registration numbers', async ({
  61. page,
  62. }) => {
  63. await page.goto('/settings/tax')
  64. const split = page.locator('#taxSplit')
  65. await expect(split).toBeVisible()
  66. await expect(async () => {
  67. if ((await split.getAttribute('aria-checked')) !== 'true') await split.click()
  68. await expect(split).toHaveAttribute('aria-checked', 'true')
  69. }).toPass()
  70. // The preset fills the names and rates; the numbers are the workshop's own.
  71. await page.locator('#taxPreset').click()
  72. await page.getByRole('option', { name: 'Québec, Canada (GST + QST)' }).click()
  73. await expect(page.locator('#taxComponentName-0')).toHaveValue('GST')
  74. await expect(page.locator('#taxComponentRate-0')).toHaveValue('5')
  75. await expect(page.locator('#taxComponentName-1')).toHaveValue('QST')
  76. await expect(page.locator('#taxComponentRate-1')).toHaveValue('9.975')
  77. await expect(page.getByTestId('tax-combined-rate')).toHaveText('14.975%')
  78. // The single-rate fields have left the page: there is one rate now, the sum.
  79. await expect(page.locator('#defaultTaxRate')).toHaveCount(0)
  80. await expect(page.locator('#taxLabel')).toHaveCount(0)
  81. await page.locator('#taxComponentRegistration-0').fill(GST_NUMBER)
  82. await page.locator('#taxComponentRegistration-1').fill(QST_NUMBER)
  83. await page.getByRole('button', { name: 'Exclusive', exact: true }).click()
  84. await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
  85. await expect(page.getByText('Settings saved', { exact: true })).toBeVisible()
  86. await page.reload()
  87. await settle(page)
  88. await expect(page.locator('#taxSplit')).toHaveAttribute('aria-checked', 'true')
  89. await expect(page.locator('#taxComponentName-1')).toHaveValue('QST')
  90. await expect(page.locator('#taxComponentRegistration-0')).toHaveValue(GST_NUMBER)
  91. await expect(page.locator('#taxComponentRegistration-1')).toHaveValue(QST_NUMBER)
  92. await expect(page.getByTestId('tax-combined-rate')).toHaveText('14.975%')
  93. })
  94. test('the invoice designer draws the split on its sample sheet', async ({ page }) => {
  95. await page.goto('/invoice-designer?view=designer&doc=invoice')
  96. // The canvas keeps a hidden measuring copy of the sheet; only the drawn
  97. // one counts, so every check is on visible text.
  98. const drawn = (text: string | RegExp) => page.getByText(text).filter({ visible: true }).first()
  99. await expect(drawn(/GST \(5%\)/)).toBeVisible()
  100. await expect(drawn(/QST \(9\.975%\)/)).toBeVisible()
  101. await expect(drawn(GST_NUMBER)).toBeVisible()
  102. await expect(drawn(QST_NUMBER)).toBeVisible()
  103. await expect(page.getByText(/^Tax \(/).filter({ visible: true })).toHaveCount(0)
  104. // The template cards in settings are drawn from the same sample.
  105. await page.goto('/settings/templates')
  106. await expect(page.getByText('GST (5%)').first()).toBeAttached()
  107. })
  108. test('a job charges GST and QST on their own lines', async ({ page }) => {
  109. job = await priceTheJob(page, `E2E Québec ${stamp}`)
  110. await expectTotals(page, {
  111. Parts: '$300.00',
  112. Labor: '$600.00',
  113. Subtotal: '$900.00',
  114. 'GST (5%)': '$45.00',
  115. 'QST (9.975%)': '$89.78',
  116. Total: '$1,034.78',
  117. })
  118. // No combined line, and no rate field: the split is the workshop's.
  119. await expect(totalsRow(page, 'Tax')).toHaveCount(0)
  120. await expect(page.getByTestId('tax-component-row')).toHaveCount(2)
  121. await expect(page.getByTestId('tax-component-row').locator('input')).toHaveCount(0)
  122. })
  123. test('a discount comes off before both taxes', async ({ page }) => {
  124. await page.goto(job)
  125. await setDiscount(page, 'Percentage', 10)
  126. await saveWorkOrder(page)
  127. await expectTotals(page, {
  128. Discount: '-$90.00',
  129. 'GST (5%)': '$40.50',
  130. 'QST (9.975%)': '$80.80',
  131. Total: '$931.30',
  132. })
  133. // The saved figures survive a reload: the server re-derived the split.
  134. await page.reload()
  135. await settle(page)
  136. await expectTotals(page, { 'GST (5%)': '$40.50', 'QST (9.975%)': '$80.80', Total: '$931.30' })
  137. })
  138. test('the customer copy and the PDF print both taxes and both registration numbers', async ({
  139. page,
  140. }) => {
  141. await page.goto(job)
  142. const url = await shareLink(page)
  143. await page.goto(url)
  144. // The sheet keeps a hidden copy of itself for measuring; only the drawn one counts.
  145. for (const text of [
  146. /GST \(5%\)/,
  147. /QST \(9\.975%\)/,
  148. '$40.50',
  149. '$80.80',
  150. '$931.30',
  151. GST_NUMBER,
  152. QST_NUMBER,
  153. ]) {
  154. await expect(
  155. page.getByText(text).filter({ visible: true }).first(),
  156. `${text} on the shared invoice`
  157. ).toBeVisible()
  158. }
  159. await expect(page.getByText(/^Tax \(/).filter({ visible: true })).toHaveCount(0)
  160. const id = job.split('/').pop()
  161. const response = await page.request.get(`/api/protected/services/${id}/pdf`)
  162. expect(response.status(), 'the invoice PDF renders').toBe(200)
  163. const pdf = await pdfContent(await response.body())
  164. for (const text of [
  165. 'GST (5%)',
  166. 'QST (9.975%)',
  167. '40.50',
  168. '80.80',
  169. '931.30',
  170. 'GST No.',
  171. GST_NUMBER,
  172. 'QST No.',
  173. QST_NUMBER,
  174. ]) {
  175. expect(pdf.flat, `${text} in the PDF`).toContain(text)
  176. }
  177. expect(pdf.flat).not.toMatch(/Tax \(14/)
  178. })
  179. test('a quote shows the same split', async ({ page }) => {
  180. await newQuote(page, `E2E Québec quote ${stamp}`)
  181. await addQuotePart(page, { name: `E2E tyre ${stamp}`, quantity: 2, unitPrice: 150 })
  182. await addQuoteLabor(page, { description: 'Fit tyres', hours: 1.5, rate: 400 })
  183. await saveQuote(page)
  184. const rows = page.getByTestId('tax-component-row')
  185. await expect(rows).toHaveCount(2)
  186. await expect(rows.nth(0)).toContainText('GST (5%)')
  187. await expect(rows.nth(0)).toContainText('$45.00')
  188. await expect(rows.nth(1)).toContainText('QST (9.975%)')
  189. await expect(rows.nth(1)).toContainText('$89.78')
  190. })
  191. test('the tax report totals each tax by name', async ({ page }) => {
  192. // The job was booked into the next free slot, which is usually tomorrow,
  193. // and the report runs to the present moment; move it into the past.
  194. await scheduleServiceRecordInThePast(job.split('/').pop()!)
  195. await page.goto('/reports?tab=financial&subtab=tax')
  196. const table = page
  197. .getByRole('heading', { name: 'Tax by Rate' })
  198. .locator('xpath=ancestor::div[.//table][1]')
  199. .getByRole('table')
  200. await expect(table).toBeVisible()
  201. const gst = table.getByRole('row').filter({ hasText: 'GST (5%)' })
  202. const qst = table.getByRole('row').filter({ hasText: 'QST (9.975%)' })
  203. await expect(gst).toHaveCount(1)
  204. await expect(qst).toHaveCount(1)
  205. // Each row carries money and a count; the exact figures depend on what
  206. // earlier runs left in the period, so the shape is what is pinned.
  207. await expect(gst).toContainText(/\$\d/)
  208. await expect(qst).toContainText(/\$\d/)
  209. })
  210. test('switching the split off puts a new job back on one tax line', async ({ page }) => {
  211. await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
  212. await page.goto('/settings/tax')
  213. await expect(page.locator('#taxSplit')).toHaveAttribute('aria-checked', 'false')
  214. await expect(page.locator('#defaultTaxRate')).toHaveValue('25')
  215. await priceTheJob(page, `E2E single again ${stamp}`)
  216. await expectTotals(page, { Tax: '$225.00', Total: '$1,125.00' })
  217. await expect(page.getByTestId('tax-component-row')).toHaveCount(0)
  218. // The Québec job keeps the taxes it was made with.
  219. await page.goto(job)
  220. await expectTotals(page, { 'GST (5%)': '$40.50', 'QST (9.975%)': '$80.80' })
  221. })