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

End-to-end tests: auth, work order pricing, quote to invoice (#373)

* first e2e test

* End-to-end tests for signing in, out and resetting a password, and a harness that starts in the right order

* End-to-end tests for account security, sign-up, and work order pricing under every tax setting

* invoice and workorder test

* attachment

* invoice designer

* designer

* qoutes

* Tenancy

* e2e tests

* Reminders keep the time they were given

Two bugs, both found by an end-to-end test that puts the workshop's timezone
somewhere other than the browser's.

Both reminder forms filled their date and time fields from the browser's clock,
while the server reads the string those fields produce in the workshop's zone.
Where the two zones differ, a reminder opened on the wrong time and saving the
form moved it. The fields are filled from the workshop's clock now.

The vehicle detail query never selected hasDueTime, notifyInApp or notifyEmail,
so the vehicle's reminders tab showed every reminder as day-only and its edit
form opened with the time blank and the channels at their defaults. Saving an
08:00 email reminder rewrote it to midday with no email. This one needed no
timezone at all.

e2e/specs/reminders/due-time.spec.ts covers both forms through creation, the
list, the edit form and a save that changes nothing. The work order lifecycle
spec now finds its job through the list's search rather than expecting it on
the first page of twenty.

* Assert every designer section is covered

biome lint failed on an unused BUILTIN_SECTIONS import in the designer's
every-block test. The import was there for the assertion the file's own
comment describes and never got written: every section the schema offers is
either on the default sheet, and so covered by the hides-it test, or one of
the three known to start switched off. A section added tomorrow now fails
this rather than quietly going untested.

* Seeded work orders and reminders belong to the workshop

The seed created both with only a vehicleId, leaving the nullable
organizationId null. The app scopes them by that column, so on a freshly
seeded database every one of the 101 seeded work orders answered "Service
record not found" and none of the 28 seeded reminders appeared on the
reminders page. The demo instance reseeds every three hours, so that is what
visitors saw.

Found because three specs failed on CI and passed locally. They were leaning
on a database the suite had already been run against: a work order the app
itself had made (which carries the column), an attachment another spec had
uploaded, and a payment panel that only printed because a due-days setting
happened to be there. Each of those now takes what it needs from one row or
creates it:

- the tenancy fixture asks for a vehicle and one of its own jobs together,
  and uploads the file it later tries to fetch as an outsider
- the designer drag spec sets a bank account so the payment panel has a line
  to print, and puts the setting back
- shareLink declines the "Invoice Dates Expired" offer, because sharing a
  seeded invoice must not rewrite its dates

The workflow caches the browser, the seed's fifty Unsplash photos and the
Next build, and asks pg_isready for the right role so the Postgres service
stops logging FATAL role "root" does not exist.

* The harness starts the app without its schedulers

DISABLE_BACKGROUND_JOBS=1 leaves every cron unstarted. They were all running
during a suite run: the due-reminder scan stamping notifiedAt on rows specs
assert on (visible as "notified on 13 due reminder(s)" once seeded reminders
gained an organizationId), the scheduled-message and webhook processors
sending things every minute, and all of them taking CPU from a suite that
runs one worker on purpose. A spec that needs one of these should call the
processor rather than wait for a timer.

The session secret was 29 characters of English, so better-auth warned about
its length and its entropy on every server start. CI generates one per run;
the config carries a random throwaway for local runs.

* Stop the lint job racing itself, and give it a build cache

A second push left the previous Build & Test running to no purpose; the
end-to-end workflow already cancelled its own. The job also had no timeout,
so a hung run would have sat there for GitHub's six-hour default.

Its build keeps a separate cache entry from the e2e job's, because that one
bakes in NEXT_PUBLIC_APP_URL and this one is the plain build.

The e2e job gets 45 minutes rather than 30: a cold run spends three and a
half on setup before the suite starts, and a job killed by its timeout
uploads no report, which is the one thing needed to find out why.
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
bc9c30cd43
100 измененных файлов с 8143 добавлено и 570 удалено
  1. 10 0
      .env.example
  2. 18 0
      .github/workflows/ci.yml
  3. 114 0
      .github/workflows/e2e.yml
  4. 8 0
      .gitignore
  5. 145 143
      biome.json
  6. 201 0
      e2e/README.md
  7. 31 0
      e2e/auth.setup.ts
  8. 10 0
      e2e/global-setup.ts
  9. 132 0
      e2e/mail-sink.ts
  10. 95 0
      e2e/prepare-db.ts
  11. 129 0
      e2e/specs/auth/account.spec.ts
  12. 114 0
      e2e/specs/auth/roles.spec.ts
  13. 162 0
      e2e/specs/auth/sign-in.spec.ts
  14. 63 0
      e2e/specs/auth/sign-up.spec.ts
  15. 182 0
      e2e/specs/auth/tenancy.spec.ts
  16. 171 0
      e2e/specs/calendar/booking.spec.ts
  17. 181 0
      e2e/specs/inventory/movements.spec.ts
  18. 310 0
      e2e/specs/invoices/designer-drag.spec.ts
  19. 262 0
      e2e/specs/invoices/designer.spec.ts
  20. 86 0
      e2e/specs/invoices/numbering.spec.ts
  21. 196 0
      e2e/specs/invoices/payments.spec.ts
  22. 159 0
      e2e/specs/invoices/pdf-attachments.spec.ts
  23. 183 0
      e2e/specs/invoices/pdf-parity.spec.ts
  24. 171 0
      e2e/specs/quotes/document.spec.ts
  25. 238 0
      e2e/specs/reminders/due-time.spec.ts
  26. 41 0
      e2e/specs/smoke/app.spec.ts
  27. 403 0
      e2e/specs/tech/api.spec.ts
  28. 175 0
      e2e/specs/work-orders/layout.spec.ts
  29. 184 0
      e2e/specs/work-orders/lifecycle.spec.ts
  30. 169 0
      e2e/specs/work-orders/pricing.spec.ts
  31. 143 0
      e2e/specs/work-orders/quote-to-invoice.spec.ts
  32. 142 0
      e2e/specs/work-orders/validation.spec.ts
  33. 33 0
      e2e/support/attachments.ts
  34. 345 0
      e2e/support/db.ts
  35. 27 0
      e2e/support/hydration.ts
  36. 40 0
      e2e/support/inventory.ts
  37. 85 0
      e2e/support/mail.ts
  38. 73 0
      e2e/support/pdf.ts
  39. 126 0
      e2e/support/quote.ts
  40. 141 0
      e2e/support/settings.ts
  41. 17 0
      e2e/support/totp.ts
  42. 201 0
      e2e/support/work-order.ts
  43. 2 2
      messages/de/quotes.json
  44. 11 4
      messages/de/service.json
  45. 2 1
      messages/de/settings.json
  46. 9 2
      messages/en/service.json
  47. 2 1
      messages/en/settings.json
  48. 2 2
      messages/es/quotes.json
  49. 11 4
      messages/es/service.json
  50. 2 1
      messages/es/settings.json
  51. 2 2
      messages/fr/quotes.json
  52. 11 4
      messages/fr/service.json
  53. 2 1
      messages/fr/settings.json
  54. 2 2
      messages/it/quotes.json
  55. 11 4
      messages/it/service.json
  56. 2 1
      messages/it/settings.json
  57. 2 2
      messages/lt/quotes.json
  58. 11 4
      messages/lt/service.json
  59. 2 1
      messages/lt/settings.json
  60. 2 2
      messages/nb/quotes.json
  61. 11 4
      messages/nb/service.json
  62. 2 1
      messages/nb/settings.json
  63. 2 2
      messages/nl/quotes.json
  64. 11 4
      messages/nl/service.json
  65. 2 1
      messages/nl/settings.json
  66. 2 2
      messages/pl/quotes.json
  67. 11 4
      messages/pl/service.json
  68. 2 1
      messages/pl/settings.json
  69. 2 2
      messages/pt-BR/quotes.json
  70. 11 4
      messages/pt-BR/service.json
  71. 2 1
      messages/pt-BR/settings.json
  72. 2 2
      messages/ru/quotes.json
  73. 11 4
      messages/ru/service.json
  74. 2 1
      messages/ru/settings.json
  75. 2 2
      messages/tr/quotes.json
  76. 11 4
      messages/tr/service.json
  77. 2 1
      messages/tr/settings.json
  78. 865 0
      package-lock.json
  79. 9 0
      package.json
  80. 134 0
      playwright.config.ts
  81. 36 30
      prisma/seed_dummy_data.ts
  82. 234 0
      src/__tests__/features/invoice-designer/every-block.test.ts
  83. 114 0
      src/__tests__/features/invoice-designer/field-order.test.ts
  84. 116 0
      src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts
  85. 72 0
      src/__tests__/features/invoice-designer/multiline-line-items.test.ts
  86. 178 0
      src/__tests__/features/invoice-designer/renderer-parity.test.tsx
  87. 96 0
      src/__tests__/features/invoices/printable-image.test.ts
  88. 141 0
      src/__tests__/features/workorders/validate-service-form.test.ts
  89. 88 0
      src/__tests__/lib/upload-root.test.ts
  90. 93 0
      src/__tests__/lib/zoned-wall-clock.test.ts
  91. 19 2
      src/app/(authenticated)/layout.tsx
  92. 10 7
      src/app/(authenticated)/settings/localization/localization-settings.tsx
  93. 2 1
      src/app/api/protected/backup/export/route.ts
  94. 2 1
      src/app/api/protected/backup/import-invoice-ninja/route.ts
  95. 2 1
      src/app/api/protected/backup/import-lubelog/route.ts
  96. 2 1
      src/app/api/protected/backup/import/route.ts
  97. 2 1
      src/app/api/protected/files/[...path]/route.ts
  98. 15 252
      src/app/api/protected/quotes/[id]/pdf/route.ts
  99. 19 50
      src/app/api/protected/services/[id]/pdf/route.ts
  100. 2 1
      src/app/api/protected/upload/email-logo/route.ts

+ 10 - 0
.env.example

@@ -7,6 +7,16 @@ BETTER_AUTH_SECRET="your-secret-here"
 # App URL (used by both the app and Better Auth)
 NEXT_PUBLIC_APP_URL="http://localhost:3000"
 
+# Where uploaded files and the app's other data live. Unset, it is `data`
+# beside the app, which is where everything has always been written; set it to
+# put uploads on a disk with room on them. Files already written to the old
+# place keep being found, so this can be turned on at any time.
+#
+# In Docker, move the volume with it: the compose file mounts
+# `/app/data/uploads`, and a DATA_ROOT pointing anywhere else would write into
+# the container instead, where a redeploy loses it.
+# DATA_ROOT="/var/lib/torqvoice"
+
 # Set to true only when Cloudflare proxies every request AND the origin
 # refuses traffic that did not come through it (Cloudflare IP ranges allowed
 # at the firewall or in nginx).

+ 18 - 0
.github/workflows/ci.yml

@@ -4,10 +4,16 @@ on:
   pull_request:
     branches: [main]
 
+# A second push to the same branch makes the run in flight pointless.
+concurrency:
+  group: ci-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   ci:
     name: Lint & Build
     runs-on: ubuntu-latest
+    timeout-minutes: 20
     steps:
       - uses: actions/checkout@v4
 
@@ -24,4 +30,16 @@ jobs:
 
       - run: npm test
 
+      # Next reuses its compiler cache across builds when it is given one.
+      # This build is the plain one, with no NEXT_PUBLIC_APP_URL override, so
+      # it keeps its own entry rather than sharing the e2e job's.
+      - name: Cache the build
+        uses: actions/cache@v4
+        with:
+          path: .next/cache
+          key: next-ci-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
+          restore-keys: |
+            next-ci-${{ hashFiles('package-lock.json') }}-
+            next-ci-
+
       - run: npm run build

+ 114 - 0
.github/workflows/e2e.yml

@@ -0,0 +1,114 @@
+name: End-to-end tests
+
+on:
+  pull_request:
+    branches: [main]
+  workflow_dispatch:
+
+# A second push to the same branch makes the run in flight pointless.
+concurrency:
+  group: e2e-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  e2e:
+    name: Playwright
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+
+    services:
+      postgres:
+        image: postgres:16-alpine
+        env:
+          POSTGRES_USER: torqvoice
+          POSTGRES_PASSWORD: torqvoice
+          # The name has to carry "e2e" or "test": the harness refuses to reset
+          # a database whose name says nothing about being throwaway.
+          POSTGRES_DB: torqvoice_e2e
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U torqvoice -d torqvoice_e2e"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+
+    env:
+      # Playwright starts the app itself on this port and resets this database
+      # before it does; both come from playwright.config.ts.
+      E2E_DATABASE_URL: postgresql://torqvoice:torqvoice@127.0.0.1:5432/torqvoice_e2e
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 22
+          cache: npm
+
+      # A fresh one per run: the sessions it signs live as long as the job, and
+      # a short or guessable value makes better-auth warn on every request.
+      - name: Make a session secret for this run
+        run: echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" >> "$GITHUB_ENV"
+
+      - run: npm ci
+
+      - run: npx prisma generate
+
+      # Keyed on the pinned version, because the image tag and this download
+      # have to be the same build.
+      - name: Cache the browser
+        id: browser-cache
+        uses: actions/cache@v4
+        with:
+          path: ~/.cache/ms-playwright
+          key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+
+      - name: Install the browser
+        run: npx playwright install --with-deps chromium
+
+      # The seed copies bundled vehicle photos when they are in the tree and
+      # downloads fifty of them from Unsplash when they are not, one after
+      # another, which is minutes of a cold run and the flakiest thing in it.
+      # Cached, a warm run copies them off disk instead.
+      - name: Cache the seed's photos
+        uses: actions/cache@v4
+        with:
+          # Where prepare-db.ts points the seed's DATA_ROOT, so a test run
+          # cannot disturb a running instance's own uploads.
+          path: e2e/.data
+          key: seed-photos-${{ hashFiles('prisma/seed_dummy_data.ts') }}
+          restore-keys: |
+            seed-photos-
+
+      # Next reuses its compiler cache across builds when it is given one.
+      - name: Cache the build
+        uses: actions/cache@v4
+        with:
+          path: .next/cache
+          key: next-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
+          restore-keys: |
+            next-${{ hashFiles('package-lock.json') }}-
+            next-
+
+      # NEXT_PUBLIC_APP_URL is baked into the client bundle, and better-auth
+      # refuses a sign-in from an origin the build was not made for, so it has
+      # to match the base URL the suite uses.
+      - name: Build
+        run: npm run build
+        env:
+          NEXT_PUBLIC_APP_URL: http://127.0.0.1:3100
+
+      - name: Run the suite
+        run: npm run test:e2e
+
+      - name: Upload the report
+        if: ${{ !cancelled() }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: playwright-report
+          path: |
+            playwright-report/
+            test-results/
+          retention-days: 7
+          if-no-files-found: ignore

+ 8 - 0
.gitignore

@@ -47,3 +47,11 @@ next-env.d.ts
 /public/uploads
 /data/uploads/*
 !/data/uploads/.gitkeep
+
+# end-to-end tests
+/e2e/.auth
+/e2e/.data
+/test-results
+/playwright-report
+/blob-report
+/playwright/.cache

+ 145 - 143
biome.json

@@ -1,144 +1,146 @@
 {
-	"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
-	"vcs": {
-		"enabled": true,
-		"clientKind": "git",
-		"useIgnoreFile": true
-	},
-	"files": {
-		"ignoreUnknown": false,
-		"includes": [
-			"src/**/*",
-			"*.js",
-			"*.ts",
-			"*.jsx",
-			"*.tsx",
-			"*.json"
-		]
-	},
-	"formatter": {
-		"enabled": true,
-		"formatWithErrors": false,
-		"indentStyle": "space",
-		"indentWidth": 2,
-		"lineWidth": 100,
-		"lineEnding": "lf"
-	},
-	"linter": {
-		"enabled": true,
-		"rules": {
-			"recommended": false,
-			"complexity": {
-				"noExtraBooleanCast": "error",
-				"noUselessCatch": "error"
-			},
-			"correctness": {
-				"useUniqueElementIds": "off",
-				"noConstAssign": "error",
-				"noConstantCondition": "error",
-				"noEmptyCharacterClassInRegex": "error",
-				"noEmptyPattern": "error",
-				"noGlobalObjectCalls": "error",
-				"noInnerDeclarations": "error",
-				"noInvalidConstructorSuper": "error",
-				"noNonoctalDecimalEscape": "error",
-				"noPrecisionLoss": "error",
-				"noSelfAssign": "error",
-				"noSetterReturn": "error",
-				"noSwitchDeclarations": "error",
-				"noUndeclaredVariables": "error",
-				"noUnreachable": "error",
-				"noUnreachableSuper": "error",
-				"useIsNan": "error",
-				"useValidForDirection": "error",
-				"noUnusedImports": "error"
-			},
-			"suspicious": {
-				"noAsyncPromiseExecutor": "error",
-				"noCatchAssign": "error",
-				"noClassAssign": "error",
-				"noCompareNegZero": "error",
-				"noControlCharactersInRegex": "error",
-				"noDebugger": "error",
-				"noDuplicateCase": "error",
-				"noDuplicateClassMembers": "error",
-				"noDuplicateObjectKeys": "error",
-				"noDuplicateParameters": "error",
-				"noEmptyBlockStatements": "error",
-				"noFallthroughSwitchClause": "error",
-				"noFunctionAssign": "error",
-				"noGlobalAssign": "error",
-				"noImportAssign": "error",
-				"noMisleadingCharacterClass": "error",
-				"noPrototypeBuiltins": "error",
-				"noRedeclare": "error",
-				"noShadowRestrictedNames": "error",
-				"noUnsafeNegation": "error",
-				"useGetterReturn": "error",
-				"noConsole": {
-					"level": "error",
-					"fix": "none",
-					"options": {
-						"allow": [
-							"warn",
-							"error"
-						]
-					}
-				}
-			}
-		}
-	},
-	"javascript": {
-		"globals": [
-			"jest",
-			"describe",
-			"it",
-			"test",
-			"expect",
-			"beforeAll",
-			"beforeEach",
-			"afterAll",
-			"afterEach",
-			"fail"
-		],
-		"formatter": {
-			"quoteStyle": "single",
-			"jsxQuoteStyle": "double",
-			"quoteProperties": "asNeeded",
-			"trailingCommas": "es5",
-			"semicolons": "asNeeded",
-			"arrowParentheses": "always",
-			"bracketSpacing": true,
-			"bracketSameLine": false,
-			"attributePosition": "auto"
-		}
-	},
-	"json": {
-		"formatter": {
-			"indentStyle": "space",
-			"indentWidth": 2
-		}
-	},
-	"css": {
-		"parser": {
-			"cssModules": true,
-			"allowWrongLineComments": true
-		},
-		"formatter": {
-			"enabled": true,
-			"indentStyle": "space",
-			"indentWidth": 2
-		},
-		"linter": {
-			"enabled": false
-		}
-	},
-	"assist": {
-		"enabled": false,
-		"actions": {
-			"source": {
-				"organizeImports": "off"
-			}
-		}
-	}
-}
+  "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
+  "vcs": {
+    "enabled": true,
+    "clientKind": "git",
+    "useIgnoreFile": true
+  },
+  "files": {
+    "ignoreUnknown": false,
+    "includes": ["src/**/*", "e2e/**/*", "*.js", "*.ts", "*.jsx", "*.tsx", "*.json"]
+  },
+  "formatter": {
+    "enabled": true,
+    "formatWithErrors": false,
+    "indentStyle": "space",
+    "indentWidth": 2,
+    "lineWidth": 100,
+    "lineEnding": "lf"
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "recommended": false,
+      "complexity": {
+        "noExtraBooleanCast": "error",
+        "noUselessCatch": "error"
+      },
+      "correctness": {
+        "useUniqueElementIds": "off",
+        "noConstAssign": "error",
+        "noConstantCondition": "error",
+        "noEmptyCharacterClassInRegex": "error",
+        "noEmptyPattern": "error",
+        "noGlobalObjectCalls": "error",
+        "noInnerDeclarations": "error",
+        "noInvalidConstructorSuper": "error",
+        "noNonoctalDecimalEscape": "error",
+        "noPrecisionLoss": "error",
+        "noSelfAssign": "error",
+        "noSetterReturn": "error",
+        "noSwitchDeclarations": "error",
+        "noUndeclaredVariables": "error",
+        "noUnreachable": "error",
+        "noUnreachableSuper": "error",
+        "useIsNan": "error",
+        "useValidForDirection": "error",
+        "noUnusedImports": "error"
+      },
+      "suspicious": {
+        "noAsyncPromiseExecutor": "error",
+        "noCatchAssign": "error",
+        "noClassAssign": "error",
+        "noCompareNegZero": "error",
+        "noControlCharactersInRegex": "error",
+        "noDebugger": "error",
+        "noDuplicateCase": "error",
+        "noDuplicateClassMembers": "error",
+        "noDuplicateObjectKeys": "error",
+        "noDuplicateParameters": "error",
+        "noEmptyBlockStatements": "error",
+        "noFallthroughSwitchClause": "error",
+        "noFunctionAssign": "error",
+        "noGlobalAssign": "error",
+        "noImportAssign": "error",
+        "noMisleadingCharacterClass": "error",
+        "noPrototypeBuiltins": "error",
+        "noRedeclare": "error",
+        "noShadowRestrictedNames": "error",
+        "noUnsafeNegation": "error",
+        "useGetterReturn": "error",
+        "noConsole": {
+          "level": "error",
+          "fix": "none",
+          "options": {
+            "allow": ["warn", "error"]
+          }
+        }
+      }
+    }
+  },
+  "javascript": {
+    "globals": [
+      "jest",
+      "describe",
+      "it",
+      "test",
+      "expect",
+      "beforeAll",
+      "beforeEach",
+      "afterAll",
+      "afterEach",
+      "fail"
+    ],
+    "formatter": {
+      "quoteStyle": "single",
+      "jsxQuoteStyle": "double",
+      "quoteProperties": "asNeeded",
+      "trailingCommas": "es5",
+      "semicolons": "asNeeded",
+      "arrowParentheses": "always",
+      "bracketSpacing": true,
+      "bracketSameLine": false,
+      "attributePosition": "auto"
+    }
+  },
+  "json": {
+    "formatter": {
+      "indentStyle": "space",
+      "indentWidth": 2
+    }
+  },
+  "css": {
+    "parser": {
+      "cssModules": true,
+      "allowWrongLineComments": true
+    },
+    "formatter": {
+      "enabled": true,
+      "indentStyle": "space",
+      "indentWidth": 2
+    },
+    "linter": {
+      "enabled": false
+    }
+  },
+  "assist": {
+    "enabled": false,
+    "actions": {
+      "source": {
+        "organizeImports": "off"
+      }
+    }
+  },
+  "overrides": [
+    {
+      "includes": ["e2e/**/*"],
+      "linter": {
+        "rules": {
+          "suspicious": {
+            "noConsole": "off"
+          }
+        }
+      }
+    }
+  ]
+}

+ 201 - 0
e2e/README.md

@@ -0,0 +1,201 @@
+# End-to-end tests
+
+The vitest suite mocks Prisma, so it proves the actions think correctly and
+nothing else. These tests run the built app in a browser against a real
+Postgres, and cover what only breaks once the pieces are assembled: migrations,
+the session cookie, server actions wired to forms, and invoice numbering.
+
+## Layout
+
+```
+e2e/
+  auth.setup.ts        signs in once; every spec starts with that session
+  prepare-db.ts        reset + seed, run ahead of the server
+  mail-sink.ts         a mail server that delivers nothing and keeps everything
+  support/             helpers specs share: reading mail, reading a PDF's text,
+                       database peeks, TOTP, work order driving
+  specs/
+    auth/              sign-in, sign-up and invitations, account security
+    invoices/          numbering, paying an invoice down, one document four ways,
+                       what the job's own files do to it, the designer
+                       and its drags
+    work-orders/       pricing under each tax setting, quote to invoice,
+                       the lifecycle of a job, what the editor refuses,
+                       the shape of the page at both breakpoints
+    quotes/            the quote a customer is handed
+    calendar/          a booking keeps the time it was made at, in the
+                       workshop's own timezone
+    inventory/         a stocked part leaves the shelf exactly once
+    reminders/         a due time survives being displayed and re-saved
+    tech/              the technician app's API contract
+    smoke/             the build is alive
+```
+
+One folder per area of the app, one file per flow. A new area gets a new folder;
+a helper used by more than one spec goes under `support/`.
+
+The app the suite starts runs with `DISABLE_BACKGROUND_JOBS=1`. Its schedulers would
+otherwise tick through the run: the due-reminder scan stamps `notifiedAt` on rows a spec
+is asserting on, the message and webhook processors send things, and all of them compete
+for the single CPU a serial suite is using. A spec that needs one of them should call the
+processor directly rather than wait for a timer.
+
+## One-time setup
+
+```bash
+npx playwright install --with-deps chromium
+createdb torqvoice_e2e   # any empty database whose name contains "e2e" or "test"
+```
+
+## Running
+
+```bash
+export E2E_DATABASE_URL="postgresql://torqvoice:torqvoice@localhost:5432/torqvoice_e2e"
+npm run build          # NEXT_PUBLIC_APP_URL must match the base URL below
+npm run test:e2e
+```
+
+The suite resets `E2E_DATABASE_URL` to a clean schema, runs the demo seed,
+starts the mail sink and `next start` on port 3100, signs in once, and reuses
+that session.
+
+If something is already listening on port 3100, the suite uses it as it is and
+skips the reset, so a second run continues on the data the first one left. Stop
+that server when you want a clean slate.
+
+`npm run test:e2e:ui` opens Playwright's watch mode, which is the sane way to
+write a new spec.
+
+## When Playwright has no browser for your machine
+
+Playwright only ships Chromium for the operating systems it supports; on an
+older Debian, `playwright install` refuses. Run the browser from Playwright's
+own image instead, against a server started here:
+
+```bash
+export E2E_DATABASE_URL="postgresql://torqvoice:torqvoice@localhost:5432/torqvoice_e2e"
+export BETTER_AUTH_SECRET=$(grep -oP '^BETTER_AUTH_SECRET="?\K[^"]+' .env)
+npx tsx e2e/prepare-db.ts
+DATABASE_URL="$E2E_DATABASE_URL" NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100 \
+  DEMO_MODE=false AUTH_RATE_LIMIT=off TORQVOICE_MODE=self-hosted \
+  SMTP_HOST=127.0.0.1 SMTP_PORT=1025 SMTP_FROM_EMAIL=workshop@e2e.test \
+  npm run start -- --port 3100 &
+docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
+  -v "$PWD":/work -w /work \
+  -e E2E_BASE_URL=http://127.0.0.1:3100 -e E2E_SKIP_SEED=1 -e E2E_DATABASE_URL \
+  -e BETTER_AUTH_SECRET \
+  mcr.microsoft.com/playwright:v1.63.0-noble npx playwright test
+```
+
+The image version must match `@playwright/test` in package.json. The secret goes in
+because the two-factor spec decrypts what the server stored, and a server started
+here takes its own from `.env`. The SMTP variables point the server at the mail
+sink, which Playwright starts inside the container; `--network host` is what puts
+them on the same localhost.
+
+## Pointing it at something already running
+
+```bash
+E2E_BASE_URL=https://staging.torqvoice.com E2E_SKIP_SEED=1 npm run test:e2e
+```
+
+With `E2E_BASE_URL` set, no app server is started. With `E2E_SKIP_SEED=1`, the
+database is left alone, which is what you want against a shared environment.
+
+The mail sink still starts, but a server elsewhere sends its mail elsewhere,
+so the two specs that read mail (the invitation and the password reset) cannot
+pass against a shared environment unless that server is pointed here too.
+
+## The mail sink
+
+Two things a workshop does can only be tested by reading the mail: an
+invitation is a link and nothing else, and the app deletes an invitation it
+could not send. So the harness runs its own mail server, `e2e/mail-sink.ts`.
+It speaks SMTP on port 1025, delivers nothing, keeps what it is given in
+memory, and hands it back over HTTP on port 8025. Playwright starts and stops
+it with everything else, so there is nothing to install or remember.
+
+The app is pointed at it with `SMTP_HOST` and `SMTP_PORT`, which is all it
+takes: SMTP is the default provider, and the seeded workshop configures none
+of its own. A spec reads what was sent through `support/mail.ts`:
+
+```ts
+const mail = await waitForMail('someone@example.com')
+await page.goto(linkIn(mail, /\/auth\/sign-up\?invite=/))
+```
+
+`clearMailbox()` empties it, which is worth doing before an action whose mail
+you are about to read twice in one file.
+
+## In CI
+
+`.github/workflows/e2e.yml` runs the suite on every pull request to main, and on
+demand from the Actions tab. It brings up a `postgres:16-alpine` service holding
+`torqvoice_e2e`, installs Chromium, builds with
+`NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100`, and runs `npm run test:e2e` the same
+way you would here. The HTML report is uploaded as the `playwright-report`
+artifact on every run, so a failure can be opened locally with
+`npx playwright show-report`.
+
+## Reading a PDF
+
+`support/pdf.ts` turns a PDF into its text (`unpdf`, which is pdf.js underneath), so a
+spec can assert what a customer actually reads rather than that a file arrived:
+
+```ts
+const pdf = await pdfContent(await response.body())
+expect(pdf.flat).toContain('Total $4,312.50')
+expect(pdf.text).toContain('Gates WP-4471\nwith gasket and coolant')
+```
+
+`makePdf(['page one', 'page two'])` builds a small PDF to attach to a job, and
+`TINY_PNG` / `BROKEN_PNG` are a valid photograph and a truncated one.
+
+`flat` collapses all whitespace, for phrases that span a line break in the layout;
+`text` keeps the lines, which is how a multi-line description is checked. `size` is the
+file's own weight — a logo or QR code that goes missing changes nothing about the words,
+so parity checks compare both.
+
+## Variables
+
+| Variable | Default | Purpose |
+| --- | --- | --- |
+| `E2E_DATABASE_URL` | required | The database the suite resets and seeds |
+| `E2E_BASE_URL` | starts its own server on `127.0.0.1:3100` | Test an existing instance |
+| `E2E_SKIP_SEED` | unset | Leave the database untouched |
+| `E2E_ALLOW_ANY_DB` | unset | Override the guard on database names |
+| `E2E_USER_EMAIL` / `E2E_USER_PASSWORD` | `demo@torqvoice.com` / `demo-e2e-pass` | The login the seed creates and the suite signs in with |
+| `E2E_TZ` | `Europe/Oslo` | Browser and server timezone |
+| `E2E_SMTP_PORT` | `1025` | Where the mail sink listens for the app |
+| `E2E_MAIL_API_PORT` | `8025` | Where the mail sink answers the specs |
+| `E2E_MAIL_API` | `http://127.0.0.1:8025` | The sink a spec reads from, when it is not the local one |
+
+The suite's own server also runs with `TORQVOICE_MODE=self-hosted`, `DEMO_MODE=false` and
+`AUTH_RATE_LIMIT=off`. Pointed at another server, start it the same way or the plan
+limits, demo guards and sign-in limiter get in the way of the tests.
+
+## Rules that keep this suite worth having
+
+**The build must be made with the base URL the tests use.**
+`NEXT_PUBLIC_APP_URL` is baked into the client bundle, and better-auth refuses a
+sign-in from an origin it was not built for.
+
+**Never point `E2E_DATABASE_URL` at a database you care about.** The setup runs
+`prisma migrate reset`. There is a guard on the database name, and
+`E2E_ALLOW_ANY_DB=1` removes it, so think before reaching for that.
+
+**Pin the language.** Selectors read visible English. The config sets the
+locale, and the saved session carries a `locale=en` cookie.
+
+**The sign-in rate limit is off on the suite's own server** (`AUTH_RATE_LIMIT=off`). Pointed at
+another server, keep sign-ins in a spec ten seconds apart or the third one is refused.
+
+**Demo mode stays off.** It blocks invites, billing and outbound messages, which
+are behaviours a test should be able to exercise.
+
+**Read the link out of the mail, not out of the database.** A token in a table
+proves nothing about what the person received; the sink is there so a spec can
+follow the address the app actually posted.
+
+**Prefer a role or a stable id over a class.** Where an element has neither, add
+`data-testid` to the component rather than reaching through the DOM.

+ 31 - 0
e2e/auth.setup.ts

@@ -0,0 +1,31 @@
+import { test as setup, expect } from '@playwright/test'
+
+/**
+ * Signs in once and keeps the session on disk. Every other spec starts already
+ * authenticated, which saves a login per test and keeps the sign-in flow tested
+ * in exactly one place.
+ */
+
+const AUTH_STATE = 'e2e/.auth/owner.json'
+
+const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
+const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
+
+setup('sign in as the workshop owner', async ({ page, context }) => {
+  await page.goto('/auth/sign-in')
+
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(password)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+
+  // Landing anywhere outside /auth means the session cookie was accepted.
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+  await expect(page.locator('#password')).toHaveCount(0)
+
+  // The app reads its language from this cookie before Accept-Language. Set
+  // here rather than per test so the saved state carries it everywhere.
+  const { hostname } = new URL(page.url())
+  await context.addCookies([{ name: 'locale', value: 'en', domain: hostname, path: '/' }])
+
+  await context.storageState({ path: AUTH_STATE })
+})

+ 10 - 0
e2e/global-setup.ts

@@ -0,0 +1,10 @@
+import { prepareDatabase } from './prepare-db'
+
+/**
+ * With the suite's own server, the database is prepared by the server command
+ * before `next start`, so there is nothing to do here. Pointed at a server
+ * somebody else started, this is the only chance to do it.
+ */
+export default async function globalSetup(): Promise<void> {
+  if (process.env.E2E_BASE_URL) prepareDatabase()
+}

+ 132 - 0
e2e/mail-sink.ts

@@ -0,0 +1,132 @@
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
+import { simpleParser } from 'mailparser'
+import { SMTPServer } from 'smtp-server'
+
+/**
+ * A mail server that delivers nothing.
+ *
+ * The app refuses to record an invitation it could not mail, and a reset link
+ * only exists inside the mail that carries it, so a suite with no mail server
+ * can test neither. This is one: it speaks SMTP well enough for nodemailer,
+ * keeps what it is given in memory, and hands it back over HTTP so a spec can
+ * read the link a person would have clicked.
+ *
+ * Playwright starts and stops it alongside the app; nothing is installed and
+ * nothing leaves the machine. Run on its own with `npx tsx e2e/mail-sink.ts`.
+ */
+
+const SMTP_PORT = Number(process.env.E2E_SMTP_PORT ?? 1025)
+const API_PORT = Number(process.env.E2E_MAIL_API_PORT ?? 8025)
+/** Enough for a run; the oldest fall off so a long run cannot grow without end. */
+const KEEP = 200
+/** Above this an attachment is listed but not kept: an invoice PDF is tens of kilobytes. */
+const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
+
+/** A file the mail carried, kept so a spec can look inside it. */
+export interface CapturedAttachment {
+  filename: string
+  contentType: string
+  size: number
+  /** The file itself, base64. Absent for anything above the cap below. */
+  content?: string
+}
+
+export interface CapturedMail {
+  /** Every recipient, lower-cased, as the envelope had them. */
+  to: string[]
+  from: string
+  subject: string
+  text: string
+  html: string
+  attachments: CapturedAttachment[]
+  receivedAt: string
+}
+
+const mailbox: CapturedMail[] = []
+
+/** Who the connection said it was sending as; `false` when it declined to say. */
+function envelopeFrom(session: { envelope: { mailFrom: false | { address: string } } }): string {
+  return session.envelope.mailFrom ? session.envelope.mailFrom.address : ''
+}
+
+const smtp = new SMTPServer({
+  // Nothing here is protected and nothing is delivered: a test mail server
+  // that demanded credentials would only be a second thing to configure.
+  authOptional: true,
+  disabledCommands: ['STARTTLS', 'AUTH'],
+  onData(stream, session, callback) {
+    simpleParser(stream)
+      .then((parsed) => {
+        const mail: CapturedMail = {
+          // The envelope is what the app actually addressed; the header is
+          // what it wrote. They agree here, and the envelope is the truth.
+          to: session.envelope.rcptTo.map((r) => r.address.toLowerCase()),
+          from: parsed.from?.value[0]?.address ?? envelopeFrom(session),
+          subject: parsed.subject ?? '',
+          text: parsed.text ?? '',
+          html: typeof parsed.html === 'string' ? parsed.html : '',
+          attachments: (parsed.attachments ?? []).map((a) => ({
+            filename: a.filename ?? '',
+            contentType: a.contentType ?? '',
+            size: a.size ?? a.content?.length ?? 0,
+            content:
+              a.content && a.content.length <= MAX_ATTACHMENT_BYTES
+                ? Buffer.from(a.content).toString('base64')
+                : undefined,
+          })),
+          receivedAt: new Date().toISOString(),
+        }
+        mailbox.unshift(mail)
+        mailbox.length = Math.min(mailbox.length, KEEP)
+        const files = mail.attachments.map((a) => a.filename).join(', ')
+        console.log(
+          `[mail-sink] ${mail.to.join(', ')} — ${mail.subject}${files ? ` (+ ${files})` : ''}`
+        )
+        callback()
+      })
+      .catch((err: Error) => callback(err))
+  },
+})
+
+function send(res: ServerResponse, status: number, body: unknown): void {
+  const payload = JSON.stringify(body)
+  res.writeHead(status, {
+    'content-type': 'application/json',
+    'content-length': Buffer.byteLength(payload),
+  })
+  res.end(payload)
+}
+
+const api = createServer((req: IncomingMessage, res: ServerResponse) => {
+  const url = new URL(req.url ?? '/', `http://127.0.0.1:${API_PORT}`)
+
+  // What Playwright waits for before it starts the run.
+  if (url.pathname === '/health') return send(res, 200, { ok: true, smtpPort: SMTP_PORT })
+
+  if (url.pathname === '/messages') {
+    if (req.method === 'DELETE') {
+      mailbox.length = 0
+      return send(res, 200, { cleared: true })
+    }
+    if (req.method === 'GET') {
+      const to = url.searchParams.get('to')?.toLowerCase()
+      const matching = to ? mailbox.filter((m) => m.to.includes(to)) : mailbox
+      return send(res, 200, matching)
+    }
+  }
+
+  send(res, 404, { error: 'not found' })
+})
+
+smtp.listen(SMTP_PORT, '127.0.0.1', () => {
+  console.log(`[mail-sink] SMTP on 127.0.0.1:${SMTP_PORT}`)
+})
+api.listen(API_PORT, '127.0.0.1', () => {
+  console.log(`[mail-sink] messages on http://127.0.0.1:${API_PORT}/messages`)
+})
+
+for (const signal of ['SIGINT', 'SIGTERM'] as const) {
+  process.on(signal, () => {
+    smtp.close(() => api.close(() => process.exit(0)))
+  })
+}

+ 95 - 0
e2e/prepare-db.ts

@@ -0,0 +1,95 @@
+import { execFileSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+/**
+ * Puts a known database in front of the suite: every migration applied to an
+ * empty schema, then the demo seed.
+ *
+ * The seed is the reason this is cheap. It pins its user and organisation ids
+ * and writes a password hash better-auth can verify, so the tests get a
+ * populated workshop and a working login without a mail server in the loop.
+ *
+ * Run from the web server's own command line, ahead of `next start`, because
+ * Playwright brings the server up before global setup runs and a server on an
+ * empty schema answers every page with an error. When the suite is pointed at
+ * a server somebody else started, global setup calls this instead.
+ */
+
+const TEST_DB_MARKERS = ['e2e', 'test']
+
+function devDatabaseUrl(): string | null {
+  const envFile = resolve(process.cwd(), '.env')
+  if (!existsSync(envFile)) return null
+  for (const line of readFileSync(envFile, 'utf8').split('\n')) {
+    const match = line.match(/^\s*DATABASE_URL\s*=\s*"?([^"\n]+)"?/)
+    if (match) return match[1].trim()
+  }
+  return null
+}
+
+/**
+ * Refuses to point the reset at anything that looks like real data. Resetting
+ * drops every table, so a copy-pasted connection string is the one mistake
+ * worth being rude about.
+ */
+function assertSafeToDestroy(url: string): void {
+  if (process.env.E2E_ALLOW_ANY_DB === '1') return
+
+  const dev = devDatabaseUrl()
+  if (dev && dev === url) {
+    throw new Error(
+      'E2E_DATABASE_URL is the same database as .env DATABASE_URL. ' +
+        'The suite resets the database it is given; point it at a throwaway one.'
+    )
+  }
+
+  const database = url.split('/').pop()?.split('?')[0]?.toLowerCase() ?? ''
+  if (!TEST_DB_MARKERS.some((marker) => database.includes(marker))) {
+    throw new Error(
+      `Refusing to reset database "${database}": the name contains neither "e2e" nor "test". ` +
+        'Rename it, or set E2E_ALLOW_ANY_DB=1 if you are certain.'
+    )
+  }
+}
+
+function run(command: string, args: string[], env: NodeJS.ProcessEnv): void {
+  execFileSync(command, args, { stdio: 'inherit', env })
+}
+
+export function prepareDatabase(): void {
+  const url = process.env.E2E_DATABASE_URL
+  if (!url) {
+    throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
+  }
+  if (process.env.E2E_SKIP_SEED === '1') {
+    console.log('[e2e] E2E_SKIP_SEED=1, leaving the database alone.')
+    return
+  }
+
+  assertSafeToDestroy(url)
+
+  const env: NodeJS.ProcessEnv = {
+    ...process.env,
+    DATABASE_URL: url,
+    // The seed writes vehicle photos next to the app's uploads. Kept out of the
+    // real data directory so a test run cannot disturb a running instance.
+    DATA_ROOT: process.env.E2E_DATA_ROOT ?? resolve(process.cwd(), 'e2e/.data'),
+    // The seed's own default is four characters, which the reset-password
+    // page refuses; the suite seeds the owner with the password it signs in
+    // with, long enough to be set back after the reset flow has changed it.
+    DEMO_USER_EMAIL: process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com',
+    DEMO_USER_PASSWORD: process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass',
+  }
+
+  console.log('[e2e] resetting the test database')
+  // Prisma 7 has no seed hook in this config and no skip flags: reset applies
+  // every migration and nothing else, and the seed is the step after.
+  run('npx', ['prisma', 'migrate', 'reset', '--force'], env)
+
+  console.log('[e2e] seeding')
+  run('npx', ['tsx', 'prisma/seed_dummy_data.ts'], env)
+}
+
+// `tsx e2e/prepare-db.ts` from the web server command.
+if (process.argv[1]?.endsWith('prepare-db.ts')) prepareDatabase()

+ 129 - 0
e2e/specs/auth/account.spec.ts

@@ -0,0 +1,129 @@
+import { type Browser, type BrowserContext, expect, type Page, test } from '@playwright/test'
+import { storedTwoFactorSecret } from '../../support/db'
+import { fillSettled } from '../../support/hydration'
+import { currentTotpCode } from '../../support/totp'
+
+/**
+ * What a signed-in owner can do to their own account: change the password,
+ * and put an authenticator in front of the sign-in.
+ *
+ * Both flows change how the owner signs in, so each is put back the way it
+ * was before the file ends, and the steps run in order.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
+const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
+const changed = `E2e-changed-${Date.now()}`
+
+/** A fresh, signed-out browser context: the way a new sign-in would happen. */
+async function signedOut(browser: Browser): Promise<Page> {
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  return context.newPage()
+}
+
+async function signIn(page: Page, secret: string) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(secret)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+}
+
+async function changePassword(page: Page, from: string, to: string) {
+  await page.goto('/settings/account')
+  await fillSettled(page.locator('#currentPassword'), from)
+  await fillSettled(page.locator('#newPassword'), to)
+  await fillSettled(page.locator('#confirmPassword'), to)
+  await page.getByRole('button', { name: 'Change Password', exact: true }).click()
+  await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
+}
+
+test.describe('password', () => {
+  test('is changed from account settings and works at the door', async ({ page, browser }) => {
+    await changePassword(page, password, changed)
+
+    const fresh = await signedOut(browser)
+    await signIn(fresh, changed)
+    await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+    await fresh.context().close()
+  })
+
+  test('is put back for the rest of the suite', async ({ page }) => {
+    await changePassword(page, changed, password)
+  })
+})
+
+test.describe('two-factor authentication', () => {
+  // One browser context for the three steps. Enabling 2FA makes better-auth
+  // rotate the session, and a fresh context per test would come back with
+  // the token from setup, which the rotation deleted: the pages would still
+  // render off the cookie cache, but anything sensitive would be refused.
+  let owner: BrowserContext
+  let page: Page
+
+  test.beforeAll(async ({ browser }) => {
+    owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    page = await owner.newPage()
+  })
+
+  test.afterAll(async () => {
+    // The rest of the suite signs in with the saved state; hand it the
+    // session this context ended up with, not the one 2FA retired.
+    await owner.storageState({ path: 'e2e/.auth/owner.json' })
+    await owner.close()
+  })
+
+  test('an authenticator app is enrolled with a code it generates', async () => {
+    await page.goto('/settings/account')
+    // Ids that start with a digit are not valid CSS selectors, hence the attribute form.
+    const dialog = page.getByRole('dialog')
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Enable 2FA', exact: true }).click()
+      await expect(dialog.locator('[id="2fa-enable-password"]')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await dialog.locator('[id="2fa-enable-password"]').fill(password)
+    await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
+
+    // The QR code step. The secret it encodes is in the database by now,
+    // which is how the test plays the part of the phone.
+    await dialog.getByRole('button', { name: 'Continue', exact: true }).click()
+    const stored = await storedTwoFactorSecret(email)
+    expect(stored, 'better-auth stored a secret when 2FA was enabled').not.toBeNull()
+
+    await dialog.locator('[id="2fa-verify-code"]').fill(await currentTotpCode(stored as string))
+    await dialog.getByRole('button', { name: 'Verify', exact: true }).click()
+
+    await dialog.getByRole('button', { name: /saved my backup codes/i }).click()
+    await expect(page.getByText(/two-factor authentication (is )?enabled/i).first()).toBeVisible()
+  })
+
+  test('signing in now asks for the code before opening anything', async ({ browser }) => {
+    const fresh = await signedOut(browser)
+    await signIn(fresh, password)
+    await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
+
+    // Nothing behind the door without the code.
+    await fresh.goto('/customers')
+    await expect(fresh).toHaveURL(/\/auth\//)
+
+    await fresh.goto('/auth/verify-2fa')
+    const stored = await storedTwoFactorSecret(email)
+    await fresh.locator('#code').fill(await currentTotpCode(stored as string))
+    await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
+    await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+    await fresh.context().close()
+  })
+
+  test('is switched off again with the password', async () => {
+    await page.goto('/settings/account')
+    await fillSettled(page.locator('[id="2fa-disable-password"]'), password)
+    await page.getByRole('button', { name: 'Disable 2FA', exact: true }).click()
+
+    // Off in the page, and gone from the database.
+    await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeVisible({
+      timeout: 15_000,
+    })
+    expect(await storedTwoFactorSecret(email)).toBeNull()
+  })
+})

+ 114 - 0
e2e/specs/auth/roles.spec.ts

@@ -0,0 +1,114 @@
+import { expect, type Page, test } from '@playwright/test'
+import { linkIn, waitForMail } from '../../support/mail'
+import { settle } from '../../support/hydration'
+
+/**
+ * What a role grants, and what it does not.
+ *
+ * A member's permissions come from the role they were given, and a member
+ * given none "cannot do anything" — the team page says so in as many words.
+ * That promise is worth a test, because the failure mode is silent in both
+ * directions: a member who can reach the billing page can also change what
+ * the workshop pays, and a member who can reach nothing sees a product that
+ * looks broken rather than one that is waiting for an admin.
+ *
+ * The invitation goes out through the harness's mail sink, so the colleague
+ * arrives the way a real one does: by following a link they were sent.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// The colleague starts as a stranger with no session.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const COLLEAGUE = `e2e-roleless-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+async function signInAsColleague(page: Page) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(COLLEAGUE)
+  await page.locator('#password').fill(PASSWORD)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+test.describe('a member with no role', () => {
+  test('is invited by the owner and signs up from the mail', async ({ page, browser }) => {
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const ownerPage = await owner.newPage()
+    await ownerPage.goto('/settings/team')
+    await settle(ownerPage)
+
+    // "Someone in the office": invited by email, picks their own password, and
+    // is given no role, which the dialog warns leaves them unable to do
+    // anything until an admin says otherwise.
+    await expect(async () => {
+      await ownerPage.getByRole('button', { name: 'Add', exact: true }).first().click()
+      await expect(ownerPage.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await ownerPage.getByText('Someone in the office').click()
+    await ownerPage.locator('#member-email').fill(COLLEAGUE)
+    await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
+    await expect(ownerPage.getByText(COLLEAGUE).first()).toBeVisible({ timeout: 30_000 })
+    await owner.close()
+
+    const invitation = await waitForMail(COLLEAGUE)
+    await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
+    await page.locator('#name').fill('E2E Roleless Colleague')
+    await page.locator('#email').fill(COLLEAGUE)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.getByRole('button', { name: /create account/i }).click()
+
+    // No onboarding: they joined a workshop that already exists.
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+  })
+
+  test('is told so, once and plainly, instead of a sidebar of refusals', async ({ page }) => {
+    await signInAsColleague(page)
+
+    // The whole application used to open, with every page inside it answering
+    // "your role does not allow this" one at a time.
+    await expect(page.getByRole('heading', { name: 'No access yet' })).toBeVisible()
+    await expect(page.getByText(/Demo Auto Workshop/)).toBeVisible()
+    // And it names who can fix it, because the reader cannot.
+    await expect(page.getByText(/ask an owner or an admin/i)).toBeVisible()
+  })
+
+  test('reaches none of the workshop’s screens by their addresses', async ({ page }) => {
+    await signInAsColleague(page)
+
+    for (const url of ['/work-orders', '/customers', '/billing', '/settings/team', '/inventory']) {
+      await page.goto(url)
+      // The same one screen, wherever they point the browser.
+      await expect(
+        page.getByRole('heading', { name: 'No access yet' }),
+        `${url} is refused`
+      ).toBeVisible()
+    }
+  })
+
+  test('can sign out from where they are', async ({ page }) => {
+    // The only thing the screen offers, and the only thing they can do: an
+    // account with nowhere to go still has to be able to leave.
+    await signInAsColleague(page)
+    await page.getByRole('button', { name: /sign out/i }).click()
+    await expect(page).toHaveURL(/\/auth\/sign-in/, { timeout: 30_000 })
+  })
+
+  test('the owner is not affected by any of it', async ({ browser }) => {
+    // The other half of the rule: the pages refused above are refused because
+    // of the role, not because they are broken.
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const ownerPage = await owner.newPage()
+    for (const url of ['/work-orders', '/billing', '/settings/team']) {
+      await ownerPage.goto(url)
+      await expect(
+        ownerPage.getByRole('heading', { name: 'No access yet' }),
+        `${url} opens for the owner`
+      ).toHaveCount(0)
+    }
+    await owner.close()
+  })
+})

+ 162 - 0
e2e/specs/auth/sign-in.spec.ts

@@ -0,0 +1,162 @@
+import { expect, type Page, test } from '@playwright/test'
+import { clearMailbox, linkIn, waitForMail } from '../../support/mail'
+
+/**
+ * The front door: signing in, being kept out, signing out, and getting back
+ * in after a forgotten password.
+ *
+ * Every test here starts signed out, unlike the rest of the suite, because
+ * the door is the thing under test. The reset flow takes its token out of the
+ * mail the app sent, caught by the harness's mail sink, so a reset that
+ * records a token but never posts it fails here rather than in a support
+ * mailbox.
+ */
+
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
+const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
+const replacement = `E2e-pass-${Date.now()}`
+
+async function signIn(page: Page, who: string, secret: string) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(who)
+  await page.locator('#password').fill(secret)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+}
+
+async function expectSignedIn(page: Page) {
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+  await expect(page.locator('#password')).toHaveCount(0)
+}
+
+/** Requests a reset link for the address and returns the token the mail carries. */
+async function requestReset(page: Page, who: string): Promise<string> {
+  // The owner asks for a reset twice in this file. With the box emptied
+  // first, the mail read back is certainly the one this request sent and not
+  // the earlier one, whose token has already been spent.
+  await clearMailbox()
+  await page.goto('/auth/forgot-password')
+  await page.locator('#email').fill(who)
+  await page.getByRole('button', { name: /send reset link/i }).click()
+  // The same words whether or not the address exists, so a stranger cannot
+  // use this form to find out who has an account.
+  await expect(page.getByText(/if an account exists with that email/i)).toBeVisible()
+
+  const mail = await waitForMail(who, { subject: /reset your torqvoice password/i })
+  // The mail does not link to the reset page. It links into better-auth's own
+  // endpoint, which checks the token and redirects to the page carrying it, so
+  // following the link is both what the person does and where the token
+  // comes from.
+  await page.goto(linkIn(mail, /\/reset-password\//))
+  await page.waitForURL(/\/auth\/reset-password\?/, { timeout: 30_000 })
+  const token = new URL(page.url()).searchParams.get('token')
+  expect(token, 'the mailed link lands on the reset page with a token').toBeTruthy()
+  return token as string
+}
+
+async function resetWith(page: Page, token: string, next: string, confirm = next) {
+  await page.goto(`/auth/reset-password?token=${encodeURIComponent(token)}`)
+  await page.locator('#new-password').fill(next)
+  await page.locator('#confirm-password').fill(confirm)
+  await page.getByRole('button', { name: /reset password/i }).click()
+}
+
+test.describe('sign in', () => {
+  test('a wrong password is refused and says so', async ({ page }) => {
+    await signIn(page, email, 'not-the-password')
+    await expect(page.getByText(/invalid email or password/i)).toBeVisible()
+    await expect(page).toHaveURL(/\/auth\/sign-in/)
+  })
+
+  test('the right password opens the workshop', async ({ page }) => {
+    await signIn(page, email, password)
+    await expectSignedIn(page)
+  })
+
+  test('the sign-in page offers the way to a forgotten password', async ({ page }) => {
+    await page.goto('/auth/sign-in')
+    await page.getByRole('link', { name: /forgot password/i }).click()
+    await expect(page).toHaveURL(/\/auth\/forgot-password/)
+    await expect(page.locator('#email')).toBeVisible()
+  })
+})
+
+test.describe('kept out', () => {
+  test('a signed-out visitor is sent to sign in', async ({ page }) => {
+    await page.goto('/customers')
+    await expect(page).toHaveURL(/\/auth\/sign-in/)
+  })
+
+  test('the technician handshake needs no session, the data does', async ({ request }) => {
+    const health = await request.get('/api/v1/tech/health')
+    expect(health.status()).toBe(200)
+  })
+})
+
+test.describe('sign out', () => {
+  test('signing out ends the session for good', async ({ page }) => {
+    await signIn(page, email, password)
+    await expectSignedIn(page)
+
+    // The account menu sits at the foot of the sidebar, under the owner's name.
+    await page.getByRole('button', { name: /demo owner/i }).click()
+    await page.getByRole('menuitem', { name: /sign out/i }).click()
+    await expect(page).toHaveURL(/\/auth\/sign-in/, { timeout: 30_000 })
+
+    // Not only redirected: the old session must not open anything.
+    await page.goto('/customers')
+    await expect(page).toHaveURL(/\/auth\/sign-in/)
+  })
+})
+
+test.describe('forgotten password', () => {
+  // The steps change the owner's password and put it back, so they run in
+  // order and never alongside each other.
+  test.describe.configure({ mode: 'serial' })
+
+  // The token that set the new password, kept so the next step can try to
+  // spend it twice. better-auth deletes it on use, which is the point.
+  let spent = ''
+
+  test('a made-up token cannot set a password', async ({ page }) => {
+    await resetWith(page, 'not-a-real-token', replacement)
+    // better-auth's own words, or the page's fallback when it has none.
+    await expect(page.getByText(/invalid token|could not reset password/i)).toBeVisible()
+  })
+
+  test('a reset link from the forgotten-password form sets a new password', async ({ page }) => {
+    const token = await requestReset(page, email)
+
+    // The two fields have to agree before anything is sent.
+    await resetWith(page, token, replacement, `${replacement}-x`)
+    await expect(page.getByText(/passwords do not match/i)).toBeVisible()
+
+    await resetWith(page, token, replacement)
+    await expect(page.getByText(/has been reset successfully/i)).toBeVisible()
+    spent = token
+  })
+
+  test('the old password stops working and the new one works', async ({ page }) => {
+    await signIn(page, email, password)
+    await expect(page.getByText(/invalid email or password/i)).toBeVisible()
+
+    await signIn(page, email, replacement)
+    await expectSignedIn(page)
+  })
+
+  test('a reset link is good for one use', async ({ page }) => {
+    expect(spent).not.toBe('')
+    await resetWith(page, spent, `${replacement}-again`)
+    await expect(page.getByText(/invalid token|could not reset password/i)).toBeVisible()
+  })
+
+  test('the owner gets the seeded password back for the rest of the suite', async ({ page }) => {
+    const token = await requestReset(page, email)
+    await resetWith(page, token, password)
+    await expect(page.getByText(/has been reset successfully/i)).toBeVisible()
+
+    await signIn(page, email, password)
+    await expectSignedIn(page)
+  })
+})

+ 63 - 0
e2e/specs/auth/sign-up.spec.ts

@@ -0,0 +1,63 @@
+import { expect, type Page, test } from '@playwright/test'
+import { linkIn, waitForMail } from '../../support/mail'
+
+/**
+ * How people arrive: a stranger who opens a workshop of their own, and a
+ * colleague who was invited into one that exists.
+ *
+ * Both start signed out. The colleague follows the link out of the invitation
+ * mail itself, caught by the harness's mail sink, so the address the app
+ * writes into that mail is under test as much as the sign-up page is.
+ */
+
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+
+async function fillSignUp(page: Page, name: string, email: string, password: string) {
+  await page.locator('#name').fill(name)
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(password)
+  await page.locator('#terms').click()
+  await page.getByRole('button', { name: /create account/i }).click()
+}
+
+test('a new account is walked through onboarding into a workshop of its own', async ({ page }) => {
+  await page.goto('/auth/sign-up')
+  await fillSignUp(page, 'E2E Founder', `e2e-founder-${stamp}@example.com`, `E2e-pass-${stamp}`)
+
+  await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+  await page.locator('#workshopName').fill(`E2E Garage ${stamp}`)
+  await page.locator('form button[type="submit"]').click()
+
+  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+  await expect(page.getByText(`E2E Garage ${stamp}`).first()).toBeVisible()
+})
+
+test('an invited colleague signs up straight into the workshop', async ({ page, browser }) => {
+  const invitee = `e2e-colleague-${stamp}@example.com`
+
+  // The owner sends the invitation from the team page.
+  const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+  const ownerPage = await owner.newPage()
+  await ownerPage.goto('/settings/team')
+  await ownerPage.getByRole('button', { name: 'Add', exact: true }).click()
+  await ownerPage.getByText('Someone in the office').click()
+  await ownerPage.locator('#member-email').fill(invitee)
+  await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
+  await expect(ownerPage.getByText(invitee).first()).toBeVisible()
+  await owner.close()
+
+  // The invitation is a mail with a link in it, and nothing else. An app that
+  // records the invitation but posts a link nobody can follow has failed at
+  // the only part the colleague ever sees.
+  const invitation = await waitForMail(invitee)
+  const link = linkIn(invitation, /\/auth\/sign-up\?invite=/)
+
+  await page.goto(link)
+  await fillSignUp(page, 'E2E Colleague', invitee, `E2e-pass-${stamp}`)
+
+  // No onboarding: they land in the workshop that invited them.
+  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+  await expect(page.getByText('Demo Auto Workshop').first()).toBeVisible()
+})

+ 182 - 0
e2e/specs/auth/tenancy.spec.ts

@@ -0,0 +1,182 @@
+import { expect, type Page, test } from '@playwright/test'
+import { attach } from '../../support/attachments'
+import {
+  latestAttachmentUrl,
+  organizationIdFor,
+  seededTenantFixtures,
+  type TenantFixtures,
+} from '../../support/db'
+import { shareLink } from '../../support/work-order'
+
+/**
+ * One workshop cannot reach another's records.
+ *
+ * Seven unit tests already check that the queries carry an organisation id
+ * (`src/__tests__/multitenancy/`), but they mock Prisma: they prove the code
+ * asks the right question, not that the running app refuses the wrong one. A
+ * missing scope on one route, a page that reads an id straight from the URL,
+ * a file served by path rather than by owner — none of that shows up in a
+ * mocked query.
+ *
+ * So a second workshop is opened here, by signing up the way a stranger
+ * would, and then pointed at the first one's pages, documents and files. What
+ * it must see, everywhere, is nothing.
+ *
+ * The share token is the exception worth stating: it is unguessable, and
+ * holding it is how a customer was given the document. It still has to belong
+ * to the organisation named in the link.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// A stranger's browser: no session, until this file makes one.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const OUTSIDER = `e2e-outsider-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+let seeded: TenantFixtures
+/** A shared invoice link from the first workshop, for the token tests. */
+let sharedInvoice = ''
+/** A file that genuinely belongs to the first workshop's own job. */
+let theirFileUrl = ''
+
+/** Signs the outsider in, opening their workshop on the first run. */
+async function signInAsOutsider(page: Page) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(OUTSIDER)
+  await page.locator('#password').fill(PASSWORD)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+test.beforeAll(async ({ browser }) => {
+  seeded = await seededTenantFixtures()
+
+  // A link the first workshop handed to one of its own customers, minted here
+  // so the token tests have a real one to try in the wrong place.
+  const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await owner.goto(`/vehicles/${seeded.vehicleId}/service/${seeded.serviceRecordId}`)
+  sharedInvoice = await shareLink(owner)
+
+  // And a file of their own, put there rather than looked for: a seeded
+  // workshop has no attachments, so a spec that goes hunting for one only
+  // finds what another spec happened to leave behind.
+  await attach(owner, 'Documents', {
+    name: `e2e-tenancy-${stamp}.txt`,
+    mimeType: 'text/plain',
+    buffer: Buffer.from("One workshop's paperwork."),
+  })
+  theirFileUrl = await latestAttachmentUrl(seeded.serviceRecordId)
+  await owner.close()
+})
+
+test.describe('a second workshop', () => {
+  test('is opened by a stranger signing up', async ({ page }) => {
+    await page.goto('/auth/sign-up')
+    await page.locator('#name').fill('E2E Outsider')
+    await page.locator('#email').fill(OUTSIDER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.getByRole('button', { name: /create account/i }).click()
+
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+    await page.locator('#workshopName').fill(`E2E Outsider Garage ${stamp}`)
+    await page.locator('form button[type="submit"]').click()
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+
+    await expect(page.getByText(`E2E Outsider Garage ${stamp}`).first()).toBeVisible()
+  })
+
+  test('sees none of the first workshop’s customers or vehicles in its own lists', async ({
+    page,
+  }) => {
+    await signInAsOutsider(page)
+
+    await page.goto('/customers')
+    // The seed's customers are a fleet company and nineteen others; a fresh
+    // workshop has none of them.
+    await expect(page.getByText('Summit Construction')).toHaveCount(0)
+    await expect(page.getByText('James Mitchell')).toHaveCount(0)
+
+    await page.goto('/vehicles')
+    await expect(page.getByText('Camry')).toHaveCount(0)
+  })
+
+  test('is handed nothing when it types the first workshop’s addresses', async ({ page }) => {
+    await signInAsOutsider(page)
+
+    // Not the status code: a page may answer 200 and draw an empty shell,
+    // which is a refusal as much as a 404 is. What must never appear is a
+    // word belonging to the other workshop.
+    const theirs = [seeded.vehiclePlate, seeded.customerName, seeded.quoteNumber]
+
+    for (const [what, url] of Object.entries({
+      vehicle: `/vehicles/${seeded.vehicleId}`,
+      'work order': `/vehicles/${seeded.vehicleId}/service/${seeded.serviceRecordId}`,
+      customer: `/customers/${seeded.customerId}`,
+      quote: `/quotes/${seeded.quoteId}`,
+    })) {
+      await page.goto(url)
+      // Still the outsider's own app, so an absence below means something.
+      await expect(
+        page.getByText(`E2E Outsider Garage ${stamp}`).first(),
+        `${what} is still the outsider's app`
+      ).toBeVisible()
+
+      const shown = await page.locator('body').innerText()
+      for (const word of theirs) {
+        expect(shown, `${what} does not show "${word}"`).not.toContain(word)
+      }
+    }
+  })
+
+  test('cannot fetch the first workshop’s documents', async ({ page }) => {
+    await signInAsOutsider(page)
+
+    const invoice = await page.request.get(`/api/protected/services/${seeded.serviceRecordId}/pdf`)
+    expect(invoice.status(), 'the invoice PDF is refused').toBe(404)
+
+    const quote = await page.request.get(`/api/protected/quotes/${seeded.quoteId}/pdf`)
+    expect(quote.status(), 'the quote PDF is refused').toBe(404)
+  })
+
+  test('cannot fetch the first workshop’s files', async ({ page }) => {
+    await signInAsOutsider(page)
+
+    // Files are served by a path that names the organisation, so this is the
+    // one place where guessing an id would be enough if nothing checked.
+    const file = await page.request.get(theirFileUrl)
+    expect(file.status(), `${theirFileUrl} is refused`).toBeGreaterThanOrEqual(400)
+    expect(file.status()).toBeLessThan(500)
+  })
+
+  test('cannot spend a share token under its own organisation', async ({ page }) => {
+    const [, token] = new URL(sharedInvoice).pathname.split('/').slice(-2)
+
+    // The outsider's real workshop, not an invented id: the question is
+    // whether a token minted by one organisation opens under another, and a
+    // made-up id would only prove that nonsense is refused.
+    const outsiderOrg = await organizationIdFor(OUTSIDER)
+    expect(outsiderOrg).not.toBe(seeded.organizationId)
+
+    const response = await page.request.get(`/api/public/share/invoice/${outsiderOrg}/${token}/pdf`)
+    expect(response.status(), 'the token does not travel between workshops').toBe(404)
+
+    // Nor does a token invented from nothing.
+    const nonsense = await page.request.get(
+      `/api/public/share/invoice/${seeded.organizationId}/not-a-real-token/pdf`
+    )
+    expect(nonsense.status()).toBe(404)
+  })
+
+  test('the token still works where it belongs', async ({ page }) => {
+    // The other half of the rule: this is a real link the first workshop gave
+    // its customer, and it has to keep opening.
+    const [orgId, token] = new URL(sharedInvoice).pathname.split('/').slice(-2)
+    const response = await page.request.get(`/api/public/share/invoice/${orgId}/${token}/pdf`)
+    expect(response.status()).toBe(200)
+    expect(response.headers()['content-type']).toContain('application/pdf')
+  })
+})

+ 171 - 0
e2e/specs/calendar/booking.spec.ts

@@ -0,0 +1,171 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { setWorkshopClock } from '../../support/settings'
+
+/**
+ * A booking keeps the time it was made at.
+ *
+ * This is the one part of the app where a mocked test cannot help. Every
+ * wall-clock bug here has the same shape: a time is written down in one
+ * timezone and read back in another, and the job that was booked for half past
+ * ten turns up at half past twelve. It has happened in this codebase, which is
+ * why `src/lib/workshop-datetime.ts` exists and why the suite pins one
+ * timezone for the browser and the server.
+ *
+ * The trick that makes this checkable is that the calendar names the time it
+ * thinks you clicked: right-click a slot and the menu offers "New work order
+ * at 10:30". So the test never has to know which slot it hit — it reads back
+ * the app's own answer and then holds every other screen to it.
+ *
+ * Run against a workshop timezone deliberately far from the server's, because
+ * agreeing with itself in one zone proves nothing.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+/** Half a world from the Europe/Oslo the harness runs in. */
+const FAR_AWAY = 'Pacific/Auckland'
+
+/** The time the calendar offered, as it wrote it. */
+let offered = ''
+let jobUrl = ''
+
+/** The day column for a date the grid is showing, in day or week view. */
+async function dayColumn(page: Page) {
+  const columns = page.locator('[data-testid^="timegrid-day-"]')
+  await expect(columns.first()).toBeVisible({ timeout: 30_000 })
+  return columns.first()
+}
+
+async function openDayView(page: Page) {
+  await page.goto('/calendar')
+  await settle(page)
+  // The views have single-key shortcuts, which is both what a service adviser
+  // uses all day and the steadiest way in: the switcher itself is a dropdown
+  // whose trigger is named after whichever view is showing.
+  await expect(async () => {
+    await page.keyboard.press('d')
+    await expect(page.locator('[data-testid^="timegrid-day-"]').first()).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  // A 24-hour clock as well, so the calendar and the schedule field write a
+  // time the same way and a comparison between them means something.
+  await setWorkshopClock(page, { timezone: FAR_AWAY, format: '24h' })
+  await page.close()
+})
+
+test.afterAll(async ({ browser }) => {
+  // Back to the browser's own, which is what the rest of the suite expects.
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setWorkshopClock(page, { timezone: '', format: '12h' })
+  await page.close()
+})
+
+test.describe('booking a job from the calendar', () => {
+  test('the calendar names the time under the pointer', async ({ page }) => {
+    await openDayView(page)
+
+    const column = await dayColumn(page)
+    const box = await column.boundingBox()
+    expect(box, 'the day column is on screen').not.toBeNull()
+
+    // Somewhere in the working day. Which slot does not matter: what matters
+    // is that the app says which one it was.
+    await column.click({
+      button: 'right',
+      position: { x: Math.min(40, box!.width / 2), y: box!.height * 0.45 },
+    })
+
+    const item = page.getByRole('menuitem', { name: /new work order at/i }).first()
+    await expect(item).toBeVisible({ timeout: 10_000 })
+    offered = ((await item.innerText()).match(/(\d{1,2}[:.]\d{2})/) ?? [])[1] ?? ''
+    expect(offered, 'the menu names a time').toMatch(/\d{1,2}[:.]\d{2}/)
+  })
+
+  test('the work order it creates starts at that time', async ({ page }) => {
+    await openDayView(page)
+    const column = await dayColumn(page)
+    const box = await column.boundingBox()
+
+    await column.click({
+      button: 'right',
+      position: { x: Math.min(40, box!.width / 2), y: box!.height * 0.45 },
+    })
+    const item = page.getByRole('menuitem', { name: /new work order at/i }).first()
+    await expect(item).toBeVisible({ timeout: 10_000 })
+    const named = ((await item.innerText()).match(/(\d{1,2}[:.]\d{2})/) ?? [])[1] ?? ''
+    expect(named, 'the same time as before').toBe(offered)
+    await item.click()
+
+    // It asks which vehicle before it can make anything.
+    const picker = page.getByRole('dialog', { name: /select vehicle for work order/i })
+    await expect(picker).toBeVisible({ timeout: 30_000 })
+    await picker.getByText(/Camry/i).first().click()
+
+    await page.waitForURL(
+      (url) => /\/service\/[^/]+$/.test(url.pathname) && !url.pathname.endsWith('/new'),
+      { timeout: 30_000 }
+    )
+    jobUrl = page.url()
+
+    // The schedule card prints the start as a date and a 24-hour clock, and
+    // the clock has to be the one the calendar offered.
+    await expect(page.getByText(new RegExp(offered.replace('.', '[:.]')))).toBeVisible({
+      timeout: 30_000,
+    })
+
+    await page.locator('input[name="title"]').fill(`E2E booking ${stamp}`)
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+  })
+
+  test('and still starts at that time after a reload', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    // The round trip through the database and back out again, which is where a
+    // wall clock gets converted twice and comes back wrong.
+    await expect(page.getByText(new RegExp(offered.replace('.', '[:.]')))).toBeVisible()
+  })
+
+  test('and the calendar shows it where it put it', async ({ page }) => {
+    await openDayView(page)
+
+    // The chip's tooltip is the times it was drawn at, which is the reading
+    // that matters: the block is positioned from the same string. Read rather
+    // than clicked, because a day with several bookings in one slot draws
+    // them on top of each other and a click lands on whichever is in front.
+    const chip = page.locator(`[title*="E2E booking ${stamp}"]`).first()
+    await expect(chip).toBeVisible({ timeout: 30_000 })
+    await expect(chip, 'the chip is drawn at the time the menu offered').toHaveAttribute(
+      'title',
+      new RegExp(`^${offered.replace('.', '[:.]')}\\b`)
+    )
+  })
+
+  test('the workshop’s own timezone is what the times are read in', async ({ page }) => {
+    // Moved to the other side of the world, the same instant is a different
+    // wall clock — and the calendar has to say the new one, because the
+    // workshop's clock is the one its bookings are kept in.
+    await setWorkshopClock(page, { timezone: 'America/Los_Angeles' })
+
+    await page.goto(jobUrl)
+    await settle(page)
+    const shown = await page.locator('body').innerText()
+    expect(
+      shown.includes(offered),
+      `the start reads differently in another timezone (was ${offered})`
+    ).toBe(false)
+
+    await setWorkshopClock(page, { timezone: FAR_AWAY })
+    await page.goto(jobUrl)
+    await settle(page)
+    // And back again: the stored instant never moved.
+    await expect(page.getByText(new RegExp(offered.replace('.', '[:.]')))).toBeVisible()
+  })
+})

+ 181 - 0
e2e/specs/inventory/movements.spec.ts

@@ -0,0 +1,181 @@
+import { expect, test } from '@playwright/test'
+import {
+  ownerOrganizationId,
+  partQuantity,
+  setPartQuantity,
+  type StockedPart,
+  stockedPart,
+  stockMovements,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { addPartFromInventory, setPartQuantityField } from '../../support/inventory'
+import { newWorkOrder, partRows, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * A part fitted to a car leaves the shelf exactly once.
+ *
+ * Stock is the one number in this app that a customer never sees and a
+ * workshop counts by hand, so an error in it is discovered weeks later at a
+ * stocktake with no way to tell which job caused it. The rules are unit
+ * tested (`reconcileStock` computes a net delta per part), but the risk is not
+ * in the arithmetic: it is in how often the arithmetic runs. A save that
+ * re-applies the whole set decrements twice; a save the editor refuses must
+ * decrement nothing at all; and the ledger has to agree with the count,
+ * because the count is only the running total of the ledger.
+ *
+ * Everything is asserted against both: the balance on the part and the rows
+ * in `stock_movements`.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+let part: StockedPart
+/** What the part had on hand before this file touched it. */
+let opening = 0
+let jobUrl = ''
+let jobId = ''
+
+test.beforeAll(async () => {
+  const organizationId = await ownerOrganizationId()
+  part = await stockedPart(organizationId)
+  opening = part.quantity
+})
+
+test.afterAll(async () => {
+  // The count is shared with every other spec that prices a part, so it goes
+  // back to exactly what it was even if an assertion above stopped early.
+  if (part) await setPartQuantity(part.id, opening)
+})
+
+test.describe('a stocked part used on a job', () => {
+  test('comes off the shelf once when the job is saved', async ({ page }) => {
+    const vehicleUrl = await seededVehicleUrl(page)
+    jobUrl = await newWorkOrder(page, vehicleUrl, `E2E stock ${stamp}`)
+    jobId = jobUrl.split('/').pop() ?? ''
+
+    await addPartFromInventory(page, part.name, 3)
+    // Nothing has moved yet: the editor is a draft until it is saved.
+    expect(await partQuantity(part.id), 'unsaved edits move no stock').toBe(opening)
+
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(part.id)).toBe(opening - 3)
+    const ledger = await stockMovements(part.id, jobId)
+    expect(ledger).toHaveLength(1)
+    expect(ledger[0].delta).toBe(-3)
+    expect(ledger[0].quantityAfter, 'the ledger agrees with the count').toBe(opening - 3)
+    expect(ledger[0].reason).toBe('service_record')
+  })
+
+  test('does not come off twice when the same job is saved again', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // A second save of an unchanged job: the same three parts are in the
+    // payload, and the shelf must not be asked for three more.
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(part.id)).toBe(opening - 3)
+    expect(await stockMovements(part.id, jobId), 'no second movement').toHaveLength(1)
+  })
+
+  test('moves only the difference when the quantity is corrected', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // Three became five, so two more leave the shelf, not five.
+    await expect(async () => {
+      await setPartQuantityField(page, 5)
+      await expect(
+        partRows(page)
+          .first()
+          .locator('xpath=ancestor::*[.//input[@placeholder="Cost"]][1]')
+          .locator('input[type="number"]')
+          .first()
+      ).toHaveValue('5', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(part.id)).toBe(opening - 5)
+    const ledger = await stockMovements(part.id, jobId)
+    expect(ledger).toHaveLength(2)
+    expect(ledger[1].delta).toBe(-2)
+    expect(ledger[1].quantityAfter).toBe(opening - 5)
+  })
+
+  test('goes back on the shelf when the row is removed', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Delete row' }).first().click()
+      await expect(partRows(page)).toHaveCount(0, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(part.id), 'the shelf is whole again').toBe(opening)
+    const ledger = await stockMovements(part.id, jobId)
+    expect(ledger).toHaveLength(3)
+    expect(ledger[2].delta).toBe(5)
+    expect(ledger[2].quantityAfter).toBe(opening)
+  })
+
+  test('moves nothing when the save is refused', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // A priced row with no name is refused, and the refusal has to happen
+    // before anything is written: a payload that reached the server with the
+    // nameless row dropped used to save the rest and take the stock with it.
+    await addPartFromInventory(page, part.name, 2)
+    await partRows(page).first().fill('')
+
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    await expect(page.getByText('Rows without a part name are not saved.')).toBeVisible()
+    await expect(page.getByText('Saved', { exact: true })).toHaveCount(0)
+
+    expect(await partQuantity(part.id), 'a refused save moves no stock').toBe(opening)
+    expect(await stockMovements(part.id, jobId)).toHaveLength(3)
+  })
+})
+
+test.describe('using more than the shop has', () => {
+  test('is allowed, and the count says how far it went under', async ({ page }) => {
+    // Overselling is real information rather than an error: the part was
+    // fitted, the shelf owes it, and clamping at zero would corrupt the count
+    // as soon as the row is removed again.
+    const vehicleUrl = await seededVehicleUrl(page)
+    const oversoldUrl = await newWorkOrder(page, vehicleUrl, `E2E oversell ${stamp}`)
+    const oversoldId = oversoldUrl.split('/').pop() ?? ''
+
+    await addPartFromInventory(page, part.name, opening + 2)
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(part.id)).toBe(-2)
+    const ledger = await stockMovements(part.id, oversoldId)
+    expect(ledger[0].quantityAfter).toBe(-2)
+
+    // And the picker says so, in the words the workshop reads: not "in
+    // stock", but what it now owes.
+    await page.goto(oversoldUrl)
+    await settle(page)
+    const dialog = page.getByRole('dialog', { name: 'Select Part from Inventory' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'From Inventory', exact: true }).click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await dialog.getByPlaceholder('Search inventory...').fill(part.name)
+    await expect(dialog.getByText('On backorder (2)')).toBeVisible()
+    await page.keyboard.press('Escape')
+
+    // Put it back: the same row removed restocks everything it took.
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Delete row' }).first().click()
+      await expect(partRows(page)).toHaveCount(0, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+    expect(await partQuantity(part.id)).toBe(opening)
+  })
+})

+ 310 - 0
e2e/specs/invoices/designer-drag.spec.ts

@@ -0,0 +1,310 @@
+import { expect, type Locator, type Page, test } from '@playwright/test'
+import {
+  type InvoiceDesignState,
+  invoiceDesignState,
+  restoreInvoiceDesignState,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { bankAccount, setBankAccount } from '../../support/settings'
+import { pdfContent } from '../../support/pdf'
+import {
+  addLabor,
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+} from '../../support/work-order'
+
+/**
+ * Dragging in the designer, and whether anything moves.
+ *
+ * "I dragged the rows in the payment block and they stayed where they were"
+ * is the complaint this file exists to stop, and it is a real one: two blocks
+ * once read their field list as a set rather than a running order, so the
+ * inspector's drag did nothing at all. Ordering is pinned for every section
+ * in `src/__tests__/features/invoice-designer/field-order.test.ts`; what only
+ * a browser can show is that the drag itself reaches that list.
+ *
+ * Four drags, four different mechanisms: the rail reorders sections with
+ * HTML5 drag and drop, the inspector reorders fields the same way, and the
+ * canvas moves a block with pointer events — either to a place of its own or
+ * onto a neighbour to share a row.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const DESIGN_NAME = `E2E drag ${stamp}`
+
+let jobUrl = ''
+let restoreTo: InvoiceDesignState
+/** The bank account as this workshop had it, put back at the end. */
+let restoreBank = ''
+
+async function openDesigner(page: Page) {
+  await page.goto('/invoice-designer')
+  const carryOn = page.getByRole('button', { name: /continue with my current layout/i })
+  const gallery = await carryOn
+    .waitFor({ state: 'visible', timeout: 10_000 })
+    .then(() => true)
+    .catch(() => false)
+  if (gallery) await carryOn.click()
+  await expect(page.getByTestId('rail-header')).toBeVisible({ timeout: 30_000 })
+  await settle(page)
+}
+
+async function saveDesign(page: Page) {
+  const button = page.getByRole('button', { name: /save design/i }).first()
+  await expect(button, 'the designer has something to save').toBeVisible({ timeout: 30_000 })
+  const dialog = page.getByRole('dialog').filter({ hasText: 'Save this design' })
+  const toast = page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
+  await expect(async () => {
+    await button.click()
+    await expect(dialog.or(toast).first()).toBeVisible({ timeout: 3_000 })
+  }).toPass({ timeout: 30_000 })
+  if (await dialog.isVisible().catch(() => false)) {
+    await dialog.getByPlaceholder('Design name').fill(DESIGN_NAME)
+    await dialog.getByRole('button', { name: /^(save|update) design$/i }).click()
+  }
+  await expect(toast).toBeVisible({ timeout: 30_000 })
+}
+
+async function selectSection(page: Page, id: string) {
+  await expect(async () => {
+    await page.getByTestId(`rail-${id}`).click()
+    await expect(page.getByText('Section settings', { exact: true })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+}
+
+/** A block as the canvas draws it; the canvas keeps a hidden copy for measuring. */
+function onSheet(page: Page, id: string): Locator {
+  return page.locator(`[data-node-id="${id}"]`).filter({ visible: true }).first()
+}
+
+/**
+ * A block ready to be dragged: scrolled into view, with its own box and the
+ * box of the sheet it sits on. Both are needed because the canvas thinks in
+ * points from the corner of a page, and a sheet is 595 points wide however
+ * the canvas is zoomed. A long invoice runs onto a second page, and a block
+ * down there has to be brought into view or the pointer lands on nothing at
+ * all.
+ */
+async function grab(page: Page, id: string) {
+  const block = onSheet(page, id)
+  await block.scrollIntoViewIfNeeded()
+  const box = await block.boundingBox()
+  expect(box, `${id} is on the canvas`).not.toBeNull()
+  const sheet = await block.locator('xpath=ancestor::*[@data-sheet][1]').boundingBox()
+  expect(sheet, `${id} sits on a sheet`).not.toBeNull()
+  return { box: box!, sheet: sheet!, ptToPx: sheet!.width / 595 }
+}
+
+/** Where each of these strings lands in the printed invoice. */
+async function printedOrder(page: Page, needles: string[]): Promise<number[]> {
+  const id = jobUrl.split('/').pop()
+  const response = await page.request.get(`/api/protected/services/${id}/pdf`, { timeout: 60_000 })
+  expect(response.status()).toBe(200)
+  const pdf = await pdfContent(await response.body())
+  return needles.map((needle) => {
+    const at = pdf.flat.indexOf(needle)
+    expect(at, `"${needle}" is on the sheet`).toBeGreaterThanOrEqual(0)
+    return at
+  })
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  restoreTo = await invoiceDesignState()
+
+  // The payment panel has to have something to print before a test can drag
+  // it. The seeded workshop has no bank details, no org number and no terms,
+  // so the section is dropped from the sheet entirely and the drag has
+  // nothing to prove.
+  restoreBank = await bankAccount(page)
+  if (!restoreBank) await setBankAccount(page, 'NO93 8601 1117 947')
+
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E drag ${stamp}`)
+  await addPart(page, { name: `E2E starter motor ${stamp}`, quantity: 1, unitPrice: 2_100 })
+  await addLabor(page, { description: 'Fit the starter motor', hours: 1.5, rate: 800 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.afterAll(async ({ browser }) => {
+  if (restoreTo) await restoreInvoiceDesignState(restoreTo)
+  if (!restoreBank) {
+    const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await setBankAccount(page, '')
+    await page.close()
+  }
+})
+
+test.describe('dragging in the designer', () => {
+  test('a row dragged in the inspector moves on the printed sheet', async ({ page }) => {
+    // The vehicle block: its rows are the vehicle, the VIN, the plate and the
+    // mileage, and this workshop's data fills enough of them to see an order.
+    // (The payment block is where this went wrong before; its rows need
+    // bank details the seeded workshop has none of, so the row order there is
+    // pinned in the unit tests instead. What is dragged here is the block.)
+    await openDesigner(page)
+    await selectSection(page, 'vehicle')
+
+    const vin = page.getByTestId('field-row-vin')
+    const plate = page.getByTestId('field-row-license_plate')
+    await expect(vin).toBeVisible()
+    await expect(plate).toBeVisible()
+
+    await expect(async () => {
+      await plate.dragTo(vin)
+      // The canvas redraws from the same list the sheet prints from, so it is
+      // the first place the move shows.
+      const drawn = await onSheet(page, 'vehicle').innerText()
+      expect(drawn.indexOf('Plate')).toBeLessThan(drawn.indexOf('VIN'))
+    }).toPass({ timeout: 30_000 })
+
+    await saveDesign(page)
+
+    const [plateAt, vinAt] = await printedOrder(page, ['Plate:', 'VIN:'])
+    expect(plateAt, 'the plate now prints above the VIN').toBeLessThan(vinAt)
+  })
+
+  test('a field that always prints in the same place offers no drag', async ({ page }) => {
+    // The footer prints its portal line first and its closing note last,
+    // whatever the list says. Offering a drag there was worse than not
+    // offering one: the row moved under the pointer and the sheet ignored it.
+    await openDesigner(page)
+    await selectSection(page, 'footer')
+
+    for (const fixed of ['footer_note', 'portal_link', 'logo']) {
+      await expect(
+        page.getByTestId(`field-row-${fixed}`),
+        `${fixed} cannot be dragged`
+      ).toHaveAttribute('draggable', 'false')
+    }
+    // The rest of the footer's details still can be.
+    await expect(page.getByTestId('field-row-company_address')).toHaveAttribute('draggable', 'true')
+
+    // And the list does not move when one of them is dragged anyway.
+    const order = () =>
+      page
+        .getByTestId('field-row-footer_note')
+        .evaluate((el) =>
+          Array.from(el.parentElement?.children ?? []).map(
+            (row) => (row as HTMLElement).dataset.testid ?? ''
+          )
+        )
+    const before = await order()
+    await page
+      .getByTestId('field-row-footer_note')
+      .dragTo(page.getByTestId('field-row-company_email'))
+    expect(await order(), 'the list is where it was').toEqual(before)
+  })
+
+  test('a section dragged in the rail moves with it', async ({ page }) => {
+    await openDesigner(page)
+
+    // The payment panel sits below the parts; dropped on the parts table it
+    // has to print above it.
+    const [paymentBefore, partsBefore] = await printedOrder(page, ['PAYMENT INFORMATION', 'Parts'])
+    expect(paymentBefore).toBeGreaterThan(partsBefore)
+
+    await expect(async () => {
+      await page.getByTestId('rail-bank_account').dragTo(page.getByTestId('rail-parts_table'))
+      const positionOf = (id: string) =>
+        page
+          .getByTestId(`rail-${id}`)
+          .evaluate((el) => Array.from(el.parentElement?.children ?? []).indexOf(el))
+      const [payment, parts] = await Promise.all([
+        positionOf('bank_account'),
+        positionOf('parts_table'),
+      ])
+      expect(payment, 'the payment panel sits above the parts in the rail').toBeLessThan(parts)
+    }).toPass({ timeout: 30_000 })
+
+    await saveDesign(page)
+
+    const [payment, parts] = await printedOrder(page, ['PAYMENT INFORMATION', 'Parts'])
+    expect(payment, 'and above them on the sheet').toBeLessThan(parts)
+  })
+
+  test('a block dragged across the canvas is left where it was put', async ({ page }) => {
+    await openDesigner(page)
+
+    const { box, sheet, ptToPx } = await grab(page, 'totals')
+
+    // Pointer events, not HTML5 drag: the canvas tracks the pointer itself,
+    // and ignores a move of less than a few pixels so a click stays a click.
+    //
+    // Dropped into the margin, clear of the column: inside it, a release
+    // means "insert here" or "share this row", and only out here does the
+    // block stay where it was put while the flow closes up behind it.
+    await expect(async () => {
+      await page.mouse.move(box.x + box.width / 2, box.y + 8)
+      await page.mouse.down()
+      await page.mouse.move(sheet.x + 3 * ptToPx, sheet.y + 300 * ptToPx, { steps: 14 })
+      await page.mouse.up()
+      // The designer offers the way back, which is how it says a block has
+      // been taken out of the flow.
+      await expect(page.getByRole('button', { name: /return .* to the flow/i })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    await saveDesign(page)
+
+    // Still printed, and still out of the flow when the designer reopens.
+    await printedOrder(page, ['Total'])
+    await openDesigner(page)
+    await selectSection(page, 'totals')
+    await expect(page.getByRole('button', { name: /return .* to the flow/i })).toBeVisible()
+  })
+
+  test('and the way back puts it in the flow again', async ({ page }) => {
+    await openDesigner(page)
+    await selectSection(page, 'totals')
+
+    const back = page.getByRole('button', { name: /return .* to the flow/i })
+    await expect(async () => {
+      await back.click()
+      await expect(back).toBeHidden({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await saveDesign(page)
+    await openDesigner(page)
+    await selectSection(page, 'totals')
+    await expect(page.getByRole('button', { name: /return .* to the flow/i })).toHaveCount(0)
+  })
+
+  test('a block dropped onto another shares its row', async ({ page }) => {
+    await openDesigner(page)
+
+    // The totals block is full width; dropped onto the payment panel it takes
+    // a lane beside it, which the rail marks with the side it took.
+    const { box: from } = await grab(page, 'totals')
+    const target = await onSheet(page, 'bank_account').boundingBox()
+    expect(target, 'the payment panel is on the canvas beside it').not.toBeNull()
+
+    await expect(async () => {
+      await page.mouse.move(from.x + from.width / 2, from.y + 8)
+      await page.mouse.down()
+      await page.mouse.move(target!.x + target!.width - 30, target!.y + target!.height / 2, {
+        steps: 14,
+      })
+      await page.mouse.up()
+      // The rail marks the lane it took with its side; the letter is drawn
+      // uppercase by the stylesheet and stored lowercase.
+      await expect(page.getByTestId('rail-column-totals')).toHaveText(/^[lr]$/, {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    await saveDesign(page)
+
+    // Still in its lane when the designer is opened again.
+    await openDesigner(page)
+    await expect(page.getByTestId('rail-column-totals')).toHaveText(/^[lr]$/)
+  })
+})

+ 262 - 0
e2e/specs/invoices/designer.spec.ts

@@ -0,0 +1,262 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  type InvoiceDesignState,
+  invoiceDesignState,
+  restoreInvoiceDesignState,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { type PdfContent, pdfContent } from '../../support/pdf'
+import {
+  addLabor,
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+} from '../../support/work-order'
+
+/**
+ * The invoice designer, and whether what it shows is what gets printed.
+ *
+ * The spec builder behind it is covered by unit tests, section by section
+ * (`src/__tests__/features/invoice-designer/every-block.test.ts`). What no
+ * unit test can reach is the chain: a switch clicked in the rail, a design
+ * saved, and the document a customer is handed changing to match. Each test
+ * here changes one thing in the designer and then reads the invoice.
+ *
+ * The job is left as a draft on purpose. An issued invoice prints from the
+ * snapshot it was frozen with, which is the right behaviour and the wrong
+ * fixture: it would ignore every change made here.
+ *
+ * Saving in the designer writes the workshop's live layout and graduates it
+ * from the classic sheet to the designer's, so the state is taken before and
+ * put back afterwards — the pricing and parity specs pin figures on that
+ * sheet to the cent.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const DESIGN_NAME = `E2E design ${stamp}`
+
+let jobUrl = ''
+/** The sheet as the designer draws it before any of these tests touch it. */
+let baseline: PdfContent
+let restoreTo: InvoiceDesignState
+
+/** Opens the designer on the workshop's current layout, past the gallery. */
+async function openDesigner(page: Page) {
+  await page.goto('/invoice-designer')
+  const carryOn = page.getByRole('button', { name: /continue with my current layout/i })
+  const gallery = await carryOn
+    .waitFor({ state: 'visible', timeout: 10_000 })
+    .then(() => true)
+    .catch(() => false)
+  if (gallery) await carryOn.click()
+
+  // The rail is the designer: sections down the left, one row each.
+  await expect(page.getByTestId('rail-header')).toBeVisible({ timeout: 30_000 })
+  await settle(page)
+}
+
+/**
+ * Nudges the designer into having something to save, without moving a line on
+ * the sheet: the custom-fields block prints nothing until a workshop defines
+ * fields, so its switch is the one switch that cannot change the drawing.
+ */
+async function nudge(page: Page) {
+  const eye = page.getByTestId('rail-eye-general')
+  for (const state of ['true', 'false']) {
+    await expect(async () => {
+      await eye.click()
+      await expect(eye).toHaveAttribute('aria-pressed', state, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+  }
+}
+
+/** Saves the open design, naming it the first time. */
+async function saveDesign(page: Page) {
+  // The button reads "Saved" while it is idle and "Save design" once there is
+  // something to write, so its name is also the check that there is.
+  const button = page.getByRole('button', { name: /save design/i }).first()
+  await expect(button, 'the designer has something to save').toBeVisible({ timeout: 30_000 })
+
+  const dialog = page.getByRole('dialog').filter({ hasText: 'Save this design' })
+  // A toast, and not the button going quiet: the button says "Saved" when it
+  // has done nothing at all.
+  const toast = page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
+
+  await expect(async () => {
+    await button.click()
+    await expect(dialog.or(toast).first()).toBeVisible({ timeout: 3_000 })
+  }).toPass({ timeout: 30_000 })
+
+  if (await dialog.isVisible().catch(() => false)) {
+    await dialog.getByPlaceholder('Design name').fill(DESIGN_NAME)
+    await dialog.getByRole('button', { name: /^(save|update) design$/i }).click()
+  }
+  await expect(toast).toBeVisible({ timeout: 30_000 })
+}
+
+/**
+ * Words as the canvas draws them. Like the printed sheet, the canvas keeps a
+ * hidden copy of itself for measuring, so an unfiltered locator finds text
+ * nothing can see.
+ */
+function onCanvas(page: Page, text: string | RegExp) {
+  return page.getByText(text).filter({ visible: true }).first()
+}
+
+/** Selects a section in the rail so the inspector shows its settings. */
+async function selectSection(page: Page, id: string) {
+  await expect(async () => {
+    await page.getByTestId(`rail-${id}`).click()
+    await expect(page.getByText('Section settings', { exact: true })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+}
+
+async function invoicePdf(page: Page): Promise<PdfContent> {
+  const id = jobUrl.split('/').pop()
+  const response = await page.request.get(`/api/protected/services/${id}/pdf`, { timeout: 60_000 })
+  expect(response.status()).toBe(200)
+  return pdfContent(await response.body())
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  restoreTo = await invoiceDesignState()
+
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E designer ${stamp}`)
+  await addPart(page, { name: `E2E alternator ${stamp}`, quantity: 1, unitPrice: 3_400 })
+  await addLabor(page, { description: 'Fit and test the alternator', hours: 2, rate: 800 })
+  await saveWorkOrder(page)
+
+  // Saved once with nothing changed, so the baseline is the designer's own
+  // sheet: everything after this is measured against the same drawing.
+  await openDesigner(page)
+  await nudge(page)
+  await saveDesign(page)
+  baseline = await invoicePdf(page)
+  expect(baseline.flat, 'the baseline names the customer').toContain('Mitchell')
+  await page.close()
+})
+
+test.afterAll(async () => {
+  if (restoreTo) await restoreInvoiceDesignState(restoreTo)
+})
+
+test.describe('the invoice designer', () => {
+  test('opens on the workshop’s own layout, with every section in the rail', async ({ page }) => {
+    await openDesigner(page)
+
+    // The rail carries a row per section, hidden ones included.
+    for (const id of ['header', 'customer', 'vehicle', 'parts_table', 'totals', 'footer']) {
+      await expect(page.getByTestId(`rail-${id}`), `${id} is in the rail`).toBeVisible()
+    }
+    // And the canvas draws the sheet those rows describe.
+    await expect(onCanvas(page, 'Demo Auto Workshop')).toBeVisible()
+    await expect(onCanvas(page, 'INVOICE')).toBeVisible()
+  })
+
+  test('a section switched off in the rail leaves the printed invoice', async ({ page }) => {
+    await openDesigner(page)
+
+    const eye = page.getByTestId('rail-eye-vehicle')
+    await expect(eye).toHaveAttribute('aria-pressed', 'true')
+    await expect(async () => {
+      await eye.click()
+      await expect(eye).toHaveAttribute('aria-pressed', 'false', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveDesign(page)
+
+    const pdf = await invoicePdf(page)
+    // The vehicle block and everything in it are gone.
+    for (const gone of ['VIN:', 'Plate:', 'Toyota Camry']) {
+      expect(pdf.flat, `${gone} is off the sheet`).not.toContain(gone)
+    }
+    // And nothing else went with it.
+    expect(pdf.flat).toContain('Mitchell')
+    expect(pdf.flat).toContain('$3,400.00')
+  })
+
+  test('the switch is remembered when the designer is opened again', async ({ page }) => {
+    await openDesigner(page)
+    await expect(page.getByTestId('rail-eye-vehicle')).toHaveAttribute('aria-pressed', 'false')
+  })
+
+  test('the document prints the name the workshop gives it', async ({ page }) => {
+    await openDesigner(page)
+    await selectSection(page, 'document_title')
+
+    const title = page.getByTestId('section-title-text')
+    await expect(async () => {
+      await title.fill('TAX INVOICE')
+      await expect(title).toHaveValue('TAX INVOICE', { timeout: 2_000 })
+      // The canvas is the proof the change was taken, not the field.
+      await expect(onCanvas(page, 'TAX INVOICE')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveDesign(page)
+
+    const pdf = await invoicePdf(page)
+    expect(pdf.flat).toContain('TAX INVOICE')
+    expect(pdf.text.split('\n').map((line) => line.trim())).not.toContain('INVOICE')
+  })
+
+  test('a field switched off takes only its own line with it', async ({ page }) => {
+    await openDesigner(page)
+    await selectSection(page, 'customer')
+
+    const email = page.getByTestId('field-customer_email')
+    await expect(email).toHaveAttribute('aria-checked', 'true')
+    await expect(async () => {
+      await email.click()
+      await expect(email).toHaveAttribute('aria-checked', 'false', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveDesign(page)
+
+    const pdf = await invoicePdf(page)
+    expect(pdf.flat, 'the address is off the sheet').not.toContain('james.mitchell@gmail.com')
+    // The rest of the panel is untouched.
+    expect(pdf.flat).toContain('Mitchell')
+    expect(pdf.flat).toContain('+1 (555) 201-3344')
+  })
+
+  test('every change can be taken back, and the sheet returns to what it was', async ({ page }) => {
+    await openDesigner(page)
+
+    const eye = page.getByTestId('rail-eye-vehicle')
+    await expect(async () => {
+      await eye.click()
+      await expect(eye).toHaveAttribute('aria-pressed', 'true', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await selectSection(page, 'document_title')
+    const title = page.getByTestId('section-title-text')
+    await expect(async () => {
+      await title.fill('')
+      await expect(onCanvas(page, /^INVOICE$/)).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await selectSection(page, 'customer')
+    const email = page.getByTestId('field-customer_email')
+    await expect(async () => {
+      await email.click()
+      await expect(email).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await saveDesign(page)
+
+    // Word for word the sheet the designer drew before any of this.
+    expect((await invoicePdf(page)).flat).toBe(baseline.flat)
+  })
+
+  test('the saved design is the one the gallery says is in use', async ({ page }) => {
+    await page.goto('/invoice-designer')
+    await expect(page.getByText('Your designs')).toBeVisible({ timeout: 30_000 })
+    const card = page.getByText(DESIGN_NAME).first().locator('xpath=ancestor::*[.//*[text()]][1]')
+    await expect(card).toBeVisible()
+    await expect(page.getByText('In use').first()).toBeVisible()
+  })
+})

+ 86 - 0
e2e/specs/invoices/numbering.spec.ts

@@ -0,0 +1,86 @@
+import { expect, test } from '@playwright/test'
+import { invoiceStartNumber, setInvoiceNumbering } from '../../support/settings'
+import { newWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * The number on the invoice: what the workshop's format does to it, where the
+ * sequence can be made to jump, and that it only ever goes up.
+ *
+ * Two customers holding invoices with the same number is the kind of bug an
+ * accountant finds rather than a workshop, so each rule is a test of its own.
+ * Where the sequence stands depends on what the rest of the suite has already
+ * created, so the numbers are read from the first job and the later
+ * assertions are exact against that, which also lets the file be run twice on
+ * the same database.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+/** How far ahead of the sequence the workshop is made to jump. */
+const JUMP = 500
+
+let vehicleUrl = ''
+let year = ''
+let month = ''
+/** The number the sequence had reached when this run started. */
+let firstNumber = 0
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  // The tokens are filled in from the workshop's own clock, which the suite
+  // pins to one timezone for the browser and the server alike.
+  const now = await page.evaluate(() => ({
+    year: String(new Date().getFullYear()),
+    month: String(new Date().getMonth() + 1).padStart(2, '0'),
+  }))
+  year = now.year
+  month = now.month
+  await page.close()
+})
+
+test.describe('invoice numbering', () => {
+  test('the workshop format decorates the number', async ({ page }) => {
+    await setInvoiceNumbering(page, { prefix: 'E2E-{year}-' })
+
+    await newWorkOrder(page, vehicleUrl, 'E2E numbering, in sequence')
+
+    const number = await page.getByLabel('Invoice Number').inputValue()
+    expect(number, 'the format is applied to whatever number came next').toMatch(
+      new RegExp(`^E2E-${year}-\\d+$`)
+    )
+    firstNumber = Number(number.split('-').pop())
+  })
+
+  test('the sequence can be made to jump', async ({ page }) => {
+    const asked = firstNumber + JUMP
+    await setInvoiceNumbering(page, { prefix: 'E2E-{year}-', startNumber: String(asked) })
+
+    await newWorkOrder(page, vehicleUrl, `E2E numbering, jumped to ${asked}`)
+
+    await expect(page.getByLabel('Invoice Number')).toHaveValue(`E2E-${year}-${asked}`)
+  })
+
+  test('the number asked for is spent once, not every time', async ({ page }) => {
+    // Left standing, the field would hand the same number to every job that
+    // followed it, so the app clears it as soon as one has taken it.
+    expect(await invoiceStartNumber(page)).toBe('')
+  })
+
+  test('the next job carries on from the last, whatever the format says', async ({ page }) => {
+    // Only the decoration changes. A workshop that starts numbering by month
+    // partway through the year does not start the sequence again.
+    await setInvoiceNumbering(page, { prefix: 'E2E-{year}-{month}-' })
+
+    await newWorkOrder(page, vehicleUrl, 'E2E numbering, carried on')
+
+    await expect(page.getByLabel('Invoice Number')).toHaveValue(
+      `E2E-${year}-${month}-${firstNumber + JUMP + 1}`
+    )
+  })
+
+  test('the numbering settings are put back', async ({ page }) => {
+    await setInvoiceNumbering(page, { prefix: '{year}-' })
+    expect(await invoiceStartNumber(page)).toBe('')
+  })
+})

+ 196 - 0
e2e/specs/invoices/payments.spec.ts

@@ -0,0 +1,196 @@
+import { expect, type Locator, type Page, test } from '@playwright/test'
+import { setTax } from '../../support/settings'
+import {
+  addLabor,
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+} from '../../support/work-order'
+
+/**
+ * Money coming in against an invoice: part of it, the rest of it, a payment
+ * taken back, and the workshop simply declaring the job paid.
+ *
+ * The arithmetic is worth pinning because three places have to agree on it —
+ * the badge on the payments panel, the running total beside it, and the
+ * balance due on the invoice summary the customer's copy is built from. A job
+ * of exactly 900 with tax off keeps the sums readable; the tax settings are
+ * put back at the end.
+ *
+ * Serial: one job, paid down step by step, and each step needs the one before.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+/** The payments box on the work order, and not the badge in the page header. */
+function paymentsPanel(page: Page): Locator {
+  return page
+    .getByRole('heading', { name: 'Payments', exact: true })
+    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
+}
+
+/** The figure beside a label, in whichever panel the label belongs to. */
+function labelledRow(panel: Locator, label: string): Locator {
+  return panel
+    .getByText(label, { exact: true })
+    .first()
+    .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
+}
+
+/** One line of the Invoice Summary panel: what the customer's copy will say. */
+function summaryRow(page: Page, label: string): Locator {
+  const panel = page
+    .getByRole('heading', { name: 'Invoice Summary', exact: true })
+    .locator('xpath=ancestor::div[1]')
+  return labelledRow(panel, label)
+}
+
+/** What the panel calls the payment state: Unpaid, Partial or Paid. */
+function paymentBadge(page: Page, state: 'Unpaid' | 'Partial' | 'Paid'): Locator {
+  return paymentsPanel(page).getByText(state, { exact: true })
+}
+
+/**
+ * The customer is offered a message every time money is recorded. There is no
+ * SMS or mail provider in the test environment, and sending is a subject of
+ * its own, so the offer is declined.
+ */
+async function declineNotification(page: Page): Promise<void> {
+  const dialog = page.getByRole('dialog').filter({ hasText: /^Notify / })
+  const offered = await dialog
+    .waitFor({ state: 'visible', timeout: 5_000 })
+    .then(() => true)
+    .catch(() => false)
+  if (offered) await dialog.getByRole('button', { name: 'Skip', exact: true }).click()
+  await expect(dialog).toBeHidden()
+}
+
+type Method = 'Cash' | 'Card' | 'Transfer' | 'Other'
+
+/** Records one payment through the panel's own form. */
+async function recordPayment(page: Page, amount: number, method: Method): Promise<void> {
+  const panel = paymentsPanel(page)
+  const amountField = page.locator('#paymentAmount')
+  // The form opens on a click that does nothing before React has taken the
+  // page over, and says nothing when it is lost.
+  await expect(async () => {
+    await panel.getByRole('button', { name: 'Record Payment', exact: true }).click()
+    await expect(amountField).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // The field offers the whole balance; each of these tests pays its own figure.
+  await amountField.fill(String(amount))
+  // The only select in the panel is the method.
+  await panel.getByRole('combobox').click()
+  await page.getByRole('option', { name: method, exact: true }).click()
+
+  await panel.getByRole('button', { name: 'Save Payment', exact: true }).click()
+  await expect(page.getByText('Payment recorded', { exact: true })).toBeVisible()
+  await declineNotification(page)
+}
+
+let jobUrl = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setTax(page, { enabled: false })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E payments ${stamp}`)
+  // 2 × 300 in parts and two hours at 150: nine hundred, and no tax on top.
+  await addPart(page, { name: `E2E clutch kit ${stamp}`, quantity: 2, unitPrice: 300 })
+  await addLabor(page, { description: 'Replace clutch', hours: 2, rate: 150 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.describe('paying an invoice', () => {
+  test('a job nobody has paid says so', async ({ page }) => {
+    await page.goto(jobUrl)
+
+    await expect(paymentBadge(page, 'Unpaid')).toBeVisible()
+    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$0.00 / $900.00')
+    // Nothing is owed until something is paid, so the summary shows no balance.
+    await expect(summaryRow(page, 'Total')).toContainText('$900.00')
+    await expect(summaryRow(page, 'Balance Due')).toHaveCount(0)
+  })
+
+  test('part of the money leaves a balance', async ({ page }) => {
+    await page.goto(jobUrl)
+    await recordPayment(page, 400, 'Cash')
+
+    await expect(paymentBadge(page, 'Partial')).toBeVisible()
+    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$400.00 / $900.00')
+
+    // The payment itself is listed, with the method it came in by.
+    const row = paymentsPanel(page).getByRole('row').filter({ hasText: '$400.00' })
+    await expect(row).toHaveCount(1)
+    await expect(row).toContainText('cash')
+
+    await expect(summaryRow(page, 'Paid')).toContainText('-$400.00')
+    await expect(summaryRow(page, 'Balance Due')).toContainText('$500.00')
+  })
+
+  test('the rest of it settles the invoice', async ({ page }) => {
+    await page.goto(jobUrl)
+    await recordPayment(page, 500, 'Card')
+
+    await expect(paymentBadge(page, 'Paid')).toBeVisible()
+    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$900.00 / $900.00')
+    // Settled, the balance line stops being a figure and says so.
+    await expect(summaryRow(page, 'Balance Due')).toContainText('PAID')
+
+    // Money against a job makes the invoice the customer's document, whether
+    // or not it was ever sent, so it carries a number from here on.
+    await expect(page.getByLabel('Invoice Number')).not.toHaveValue('')
+  })
+
+  test('taking a payment back reopens the balance', async ({ page }) => {
+    await page.goto(jobUrl)
+
+    const row = paymentsPanel(page).getByRole('row').filter({ hasText: '$500.00' })
+    const confirm = page.getByRole('alertdialog', { name: 'Delete Payment' })
+    // A click before the page is interactive opens nothing and says nothing.
+    await expect(async () => {
+      await row.getByRole('button', { name: 'Delete', exact: true }).click()
+      await expect(confirm).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+    await expect(page.getByText('Payment deleted', { exact: true })).toBeVisible()
+
+    await expect(paymentBadge(page, 'Partial')).toBeVisible()
+    await expect(summaryRow(page, 'Balance Due')).toContainText('$500.00')
+  })
+
+  test('the workshop can declare it paid without a payment', async ({ page }) => {
+    await page.goto(jobUrl)
+    const panel = paymentsPanel(page)
+
+    await expect(async () => {
+      await panel.getByRole('button', { name: 'Mark as Paid', exact: true }).click()
+      await expect(page.getByText('Marked as paid', { exact: true })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await declineNotification(page)
+
+    // Declared paid covers the balance; the payment that was actually taken is
+    // still the only row in the table.
+    await expect(paymentBadge(page, 'Paid')).toBeVisible()
+    await expect(labelledRow(panel, 'Total Paid')).toContainText('$900.00 / $900.00')
+    await expect(panel.getByRole('row').filter({ hasText: '$400.00' })).toHaveCount(1)
+
+    await panel.getByRole('button', { name: 'Mark as Unpaid', exact: true }).click()
+    await expect(page.getByText('Marked as unpaid', { exact: true })).toBeVisible()
+
+    // Back to what was really paid, rather than to nothing.
+    await expect(paymentBadge(page, 'Partial')).toBeVisible()
+    await expect(labelledRow(panel, 'Total Paid')).toContainText('$400.00 / $900.00')
+  })
+
+  test('the tax settings are put back', async ({ page }) => {
+    await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
+  })
+})

+ 159 - 0
e2e/specs/invoices/pdf-attachments.spec.ts

@@ -0,0 +1,159 @@
+import { expect, type Page, test } from '@playwright/test'
+import { attach } from '../../support/attachments'
+import { BROKEN_PNG, makePdf, pdfContent, TINY_PNG } from '../../support/pdf'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * What the job's own files do to the invoice.
+ *
+ * The workshop's copy carries them and the customer's does not: photographs
+ * and diagnostic printouts belong to the shop's file, and an attached report
+ * is appended to the download as extra pages. That is the one place the four
+ * copies are meant to differ, which makes it the one place worth proving they
+ * differ in exactly that way and no other.
+ *
+ * The last test is a regression guard rather than a feature: an image the
+ * renderer cannot decode used to throw inside its own stream, where the route
+ * could not catch it, and the request never answered. One truncated
+ * photograph made a job's invoice unobtainable.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const REPORT = 'e2e-diagnostic.pdf'
+const PHOTO = 'e2e-photo.png'
+const REPORT_PAGES = ['E2E DIAGNOSTIC PAGE ONE', 'E2E DIAGNOSTIC PAGE TWO']
+
+let jobUrl = ''
+let vehicleUrl = ''
+/** Pages before anything was attached. */
+let barePages = 0
+
+async function workshopCopy(page: Page, url = jobUrl) {
+  const id = url.split('/').pop()
+  const response = await page.request.get(`/api/protected/services/${id}/pdf`, {
+    timeout: 60_000,
+  })
+  expect(response.status(), 'the workshop can always get its invoice').toBe(200)
+  return pdfContent(await response.body())
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E attachments ${stamp}`)
+  await addPart(page, { name: `E2E radiator ${stamp}`, quantity: 1, unitPrice: 900 })
+  await saveWorkOrder(page)
+  barePages = (await workshopCopy(page)).pages
+  expect(barePages).toBeGreaterThan(0)
+  await page.close()
+})
+
+test.describe('a job with files attached to it', () => {
+  test('the report is appended to the workshop copy, page for page', async ({ page }) => {
+    await page.goto(jobUrl)
+    await attach(page, 'Documents', {
+      name: REPORT,
+      mimeType: 'application/pdf',
+      buffer: await makePdf(REPORT_PAGES),
+    })
+
+    const pdf = await workshopCopy(page)
+    // Two pages of report, appended whole.
+    expect(pdf.pages).toBe(barePages + REPORT_PAGES.length)
+    for (const line of REPORT_PAGES) {
+      expect(pdf.text, 'the report itself is in there').toContain(line)
+    }
+    // And the invoice says where those pages came from.
+    expect(pdf.flat).toContain(REPORT)
+  })
+
+  test('a photograph gets a page of its own', async ({ page }) => {
+    await page.goto(jobUrl)
+    await attach(page, 'Images', { name: PHOTO, mimeType: 'image/png', buffer: TINY_PNG })
+
+    const pdf = await workshopCopy(page)
+    expect(pdf.pages).toBe(barePages + REPORT_PAGES.length + 1)
+    expect(pdf.flat, 'the images page names the file').toContain(PHOTO)
+  })
+
+  test('the customer gets none of it', async ({ page }) => {
+    await page.goto(jobUrl)
+    const url = await shareLink(page)
+    const [orgId, token] = new URL(url).pathname.split('/').slice(-2)
+    const response = await page.request.get(`/api/public/share/invoice/${orgId}/${token}/pdf`)
+    expect(response.status()).toBe(200)
+
+    const customer = await pdfContent(await response.body())
+    expect(customer.pages, "the customer's copy is the invoice alone").toBe(barePages)
+    for (const line of [...REPORT_PAGES, REPORT, PHOTO]) {
+      expect(customer.flat, `the customer's copy does not mention ${line}`).not.toContain(line)
+    }
+  })
+
+  test('a file kept off the invoice stays off it', async ({ page }) => {
+    await page.goto(jobUrl)
+
+    /** The documents list, and the row of the report inside it. */
+    const openReportRow = async () => {
+      await expect(async () => {
+        await page.getByRole('button', { name: /^Documents/ }).click()
+        await expect(page.getByText(REPORT).first()).toBeVisible({ timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+      return page
+        .getByText(REPORT)
+        .first()
+        .locator('xpath=ancestor::div[.//button[@role="switch"]][1]')
+    }
+
+    // Each attachment carries a switch for whether it prints. Clicked until
+    // it turns: before the page is interactive the click does nothing at all,
+    // and the switch looks exactly the same either way.
+    const toggle = (await openReportRow()).getByRole('switch')
+    await expect(async () => {
+      await toggle.click()
+      await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    // It turns before the write lands, so the answer is read back from the
+    // server rather than from the screen: asked any sooner, the PDF is built
+    // from rows the click has not reached yet.
+    await page.reload()
+    await expect((await openReportRow()).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'false'
+    )
+
+    const pdf = await workshopCopy(page)
+    expect(pdf.pages, 'the appended pages are gone').toBe(barePages + 1)
+    expect(pdf.text).not.toContain(REPORT_PAGES[0])
+    expect(pdf.flat, 'and the report is not named either').not.toContain(REPORT)
+  })
+
+  test('an image the renderer cannot read does not take the invoice with it', async ({ page }) => {
+    // Its own job: the point is that this one still answers.
+    const url = await newWorkOrder(page, vehicleUrl, `E2E broken image ${stamp}`)
+    await addPart(page, { name: `E2E hose ${stamp}`, quantity: 1, unitPrice: 120 })
+    await saveWorkOrder(page)
+    await attach(page, 'Images', {
+      name: 'e2e-broken.png',
+      mimeType: 'image/png',
+      buffer: BROKEN_PNG,
+    })
+
+    // Answers at all, which it did not before: the decode threw inside the
+    // renderer's own stream and the request hung until it timed out.
+    const pdf = await workshopCopy(page, url)
+    expect(pdf.pages).toBeGreaterThan(0)
+    expect(pdf.flat, 'the invoice is still the invoice').toContain('$120.00')
+    // Listed by name, the way an unreadable file already was, rather than drawn.
+    expect(pdf.flat).toContain('e2e-broken.png')
+  })
+})

+ 183 - 0
e2e/specs/invoices/pdf-parity.spec.ts

@@ -0,0 +1,183 @@
+import { expect, test } from '@playwright/test'
+import { attachment, attachmentBytes, clearMailbox, waitForMail } from '../../support/mail'
+import { type PdfContent, pdfContent } from '../../support/pdf'
+import { setTax } from '../../support/settings'
+import {
+  addLabor,
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * One invoice, four ways to hand it over: the preview, the workshop's
+ * download, the customer's share link, and the copy attached to an email.
+ *
+ * They must be the same document, and they were not. The emailed copy was
+ * rendered from its own call and went out without the portal link, the
+ * Telegram code and the Torqvoice mark the other three carry — a customer
+ * comparing the mail with the link would have been looking at two different
+ * invoices. All four go through one renderer now.
+ *
+ * Read as text rather than weighed: the figures are asserted where the
+ * customer reads them, so a copy that prints the right shape with the wrong
+ * total fails here. The part and the labour are written over several lines on
+ * purpose — the sheet has to keep the breaks, which is the last place that
+ * could still flatten them.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const CUSTOMER = `e2e-invoice-${stamp}@example.com`
+
+/** Two parts at 725 and two and a half hours at 800: 1,450 + 2,000. */
+const PART = `E2E water pump ${stamp}\nGates WP-4471\nwith gasket and coolant`
+const LABOR = 'Replace water pump\nand the timing belt with it\nrefill and bleed the coolant'
+
+/** What every copy has to say, whoever it is for. */
+const FACTS = [
+  '$1,450.00',
+  '$2,000.00',
+  'Subtotal $3,450.00',
+  'Tax (25%) $862.50',
+  'Total $4,312.50',
+]
+
+let jobUrl = ''
+let invoiceNumber = ''
+let customerName = ''
+let shared: PdfContent
+let downloaded: PdfContent
+
+/** Every line of a block the workshop typed, as its own line on the sheet. */
+function linesOf(block: string): string[] {
+  return block.split('\n')
+}
+
+async function expectSaysEverything(pdf: PdfContent, whose: string) {
+  expect(pdf.pages, `${whose} has pages`).toBeGreaterThan(0)
+  expect(pdf.flat, `${whose} names the invoice`).toContain(invoiceNumber)
+  expect(pdf.flat, `${whose} names the customer`).toContain(customerName)
+  expect(pdf.flat, `${whose} names the vehicle`).toContain('Toyota Camry')
+
+  for (const fact of FACTS) {
+    expect(pdf.flat, `${whose} prints ${fact}`).toContain(fact)
+  }
+
+  // Every line on a line of its own. Asserted per line for the failure
+  // message, then as the whole block: a block found with its breaks intact is
+  // a block the sheet did not flatten.
+  for (const line of [...linesOf(PART), ...linesOf(LABOR)]) {
+    expect(pdf.text, `${whose} keeps the line "${line}"`).toContain(line)
+  }
+  expect(pdf.text, `${whose} keeps the part name on its three lines`).toContain(PART)
+  expect(pdf.text, `${whose} keeps the labour on its three lines`).toContain(LABOR)
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  // The figures above are pinned, so the tax that makes them is set here. This
+  // is also the setting the rest of the suite leaves behind.
+  await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
+
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E pdf parity ${stamp}`)
+  await addPart(page, { name: PART, quantity: 2, unitPrice: 725 })
+  await addLabor(page, { description: LABOR, hours: 2.5, rate: 800 })
+  await saveWorkOrder(page)
+
+  invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+  expect(invoiceNumber).not.toBe('')
+  // Whoever the seeded vehicle belongs to; the sheet bills them by name.
+  customerName =
+    (
+      await page
+        .getByText(/Mitchell/)
+        .first()
+        .textContent()
+    )?.trim() ?? ''
+  expect(customerName).not.toBe('')
+  await page.close()
+})
+
+test.describe('the same invoice however it is handed over', () => {
+  test('the workshop can download it, and it says everything', async ({ page }) => {
+    await page.goto(jobUrl)
+    const id = jobUrl.split('/').pop()
+    const response = await page.request.get(`/api/protected/services/${id}/pdf`)
+    expect(response.status()).toBe(200)
+    expect(response.headers()['content-type']).toContain('application/pdf')
+
+    downloaded = await pdfContent(await response.body())
+    await expectSaysEverything(downloaded, "the workshop's copy")
+  })
+
+  test('the preview is that same download, not a second rendering', async ({ page }) => {
+    await page.goto(jobUrl)
+    const id = jobUrl.split('/').pop()
+
+    // The dialog fetches the document rather than drawing its own, which is
+    // the only way looking before sending means anything.
+    const request = page.waitForRequest((r) => r.url().includes(`/services/${id}/pdf`))
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Preview', exact: true }).click()
+      await expect(page.getByRole('dialog', { name: 'PDF preview' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await request
+
+    await expect(page.getByText('Could not generate the PDF preview.')).toHaveCount(0)
+    await expect(page.locator('iframe, embed, object').first()).toBeVisible()
+  })
+
+  test('the customer reads the same words from the share link', async ({ page }) => {
+    await page.goto(jobUrl)
+    const url = await shareLink(page)
+
+    // The link the customer opens is a page; its PDF lives on the public API
+    // under the same organisation and token.
+    const [orgId, token] = new URL(url).pathname.split('/').slice(-2)
+    const response = await page.request.get(`/api/public/share/invoice/${orgId}/${token}/pdf`)
+    expect(response.status()).toBe(200)
+
+    shared = await pdfContent(await response.body())
+    await expectSaysEverything(shared, "the customer's copy")
+
+    // No attachments on this job, so the two copies are the same sheet down
+    // to the last word — and to within a couple of kilobytes, which is what
+    // catches a copy that lost an image rather than a word.
+    expect(shared.pages).toBe(downloaded.pages)
+    expect(shared.flat).toBe(downloaded.flat)
+    expect(Math.abs(shared.size - downloaded.size)).toBeLessThan(2_048)
+  })
+
+  test('the emailed copy is that document again, word for word', async ({ page }) => {
+    await clearMailbox()
+    await page.goto(jobUrl)
+
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Email', exact: true }).click()
+      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.locator('#email').fill(CUSTOMER)
+    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+
+    const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
+    const file = attachment(mail, /\.pdf$/)
+    expect(file.contentType).toContain('pdf')
+    expect(file.filename).toContain(invoiceNumber)
+
+    const emailed = await pdfContent(attachmentBytes(file))
+    await expectSaysEverything(emailed, 'the emailed copy')
+    expect(emailed.pages).toBe(shared.pages)
+    expect(emailed.flat).toBe(shared.flat)
+    // The Torqvoice mark and the Telegram code are images and print no words:
+    // the copy that went out without them read the same and weighed
+    // kilobytes less, so both are checked.
+    expect(Math.abs(emailed.size - shared.size)).toBeLessThan(2_048)
+  })
+})

+ 171 - 0
e2e/specs/quotes/document.spec.ts

@@ -0,0 +1,171 @@
+import { expect, type Page, test } from '@playwright/test'
+import { attachment, attachmentBytes, clearMailbox, waitForMail } from '../../support/mail'
+import { type PdfContent, pdfContent } from '../../support/pdf'
+import {
+  addQuoteLabor,
+  addQuotePart,
+  newQuote,
+  quotePdfUrl,
+  quoteShareLink,
+  saveQuote,
+} from '../../support/quote'
+import { setTax } from '../../support/settings'
+
+/**
+ * The quote a customer is actually handed.
+ *
+ * Turning a quote into a work order is covered elsewhere; this is the document
+ * itself, which is a priced offer and has been untested. It is drawn by its
+ * own renderer (`QuotePDF`), from its own layout, and reaches the customer
+ * three ways: the workshop's download, a public link, and a copy attached to
+ * an email. Then the customer answers it, and the answer has to come back.
+ *
+ * The figures are pinned: two parts at 900 and three hours at 800, so 1,800
+ * and 2,400 make a subtotal of 4,200, and 25% on top makes 5,250. If one of
+ * these moves, the quote path has changed.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TITLE = `E2E quote ${stamp}`
+const PART = `E2E clutch kit ${stamp}\nLuK 624 3163 33\nwith release bearing`
+const LABOR = 'Replace the clutch\nand bleed the system'
+const CUSTOMER = `e2e-quote-${stamp}@example.com`
+
+/** What every copy of this quote has to say. */
+const FACTS = ['$1,800.00', '$2,400.00', '$4,200.00', '$5,250.00']
+
+let quoteUrl = ''
+let shareUrl = ''
+let downloaded: PdfContent
+let shared: PdfContent
+
+async function workshopPdf(page: Page): Promise<PdfContent> {
+  const id = quoteUrl.split('/').pop()
+  const response = await page.request.get(`/api/protected/quotes/${id}/pdf`, { timeout: 60_000 })
+  expect(response.status()).toBe(200)
+  expect(response.headers()['content-type']).toContain('application/pdf')
+  return pdfContent(await response.body())
+}
+
+async function expectSaysEverything(pdf: PdfContent, whose: string) {
+  expect(pdf.pages, `${whose} has pages`).toBeGreaterThan(0)
+  expect(pdf.flat, `${whose} names the customer`).toContain('Mitchell')
+  expect(pdf.flat, `${whose} names the vehicle`).toContain('Toyota Camry')
+  for (const fact of FACTS) {
+    expect(pdf.flat, `${whose} prints ${fact}`).toContain(fact)
+  }
+  // Both lines were written over several lines, and a quote is read as
+  // carefully as an invoice.
+  expect(pdf.text, `${whose} keeps the part on its three lines`).toContain(PART)
+  expect(pdf.text, `${whose} keeps the labour on its two lines`).toContain(LABOR)
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  // The figures above are pinned, so the tax that makes them is set here. This
+  // is also the setting the rest of the suite leaves behind.
+  await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
+
+  quoteUrl = await newQuote(page, TITLE)
+  await addQuotePart(page, { name: PART, quantity: 2, unitPrice: 900 })
+  await addQuoteLabor(page, { description: LABOR, hours: 3, rate: 800 })
+  await saveQuote(page)
+  await page.close()
+})
+
+test.describe('a quote as the customer receives it', () => {
+  test('the editor adds up to the figures the quote will carry', async ({ page }) => {
+    await page.goto(quoteUrl)
+    // Loosely matched: the thousands separator follows the workshop's locale,
+    // and this assertion is about arithmetic.
+    for (const figure of [/1[\s.,]?800/, /2[\s.,]?400/, /4[\s.,]?200/, /5[\s.,]?250/]) {
+      await expect(page.getByText(figure).first()).toBeVisible()
+    }
+  })
+
+  test('the workshop can download it, and it says everything', async ({ page }) => {
+    await page.goto(quoteUrl)
+    downloaded = await workshopPdf(page)
+    await expectSaysEverything(downloaded, "the workshop's copy")
+  })
+
+  test('the preview is that same download, not a second rendering', async ({ page }) => {
+    await page.goto(quoteUrl)
+    const id = quoteUrl.split('/').pop()
+
+    const request = page.waitForRequest((r) => r.url().includes(`/quotes/${id}/pdf`))
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Preview', exact: true }).click()
+      await expect(page.getByRole('dialog', { name: 'PDF preview' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await request
+
+    await expect(page.getByText('Could not generate the PDF preview.')).toHaveCount(0)
+  })
+
+  test('the public link opens the same document, word for word', async ({ page }) => {
+    await page.goto(quoteUrl)
+    shareUrl = await quoteShareLink(page)
+
+    // The page the customer opens.
+    await page.goto(shareUrl)
+    for (const fact of FACTS) {
+      await expect(
+        page.getByText(fact).filter({ visible: true }).first(),
+        `${fact} on the shared quote`
+      ).toBeVisible()
+    }
+
+    // And the PDF behind its download button.
+    const response = await page.request.get(quotePdfUrl(shareUrl))
+    expect(response.status()).toBe(200)
+    shared = await pdfContent(await response.body())
+    await expectSaysEverything(shared, "the customer's copy")
+
+    expect(shared.pages).toBe(downloaded.pages)
+    expect(shared.flat).toBe(downloaded.flat)
+    expect(Math.abs(shared.size - downloaded.size)).toBeLessThan(2_048)
+  })
+
+  test('the emailed copy is that document again', async ({ page }) => {
+    await clearMailbox()
+    await page.goto(quoteUrl)
+
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Email', exact: true }).click()
+      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.locator('#email').fill(CUSTOMER)
+    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+
+    const mail = await waitForMail(CUSTOMER, { timeout: 30_000 })
+    const file = attachment(mail, /\.pdf$/)
+    expect(file.contentType).toContain('pdf')
+
+    const emailed = await pdfContent(attachmentBytes(file))
+    await expectSaysEverything(emailed, 'the emailed copy')
+    expect(emailed.pages).toBe(shared.pages)
+    expect(emailed.flat).toBe(shared.flat)
+  })
+
+  test('the customer accepts it, and the workshop sees the answer', async ({ page }) => {
+    // Signed out, the way a customer opens a link.
+    const customer = await page.context().browser()?.newContext()
+    const customerPage = await (customer as NonNullable<typeof customer>).newPage()
+    await customerPage.goto(shareUrl)
+
+    await expect(async () => {
+      await customerPage.getByRole('button', { name: 'Accept Quote' }).click()
+      await expect(customerPage.getByText('Quote Accepted')).toBeVisible({ timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+    await customer?.close()
+
+    // The workshop's own page reads the new status.
+    await page.goto(quoteUrl)
+    await expect(page.getByText('Accepted').first()).toBeVisible()
+  })
+})

+ 238 - 0
e2e/specs/reminders/due-time.spec.ts

@@ -0,0 +1,238 @@
+import { expect, type Page, test } from '@playwright/test'
+import { deleteRemindersTitled, reminderDueDate } from '../../support/db'
+import { settle } from '../../support/hydration'
+import { setWorkshopClock } from '../../support/settings'
+import { seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * A reminder due at half past two is due at half past two tomorrow as well.
+ *
+ * The trap here is not the storing, it is the editing. A due date is handed
+ * to the server as a wall clock ("2026-09-11T14:30") and read in the
+ * workshop's zone, while the two fields that produce that string were filled
+ * from whatever zone the browser happens to be in. Where the two agree, and
+ * they do on every developer's laptop, nothing looks wrong. Where they do not,
+ * the form opens on the wrong time and saving it moves the reminder, without
+ * anyone touching the field.
+ *
+ * So the workshop is put somewhere far from the browser and one reminder is
+ * followed through creation, display, the edit form, and a save that changes
+ * nothing. The last of those is the assertion that matters: reopening and
+ * saving a reminder has to leave it where it was.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TITLE = `E2E reminder ${stamp}`
+/** Far from the harness's Europe/Oslo, and on the other side of a date line. */
+const FAR_AWAY = 'Pacific/Auckland'
+
+/** The wall clock the calendar slot stood for, as the form was seeded with it. */
+let due = ''
+
+/** The reminders list, hydrated. */
+async function openReminders(page: Page) {
+  await page.goto('/reminders')
+  await settle(page)
+}
+
+/**
+ * The row for this spec's reminder: the innermost element holding both its
+ * title and its own menu button, which is the row and not the whole list.
+ */
+function reminderRow(page: Page) {
+  return page
+    .locator('div')
+    .filter({ has: page.getByText(TITLE, { exact: true }) })
+    .filter({ has: page.getByRole('button', { name: 'Open menu' }) })
+    .last()
+}
+
+/** Opens the edit dialog from the row's own menu. */
+async function openEdit(page: Page) {
+  const row = reminderRow(page)
+  await expect(async () => {
+    await row.getByRole('button', { name: 'Open menu' }).first().click()
+    await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('menuitem', { name: 'Edit' }).click()
+  await expect(page.getByRole('dialog', { name: 'Edit Reminder' })).toBeVisible()
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setWorkshopClock(page, { timezone: FAR_AWAY, format: '24h' })
+  await page.close()
+})
+
+test.afterAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setWorkshopClock(page, { timezone: '', format: '12h' })
+  await page.close()
+})
+
+test.describe('a reminder due at a time of day', () => {
+  test('is booked at the time the calendar slot named', async ({ page }) => {
+    await page.goto('/calendar')
+    await settle(page)
+    await expect(async () => {
+      await page.keyboard.press('d')
+      await expect(page.locator('[data-testid^="timegrid-day-"]').first()).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    const column = page.locator('[data-testid^="timegrid-day-"]').first()
+    const box = await column.boundingBox()
+    await column.click({
+      button: 'right',
+      position: { x: Math.min(40, box!.width / 2), y: box!.height * 0.45 },
+    })
+    await page.getByRole('menuitem', { name: 'New reminder' }).click()
+
+    const dialog = page.getByRole('dialog', { name: 'Add Reminder' })
+    await expect(dialog).toBeVisible({ timeout: 30_000 })
+    // The slot the pointer was on, seeded into the form by the calendar. The
+    // spec never picks the time itself: it reads back what the app offered.
+    due = await dialog.locator('#reminder-time').inputValue()
+    expect(due, 'the calendar seeded a time').toMatch(/^\d{2}:\d{2}$/)
+
+    await dialog.locator('#reminder-title').fill(TITLE)
+    await dialog.getByRole('button', { name: 'Add Reminder', exact: true }).click()
+    await expect(page.getByText('Reminder created')).toBeVisible({ timeout: 30_000 })
+
+    // What was stored is that wall clock in the workshop's zone, whatever the
+    // browser's zone is. Read from the database, so the check does not lean
+    // on the same formatting the page uses.
+    const stored = await reminderDueDate(TITLE)
+    const inWorkshop = new Intl.DateTimeFormat('en-GB', {
+      timeZone: FAR_AWAY,
+      hour: '2-digit',
+      minute: '2-digit',
+      hour12: false,
+    }).format(stored)
+    expect(inWorkshop, 'stored as the workshop reads it').toBe(due)
+  })
+
+  test('is listed at that time', async ({ page }) => {
+    await openReminders(page)
+    await expect(reminderRow(page).getByText(due)).toBeVisible()
+  })
+
+  test('opens on that time in the edit form', async ({ page }) => {
+    await openReminders(page)
+    await openEdit(page)
+
+    // The field a person would look at before deciding whether to change
+    // anything. Filled from the browser's clock, this read thirteen hours out.
+    await expect(page.locator('#reminder-time')).toHaveValue(due)
+  })
+
+  test('and a save that changes nothing leaves it where it was', async ({ page }) => {
+    const before = await reminderDueDate(TITLE)
+
+    await openReminders(page)
+    await openEdit(page)
+    await page
+      .getByRole('dialog', { name: 'Edit Reminder' })
+      .getByRole('button', { name: 'Save Changes', exact: true })
+      .click()
+    await expect(page.getByText('Reminder updated')).toBeVisible({ timeout: 30_000 })
+
+    expect((await reminderDueDate(TITLE)).getTime(), 'the reminder did not move').toBe(
+      before.getTime()
+    )
+    await openReminders(page)
+    await expect(reminderRow(page).getByText(due)).toBeVisible()
+  })
+
+  test('and is tidied away again', async ({ page }) => {
+    // The calendar and the reminder list are read by other specs; this one
+    // does not leave a reminder sitting in them.
+    await openReminders(page)
+    const row = reminderRow(page)
+    await expect(async () => {
+      await row.getByRole('button', { name: 'Open menu' }).first().click()
+      await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('menuitem', { name: 'Delete' }).click()
+    await expect(page.getByText('Reminder deleted')).toBeVisible({ timeout: 30_000 })
+  })
+})
+
+test.describe('the same reminder, from the vehicle it belongs to', () => {
+  /**
+   * A second form, on the vehicle's own reminders tab, filling the same two
+   * fields from the same stored instant. It had the same defect, and one form
+   * being right says nothing about the other.
+   */
+  const VEHICLE_TITLE = `E2E vehicle reminder ${stamp}`
+  const AT = '15:45'
+
+  test('is opened on the workshop clock there too', async ({ page }) => {
+    const vehicleUrl = await seededVehicleUrl(page)
+    await page.goto(`${vehicleUrl}?tab=reminders`)
+    await settle(page)
+
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add Reminder' }).first().click()
+      await expect(page.getByRole('dialog', { name: 'Add Reminder' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    const dialog = page.getByRole('dialog', { name: 'Add Reminder' })
+    await dialog.locator('#reminder-title').fill(VEHICLE_TITLE)
+    // A day far enough ahead that "overdue" cannot change what the row says.
+    await dialog.locator('#reminder-dueDate').fill('2027-03-15')
+    await dialog.locator('#reminder-dueTime').fill(AT)
+    await dialog.getByRole('button', { name: 'Add Reminder', exact: true }).click()
+    await expect(page.getByText('Reminder created')).toBeVisible({ timeout: 30_000 })
+
+    // Typed as the workshop's clock, so that is the instant that was stored.
+    const stored = await reminderDueDate(VEHICLE_TITLE)
+    expect(
+      new Intl.DateTimeFormat('en-GB', {
+        timeZone: FAR_AWAY,
+        hour: '2-digit',
+        minute: '2-digit',
+        hour12: false,
+      }).format(stored)
+    ).toBe(AT)
+
+    // And reopening shows what was typed, rather than the same instant read
+    // on the clock of whoever opened it.
+    await page.reload()
+    await settle(page)
+    const row = page
+      .locator('div')
+      .filter({ has: page.getByText(VEHICLE_TITLE, { exact: true }) })
+      .filter({ has: page.getByRole('button', { name: 'Open menu' }) })
+      .last()
+    await expect(async () => {
+      await row.getByRole('button', { name: 'Open menu' }).first().click()
+      await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('menuitem', { name: 'Edit' }).click()
+
+    const edit = page.getByRole('dialog', { name: 'Edit Reminder' })
+    await expect(edit).toBeVisible()
+    // Empty here is not a cosmetic problem: saving the form as it opens sends
+    // "no time", which rewrites the reminder to midday and drops the hour the
+    // workshop chose.
+    await expect(edit.locator('#reminder-dueTime')).toHaveValue(AT)
+    await expect(edit.locator('#reminder-dueDate')).toHaveValue(/15/)
+
+    await edit.getByRole('button', { name: 'Save Changes', exact: true }).click()
+    await expect(page.getByText('Reminder updated')).toBeVisible({ timeout: 30_000 })
+
+    // The whole point: opening and saving changed nothing.
+    const after = await reminderDueDate(VEHICLE_TITLE)
+    expect(after.getTime()).toBe(stored.getTime())
+  })
+
+  test('and is tidied away', async () => {
+    await deleteRemindersTitled(VEHICLE_TITLE)
+  })
+})

+ 41 - 0
e2e/specs/smoke/app.spec.ts

@@ -0,0 +1,41 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * The cheapest possible proof that a build is not dead on arrival: the session
+ * survives, the main lists render, and the contract the technician app depends
+ * on still answers.
+ */
+
+test.describe('smoke', () => {
+  test('the authenticated shell loads', async ({ page }) => {
+    await page.goto('/')
+    await expect(page).not.toHaveURL(/\/auth\//)
+    await expect(page.locator('#password')).toHaveCount(0)
+  })
+
+  test('the seeded workshop has customers and vehicles', async ({ page }) => {
+    // Lists render a card for phones and a table for wider screens and hide
+    // one of them; the table is the visible one at the desktop size used here.
+    await page.goto('/customers')
+    await expect(page.getByRole('table').getByText('James Mitchell').first()).toBeVisible()
+
+    // The vehicle list opens as a grid of cards, each headed by the vehicle's
+    // name; searched, so the one asserted on is on the first page.
+    await page.goto('/vehicles?search=Camry')
+    await expect(page.getByRole('heading', { name: /Camry/i }).first()).toBeVisible()
+  })
+
+  test('the technician app handshake still answers', async ({ request }) => {
+    // The mobile app calls this before a technician can type anything else. A
+    // change to its shape strands every phone, and no unit test would notice.
+    const response = await request.get('/api/v1/tech/health')
+    expect(response.status()).toBe(200)
+
+    const body = (await response.json()) as {
+      data?: { service?: string; api?: string; minAppVersion?: string }
+    }
+    expect(body.data?.service).toBe('torqvoice')
+    expect(body.data?.api).toBe('v1')
+    expect(body.data?.minAppVersion).toMatch(/^\d+\.\d+\.\d+$/)
+  })
+})

+ 403 - 0
e2e/specs/tech/api.spec.ts

@@ -0,0 +1,403 @@
+import { expect, type APIRequestContext, type Page, test } from '@playwright/test'
+import { foreignServiceRecordId, organizationIdFor, seededTenantFixtures } from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * The contract the technician app is built against.
+ *
+ * `/api/v1/tech/*` is consumed by a phone app that lives in another
+ * repository and ships through two app stores, so a break here is not a
+ * deploy away from being fixed: it is a review queue away. Only `/health` was
+ * covered, which proves the routes are mounted and nothing else.
+ *
+ * The whole path is walked as the app walks it: the desk adds a technician and
+ * reads them a setup code, the phone exchanges the code for a token, and the
+ * token is used to list the day's work and put the clock on a job. Then the
+ * refusals, which matter more than the successes — the token must not reach
+ * another technician's job, and must not reach another workshop's at all,
+ * neither to read it nor to book time against it.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TECHNICIAN = `E2E Tech ${stamp}`
+const PHONE = `555${String(stamp).slice(-7)}`
+/** The outsider whose workshop provides a job this token has no business with. */
+const OUTSIDER = `e2e-tech-outsider-${stamp}@example.com`
+const OUTSIDER_PASSWORD = `E2e-pass-${stamp}`
+
+/** The window the app asks its day summary for; the phone owns the timezone. */
+const DAY = {
+  from: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
+  to: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+}
+const ENTRIES = `/api/v1/tech/time/entries?from=${DAY.from}&to=${DAY.to}`
+
+let setupCode = ''
+let token = ''
+/** The phone's own context: no cookies, so only the token speaks for it. */
+let device: APIRequestContext
+let jobId = ''
+/** A job in this workshop that belongs to a different technician. */
+let someoneElsesJob = ''
+/** A job in another workshop altogether. */
+let foreignJob = ''
+
+/**
+ * The phone: a request context carrying nothing but the token it was given.
+ *
+ * A cookie-free context on purpose. The suite's own contexts are signed in as
+ * the workshop owner, and `withApiAuth` treats the bearer header as a gate and
+ * then resolves the session from the request's headers — so a context with the
+ * owner's cookie in it answers as the owner however the token reads, and a
+ * test written on it proves nothing about the token at all.
+ */
+function phone(request: APIRequestContext, bearer = token) {
+  return {
+    get: (url: string) => request.get(url, { headers: { authorization: `Bearer ${bearer}` } }),
+    post: (url: string, data?: unknown) =>
+      request.post(url, {
+        headers: { authorization: `Bearer ${bearer}` },
+        ...(data ? { data } : {}),
+      }),
+    patch: (url: string, data?: unknown) =>
+      request.patch(url, {
+        headers: { authorization: `Bearer ${bearer}` },
+        ...(data ? { data } : {}),
+      }),
+  }
+}
+
+async function openTeamSettings(page: Page) {
+  await page.goto('/settings/team')
+  await settle(page)
+}
+
+test.beforeAll(async ({ browser, playwright, baseURL }) => {
+  // An empty storage state, spelled out: a context made through the
+  // `playwright` fixture inherits the project's, which is the workshop owner
+  // signed in. With that cookie present the session comes back as the owner
+  // however the bearer token reads, and every assertion below would be about
+  // the wrong person.
+  device = await playwright.request.newContext({
+    baseURL,
+    storageState: { cookies: [], origins: [] },
+  })
+  const seeded = await seededTenantFixtures()
+
+  // A job in this workshop that will not be assigned to the new technician.
+  someoneElsesJob = seeded.serviceRecordId
+
+  // A second workshop, for the cross-workshop refusals. Signing up gives it a
+  // few work orders of its own, which is what makes it a useful target.
+  const outsider = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const outsiderPage = await outsider.newPage()
+  await outsiderPage.goto('/auth/sign-up')
+  await outsiderPage.locator('#name').fill('E2E Tech Outsider')
+  await outsiderPage.locator('#email').fill(OUTSIDER)
+  await outsiderPage.locator('#password').fill(OUTSIDER_PASSWORD)
+  await outsiderPage.locator('#terms').click()
+  await outsiderPage.getByRole('button', { name: /create account/i }).click()
+  await outsiderPage.waitForURL(/\/onboarding/, { timeout: 30_000 })
+  await outsiderPage.locator('#workshopName').fill(`E2E Tech Outsider Garage ${stamp}`)
+  await outsiderPage.locator('form button[type="submit"]').click()
+  await outsiderPage.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
+    timeout: 30_000,
+  })
+  await outsider.close()
+
+  foreignJob = await foreignServiceRecordId(await organizationIdFor(OUTSIDER))
+  expect(foreignJob).not.toBe(someoneElsesJob)
+})
+
+test.afterAll(async () => {
+  await device?.dispose()
+})
+
+test.describe('the technician app', () => {
+  test('answers before anybody has signed in', async () => {
+    const health = await device.get('/api/v1/tech/health')
+    expect(health.status()).toBe(200)
+  })
+
+  test('refuses every endpoint without a token', async () => {
+    for (const url of [
+      '/api/v1/tech/me',
+      '/api/v1/tech/jobs',
+      ENTRIES,
+      '/api/v1/tech/parts/lookup?barcode=1234567890128',
+    ]) {
+      const response = await device.get(url)
+      expect(response.status(), `${url} without a token`).toBe(401)
+    }
+    const start = await device.post('/api/v1/tech/time/start', {
+      data: { serviceRecordId: someoneElsesJob },
+    })
+    expect(start.status(), 'starting the clock without a token').toBe(401)
+  })
+
+  test('the desk adds a technician and reads them a code', async ({ page }) => {
+    await openTeamSettings(page)
+
+    // One Add button, then a choice: the two kinds of person are set up
+    // differently, and a mechanic is the one who gets the app.
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add', exact: true }).first().click()
+      await expect(page.getByText('A mechanic')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByText('A mechanic').click()
+
+    await expect(page.getByPlaceholder('Their full name')).toBeVisible({ timeout: 10_000 })
+    await page.getByPlaceholder('Their full name').fill(TECHNICIAN)
+
+    // A mobile number cannot be read without knowing which country's it is,
+    // and this workshop has never said. Asked once, then remembered.
+    const country = page
+      .getByRole('combobox')
+      .filter({ hasText: /choose a country/i })
+      .first()
+    if (await country.isVisible().catch(() => false)) {
+      await country.click()
+      await page
+        .getByRole('option', { name: /United States/i })
+        .first()
+        .click()
+    }
+
+    await page.getByPlaceholder('The phone in their pocket').fill(PHONE)
+    await page.getByRole('button', { name: 'Create', exact: true }).click()
+
+    // The dialog turns into the setup instructions, with the code printed for
+    // a technician who is not standing at the desk.
+    await expect(page.getByText(/or read them this code/i)).toBeVisible({ timeout: 30_000 })
+    const codeText = await page
+      .getByText(/^[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}[\s-]?[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{4}$/)
+      .first()
+      .innerText()
+    setupCode = codeText.replace(/[^A-Z2-9]/g, '')
+    expect(setupCode, 'the code is eight characters').toHaveLength(8)
+  })
+
+  // The redeem endpoint is the one thing here anybody on the internet can
+  // reach with a guess, so it allows five anonymous attempts a minute. This
+  // file spends three of them and no more: hammering it would only prove the
+  // limiter works, at the cost of the tests that come after.
+  test('a code can be spent once, and only once', async () => {
+    const redeemed = await device.post('/api/v1/tech/setup/redeem', { data: { code: setupCode } })
+    expect(redeemed.status()).toBe(200)
+    const body = await redeemed.json()
+    token = body.data.token
+    expect(token, 'the phone is given a token').toBeTruthy()
+    expect(body.data.workshop).toBe('Demo Auto Workshop')
+
+    // Two phones scanning the same screen: exactly one of them wins.
+    const again = await device.post('/api/v1/tech/setup/redeem', { data: { code: setupCode } })
+    expect(again.status()).toBe(400)
+    expect((await again.json()).error.code).toBe('code_used')
+  })
+
+  test('a made-up code is refused, and says nothing about who exists', async () => {
+    const response = await device.post('/api/v1/tech/setup/redeem', { data: { code: 'ZZZZ9999' } })
+
+    // Run again inside the same minute and the limiter answers before the
+    // code is even looked at, which is the right order for it to answer in.
+    expect([400, 429]).toContain(response.status())
+    if (response.status() === 400) {
+      const body = await response.json()
+      // Not "no such technician", not "wrong workshop": one answer for
+      // everything, so the endpoint cannot be used to find out who exists.
+      expect(body.error.code).toBe('invalid_code')
+    }
+  })
+
+  test('says who is holding the phone, and which workshop', async () => {
+    const me = await phone(device).get('/api/v1/tech/me')
+    expect(me.status()).toBe(200)
+    const { data } = await me.json()
+
+    // Everything the app's first screen is built from, in one answer.
+    expect(data.organization.name).toBe('Demo Auto Workshop')
+    expect(data.technicians.map((t: { name: string }) => t.name)).toContain(TECHNICIAN)
+    expect(data.isTechnician).toBe(true)
+    expect(data.isAdmin).toBe(false)
+    // The app refuses to run below this, so it has to keep coming back.
+    expect(data.minAppVersion, 'the minimum version the app must meet').toBeTruthy()
+  })
+
+  test('lists nothing until there is work assigned', async () => {
+    const jobs = await phone(device).get('/api/v1/tech/jobs')
+    expect(jobs.status()).toBe(200)
+    const { data } = await jobs.json()
+
+    // A technician who has just been created is assigned nothing, and the
+    // app's home screen has to cope with that rather than with an error.
+    expect(data.jobs).toEqual([])
+    // The same answer says whether a clock is already running, so the app can
+    // draw its running bar without a second request.
+    expect(data.openEntryJobId).toBeNull()
+  })
+
+  test('the day’s work appears once the desk assigns it', async ({ page }) => {
+    // Assigned from the work order's schedule card, which is where a service
+    // adviser does it.
+    await page.goto(`/vehicles/${(await seededTenantFixtures()).vehicleId}/service/new`)
+    // `/service/new` creates the draft and redirects to its id, and the
+    // pattern for the second matches the first: wait for the address to stop
+    // saying "new" or the job id is the word "new".
+    await page.waitForURL(
+      (url) => /\/service\/[^/]+$/.test(url.pathname) && !url.pathname.endsWith('/new'),
+      { timeout: 30_000 }
+    )
+    jobId = page.url().split('/').pop() as string
+    await page.locator('input[name="title"]').fill(`E2E tech job ${stamp}`)
+
+    await expect(async () => {
+      await page
+        .getByRole('combobox')
+        .filter({ hasText: /select technician/i })
+        .first()
+        .click()
+      await expect(page.getByPlaceholder(/search or create technician/i)).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await page.getByPlaceholder(/search or create technician/i).fill(TECHNICIAN)
+    await page
+      .getByRole('option', { name: new RegExp(TECHNICIAN) })
+      .first()
+      .click()
+
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+
+    const jobs = await phone(device).get('/api/v1/tech/jobs')
+    const { data } = await jobs.json()
+    expect(
+      data.jobs.map((job: { id: string }) => job.id),
+      'the assigned job reached the phone'
+    ).toContain(jobId)
+  })
+
+  test('puts the clock on a job and takes it off again', async () => {
+    const started = await phone(device).post('/api/v1/tech/time/start', {
+      serviceRecordId: jobId,
+    })
+    expect(started.status()).toBe(200)
+    const { data: startData } = await started.json()
+    expect(startData.entry.serviceRecordId).toBe(jobId)
+    expect(startData.entry.startedAt, 'the entry says when it started').toBeTruthy()
+
+    const entries = await phone(device).get(ENTRIES)
+    expect(entries.status()).toBe(200)
+    expect(JSON.stringify(await entries.json())).toContain(jobId)
+
+    // The job list now says the clock is on it, which is what draws the bar.
+    const running = await phone(device).get('/api/v1/tech/jobs')
+    expect((await running.json()).data.openEntryJobId).toBe(jobId)
+
+    const stopped = await phone(device).post('/api/v1/tech/time/stop')
+    expect(stopped.status()).toBe(200)
+
+    // Nothing running, so a second stop is a conflict rather than a crash.
+    const again = await phone(device).post('/api/v1/tech/time/stop')
+    expect(again.status()).toBe(409)
+  })
+
+  test('asks for a day rather than everything', async () => {
+    // The phone owns the technician's timezone, so it sends the window; a
+    // request without one is a client mistake and says which field is missing.
+    const unbounded = await phone(device).get('/api/v1/tech/time/entries')
+    expect(unbounded.status()).toBe(400)
+    expect(JSON.stringify(await unbounded.json())).toContain('from')
+  })
+
+  test('looks a part up by its barcode, and says so when there is none', async () => {
+    // The phone scans a box in the stores. A code for something this workshop
+    // does not stock is the answer the app shows most often, and it has to be
+    // distinguishable from a fault.
+    const missing = await phone(device).get('/api/v1/tech/parts/lookup?barcode=1234567890128')
+    expect(missing.status()).toBe(404)
+    expect((await missing.json()).error.code).toBe('not_found')
+
+    // No barcode at all is the client's mistake, not the workshop's.
+    const nothing = await phone(device).get('/api/v1/tech/parts/lookup')
+    expect(nothing.status()).toBe(400)
+  })
+
+  test('moves a job through its statuses', async () => {
+    // The technician's own screen: pick the job up, and put it down again.
+    const started = await phone(device).post('/api/v1/tech/jobs/' + jobId + '/status', {
+      status: 'in-progress',
+    })
+    expect(started.status(), 'PATCH is the method the app uses').toBe(405)
+
+    const patched = await phone(device).patch(`/api/v1/tech/jobs/${jobId}/status`, {
+      status: 'in-progress',
+    })
+    expect(patched.status()).toBe(200)
+    expect((await patched.json()).data.job.status).toBe('in-progress')
+
+    const refused = await phone(device).patch(`/api/v1/tech/jobs/${jobId}/status`, {
+      status: 'invented',
+    })
+    expect(refused.status(), 'a status the app made up').toBeGreaterThanOrEqual(400)
+  })
+
+  test('cannot read or clock another technician’s job', async () => {
+    // Same workshop, somebody else's work: the list is scoped to the
+    // technician's own rows, and so is everything reached by id.
+    const read = await phone(device).get(`/api/v1/tech/jobs/${someoneElsesJob}`)
+    expect(read.status(), 'reading it').toBe(404)
+
+    const moved = await phone(device).patch(`/api/v1/tech/jobs/${someoneElsesJob}/status`, {
+      status: 'completed',
+    })
+    expect(moved.status(), 'moving its status').toBe(404)
+
+    const clock = await phone(device).post('/api/v1/tech/time/start', {
+      serviceRecordId: someoneElsesJob,
+    })
+    // The clock is scoped to the workshop rather than to the technician, so
+    // this one is allowed by design: a mechanic who picks up a colleague's job
+    // books their own time against it. Stopped again so the next test starts
+    // from a clean clock.
+    if (clock.status() === 200) await phone(device).post('/api/v1/tech/time/stop')
+  })
+
+  test('cannot reach another workshop’s job at all', async () => {
+    const read = await phone(device).get(`/api/v1/tech/jobs/${foreignJob}`)
+    expect(read.status(), 'reading it').toBe(404)
+
+    // The writes, which are the half a read-only test would miss: booking
+    // time against a job in a workshop this token has nothing to do with, and
+    // moving that job's status.
+    const clock = await phone(device).post('/api/v1/tech/time/start', {
+      serviceRecordId: foreignJob,
+    })
+    expect(clock.status(), 'booking time against it').toBe(404)
+    // The message the app shows the technician, and it says why rather than
+    // just refusing: the job is not in this workshop.
+    expect((await clock.json()).error.message).toContain('does not exist in this workshop')
+
+    const moved = await phone(device).patch(`/api/v1/tech/jobs/${foreignJob}/status`, {
+      status: 'completed',
+    })
+    expect(moved.status(), 'moving its status').toBe(404)
+
+    // And nothing was booked.
+    const entries = await phone(device).get(ENTRIES)
+    expect(JSON.stringify(await entries.json())).not.toContain(foreignJob)
+  })
+
+  /**
+   * Not covered: the desk signing a phone out.
+   *
+   * The behaviour is right — revoking deletes the technician's sessions and
+   * deactivates the row, so the token stops opening anything — but the control
+   * is one icon button per member row, and driving the row for one particular
+   * technician among the several this suite creates proved unreliable enough
+   * that the test failed for the wrong reason more often than the right one. It
+   * needs a `data-testid` on the row before it is worth automating.
+   */
+})

+ 175 - 0
e2e/specs/work-orders/layout.spec.ts

@@ -0,0 +1,175 @@
+import { expect, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * The shape of the work order page, and the one rule underneath it: each field
+ * exists once.
+ *
+ * Both columns used to be rendered twice, one layer per breakpoint with the
+ * other hidden by CSS, which put two of every input in the document under the
+ * same id and name. The form submitted the hidden copy's values, native
+ * validation objected to controls the browser then refused to focus and
+ * abandoned the submit in silence, and every editor row was mounted twice.
+ * These tests are what keeps the page down to one copy.
+ *
+ * The rest is what the layout has to keep doing either way: the job and the
+ * sidebar scrolling separately on a wide screen, one stack on a narrow one,
+ * a sidebar that can be dragged, and a page that never grows taller than the
+ * window whatever is piled into it.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+let jobUrl = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E layout ${stamp}`)
+  await addPart(page, { name: `E2E cabin filter ${stamp}`, quantity: 1, unitPrice: 240 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.describe('the work order page', () => {
+  test('holds one copy of each field, not one per breakpoint', async ({ page }) => {
+    await page.goto(jobUrl)
+    await expect(page.getByTestId('service-layout')).toBeVisible()
+    // Counted on a page that has finished arriving: across a navigation the
+    // old document and the new one can both answer for a moment, and a count
+    // taken then is a count of two pages.
+    await settle(page)
+
+    // Named fields, an id, and an editor row: one of each.
+    for (const selector of [
+      'input[name="title"]',
+      '#invoiceNumber',
+      'textarea[placeholder="Name *"]',
+    ]) {
+      await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
+    }
+
+    // And nothing in the form shares a name with anything else in it.
+    const duplicates = await page.evaluate(() => {
+      const form = document.querySelector('form')
+      if (!form) return ['no form']
+      const seen = new Map<string, number>()
+      for (const el of form.querySelectorAll<HTMLInputElement>('input[name], textarea[name]')) {
+        seen.set(el.name, (seen.get(el.name) ?? 0) + 1)
+      }
+      return [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name)
+    })
+    expect(duplicates, 'field names used twice in the form').toEqual([])
+  })
+
+  test('scrolls the job and the sidebar separately on a wide screen', async ({ page }) => {
+    await page.setViewportSize({ width: 1440, height: 800 })
+    await page.goto(jobUrl)
+
+    const main = page.getByTestId('service-main')
+    const sidebar = page.getByTestId('service-sidebar')
+
+    // The two columns share one row of the grid, so they are the same height
+    // as the layer around them and each takes its own overflow.
+    const layerHeight = await page.getByTestId('service-layout').evaluate((el) => el.clientHeight)
+    for (const [name, column] of [
+      ['the job', main],
+      ['the sidebar', sidebar],
+    ] as const) {
+      const box = await column.evaluate((el) => ({
+        client: el.clientHeight,
+        scroll: el.scrollHeight,
+        overflowY: getComputedStyle(el).overflowY,
+      }))
+      expect(box.client, `${name} fills the row`).toBe(layerHeight)
+      expect(box.overflowY, `${name} scrolls itself`).toBe('auto')
+      expect(box.scroll, `${name} has more than fits`).toBeGreaterThan(box.client)
+    }
+
+    await main.evaluate((el) => el.scrollBy(0, 200))
+    expect(await main.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
+    expect(
+      await sidebar.evaluate((el) => el.scrollTop),
+      'scrolling the job leaves the sidebar where it was'
+    ).toBe(0)
+  })
+
+  test('never lets the page grow taller than the window', async ({ page }) => {
+    await page.setViewportSize({ width: 1440, height: 800 })
+    await page.goto(jobUrl)
+
+    // The whole reason the shell is built the way it is: content in either
+    // column must not push the document past the viewport.
+    const overflow = await page.evaluate(
+      () => (document.scrollingElement?.scrollHeight ?? 0) - window.innerHeight
+    )
+    expect(overflow, 'the document is no taller than the window').toBeLessThanOrEqual(1)
+  })
+
+  test('lets the sidebar be dragged wider', async ({ page }) => {
+    await page.setViewportSize({ width: 1440, height: 800 })
+    await page.goto(jobUrl)
+
+    const sidebar = page.getByTestId('service-sidebar')
+    const before = (await sidebar.boundingBox())?.width ?? 0
+    const handle = page.getByTestId('service-resize')
+    const grip = await handle.boundingBox()
+    expect(grip, 'the drag handle is on screen').not.toBeNull()
+
+    await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2)
+    await page.mouse.down()
+    await page.mouse.move(grip!.x - 160, grip!.y + grip!.height / 2, { steps: 8 })
+    await page.mouse.up()
+
+    const after = (await sidebar.boundingBox())?.width ?? 0
+    expect(after, 'dragging left widens the sidebar').toBeGreaterThan(before + 100)
+  })
+
+  test('stacks into one scroller on a narrow screen', async ({ page }) => {
+    await page.setViewportSize({ width: 390, height: 844 })
+    await page.goto(jobUrl)
+
+    // Still one of each field, and still exactly one of them.
+    await expect(page.locator('input[name="title"]')).toHaveCount(1)
+    await expect(page.locator('input[name="title"]')).toBeVisible()
+
+    // Nothing to drag when there is nothing beside anything.
+    await expect(page.getByTestId('service-resize')).toBeHidden()
+
+    // One scroller around the pair, rather than one each.
+    const layer = page.getByTestId('service-layout')
+    expect(await layer.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(true)
+    for (const id of ['service-main', 'service-sidebar']) {
+      expect(
+        await page.getByTestId(id).evaluate((el) => el.scrollHeight > el.clientHeight + 1),
+        `${id} does not scroll on its own`
+      ).toBe(false)
+    }
+
+    await layer.evaluate((el) => el.scrollBy(0, 300))
+    expect(await layer.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
+  })
+
+  test('saves from a narrow screen, with the values that are on it', async ({ page }) => {
+    await page.setViewportSize({ width: 390, height: 844 })
+    await page.goto(jobUrl)
+
+    const title = page.locator('input[name="title"]')
+    const renamed = `E2E layout narrow ${stamp}`
+    await expect(async () => {
+      await title.fill(renamed)
+      await expect(title).toHaveValue(renamed, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+
+    // The old shell submitted whichever copy the layout had hidden, so a
+    // narrow screen could save the desktop copy's stale title.
+    await page.reload()
+    await settle(page)
+    await expect(page.locator('input[name="title"]')).toHaveCount(1)
+    await expect(page.locator('input[name="title"]')).toHaveValue(renamed)
+  })
+})

+ 184 - 0
e2e/specs/work-orders/lifecycle.spec.ts

@@ -0,0 +1,184 @@
+import { expect, type Locator, type Page, test } from '@playwright/test'
+import {
+  addLabor,
+  addPart,
+  laborRows,
+  newWorkOrder,
+  partRowOf,
+  partRows,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+  totalsRow,
+} from '../../support/work-order'
+
+/**
+ * A job from the moment it is written up to the moment it is finished: parts
+ * and labour added, priced, corrected, a line removed, the status walked
+ * through to completed, and the customer's copy checked at the end.
+ *
+ * This is the path every workshop walks several times a day, so it is a
+ * single serial story rather than independent tests: each step edits the job
+ * the step before it left.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TITLE = `E2E lifecycle ${stamp}`
+const PART = `E2E oil filter ${stamp}`
+const SECOND_PART = `E2E air filter ${stamp}`
+
+/** A part name written over three lines, the way a counter hand describes one. */
+const MULTILINE_PART = `E2E timing belt kit ${stamp}\nGates K015603XS\nincludes tensioner and idler`
+const MULTILINE_LABOR = 'Replace timing belt\nand water pump\nrefill coolant'
+
+let vehicleUrl = ''
+let jobUrl = ''
+
+/** The status control in the invoice details panel. */
+function statusSelect(page: Page): Locator {
+  return page
+    .getByText('Status', { exact: true })
+    .locator('xpath=ancestor::div[1]')
+    .getByRole('combobox')
+}
+
+async function setStatus(page: Page, option: string): Promise<void> {
+  await expect(async () => {
+    await statusSelect(page).click()
+    await expect(page.getByRole('option', { name: option, exact: true })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: option, exact: true }).click()
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  await page.close()
+})
+
+test.describe('a work order from intake to completion', () => {
+  test('a job is opened on the vehicle and saved with one part', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, vehicleUrl, TITLE)
+    await addPart(page, { name: PART, quantity: 2, unitPrice: 120 })
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await expect(page.locator('input[name="title"]')).toHaveValue(TITLE)
+    await expect(totalsRow(page, 'Parts')).toContainText('$240.00')
+  })
+
+  test('labour is added and the totals follow', async ({ page }) => {
+    await page.goto(jobUrl)
+    await addLabor(page, { description: 'Change the oil and filter', hours: 1, rate: 800 })
+    await saveWorkOrder(page)
+
+    await expect(totalsRow(page, 'Labor')).toContainText('$800.00')
+    await expect(totalsRow(page, 'Subtotal')).toContainText('$1,040.00')
+  })
+
+  test('a price is corrected and the totals follow it', async ({ page }) => {
+    await page.goto(jobUrl)
+
+    const quantity = partRowOf(partRows(page).first()).locator('input[type="number"]').first()
+    await expect(quantity).toHaveValue('2')
+
+    // A field can carry the typed value while React's state is still empty,
+    // and the editor saves its state, not the DOM. The totals moving is the
+    // only proof the change was taken.
+    await expect(async () => {
+      await quantity.fill('3')
+      await expect(totalsRow(page, 'Parts')).toContainText('$360.00', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await expect(totalsRow(page, 'Parts')).toContainText('$360.00')
+    await expect(totalsRow(page, 'Subtotal')).toContainText('$1,160.00')
+  })
+
+  test('a second part is added and then removed again', async ({ page }) => {
+    await page.goto(jobUrl)
+    await addPart(page, { name: SECOND_PART, quantity: 1, unitPrice: 90 })
+    await saveWorkOrder(page)
+    await expect(totalsRow(page, 'Parts')).toContainText('$450.00')
+
+    await page.reload()
+    await expect(async () => {
+      await partRowOf(partRows(page).first())
+        .getByRole('button', { name: 'Delete row', exact: true })
+        .click()
+      await expect(partRows(page)).toHaveCount(1, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await expect(totalsRow(page, 'Parts')).toContainText('$360.00')
+    await expect(partRows(page)).toHaveCount(1)
+  })
+
+  test('a line written over several lines keeps its shape', async ({ page }) => {
+    await page.goto(jobUrl)
+
+    // Both fields are textareas, so a description too long for one line is
+    // written the way it reads. What is typed has to survive the save, the
+    // reload and the customer's copy.
+    await addPart(page, { name: MULTILINE_PART, quantity: 1, unitPrice: 4200 })
+    await addLabor(page, { description: MULTILINE_LABOR, hours: 4, rate: 800 })
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await expect(partRows(page).first()).toHaveValue(MULTILINE_PART)
+    await expect(laborRows(page).first()).toHaveValue(MULTILINE_LABOR)
+  })
+
+  test('the customer copy prints every line of it', async ({ page }) => {
+    await page.goto(jobUrl)
+    const url = await shareLink(page)
+    await page.goto(url)
+
+    for (const line of [...MULTILINE_PART.split('\n'), ...MULTILINE_LABOR.split('\n')]) {
+      await expect(
+        page.getByText(line, { exact: false }).filter({ visible: true }).first(),
+        `"${line}" on the shared invoice`
+      ).toBeVisible()
+    }
+  })
+
+  test('the status walks through to completed', async ({ page }) => {
+    await page.goto(jobUrl)
+
+    await setStatus(page, 'In Progress')
+    await saveWorkOrder(page)
+    await page.reload()
+    await expect(statusSelect(page)).toContainText('In Progress')
+    // The badge in the header used to print the stored value beside a select
+    // that said it properly, so the same job read "in-progress" and
+    // "In Progress" an inch apart.
+    await expect(page.getByText('in-progress', { exact: true })).toHaveCount(0)
+
+    await setStatus(page, 'Completed')
+    await saveWorkOrder(page)
+    await page.reload()
+    await expect(statusSelect(page)).toContainText('Completed')
+  })
+
+  test('the finished job is on the work orders list with its number', async ({ page }) => {
+    const number = await (async () => {
+      await page.goto(jobUrl)
+      return page.getByLabel('Invoice Number').inputValue()
+    })()
+    expect(number).not.toBe('')
+
+    // Found through the list's own search, not by scrolling: the list shows
+    // twenty jobs a page, and a workshop with a few hundred of them (or a
+    // long-lived e2e database) never has today's job on the first one.
+    await page.goto(`/work-orders?search=${encodeURIComponent(TITLE)}`)
+    // The list draws a card copy for narrow screens beside the table.
+    await expect(page.getByText(TITLE).filter({ visible: true }).first()).toBeVisible()
+    await expect(page.getByText(number).filter({ visible: true }).first()).toBeVisible()
+  })
+})

+ 169 - 0
e2e/specs/work-orders/pricing.spec.ts

@@ -0,0 +1,169 @@
+import { expect, type Page, test } from '@playwright/test'
+import { setTax } from '../../support/settings'
+import {
+  addLabor,
+  addPart,
+  lastPartUnitPrice,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  setDiscount,
+  shareLink,
+  totalsRow,
+} from '../../support/work-order'
+
+/**
+ * The money on a work order, under every tax setting the workshop can choose.
+ *
+ * One job, priced the same way each time: two parts at a cost of 100 with a
+ * 50% markup, and an hour and a half of labour at 400. What that comes to on
+ * the editor, on the shared invoice and in the PDF is pinned here to the
+ * cent, so a change anywhere in the pricing path that moves a customer's
+ * invoice fails a test before it ships.
+ *
+ * Serial, because the tax settings are the workshop's and each scenario
+ * creates its work order after changing them. The settings are put back at
+ * the end.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+/** The standard job: parts 2 × 150 = 300, labour 1.5 × 400 = 600, before tax or discount 900. */
+async function priceTheJob(page: Page, vehicleUrl: string, 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,
+  })
+  // The markup decides the price: 100 plus half is 150.
+  expect(await lastPartUnitPrice(page)).toBe('150')
+  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)
+  }
+}
+
+/** Opens the customer's copy and checks the same figures print there. */
+async function expectOnSharedInvoice(page: Page, workOrderUrl: string, texts: (string | RegExp)[]) {
+  await page.goto(workOrderUrl)
+  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 texts) {
+    await expect(
+      page.getByText(text).filter({ visible: true }).first(),
+      `${text} on the shared invoice`
+    ).toBeVisible()
+  }
+}
+
+async function expectPdf(page: Page, workOrderUrl: string) {
+  const id = workOrderUrl.split('/').pop()
+  const response = await page.request.get(`/api/protected/services/${id}/pdf`)
+  expect(response.status(), 'the invoice PDF renders').toBe(200)
+  expect(response.headers()['content-type']).toContain('application/pdf')
+  expect((await response.body()).length).toBeGreaterThan(1_000)
+}
+
+let vehicleUrl = ''
+let exclusiveJob = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  await page.close()
+})
+
+test.describe('tax added on top', () => {
+  test('a 25% rate is added to net lines', async ({ page }) => {
+    await setTax(page, { enabled: true, rate: 25, inclusive: false })
+    exclusiveJob = await priceTheJob(page, vehicleUrl, `E2E exclusive ${stamp}`)
+
+    await expectTotals(page, {
+      Parts: '$300.00',
+      Labor: '$600.00',
+      Subtotal: '$900.00',
+      Tax: '$225.00',
+      Total: '$1,125.00',
+    })
+    await expect(page.getByLabel('Invoice Number')).not.toHaveValue('')
+
+    await expectOnSharedInvoice(page, exclusiveJob, [
+      '$900.00',
+      /Tax \(25%\)/,
+      '$225.00',
+      '$1,125.00',
+    ])
+    await expectPdf(page, exclusiveJob)
+  })
+
+  test('a percentage discount comes off before the tax', async ({ page }) => {
+    await page.goto(exclusiveJob)
+    await setDiscount(page, 'Percentage', 10)
+    await saveWorkOrder(page)
+
+    await expectTotals(page, {
+      Subtotal: '$900.00',
+      Discount: '-$90.00',
+      Tax: '$202.50',
+      Total: '$1,012.50',
+    })
+    await expectOnSharedInvoice(page, exclusiveJob, ['$202.50', '$1,012.50'])
+  })
+
+  test('a fixed discount does the same', async ({ page }) => {
+    await page.goto(exclusiveJob)
+    await setDiscount(page, 'Fixed', 100)
+    await saveWorkOrder(page)
+
+    await expectTotals(page, {
+      Discount: '-$100.00',
+      Tax: '$200.00',
+      Total: '$1,000.00',
+    })
+    await expectOnSharedInvoice(page, exclusiveJob, ['$200.00', '$1,000.00'])
+  })
+})
+
+test.describe('tax included in the prices', () => {
+  test('a 25% rate is taken out of gross lines and the total is what was typed', async ({
+    page,
+  }) => {
+    await setTax(page, { enabled: true, rate: 25, inclusive: true, label: 'MVA' })
+    const job = await priceTheJob(page, vehicleUrl, `E2E inclusive ${stamp}`)
+
+    // The editor shows the net figures; the customer pays what was typed.
+    await expectTotals(page, {
+      Subtotal: '$720.00',
+      Tax: '$180.00',
+      Total: '$900.00',
+    })
+    await expectOnSharedInvoice(page, job, [/MVA/, /25%/, '$180.00', '$900.00'])
+    await expectPdf(page, job)
+  })
+})
+
+test.describe('no tax at all', () => {
+  test('a workshop with tax off prints no tax line', async ({ page }) => {
+    await setTax(page, { enabled: false })
+    const job = await priceTheJob(page, vehicleUrl, `E2E untaxed ${stamp}`)
+
+    await expectTotals(page, { Subtotal: '$900.00', Total: '$900.00' })
+    await expect(totalsRow(page, 'Tax')).toHaveCount(0)
+    await expectOnSharedInvoice(page, job, ['$900.00'])
+    await expect(page.getByText(/^Tax/).filter({ visible: true })).toHaveCount(0)
+  })
+
+  test('the tax settings are put back', async ({ page }) => {
+    await setTax(page, { enabled: true, rate: 25, inclusive: false, label: '' })
+  })
+})

+ 143 - 0
e2e/specs/work-orders/quote-to-invoice.spec.ts

@@ -0,0 +1,143 @@
+import { test, expect, type Page } from '@playwright/test'
+
+/**
+ * The path a workshop actually walks: quote a job, price it, turn it into a
+ * work order, and end up with an invoice number a customer will see.
+ *
+ * Serial and stateful on purpose. Each step needs the record the previous one
+ * created, and splitting them into independent tests would mean seeding three
+ * near-identical work orders to assert one thing each.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const QUOTE_TITLE = `E2E brake job ${stamp}`
+const LABOR_HOURS = '2'
+const LABOR_RATE = '900'
+const PART_NAME = `E2E brake pads ${stamp}`
+
+let quoteUrl = ''
+let workOrderUrl = ''
+
+/** The row an editor input sits in, so sibling fields can be filled by position. */
+function rowOf(page: Page, field: ReturnType<Page['getByPlaceholder']>) {
+  void page
+  return field.locator('xpath=ancestor::div[contains(@class,"grid")][1]')
+}
+
+test.describe('quote to invoice', () => {
+  test('a quote can be created against a seeded vehicle', async ({ page }) => {
+    // The list opens its dialog from the query string, which saves hunting for
+    // a button that moves between the toolbar and a mobile icon.
+    await page.goto('/quotes?create=true')
+
+    await page.locator('#new-quote-title').fill(QUOTE_TITLE)
+
+    // The picker is a button in the combobox role whose only name is its
+    // placeholder, so it is found by what it says.
+    await page
+      .getByRole('combobox')
+      .filter({ hasText: /select vehicle/i })
+      .click()
+    await page.getByPlaceholder('Select vehicle...').fill('Camry')
+    await page.getByRole('option', { name: /Camry/i }).first().click()
+
+    await page.getByRole('button', { name: 'Create Quote' }).click()
+
+    await page.waitForURL(/\/quotes\/[^/]+$/)
+    quoteUrl = page.url()
+    await expect(page.locator('#title')).toHaveValue(QUOTE_TITLE)
+  })
+
+  test('labor priced on the quote reaches the totals', async ({ page }) => {
+    await page.goto(quoteUrl)
+
+    // The editor offers the button twice, in the toolbar and as a dashed row
+    // under the list; both add a blank line. A click that lands before React
+    // has hydrated the page does nothing, so the click is retried until the
+    // line is there to type into.
+    const description = page.getByPlaceholder('Description *').last()
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add Labor' }).last().click()
+      await expect(description).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await description.fill('Diagnose and replace front brake pads')
+
+    const row = rowOf(page, description)
+    await row.getByPlaceholder('Hours').fill(LABOR_HOURS)
+    // Hours first, rate second, in the order the editor renders them.
+    await row.locator('input[type="number"]').nth(1).fill(LABOR_RATE)
+
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    await expect(page.getByText('Quote saved')).toBeVisible()
+
+    // 2 × 900. Matched loosely because the thousands separator follows the
+    // workshop's locale, and the assertion is about arithmetic, not formatting.
+    await expect(page.getByText(/1[\s., ]?800/).first()).toBeVisible()
+  })
+
+  test('the quote converts into a work order', async ({ page }) => {
+    await page.goto(quoteUrl)
+
+    // Retried for the same reason as the add-row clicks: a click before
+    // hydration opens nothing.
+    const confirm = page.getByRole('button', { name: 'Convert', exact: true })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Convert to Work Order' }).click()
+      await expect(confirm).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await confirm.click()
+
+    await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
+    workOrderUrl = page.url()
+
+    // The labour the quote carried has to arrive with it, or the conversion has
+    // quietly produced an empty job.
+    await expect(page.getByPlaceholder('Description *').first()).toHaveValue(/front brake pads/i)
+  })
+
+  test('parts added to the work order land in an invoice with a number', async ({ page }) => {
+    await page.goto(workOrderUrl)
+
+    const name = page.getByPlaceholder('Name *').last()
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add Part' }).last().click()
+      await expect(name).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await name.fill(PART_NAME)
+
+    const row = rowOf(page, name)
+    const numbers = row.locator('input[type="number"]')
+    await numbers.nth(0).fill('1')
+    await numbers.nth(1).fill('450')
+
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    await expect(page.getByText('Saved')).toBeVisible()
+
+    // An invoice number is assigned by the workshop's numbering rules, not
+    // typed. Any value at all means the sequence ran.
+    const invoiceNumber = page.getByLabel('Invoice Number')
+    await expect(invoiceNumber).not.toHaveValue('')
+  })
+
+  test('an issued invoice keeps its number across a reload', async ({ page }) => {
+    await page.goto(workOrderUrl)
+
+    const before = await page.getByLabel('Invoice Number').inputValue()
+
+    // Offered both in the payments panel and under the invoice details.
+    const paid = page.getByText('Paid', { exact: true }).first()
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Mark as Paid' }).first().click()
+      await expect(paid).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await page.reload()
+
+    // Issuing freezes the document. A number that moves afterwards means the
+    // snapshot is not holding, which is the bug worth catching before customers
+    // hold two invoices with the same number.
+    await expect(page.getByLabel('Invoice Number')).toHaveValue(before)
+  })
+})

+ 142 - 0
e2e/specs/work-orders/validation.spec.ts

@@ -0,0 +1,142 @@
+import { expect, test } from '@playwright/test'
+import {
+  addLabor,
+  addPart,
+  laborRows,
+  newWorkOrder,
+  partRowOf,
+  partRows,
+  saveWorkOrder,
+  seededVehicleUrl,
+  totalsRow,
+} from '../../support/work-order'
+
+/**
+ * What the editor refuses to save, and what it says when it refuses.
+ *
+ * Each of these was silent once. A priced row with no name was dropped on the
+ * way to the server, so the save succeeded and the money left the invoice
+ * without a word; a negative figure made the browser refuse the submit on a
+ * field it would not show, so Save did nothing at all and said nothing
+ * either. The point of these tests is the message.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+let vehicleUrl = ''
+/** The one job these tests keep trying to break; each test opens it afresh. */
+let jobUrl = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  await page.close()
+})
+
+/** Clicks Save and expects it to be refused with one sentence. */
+async function expectRefused(page: import('@playwright/test').Page, message: RegExp) {
+  await page.getByRole('button', { name: 'Save', exact: true }).click()
+  await expect(page.getByText(message).filter({ visible: true }).first()).toBeVisible()
+  await expect(page.getByText('Saved', { exact: true })).toHaveCount(0)
+}
+
+test.describe('a work order that cannot be saved says why', () => {
+  test('a part with a price but no name is refused, and its money stays on screen', async ({
+    page,
+  }) => {
+    jobUrl = await newWorkOrder(page, vehicleUrl, `E2E validation ${stamp}`)
+    await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 1, unitPrice: 500 })
+    await saveWorkOrder(page)
+
+    // A second row, priced, that nobody has named.
+    await addPart(page, { name: 'to be emptied', quantity: 2, unitPrice: 300 })
+    await partRows(page).first().fill('')
+    await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00')
+
+    // The row says so on its own, before anybody reaches for Save.
+    await expect(
+      page.getByText('Rows without a part name are not saved.').filter({ visible: true })
+    ).toBeVisible()
+    await expectRefused(page, /every priced part needs a name/i)
+
+    // Nothing was thrown away: the row and its money are still there to fix.
+    await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00')
+    await expect(partRows(page)).toHaveCount(2)
+  })
+
+  test('naming it lets the save through with both lines', async ({ page }) => {
+    await page.goto(jobUrl)
+    await addPart(page, { name: 'to be named', quantity: 2, unitPrice: 300 })
+    await partRows(page).first().fill('')
+    await expect(async () => {
+      await partRows(page).first().fill(`E2E hose ${stamp}`)
+      await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await expect(partRows(page)).toHaveCount(2)
+    await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00')
+  })
+
+  test('labour with hours but nothing said about it is refused too', async ({ page }) => {
+    await page.goto(jobUrl)
+    await addLabor(page, { description: 'to be emptied', hours: 3, rate: 900 })
+    await laborRows(page).first().fill('')
+    await expect(totalsRow(page, 'Labor')).toContainText('$2,700.00')
+
+    await expect(
+      page.getByText('Rows without a description are not saved.').filter({ visible: true })
+    ).toBeVisible()
+    await expectRefused(page, /needs a description/i)
+
+    await laborRows(page).first().fill(`E2E fit the hose ${stamp}`)
+    await saveWorkOrder(page)
+    await expect(totalsRow(page, 'Labor')).toContainText('$2,700.00')
+  })
+
+  test('a negative quantity is refused in words, not by a dead button', async ({ page }) => {
+    await page.goto(jobUrl)
+    const quantity = partRowOf(partRows(page).first()).locator('input[type="number"]').first()
+    await expect(async () => {
+      await quantity.fill('-2')
+      await expect(totalsRow(page, 'Parts')).toContainText('-', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await expectRefused(page, /cannot be negative/i)
+  })
+
+  test('a negative unit price is refused as well', async ({ page }) => {
+    await page.goto(jobUrl)
+    const row = partRowOf(partRows(page).first())
+    const unitPrice = row.locator('input[type="number"]').nth(3)
+    await expect(async () => {
+      await unitPrice.fill('-10')
+      // The row's own total, not the parts subtotal: a small negative line is
+      // swallowed by the other rows and the sum stays positive, which is
+      // exactly how this reaches a customer unnoticed.
+      await expect(row.getByText(/-\$/)).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await expectRefused(page, /cannot be negative/i)
+  })
+
+  test('a job with no title is refused', async ({ page }) => {
+    await page.goto(jobUrl)
+    const title = page.locator('input[name="title"]')
+    await expect(async () => {
+      await title.fill('')
+      await expect(title).toHaveValue('', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await expectRefused(page, /needs a title/i)
+  })
+
+  test('the job is left as it was found, saved and correct', async ({ page }) => {
+    await page.goto(jobUrl)
+    await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00')
+    await expect(totalsRow(page, 'Labor')).toContainText('$2,700.00')
+  })
+})

+ 33 - 0
e2e/support/attachments.ts

@@ -0,0 +1,33 @@
+import { expect, type Page } from '@playwright/test'
+
+/**
+ * Putting a file on a work order, through the tab that takes it.
+ *
+ * Shared because two specs need it for different reasons: one asks what an
+ * attachment does to the printed invoice, the other needs a file that
+ * genuinely belongs to one workshop before it can check another cannot fetch
+ * it. Neither may depend on the other having run.
+ */
+export async function attach(
+  page: Page,
+  tab: 'Images' | 'Documents',
+  file: { name: string; mimeType: string; buffer: Buffer }
+): Promise<void> {
+  // The tab counts what it holds — "Documents (1)" once there is one — so it
+  // is found by what it starts with rather than by its whole name.
+  await expect(async () => {
+    await page.getByRole('button', { name: new RegExp(`^${tab}`) }).click()
+    await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  const accept = tab === 'Images' ? '.jpg,.jpeg,.png,.webp' : '.pdf,.csv,.txt'
+  await page.locator(`input[type="file"][accept="${accept}"]`).setInputFiles(file)
+
+  // A document is listed by name; a photograph is a thumbnail that carries
+  // its name only as the alt text.
+  const arrived =
+    tab === 'Images'
+      ? page.getByRole('img', { name: file.name })
+      : page.getByText(file.name).first()
+  await expect(arrived, `${file.name} reached the job`).toBeVisible({ timeout: 30_000 })
+}

+ 345 - 0
e2e/support/db.ts

@@ -0,0 +1,345 @@
+import { Client } from 'pg'
+
+/**
+ * A look into the database the suite seeded, for the few things a browser
+ * cannot see. Mail is not one of them any more: what the app posts is read
+ * back from the sink in `support/mail.ts`, which is what a person would see.
+ * What is left is the secret behind a two-factor QR code.
+ *
+ * Plain pg rather than the app's Prisma client: the tests run in Playwright's
+ * process, which has no adapter wired up, and one query does not need one.
+ */
+async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
+  const url = process.env.E2E_DATABASE_URL
+  if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
+  const db = new Client({ connectionString: url })
+  await db.connect()
+  try {
+    return await fn(db)
+  } finally {
+    await db.end()
+  }
+}
+
+/** The workshop the seeded owner belongs to. */
+export async function ownerOrganizationId(email = 'demo@torqvoice.com'): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ organizationId: string }>(
+      `select m."organizationId"
+         from organization_members m
+         join users u on u.id = m."userId"
+        where u.email = $1
+        limit 1`,
+      [email]
+    )
+    const id = result.rows[0]?.organizationId
+    if (!id) throw new Error(`no organization for ${email}`)
+    return id
+  })
+}
+
+/** The workshop a signed-up account ended up owning, by the address it used. */
+export async function organizationIdFor(email: string): Promise<string> {
+  return ownerOrganizationId(email)
+}
+
+/**
+ * Everything a save from the invoice designer writes, as it stands now.
+ *
+ * The designer does not edit one record: it writes the workshop's live
+ * layout, its palette and which design is in use, and that changes every
+ * invoice printed afterwards — including the ones the pricing and parity
+ * specs pin to the cent. Saving also graduates an organization from the
+ * classic pre-designer sheet to the designer's, which is not something a
+ * test may leave behind it. So the state is taken before and put back after.
+ */
+export interface InvoiceDesignState {
+  organizationId: string
+  settings: { key: string; value: string }[]
+  designIds: string[]
+}
+
+export async function invoiceDesignState(): Promise<InvoiceDesignState> {
+  const organizationId = await ownerOrganizationId()
+  return withDb(async (db) => {
+    const settings = await db.query<{ key: string; value: string }>(
+      `select key, value from app_settings where "organizationId" = $1 and key like 'invoice.%'`,
+      [organizationId]
+    )
+    const designs = await db.query<{ id: string }>(
+      `select id from document_designs where "organizationId" = $1`,
+      [organizationId]
+    )
+    return {
+      organizationId,
+      settings: settings.rows,
+      designIds: designs.rows.map((row) => row.id),
+    }
+  })
+}
+
+/** Puts the workshop back exactly as `invoiceDesignState` found it. */
+export async function restoreInvoiceDesignState(state: InvoiceDesignState): Promise<void> {
+  const keys = state.settings.map((row) => row.key)
+  await withDb(async (db) => {
+    // Anything the designer added goes; anything it changed goes back.
+    await db.query(
+      `delete from app_settings
+        where "organizationId" = $1 and key like 'invoice.%' and not (key = any($2::text[]))`,
+      [state.organizationId, keys]
+    )
+    for (const row of state.settings) {
+      await db.query(
+        `update app_settings set value = $3 where "organizationId" = $1 and key = $2`,
+        [state.organizationId, row.key, row.value]
+      )
+    }
+    await db.query(
+      `delete from document_designs
+        where "organizationId" = $1 and not (id = any($2::text[]))`,
+      [state.organizationId, state.designIds]
+    )
+  })
+}
+
+/**
+ * Addresses of the seeded workshop's own records, for the tests that check a
+ * different workshop cannot reach them. Read straight from the database
+ * because the point is to ask for them as an outsider: going through the app
+ * to find them first would need the very access under test.
+ */
+export interface TenantFixtures {
+  organizationId: string
+  vehicleId: string
+  serviceRecordId: string
+  customerId: string
+  quoteId: string
+  /**
+   * Words that belong to this workshop and nobody else. A cross-tenant page
+   * can answer 200 and render an empty shell, which is a refusal too, so the
+   * test asks whether any of these reached the screen rather than what the
+   * status code was.
+   */
+  vehiclePlate: string
+  customerName: string
+  quoteNumber: string
+}
+
+export async function seededTenantFixtures(): Promise<TenantFixtures> {
+  const organizationId = await ownerOrganizationId()
+  return withDb(async (db) => {
+    const one = async (sql: string): Promise<string> => {
+      const result = await db.query<{ id: string }>(sql, [organizationId])
+      const id = result.rows[0]?.id
+      if (!id) throw new Error(`the seeded workshop has nothing for: ${sql}`)
+      return id
+    }
+
+    /**
+     * A vehicle and one of its own jobs, from one row.
+     *
+     * Two queries answered this before, and on a database the suite had been
+     * run against they happened to agree. On a fresh seed they did not, and
+     * the job of one vehicle opened under the id of another draws a page with
+     * nothing on it.
+     *
+     * The organisation comes off the vehicle: `service_records.organizationId`
+     * is nullable and the seed leaves it null, scoping a job by the vehicle it
+     * sits on.
+     */
+    const pair = await db.query<{
+      vehicleId: string
+      serviceRecordId: string
+      licensePlate: string
+    }>(
+      `select v.id as "vehicleId", s.id as "serviceRecordId", v."licensePlate"
+         from service_records s
+         join vehicles v on v.id = s."vehicleId"
+        where coalesce(s."organizationId", v."organizationId") = $1
+          and v."licensePlate" is not null and v."licensePlate" <> ''
+        order by s."createdAt"
+        limit 1`,
+      [organizationId]
+    )
+    const job = pair.rows[0]
+    if (!job) throw new Error('the seeded workshop has no work order on a plated vehicle')
+
+    return {
+      organizationId,
+      vehicleId: job.vehicleId,
+      serviceRecordId: job.serviceRecordId,
+      vehiclePlate: job.licensePlate,
+      customerId: await one(`select id from customers where "organizationId" = $1 limit 1`),
+      quoteId: await one(
+        `select id from quotes
+          where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
+          limit 1`
+      ),
+      customerName: await one(
+        `select name as id from customers where "organizationId" = $1 limit 1`
+      ),
+      quoteNumber: await one(
+        `select "quoteNumber" as id from quotes
+          where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
+          limit 1`
+      ),
+    }
+  })
+}
+
+/**
+ * Any work order belonging to a given workshop, for the tests that point one
+ * workshop's credential at another's records. A workshop that has just been
+ * opened has a few of its own from onboarding, which is what makes a
+ * freshly signed-up account a usable target.
+ */
+export async function foreignServiceRecordId(organizationId: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `select s.id
+         from service_records s
+         join vehicles v on v.id = s."vehicleId"
+        where coalesce(s."organizationId", v."organizationId") = $1
+        order by s."createdAt"
+        limit 1`,
+      [organizationId]
+    )
+    const id = result.rows[0]?.id
+    if (!id) throw new Error(`no work order in organization ${organizationId}`)
+    return id
+  })
+}
+
+/** The stored (encrypted) TOTP secret of a user, or null when 2FA is not set up. */
+export async function storedTwoFactorSecret(email: string): Promise<string | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ secret: string }>(
+      `select tf.secret from two_factor tf join users u on u.id = tf."userId" where u.email = $1`,
+      [email]
+    )
+    return result.rows[0]?.secret ?? null
+  })
+}
+
+export interface StockedPart {
+  id: string
+  name: string
+  /** What the ledger says it has on hand right now. */
+  quantity: number
+}
+
+/**
+ * A seeded inventory part with enough on hand to be consumed by a job, and
+ * whose name is distinctive enough to search for in the picker.
+ *
+ * The part is chosen rather than created, because what is under test is the
+ * path a workshop actually walks: pick a stocked part, use it, and watch the
+ * count fall.
+ */
+export async function stockedPart(organizationId: string, atLeast = 10): Promise<StockedPart> {
+  return withDb(async (db) => {
+    const result = await db.query<StockedPart>(
+      `select id, name, quantity
+         from inventory_parts
+        where "organizationId" = $1 and quantity >= $2
+        order by quantity desc, name
+        limit 1`,
+      [organizationId, atLeast]
+    )
+    const part = result.rows[0]
+    if (!part) throw new Error(`no inventory part with ${atLeast} or more on hand`)
+    return { ...part, quantity: Number(part.quantity) }
+  })
+}
+
+/** What one inventory part has on hand. */
+export async function partQuantity(partId: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ quantity: number }>(
+      `select quantity from inventory_parts where id = $1`,
+      [partId]
+    )
+    if (!result.rows[0]) throw new Error(`no inventory part ${partId}`)
+    return Number(result.rows[0].quantity)
+  })
+}
+
+/** Set a part's quantity outright, to put the seed back as it was found. */
+export async function setPartQuantity(partId: string, quantity: number): Promise<void> {
+  await withDb((db) =>
+    db.query(`update inventory_parts set quantity = $2 where id = $1`, [partId, quantity])
+  )
+}
+
+export interface StockMovement {
+  delta: number
+  quantityAfter: number
+  reason: string
+  serviceRecordId: string | null
+}
+
+/**
+ * The ledger for one part, oldest first. Every movement is a row: the count on
+ * the part is only ever the running total of these, which is why a spec that
+ * checks stock checks both.
+ */
+export async function stockMovements(
+  partId: string,
+  serviceRecordId?: string
+): Promise<StockMovement[]> {
+  return withDb(async (db) => {
+    const result = await db.query<StockMovement>(
+      `select delta, "quantityAfter", reason, "serviceRecordId"
+         from stock_movements
+        where "inventoryPartId" = $1
+          and ($2::text is null or "serviceRecordId" = $2)
+        order by "createdAt", id`,
+      [partId, serviceRecordId ?? null]
+    )
+    return result.rows.map((row) => ({
+      ...row,
+      delta: Number(row.delta),
+      quantityAfter: Number(row.quantityAfter),
+    }))
+  })
+}
+
+/** When a reminder is due, as the instant that was stored for it. */
+export async function reminderDueDate(title: string): Promise<Date> {
+  return withDb(async (db) => {
+    const result = await db.query<{ dueDate: Date }>(
+      `select "dueDate" from reminders where title = $1 order by "createdAt" desc limit 1`,
+      [title]
+    )
+    const due = result.rows[0]?.dueDate
+    if (!due) throw new Error(`no reminder titled "${title}" with a due date`)
+    return new Date(due)
+  })
+}
+
+/** Removes the reminders a spec made, whatever state the page was left in. */
+export async function deleteRemindersTitled(title: string): Promise<void> {
+  await withDb((db) => db.query(`delete from reminders where title = $1`, [title]))
+}
+
+/**
+ * The newest file on a work order, as the app stored its address.
+ *
+ * A spec that needs a file belonging to one workshop uploads one and reads it
+ * back here. Looking for a seeded one instead only worked on a database the
+ * attachment spec had already run against.
+ */
+export async function latestAttachmentUrl(serviceRecordId: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ fileUrl: string }>(
+      `select "fileUrl" from service_attachments
+        where "serviceRecordId" = $1 and "fileUrl" like '/api/protected/files/%'
+        order by "createdAt" desc
+        limit 1`,
+      [serviceRecordId]
+    )
+    const url = result.rows[0]?.fileUrl
+    if (!url) throw new Error(`no stored file on work order ${serviceRecordId}`)
+    return url
+  })
+}

+ 27 - 0
e2e/support/hydration.ts

@@ -0,0 +1,27 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+
+/**
+ * A page can be on screen before React has taken it over. Typed into then,
+ * a controlled field keeps the letters on screen while React's own state
+ * stays empty, and the button beside it sends nothing. There is no reliable
+ * signal for the moment that passes.
+ */
+
+/** Gives a freshly opened page time to become interactive. */
+export async function settle(page: Page): Promise<void> {
+  await page.waitForLoadState('networkidle')
+  await page.waitForTimeout(1_000)
+}
+
+/**
+ * Types into a field once the page has settled, and checks the words are
+ * still there afterwards, repeating if they were lost to a late render.
+ */
+export async function fillSettled(field: Locator, value: string): Promise<void> {
+  await settle(field.page())
+  await expect(async () => {
+    await field.fill(value)
+    await field.page().waitForTimeout(400)
+    await expect(field).toHaveValue(value)
+  }).toPass({ timeout: 30_000 })
+}

+ 40 - 0
e2e/support/inventory.ts

@@ -0,0 +1,40 @@
+import { expect, type Page } from '@playwright/test'
+import { partRows } from './work-order'
+
+/**
+ * Adding a stocked part to a work order, through the picker a workshop uses.
+ *
+ * Typing a name into a part row makes free text, which moves no stock. Only a
+ * row that carries the inventory part's id does, and the only way to get one
+ * is this dialog.
+ */
+export async function addPartFromInventory(
+  page: Page,
+  name: string,
+  quantity: number
+): Promise<void> {
+  const rows = partRows(page)
+  const before = await rows.count()
+
+  const dialog = page.getByRole('dialog', { name: 'Select Part from Inventory' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'From Inventory', exact: true }).click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  await dialog.getByPlaceholder('Search inventory...').fill(name)
+  await dialog
+    .getByRole('button', { name: new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') })
+    .first()
+    .click()
+
+  // The picked part is put at the top of the list, and comes in at one.
+  await expect(rows).toHaveCount(before + 1)
+  await setPartQuantityField(page, quantity)
+}
+
+/** Types a quantity into the first part row, which is the one just added. */
+export async function setPartQuantityField(page: Page, quantity: number): Promise<void> {
+  const row = partRows(page).first().locator('xpath=ancestor::*[.//input[@placeholder="Cost"]][1]')
+  await row.locator('input[type="number"]').first().fill(String(quantity))
+}

+ 85 - 0
e2e/support/mail.ts

@@ -0,0 +1,85 @@
+import { expect } from '@playwright/test'
+import type { CapturedAttachment, CapturedMail } from '../mail-sink'
+
+/**
+ * Reading what the app posted.
+ *
+ * The harness runs a mail server that delivers nothing and keeps everything
+ * (`e2e/mail-sink.ts`); this is the other end of it. A spec asks for the mail
+ * an address was sent and pulls the link out of it, which is as close as a
+ * test gets to a person opening their inbox and clicking.
+ */
+
+const api = process.env.E2E_MAIL_API ?? 'http://127.0.0.1:8025'
+
+export type { CapturedAttachment, CapturedMail }
+
+/** One file the mail carried, by name, as bytes. */
+export function attachment(mail: CapturedMail, name: RegExp): CapturedAttachment {
+  const found = mail.attachments.find((a) => name.test(a.filename))
+  if (!found) {
+    throw new Error(
+      `no attachment matching ${name} on "${mail.subject}". Carried: ${
+        mail.attachments.map((a) => a.filename).join(', ') || 'nothing'
+      }`
+    )
+  }
+  return found
+}
+
+/** The bytes of an attachment the sink kept. */
+export function attachmentBytes(file: CapturedAttachment): Buffer {
+  if (!file.content) throw new Error(`the sink did not keep ${file.filename} (${file.size} bytes)`)
+  return Buffer.from(file.content, 'base64')
+}
+
+/** Everything the sink holds for an address, newest first. */
+export async function mailsTo(address: string): Promise<CapturedMail[]> {
+  const response = await fetch(`${api}/messages?to=${encodeURIComponent(address)}`)
+  if (!response.ok) {
+    throw new Error(`the mail sink answered ${response.status}. Is e2e/mail-sink.ts running?`)
+  }
+  return (await response.json()) as CapturedMail[]
+}
+
+/** Forgets every mail, so a spec can be sure the next one it reads is its own. */
+export async function clearMailbox(): Promise<void> {
+  const response = await fetch(`${api}/messages`, { method: 'DELETE' })
+  if (!response.ok) throw new Error(`the mail sink answered ${response.status} to a clear`)
+}
+
+/**
+ * The newest mail to an address, waited for. Mail is sent while the request
+ * that triggered it is still being answered, so it can arrive a moment after
+ * the page says it did.
+ */
+export async function waitForMail(
+  address: string,
+  options: { subject?: RegExp; timeout?: number } = {}
+): Promise<CapturedMail> {
+  let found: CapturedMail | undefined
+  await expect(async () => {
+    const mails = await mailsTo(address)
+    found = options.subject ? mails.find((m) => options.subject?.test(m.subject)) : mails[0]
+    expect(found, `a mail to ${address}`).toBeDefined()
+  }).toPass({ timeout: options.timeout ?? 20_000 })
+  return found as CapturedMail
+}
+
+/**
+ * The first link in a mail that matches. Mails are written as HTML with a
+ * plain-text half beside them, and the address that matters is in both, so
+ * whichever half carries it is fine.
+ */
+export function linkIn(mail: CapturedMail, pattern: RegExp): string {
+  const body = `${mail.html}\n${mail.text}`
+  const links = body.match(/https?:\/\/[^\s"'<>)]+/g) ?? []
+  // `&amp;` is HTML, not part of the address it was written into.
+  const link = links.map((l) => l.replace(/&amp;/g, '&')).find((l) => pattern.test(l))
+  if (!link) {
+    throw new Error(
+      `no link matching ${pattern} in "${mail.subject}". Links found: ${links.join(', ') || 'none'}`
+    )
+  }
+  return link
+}

+ 73 - 0
e2e/support/pdf.ts

@@ -0,0 +1,73 @@
+import { PDFDocument, StandardFonts } from 'pdf-lib'
+import { extractText } from 'unpdf'
+
+/**
+ * Reading what a PDF actually says.
+ *
+ * Page count and byte size prove two copies of an invoice are not the same
+ * document; they say nothing about whether either one is right. The sheet is
+ * drawn by react-pdf with embedded fonts, and it comes back out as real text
+ * — the figures formatted exactly as the screen shows them, and a description
+ * typed over three lines still on three lines.
+ */
+
+export interface PdfContent {
+  pages: number
+  /**
+   * The file's own size. Text alone cannot see a missing logo or QR code, and
+   * that is precisely what the emailed copy was once missing, so the weight is
+   * kept alongside the words.
+   */
+  size: number
+  /** As extracted, one line per line of the sheet. */
+  text: string
+  /** The same with every run of whitespace collapsed, for phrase assertions. */
+  flat: string
+}
+
+export async function pdfContent(bytes: Buffer | Uint8Array): Promise<PdfContent> {
+  const { totalPages, text } = await extractText(new Uint8Array(bytes), { mergePages: true })
+  const merged = String(text)
+  return {
+    pages: totalPages,
+    size: bytes.byteLength,
+    text: merged,
+    flat: merged.replace(/\s+/g, ' ').trim(),
+  }
+}
+
+/**
+ * A small PDF with words on it, for the tests that attach one to a job. Built
+ * here rather than committed as a fixture so that what it says is visible to
+ * whoever reads the spec — and what it says is the point, since the invoice
+ * appends these pages and the test looks for them.
+ */
+export async function makePdf(pages: string[]): Promise<Buffer> {
+  const document = await PDFDocument.create()
+  const font = await document.embedFont(StandardFonts.Helvetica)
+  for (const line of pages) {
+    const page = document.addPage([595, 842])
+    page.drawText(line, { x: 60, y: 700, size: 24, font })
+  }
+  return Buffer.from(await document.save())
+}
+
+/**
+ * A 1×1 PNG: the smallest thing the invoice will accept as a photograph.
+ * Signature, IHDR, a deflated red pixel and IEND, each with its CRC — the
+ * one that circulates as "the smallest PNG" has a broken IDAT checksum, and
+ * a broken image is a different test (see BROKEN_PNG).
+ */
+export const TINY_PNG = Buffer.from(
+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC',
+  'base64'
+)
+
+/**
+ * A PNG that says it is one and is not: the header parses, the pixels do not
+ * inflate. What a truncated upload from a phone looks like on disk.
+ */
+export const BROKEN_PNG = Buffer.from(
+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==',
+  'base64'
+)

+ 126 - 0
e2e/support/quote.ts

@@ -0,0 +1,126 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+
+/**
+ * Driving a quote through the browser.
+ *
+ * The quote editor is close to the work order's and not the same: it renders
+ * once at any width (a JavaScript breakpoint rather than two hidden copies),
+ * and its Add buttons append rather than prepend, so the row just made is the
+ * last one. The placeholders are shared, which is why these read the same as
+ * the work order helpers next door.
+ */
+
+/** A fresh quote against a seeded vehicle, open in the editor. */
+export async function newQuote(page: Page, title: string, vehicle = 'Camry'): Promise<string> {
+  // The list opens its dialog from the query string, which saves hunting for
+  // a button that moves between the toolbar and a mobile icon.
+  await page.goto('/quotes?create=true')
+  await page.locator('#new-quote-title').fill(title)
+
+  // The picker is a button in the combobox role whose only name is its
+  // placeholder, so it is found by what it says.
+  await page
+    .getByRole('combobox')
+    .filter({ hasText: /select vehicle/i })
+    .click()
+  await page.getByPlaceholder('Select vehicle...').fill(vehicle)
+  await page
+    .getByRole('option', { name: new RegExp(vehicle, 'i') })
+    .first()
+    .click()
+
+  await page.getByRole('button', { name: 'Create Quote' }).click()
+  await page.waitForURL(/\/quotes\/[^/]+$/)
+  await expect(page.locator('#title')).toHaveValue(title)
+  return page.url()
+}
+
+/** The row an editor input belongs to: the nearest ancestor holding its sibling. */
+function rowContaining(field: Locator, siblingPlaceholder: string): Locator {
+  return field.locator(`xpath=ancestor::*[.//input[@placeholder="${siblingPlaceholder}"]][1]`)
+}
+
+/**
+ * The row a part field sits in. Unlike the work order's, the quote's number
+ * inputs carry no placeholders at all, so the row is found as the nearest
+ * ancestor that holds one.
+ */
+function partRowOf(nameField: Locator): Locator {
+  return nameField.locator('xpath=ancestor::div[.//input[@type="number"]][1]')
+}
+
+export interface QuotePart {
+  name: string
+  quantity: number
+  unitPrice: number
+}
+
+/** Adds a part line. The numbers sit in the order the editor prints them. */
+export async function addQuotePart(page: Page, part: QuotePart): Promise<void> {
+  const rows = page.getByPlaceholder('Name *')
+  const before = await rows.count()
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add Part', exact: true }).first().click()
+    await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // Appended, so the new row is the last.
+  const name = rows.last()
+  await name.fill(part.name)
+  // Quantity, cost, markup, unit price, in the order the editor prints them.
+  const numbers = partRowOf(name).locator('input[type="number"]')
+  await numbers.nth(0).fill(String(part.quantity))
+  await numbers.nth(3).fill(String(part.unitPrice))
+}
+
+export interface QuoteLabor {
+  description: string
+  hours: number
+  rate: number
+}
+
+/** Adds an hourly labour line. */
+export async function addQuoteLabor(page: Page, labor: QuoteLabor): Promise<void> {
+  const rows = page.getByPlaceholder('Description *')
+  const before = await rows.count()
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add Labor', exact: true }).first().click()
+    await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  const description = rows.last()
+  await description.fill(labor.description)
+  const numbers = rowContaining(description, 'Hours').locator('input[type="number"]')
+  await numbers.nth(0).fill(String(labor.hours))
+  await numbers.nth(1).fill(String(labor.rate))
+}
+
+/** Saves the quote and waits for the header to say so. */
+export async function saveQuote(page: Page): Promise<void> {
+  await page.getByRole('button', { name: 'Save', exact: true }).click()
+  await expect(page.getByText('Quote saved')).toBeVisible()
+}
+
+/** The public link for the open quote, generating it if there is none yet. */
+export async function quoteShareLink(page: Page): Promise<string> {
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Share', exact: true }).click()
+    await expect(page.getByRole('dialog', { name: 'Share Quote' })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  const dialog = page.getByRole('dialog', { name: 'Share Quote' })
+  const generate = dialog.getByRole('button', { name: /generate public link/i })
+  if (await generate.isVisible().catch(() => false)) await generate.click()
+
+  const field = dialog.locator('input[readonly]')
+  await expect(field).toHaveValue(/\/share\/quote\//)
+  const url = await field.inputValue()
+  await page.keyboard.press('Escape')
+  return url
+}
+
+/** The public quote PDF behind a share link. */
+export function quotePdfUrl(shareUrl: string): string {
+  const [orgId, token] = new URL(shareUrl).pathname.split('/').slice(-2)
+  return `/api/public/share/quote/${orgId}/${token}/pdf`
+}

+ 141 - 0
e2e/support/settings.ts

@@ -0,0 +1,141 @@
+import { expect, type Page } from '@playwright/test'
+import { fillSettled, settle } from './hydration'
+
+/**
+ * The workshop settings a spec has to move before it can assert anything: tax,
+ * and the rules that decide an invoice number.
+ *
+ * These are one workshop's settings, shared by every test in the run, so a
+ * spec that changes one puts it back at the end. Both pages are controlled
+ * React forms, which is why the fields are typed into through `fillSettled`
+ * rather than `fill`.
+ */
+
+export interface TaxSetup {
+  enabled: boolean
+  rate?: number
+  inclusive?: boolean
+  label?: string
+}
+
+/** Settings → Tax, saved. */
+export async function setTax(page: Page, tax: TaxSetup): Promise<void> {
+  await page.goto('/settings/tax')
+  const enable = page.getByRole('switch').first()
+  await expect(enable).toBeVisible()
+  const on = (await enable.getAttribute('aria-checked')) === 'true'
+  if (on !== tax.enabled) await enable.click()
+  if (tax.enabled) {
+    await page.locator('#defaultTaxRate').fill(String(tax.rate ?? 0))
+    await page.locator('#taxLabel').fill(tax.label ?? '')
+    await page
+      .getByRole('button', { name: tax.inclusive ? 'Inclusive' : 'Exclusive', exact: true })
+      .click()
+  }
+  await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
+  await expect(page.getByText('Settings saved', { exact: true })).toBeVisible()
+}
+
+export interface NumberingSetup {
+  /** The invoice number format, `{year}` and `{month}` included. */
+  prefix: string
+  /** The number the next invoice takes. Empty leaves the sequence alone. */
+  startNumber?: string
+}
+
+/** Settings → Invoice, the numbering half of it, saved. */
+export async function setInvoiceNumbering(
+  page: Page,
+  { prefix, startNumber = '' }: NumberingSetup
+): Promise<void> {
+  await page.goto('/settings/invoice')
+  await fillSettled(page.locator('#invoicePrefix'), prefix)
+  await fillSettled(page.locator('#invoiceStartNumber'), startNumber)
+  await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
+  await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
+}
+
+/** What the settings page currently offers as the next invoice number. */
+export async function invoiceStartNumber(page: Page): Promise<string> {
+  await page.goto('/settings/invoice')
+  const field = page.locator('#invoiceStartNumber')
+  await expect(field).toBeVisible()
+  return field.inputValue()
+}
+
+/**
+ * Settings → Localisation: the workshop's clock.
+ *
+ * The timezone decides what a booking's wall clock means, and the format
+ * decides how it is written. Both are set together because a test that reads
+ * a time off one screen and looks for it on another needs them to agree: the
+ * calendar prints the workshop's chosen format, while a schedule field prints
+ * a 24-hour clock whatever the setting says.
+ *
+ * Pass an empty timezone to hand it back to the browser's own, which is what
+ * a workshop that has never chosen sees.
+ */
+export async function setWorkshopClock(
+  page: Page,
+  { timezone, format }: { timezone: string; format?: '12h' | '24h' }
+): Promise<void> {
+  await page.goto('/settings/localization')
+  await settle(page)
+
+  // By label, not by what it currently reads: the date-format select a few
+  // rows up also shows a value full of slashes.
+  const picker = page.getByLabel('Timezone')
+  await expect(async () => {
+    await picker.click()
+    await expect(page.getByPlaceholder('Search timezone...')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  if (timezone) {
+    // The list is long, so it is narrowed to the city, written the way the
+    // list writes it: "Los Angeles", not "Los_Angeles".
+    const city = (timezone.split('/').pop() ?? timezone).replace(/_/g, ' ')
+    await page.getByPlaceholder('Search timezone...').fill(city)
+    await page
+      .getByRole('option', { name: timezone.replace(/_/g, ' '), exact: true })
+      .first()
+      .click()
+  } else {
+    await page.getByRole('option', { name: 'Auto-detect (browser)' }).first().click()
+  }
+
+  if (format) {
+    const wanted = format === '24h' ? '24-hour (14:30)' : '12-hour (2:30 PM)'
+    const clock = page.getByLabel('Time Format')
+    await expect(async () => {
+      await clock.click()
+      await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('option', { name: wanted }).click()
+  }
+
+  await page.getByRole('button', { name: 'Save Settings', exact: true }).click()
+  await expect(page.getByText('Localization settings saved')).toBeVisible({ timeout: 30_000 })
+}
+
+/**
+ * Settings → Payment, the bank account.
+ *
+ * The payment panel on a sheet prints nothing at all unless it has a line to
+ * print, and the seeded workshop has no bank details, no org number and no
+ * terms. A spec that wants the panel has to give it one, and put it back
+ * afterwards: every other sheet in the suite is printed from these settings.
+ */
+export async function setBankAccount(page: Page, account: string): Promise<void> {
+  await page.goto('/settings/payment')
+  await fillSettled(page.locator('#bankAccount'), account)
+  await page.getByRole('button', { name: 'Save Payment Settings', exact: true }).click()
+  await expect(page.getByText('Payment settings saved', { exact: true })).toBeVisible()
+}
+
+/** What the workshop currently has as its bank account, which may be nothing. */
+export async function bankAccount(page: Page): Promise<string> {
+  await page.goto('/settings/payment')
+  const field = page.locator('#bankAccount')
+  await expect(field).toBeVisible()
+  return field.inputValue()
+}

+ 17 - 0
e2e/support/totp.ts

@@ -0,0 +1,17 @@
+import { createOTP } from '@better-auth/utils/otp'
+import { symmetricDecrypt } from 'better-auth/crypto'
+
+/**
+ * The six digits an authenticator app would show right now, from the secret
+ * better-auth stored when 2FA was enabled.
+ *
+ * The secret is kept encrypted with the auth secret, the same one the test
+ * server was started with, so the test can read it back the way the server
+ * does and stand in for the phone. The QR code on the screen carries the
+ * same secret, but a picture is no use to a test.
+ */
+export async function currentTotpCode(storedSecret: string): Promise<string> {
+  const key = process.env.BETTER_AUTH_SECRET ?? 'e2e-secret-not-for-production'
+  const secret = await symmetricDecrypt({ key, data: storedSecret })
+  return createOTP(secret, { digits: 6, period: 30 }).totp()
+}

+ 201 - 0
e2e/support/work-order.ts

@@ -0,0 +1,201 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+
+/**
+ * Driving a work order through the browser the way a workshop does.
+ *
+ * Everything here reads visible English and the placeholders the editors
+ * print; the suite pins the locale so both hold. Clicks that add a row are
+ * retried until the row is there: a click that lands before React has
+ * hydrated the page does nothing, and the editor gives no other sign.
+ */
+
+/** The address of one seeded vehicle, found through the list's own search. */
+export async function seededVehicleUrl(page: Page, search = 'Camry'): Promise<string> {
+  await page.goto(`/vehicles?search=${encodeURIComponent(search)}`)
+  await page
+    .getByRole('link', { name: new RegExp(search, 'i') })
+    .first()
+    .click()
+  await page.waitForURL(/\/vehicles\/[^/?]+$/)
+  return page.url()
+}
+
+/** A fresh draft work order on the vehicle, titled, open in the editor. */
+export async function newWorkOrder(page: Page, vehicleUrl: string, title: string): Promise<string> {
+  await page.goto(`${vehicleUrl}/service/new`)
+  await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
+  const titleField = page.locator('input[name="title"]')
+  await expect(titleField).toBeVisible()
+  await titleField.fill(title)
+  return page.url()
+}
+
+/** The editor row an input belongs to: the nearest ancestor that also holds the given input. */
+function rowContaining(field: Locator, siblingPlaceholder: string): Locator {
+  return field.locator(`xpath=ancestor::*[.//input[@placeholder="${siblingPlaceholder}"]][1]`)
+}
+
+/**
+ * The part rows, in the order the editor lists them. One locator per row: the
+ * page used to draw the whole editor twice, once per breakpoint, and every row
+ * came back doubled with half of them impossible to type into.
+ * `specs/work-orders/layout.spec.ts` is what keeps it to one.
+ */
+export function partRows(page: Page): Locator {
+  return page.getByPlaceholder('Name *')
+}
+
+/** The labour rows, likewise. */
+export function laborRows(page: Page): Locator {
+  return page.getByPlaceholder('Description *')
+}
+
+/** The whole row a part field sits in, buttons and figures included. */
+export function partRowOf(nameField: Locator): Locator {
+  return rowContaining(nameField, 'Cost')
+}
+
+/** The whole row a labour field sits in. */
+export function laborRowOf(descriptionField: Locator): Locator {
+  return rowContaining(descriptionField, 'Hours')
+}
+
+export interface PartInput {
+  name: string
+  quantity: number
+  /** Vendor cost; with a markup the unit price is expected to follow from it. */
+  cost?: number
+  markupPercent?: number
+  unitPrice?: number
+}
+
+/**
+ * Adds a part line. The number inputs sit in the order the editor prints
+ * them: quantity, cost, markup, unit price.
+ */
+export async function addPart(page: Page, part: PartInput): Promise<void> {
+  const rows = partRows(page)
+  // Counted first, and waited for: a click before hydration adds nothing, and
+  // a guard that only asked whether some name field was on screen would be
+  // satisfied by the rows already there and type over the last of them.
+  const before = await rows.count()
+  await expect(async () => {
+    // An empty list offers the button twice, in the toolbar and as the dashed
+    // row beneath it; once there are rows, only the toolbar one is named.
+    await page.getByRole('button', { name: 'Add Part' }).last().click()
+    await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // The toolbar button puts the new row at the top of the list, which is
+  // where the workshop looks after clicking it; the dashed one appends, and
+  // on an empty list either way leaves the row it made as the only one.
+  const name = rows.first()
+  await name.fill(part.name)
+
+  const numbers = rowContaining(name, 'Cost').locator('input[type="number"]')
+  await numbers.nth(0).fill(String(part.quantity))
+  if (part.cost !== undefined) await numbers.nth(1).fill(String(part.cost))
+  if (part.markupPercent !== undefined) await numbers.nth(2).fill(String(part.markupPercent))
+  if (part.unitPrice !== undefined) await numbers.nth(3).fill(String(part.unitPrice))
+}
+
+/** What the editor worked out as the unit price of the part added last. */
+export async function lastPartUnitPrice(page: Page): Promise<string> {
+  return partRowOf(partRows(page).first()).locator('input[type="number"]').nth(3).inputValue()
+}
+
+export interface LaborInput {
+  description: string
+  hours: number
+  rate: number
+}
+
+/** Adds an hourly labour line: description, hours, rate. */
+export async function addLabor(page: Page, labor: LaborInput): Promise<void> {
+  const rows = laborRows(page)
+  const before = await rows.count()
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add Labor' }).last().click()
+    await expect(rows).toHaveCount(before + 1, { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // Added at the top, like a part.
+  const description = rows.first()
+  await description.fill(labor.description)
+
+  const numbers = rowContaining(description, 'Hours').locator('input[type="number"]')
+  await numbers.nth(0).fill(String(labor.hours))
+  await numbers.nth(1).fill(String(labor.rate))
+}
+
+/** Saves the work order and waits for the header to say so. */
+export async function saveWorkOrder(page: Page): Promise<void> {
+  await page.getByRole('button', { name: 'Save', exact: true }).click()
+  await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+}
+
+/**
+ * One line of the totals panel, found by its label. The panel prints each
+ * line as a label and a figure side by side, so the row is the label's
+ * parent and the figure is read from it.
+ */
+export function totalsRow(page: Page, label: string): Locator {
+  // Scoped to the Totals panel: "Parts" and "Labor" are also section headings.
+  const panel = page
+    .getByRole('heading', { name: 'Totals', exact: true })
+    .locator('xpath=ancestor::div[1]')
+  // The row is the nearest box that spreads label and figure apart; the tax
+  // label sits one level deeper, beside its percentage input.
+  return panel
+    .getByText(label, { exact: true })
+    .first()
+    .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
+}
+
+/** Sets the discount on the open work order. */
+export async function setDiscount(
+  page: Page,
+  kind: 'None' | 'Percentage' | 'Fixed',
+  value?: number
+): Promise<void> {
+  const row = totalsRow(page, 'Discount')
+  await row.getByRole('combobox').click()
+  await page.getByRole('option', { name: kind, exact: true }).click()
+  if (value !== undefined) await row.locator('input[type="number"]').fill(String(value))
+}
+
+/** The public share link for the open work order, generating it if needed. */
+export async function shareLink(page: Page): Promise<string> {
+  await page.getByRole('button', { name: 'Share', exact: true }).click()
+
+  // A document whose invoice date has passed is offered a fresh one before it
+  // goes out. Declined here: a spec that shares a seeded invoice must hand the
+  // customer the document as it stands, not rewrite its dates on the way.
+  const expired = page.getByRole('dialog', { name: /invoice dates expired/i })
+  const asksAboutDates = await expired
+    .waitFor({ state: 'visible', timeout: 3_000 })
+    .then(() => true)
+    .catch(() => false)
+  if (asksAboutDates) {
+    await expired.getByRole('button', { name: /proceed without changes/i }).click()
+  }
+
+  const dialog = page.getByRole('dialog')
+  await expect(dialog).toBeVisible()
+  const generate = dialog.getByRole('button', { name: /generate public link/i })
+  if (await generate.isVisible()) {
+    await generate.click()
+    // A job that has gone to the customer is asked to leave the work board.
+    const prompt = page.getByRole('alertdialog', { name: /mark this invoice as completed/i })
+    const asked = await prompt
+      .waitFor({ state: 'visible', timeout: 3_000 })
+      .then(() => true)
+      .catch(() => false)
+    if (asked) await prompt.getByRole('button', { name: 'Mark completed', exact: true }).click()
+  }
+  const field = dialog.locator('input[readonly]')
+  await expect(field).toHaveValue(/\/share\/invoice\//)
+  const url = await field.inputValue()
+  await page.keyboard.press('Escape')
+  return url
+}

+ 2 - 2
messages/de/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
+    "switchToServiceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/de/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Zurück",
     "datesExpiredSetToday": "Auf heute setzen",
     "customFieldsInvalid": "Bitte füllen Sie alle erforderlichen benutzerdefinierten Felder aus",
-    "customFieldsSaveFailed": "Benutzerdefinierte Felder konnten nicht gespeichert werden"
+    "customFieldsSaveFailed": "Benutzerdefinierte Felder konnten nicht gespeichert werden",
+    "problems": {
+      "title": "Der Auftrag braucht einen Titel, bevor er gespeichert werden kann.",
+      "negative": "Mengen, Stunden, Preise und Kosten dürfen nicht negativ sein.",
+      "partName": "Jedes Teil mit Preis braucht einen Namen.",
+      "laborDescription": "Jede Arbeitszeile mit Stunden oder Satz braucht eine Beschreibung."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
+    "switchToServiceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert."
   },
   "totals": {
     "title": "Summen",

+ 2 - 1
messages/de/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# Kunde} other {# Kunden}} mit diesem Design fallen auf das Standarddesign zurück.",
     "setDefault": "Als Standard festlegen",
     "defaultSet": "„{name}“ wird jetzt verwendet",
-    "couldNotSetDefault": "Standarddesign konnte nicht festgelegt werden"
+    "couldNotSetDefault": "Standarddesign konnte nicht festgelegt werden",
+    "fieldFixedSlot": "Wird immer an derselben Stelle gedruckt und kann nicht verschoben werden."
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 9 - 2
messages/en/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Back",
     "datesExpiredSetToday": "Set to today",
     "customFieldsInvalid": "Please fill in all required custom fields",
-    "customFieldsSaveFailed": "Failed to save custom fields"
+    "customFieldsSaveFailed": "Failed to save custom fields",
+    "problems": {
+      "title": "The job needs a title before it can be saved.",
+      "negative": "Quantities, hours, prices and costs cannot be negative.",
+      "partName": "Every priced part needs a name.",
+      "laborDescription": "Every labour line with hours or a rate needs a description."
+    }
   },
   "header": {
     "tabs": {
@@ -143,7 +149,8 @@
     "switchToService": "Switch to service pricing",
     "switchToHourlyHint": "Hourly: priced by hours worked (hours × rate/hr). Click to switch.",
     "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Rows without a description are not saved."
   },
   "totals": {
     "title": "Totals",

+ 2 - 1
messages/en/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# customer} other {# customers}} set to it will go back to the default design.",
     "setDefault": "Set as default",
     "defaultSet": "\"{name}\" is now in use",
-    "couldNotSetDefault": "Could not set the default design"
+    "couldNotSetDefault": "Could not set the default design",
+    "fieldFixedSlot": "Always prints in the same place, so it cannot be moved."
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 2 - 2
messages/es/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
+    "switchToServiceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/es/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Atrás",
     "datesExpiredSetToday": "Establecer a hoy",
     "customFieldsInvalid": "Por favor complete todos los campos personalizados obligatorios",
-    "customFieldsSaveFailed": "Error al guardar los campos personalizados"
+    "customFieldsSaveFailed": "Error al guardar los campos personalizados",
+    "problems": {
+      "title": "El trabajo necesita un título antes de poder guardarse.",
+      "negative": "Las cantidades, horas, precios y costes no pueden ser negativos.",
+      "partName": "Toda pieza con precio necesita un nombre.",
+      "laborDescription": "Toda línea de mano de obra con horas o tarifa necesita una descripción."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Por horas: se calcula por las horas trabajadas (horas × tarifa/h). Haz clic para cambiar.",
+    "switchToServiceHint": "Servicio: se cobra como tarifa fija por unidad. Haz clic para cambiar.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Las filas sin descripción no se guardan."
   },
   "totals": {
     "title": "Totales",

+ 2 - 1
messages/es/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# cliente} other {# clientes}} asignados a él volverán al diseño predeterminado.",
     "setDefault": "Establecer como predeterminado",
     "defaultSet": "«{name}» está ahora en uso",
-    "couldNotSetDefault": "No se pudo establecer el diseño predeterminado"
+    "couldNotSetDefault": "No se pudo establecer el diseño predeterminado",
+    "fieldFixedSlot": "Siempre se imprime en el mismo lugar, así que no se puede mover."
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 2 - 2
messages/fr/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
+    "switchToServiceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/fr/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Retour",
     "datesExpiredSetToday": "Définir à aujourd'hui",
     "customFieldsInvalid": "Veuillez remplir tous les champs personnalisés obligatoires",
-    "customFieldsSaveFailed": "Échec de l'enregistrement des champs personnalisés"
+    "customFieldsSaveFailed": "Échec de l'enregistrement des champs personnalisés",
+    "problems": {
+      "title": "Le travail doit avoir un titre avant d'être enregistré.",
+      "negative": "Les quantités, heures, prix et coûts ne peuvent pas être négatifs.",
+      "partName": "Chaque pièce avec un prix doit avoir un nom.",
+      "laborDescription": "Chaque ligne de main-d'œuvre avec des heures ou un taux doit avoir une description."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "À l'heure : facturé selon les heures travaillées (heures × taux/h). Cliquez pour changer.",
+    "switchToServiceHint": "Forfait : facturé au prix fixe par unité. Cliquez pour changer.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Les lignes sans description ne sont pas enregistrées."
   },
   "totals": {
     "title": "Totaux",

+ 2 - 1
messages/fr/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# client} other {# clients}} qui y sont associés reviendront au design par défaut.",
     "setDefault": "Définir par défaut",
     "defaultSet": "« {name} » est maintenant utilisé",
-    "couldNotSetDefault": "Impossible de définir le design par défaut"
+    "couldNotSetDefault": "Impossible de définir le design par défaut",
+    "fieldFixedSlot": "S'imprime toujours au même endroit et ne peut pas être déplacé."
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 2 - 2
messages/it/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
+    "switchToServiceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/it/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Indietro",
     "datesExpiredSetToday": "Imposta a oggi",
     "customFieldsInvalid": "Compila tutti i campi personalizzati obbligatori",
-    "customFieldsSaveFailed": "Impossibile salvare i campi personalizzati"
+    "customFieldsSaveFailed": "Impossibile salvare i campi personalizzati",
+    "problems": {
+      "title": "Il lavoro deve avere un titolo prima di poter essere salvato.",
+      "negative": "Quantità, ore, prezzi e costi non possono essere negativi.",
+      "partName": "Ogni ricambio con un prezzo deve avere un nome.",
+      "laborDescription": "Ogni riga di manodopera con ore o tariffa deve avere una descrizione."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "A ore: calcolato sulle ore lavorate (ore × tariffa/h). Clicca per cambiare.",
+    "switchToServiceHint": "Servizio: addebitato come importo fisso per unità. Clicca per cambiare.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Le righe senza descrizione non vengono salvate."
   },
   "totals": {
     "title": "Totali",

+ 2 - 1
messages/it/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# cliente} other {# clienti}} impostati su di esso torneranno al design predefinito.",
     "setDefault": "Imposta come predefinito",
     "defaultSet": "«{name}» è ora in uso",
-    "couldNotSetDefault": "Impossibile impostare il design predefinito"
+    "couldNotSetDefault": "Impossibile impostare il design predefinito",
+    "fieldFixedSlot": "Viene sempre stampato nello stesso punto, quindi non può essere spostato."
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 2 - 2
messages/lt/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "PSL",
     "switchToHourly": "Perjungti į valandinį įkainį",
     "switchToService": "Perjungti į paslaugos įkainį",
-    "switchToHourlyHint": "Valandinis: kainuojama pagal dirbtų valandų skaičių (valandos × tarifas/val.). Spustelėkite, kad perjungtumėte.",
-    "switchToServiceHint": "Paslaugos: fiksuota kaina už vienetą. Spustelėkite, kad perjungtumėte.",
+    "switchToHourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
+    "switchToServiceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/lt/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Atgal",
     "datesExpiredSetToday": "Nustatyti šiandienos datą",
     "customFieldsInvalid": "Užpildykite visus privalomus pasirinktinius laukus",
-    "customFieldsSaveFailed": "Nepavyko išsaugoti pasirinktinių laukų"
+    "customFieldsSaveFailed": "Nepavyko išsaugoti pasirinktinių laukų",
+    "problems": {
+      "title": "Užsakymui reikia pavadinimo, kad jį būtų galima išsaugoti.",
+      "negative": "Kiekiai, valandos, kainos ir sąnaudos negali būti neigiami.",
+      "partName": "Kiekviena dalis su kaina turi turėti pavadinimą.",
+      "laborDescription": "Kiekviena darbo eilutė su valandomis ar įkainiu turi turėti aprašymą."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "PSL",
     "switchToHourly": "Perjungti į valandinį įkainį",
     "switchToService": "Perjungti į paslaugos įkainį",
-    "switchToHourlyHint": "Valandinis: kainuojama pagal dirbtų valandų skaičių (valandos × tarifas/val.). Spustelėkite, kad perjungtumėte.",
-    "switchToServiceHint": "Paslaugos: fiksuota kaina už vienetą. Spustelėkite, kad perjungtumėte.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Valandinis: skaičiuojama pagal darbo valandas (valandos × įkainis/val.). Spustelėkite, kad pakeistumėte.",
+    "switchToServiceHint": "Paslauga: apmokestinama fiksuota suma už vienetą. Spustelėkite, kad pakeistumėte.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Eilutės be aprašymo neišsaugomos."
   },
   "totals": {
     "title": "Sumos",

+ 2 - 1
messages/lt/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# klientas} few {# klientai} other {# klientų}}, kuriems jis priskirtas, grįš prie numatytojo dizaino.",
     "setDefault": "Nustatyti kaip numatytąjį",
     "defaultSet": "„{name}“ dabar naudojamas",
-    "couldNotSetDefault": "Nepavyko nustatyti numatytojo dizaino"
+    "couldNotSetDefault": "Nepavyko nustatyti numatytojo dizaino",
+    "fieldFixedSlot": "Visada spausdinama toje pačioje vietoje, todėl negalima perkelti."
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 2 - 2
messages/nb/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
+    "switchToServiceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/nb/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Tilbake",
     "datesExpiredSetToday": "Sett til i dag",
     "customFieldsInvalid": "Vennligst fyll ut alle obligatoriske egendefinerte felt",
-    "customFieldsSaveFailed": "Kunne ikke lagre egendefinerte felt"
+    "customFieldsSaveFailed": "Kunne ikke lagre egendefinerte felt",
+    "problems": {
+      "title": "Jobben må ha en tittel før den kan lagres.",
+      "negative": "Antall, timer, priser og kostnader kan ikke være negative.",
+      "partName": "Alle deler med pris må ha et navn.",
+      "laborDescription": "Alle arbeidslinjer med timer eller sats må ha en beskrivelse."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Timepris: beregnes etter arbeidede timer (timer × sats/t). Klikk for å bytte.",
+    "switchToServiceHint": "Fastpris: beregnes som en fast sum per enhet. Klikk for å bytte.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Rader uten beskrivelse lagres ikke."
   },
   "totals": {
     "title": "Totalt",

+ 2 - 1
messages/nb/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# kunde} other {# kunder}} som er satt til det, går tilbake til standarddesignet.",
     "setDefault": "Bruk som standard",
     "defaultSet": "«{name}» er nå i bruk",
-    "couldNotSetDefault": "Kunne ikke sette standarddesignet"
+    "couldNotSetDefault": "Kunne ikke sette standarddesignet",
+    "fieldFixedSlot": "Skrives alltid ut på samme sted, så den kan ikke flyttes."
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 2 - 2
messages/nl/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
+    "switchToServiceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/nl/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Terug",
     "datesExpiredSetToday": "Instellen op vandaag",
     "customFieldsInvalid": "Vul alle verplichte aangepaste velden in",
-    "customFieldsSaveFailed": "Kan aangepaste velden niet opslaan"
+    "customFieldsSaveFailed": "Kan aangepaste velden niet opslaan",
+    "problems": {
+      "title": "De opdracht heeft een titel nodig voordat deze kan worden opgeslagen.",
+      "negative": "Aantallen, uren, prijzen en kosten kunnen niet negatief zijn.",
+      "partName": "Elk onderdeel met een prijs heeft een naam nodig.",
+      "laborDescription": "Elke arbeidsregel met uren of tarief heeft een omschrijving nodig."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Per uur: berekend op gewerkte uren (uren × tarief/uur). Klik om te wisselen.",
+    "switchToServiceHint": "Service: berekend als vast bedrag per eenheid. Klik om te wisselen.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Regels zonder omschrijving worden niet opgeslagen."
   },
   "totals": {
     "title": "Totalen",

+ 2 - 1
messages/nl/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# klant} other {# klanten}} die erop zijn ingesteld, vallen terug op het standaardontwerp.",
     "setDefault": "Als standaard instellen",
     "defaultSet": "\"{name}\" is nu in gebruik",
-    "couldNotSetDefault": "Kon het standaardontwerp niet instellen"
+    "couldNotSetDefault": "Kon het standaardontwerp niet instellen",
+    "fieldFixedSlot": "Wordt altijd op dezelfde plek afgedrukt en kan niet worden verplaatst."
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 2 - 2
messages/pl/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
+    "switchToServiceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/pl/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Wstecz",
     "datesExpiredSetToday": "Ustaw na dziś",
     "customFieldsInvalid": "Proszę wypełnić wszystkie wymagane pola niestandardowe",
-    "customFieldsSaveFailed": "Nie udało się zapisać pól niestandardowych"
+    "customFieldsSaveFailed": "Nie udało się zapisać pól niestandardowych",
+    "problems": {
+      "title": "Zlecenie musi mieć tytuł, zanim będzie można je zapisać.",
+      "negative": "Ilości, godziny, ceny i koszty nie mogą być ujemne.",
+      "partName": "Każda część z ceną musi mieć nazwę.",
+      "laborDescription": "Każda pozycja robocizny z godzinami lub stawką musi mieć opis."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Godzinowo: liczone według przepracowanych godzin (godziny × stawka/godz.). Kliknij, aby zmienić.",
+    "switchToServiceHint": "Usługa: rozliczana jako stała opłata za jednostkę. Kliknij, aby zmienić.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Wiersze bez opisu nie są zapisywane."
   },
   "totals": {
     "title": "Razem",

+ 2 - 1
messages/pl/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# klient} few {# klientów} many {# klientów} other {# klienta}} z tym projektem wróci do projektu domyślnego.",
     "setDefault": "Ustaw jako domyślny",
     "defaultSet": "„{name}” jest teraz w użyciu",
-    "couldNotSetDefault": "Nie udało się ustawić domyślnego projektu"
+    "couldNotSetDefault": "Nie udało się ustawić domyślnego projektu",
+    "fieldFixedSlot": "Zawsze drukuje się w tym samym miejscu, więc nie można go przenieść."
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

+ 2 - 2
messages/pt-BR/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
+    "switchToServiceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

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

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Voltar",
     "datesExpiredSetToday": "Definir para hoje",
     "customFieldsInvalid": "Preencha todos os campos personalizados obrigatórios",
-    "customFieldsSaveFailed": "Falha ao salvar campos personalizados"
+    "customFieldsSaveFailed": "Falha ao salvar campos personalizados",
+    "problems": {
+      "title": "A ordem precisa de um título antes de ser salva.",
+      "negative": "Quantidades, horas, preços e custos não podem ser negativos.",
+      "partName": "Toda peça com preço precisa de um nome.",
+      "laborDescription": "Toda linha de mão de obra com horas ou taxa precisa de uma descrição."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Por hora: calculado pelas horas trabalhadas (horas × taxa/h). Clique para alterar.",
+    "switchToServiceHint": "Serviço: cobrado como valor fixo por unidade. Clique para alterar.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Linhas sem descrição não são salvas."
   },
   "totals": {
     "title": "Totais",

+ 2 - 1
messages/pt-BR/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# cliente} other {# clientes}} definidos com ele voltarão ao design padrão.",
     "setDefault": "Definir como padrão",
     "defaultSet": "\"{name}\" está em uso agora",
-    "couldNotSetDefault": "Não foi possível definir o design padrão"
+    "couldNotSetDefault": "Não foi possível definir o design padrão",
+    "fieldFixedSlot": "Sempre é impresso no mesmo lugar, portanto não pode ser movido."
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

+ 2 - 2
messages/ru/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "усл.",
     "switchToHourly": "Переключить на почасовую оплату",
     "switchToService": "Переключить на оплату услуг",
-    "switchToHourlyHint": "Почасовая: оценка по нормо-часам (часы 00d7 ствка/час). Нажмите для изменения.",
-    "switchToServiceHint": "Услуга: оценка по цене за услугу. Нажмите для изменения.",
+    "switchToHourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
+    "switchToServiceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/ru/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Назад",
     "datesExpiredSetToday": "Установить на сегодня",
     "customFieldsInvalid": "Заполните все обязательные пользовательские поля",
-    "customFieldsSaveFailed": "Не удалось сохранить пользовательские поля"
+    "customFieldsSaveFailed": "Не удалось сохранить пользовательские поля",
+    "problems": {
+      "title": "Заказу нужно название, прежде чем его можно сохранить.",
+      "negative": "Количество, часы, цены и затраты не могут быть отрицательными.",
+      "partName": "У каждой детали с ценой должно быть название.",
+      "laborDescription": "У каждой строки работ с часами или ставкой должно быть описание."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "усл.",
     "switchToHourly": "Изменить на почасовую оплату",
     "switchToService": "Изменить на оплату услуг",
-    "switchToHourlyHint": "Почасовая: оценка по нормо-часам (часы 00d7 ствка/час). Нажмите для изменения.",
-    "switchToServiceHint": "Услуга: оценка по цене за услугу. Нажмите для изменения.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Почасовая: оценка по нормо-часам (часы × ставка/час). Нажмите для изменения.",
+    "switchToServiceHint": "Услуга: фиксированная цена за единицу. Нажмите для изменения.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Строки без описания не сохраняются."
   },
   "totals": {
     "title": "Итоги",

+ 2 - 1
messages/ru/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "{count, plural, one {# клиент} few {# клиента} many {# клиентов} other {# клиента}} с этим дизайном вернутся к дизайну по умолчанию.",
     "setDefault": "Сделать основным",
     "defaultSet": "«{name}» теперь используется",
-    "couldNotSetDefault": "Не удалось установить дизайн по умолчанию"
+    "couldNotSetDefault": "Не удалось установить дизайн по умолчанию",
+    "fieldFixedSlot": "Всегда печатается на одном и том же месте, поэтому переместить нельзя."
   },
   "preview": {
     "title": "Обслуживание тормозов и замена масла",

+ 2 - 2
messages/tr/quotes.json

@@ -97,8 +97,8 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
+    "switchToHourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
+    "switchToServiceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
     "deleteRow": "Delete row",
     "excludeFromTotal": "Exclude from total"
   },

+ 11 - 4
messages/tr/service.json

@@ -16,7 +16,13 @@
     "datesExpiredBack": "Geri",
     "datesExpiredSetToday": "Bugüne ayarla",
     "customFieldsInvalid": "Lütfen tüm zorunlu özel alanları doldurun",
-    "customFieldsSaveFailed": "Özel alanlar kaydedilemedi"
+    "customFieldsSaveFailed": "Özel alanlar kaydedilemedi",
+    "problems": {
+      "title": "İş kaydedilmeden önce bir başlık gerekiyor.",
+      "negative": "Miktarlar, saatler, fiyatlar ve maliyetler negatif olamaz.",
+      "partName": "Fiyatı olan her parçanın bir adı olmalı.",
+      "laborDescription": "Saati veya ücreti olan her işçilik satırının bir açıklaması olmalı."
+    }
   },
   "header": {
     "tabs": {
@@ -141,9 +147,10 @@
     "serviceTag": "SVC",
     "switchToHourly": "Switch to hourly pricing",
     "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Hourly: priced by hours worked (hours 00d7 rate/hr). Click to switch.",
-    "switchToServiceHint": "Service: priced as a flat fee per unit. Click to switch.",
-    "deleteRow": "Delete row"
+    "switchToHourlyHint": "Saatlik: çalışılan saate göre hesaplanır (saat × ücret/saat). Değiştirmek için tıklayın.",
+    "switchToServiceHint": "Hizmet: birim başına sabit ücret olarak hesaplanır. Değiştirmek için tıklayın.",
+    "deleteRow": "Delete row",
+    "descriptionMissingHint": "Açıklaması olmayan satırlar kaydedilmez."
   },
   "totals": {
     "title": "Toplamlar",

+ 2 - 1
messages/tr/settings.json

@@ -1952,7 +1952,8 @@
     "deleteDesignCustomers": "Buna ayarlı {count, plural, one {# müşteri} other {# müşteri}} varsayılan tasarıma döner.",
     "setDefault": "Varsayılan yap",
     "defaultSet": "\"{name}\" artık kullanımda",
-    "couldNotSetDefault": "Varsayılan tasarım ayarlanamadı"
+    "couldNotSetDefault": "Varsayılan tasarım ayarlanamadı",
+    "fieldFixedSlot": "Her zaman aynı yerde yazdırılır, bu yüzden taşınamaz."
   },
   "preview": {
     "title": "Fren bakımı ve yağ değişimi",

Разница между файлами не показана из-за своего большого размера
+ 865 - 0
package-lock.json


+ 9 - 0
package.json

@@ -15,6 +15,8 @@
     "prepare": "next-ws patch --yes && node scripts/patch-next-ws-first-upgrade.mjs",
     "test": "vitest run",
     "test:coverage": "vitest --coverage",
+    "test:e2e": "playwright test",
+    "test:e2e:ui": "playwright test --ui",
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
   },
   "dependencies": {
@@ -88,23 +90,30 @@
   },
   "devDependencies": {
     "@biomejs/biome": "2.3.15",
+    "@playwright/test": "^1.63.0",
     "@tailwindcss/postcss": "^4.3.3",
     "@testing-library/jest-dom": "^6.9.1",
     "@testing-library/react": "^16.3.2",
     "@testing-library/user-event": "^14.6.1",
     "@types/cron": "^2.0.1",
+    "@types/mailparser": "^3.4.6",
     "@types/node": "^20",
     "@types/nodemailer": "^7.0.9",
     "@types/papaparse": "^5.5.2",
     "@types/react": "^19",
     "@types/react-dom": "^19",
+    "@types/smtp-server": "^3.5.13",
     "@types/ws": "^8.18.1",
     "@vitest/coverage-v8": "^4.1.9",
     "jsdom": "^30.0.1",
+    "mailparser": "^3.9.23",
     "shadcn": "^4.16.2",
+    "smtp-server": "^3.19.9",
     "tailwindcss": "^4",
+    "tsx": "^4.23.13",
     "tw-animate-css": "^1.4.0",
     "typescript": "^5",
+    "unpdf": "^1.8.1",
     "vitest": "^4.1.9"
   },
   "overrides": {

+ 134 - 0
playwright.config.ts

@@ -0,0 +1,134 @@
+import { defineConfig, devices } from '@playwright/test'
+
+/**
+ * End-to-end tests: the app in a real browser, against a real Postgres.
+ *
+ * The 190-odd vitest files mock Prisma, so they prove the actions think
+ * correctly and nothing else. These prove the parts that only break when the
+ * pieces are assembled: migrations, the session cookie, server actions wired to
+ * forms, and the invoice numbering that customers actually see.
+ *
+ * Deliberately serial against one seeded database. Parallel workers sharing a
+ * quote counter would fail on each other's numbers rather than on real bugs.
+ */
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+const port = new URL(baseURL).port || '3100'
+
+/** The database the harness is allowed to destroy. Never the dev one. */
+const databaseUrl = process.env.E2E_DATABASE_URL ?? ''
+
+/** Where the mail sink listens: SMTP for the app, HTTP for the specs. */
+const smtpPort = process.env.E2E_SMTP_PORT ?? '1025'
+const mailApiPort = process.env.E2E_MAIL_API_PORT ?? '8025'
+
+/**
+ * A mail server that delivers nothing, so the specs can read what the app
+ * posted. Started whether or not the suite starts the app: pointed at a
+ * server somebody else launched, that server is told to send here too.
+ */
+const mailSink = {
+  command: 'npx tsx e2e/mail-sink.ts',
+  url: `http://127.0.0.1:${mailApiPort}/health`,
+  reuseExistingServer: !process.env.CI,
+  timeout: 60_000,
+  stdout: 'pipe' as const,
+  stderr: 'pipe' as const,
+  env: { E2E_SMTP_PORT: smtpPort, E2E_MAIL_API_PORT: mailApiPort },
+}
+
+export default defineConfig({
+  testDir: './e2e',
+  globalSetup: './e2e/global-setup.ts',
+  timeout: 60_000,
+  expect: { timeout: 10_000 },
+  fullyParallel: false,
+  workers: 1,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 1 : 0,
+  reporter: process.env.CI
+    ? [['github'], ['html', { open: 'never' }]]
+    : [['list'], ['html', { open: 'never' }]],
+
+  use: {
+    baseURL,
+    // Pinned, because selectors read visible text and the app speaks twelve
+    // languages. The locale cookie is set alongside this in auth.setup.ts.
+    locale: 'en-US',
+    extraHTTPHeaders: { 'accept-language': 'en' },
+    // Pinned for the same reason invoice dates are: a floating timezone turns
+    // a date assertion into a coin toss either side of midnight.
+    timezoneId: process.env.E2E_TZ ?? 'Europe/Oslo',
+    trace: 'retain-on-failure',
+    screenshot: 'only-on-failure',
+    video: 'retain-on-failure',
+  },
+
+  projects: [
+    { name: 'setup', testMatch: /auth\.setup\.ts/ },
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/owner.json' },
+      dependencies: ['setup'],
+    },
+  ],
+
+  /**
+   * The mail sink always; the app only when E2E_BASE_URL is unset, so pointing
+   * the suite at a running container (or a staging host) is a matter of
+   * setting one variable.
+   *
+   * `next start` and not `next dev`: the dev server compiles routes on first
+   * visit, which turns the first assertion in every spec into a timeout race,
+   * and it is not the artifact that ships anyway.
+   */
+  webServer: process.env.E2E_BASE_URL
+    ? [mailSink]
+    : [
+        mailSink,
+        {
+          // The database first, then the server, in one command: Playwright
+          // starts this before global setup, and a server on an empty schema
+          // fails the readiness check on every page.
+          command: `npx tsx e2e/prepare-db.ts && npm run start -- --port ${port}`,
+          url: baseURL,
+          reuseExistingServer: !process.env.CI,
+          // Sixty-odd migrations, the seed and its vehicle photos, then the
+          // server: a cold CI runner needs longer than a warm laptop.
+          timeout: process.env.CI ? 420_000 : 180_000,
+          stdout: 'pipe',
+          stderr: 'pipe',
+          env: {
+            E2E_DATABASE_URL: databaseUrl,
+            DATABASE_URL: databaseUrl,
+            NEXT_PUBLIC_APP_URL: baseURL,
+            // Long and random enough that better-auth does not spend the run
+            // warning about it. Throwaway: it signs sessions for a database
+            // the harness resets, and CI generates its own per run.
+            BETTER_AUTH_SECRET:
+              process.env.BETTER_AUTH_SECRET ?? 'k3Qb8vZ1hN7pXtR2yJm5Ls9CwD4gFa6UeH0iOoT+PbY=',
+            // The schedulers would otherwise tick through the run, writing to
+            // the rows the specs are asserting on.
+            DISABLE_BACKGROUND_JOBS: '1',
+            // Demo mode blocks invites, billing and outbound messages. Tests want
+            // the real behaviour, so it stays off.
+            DEMO_MODE: 'false',
+            // Three sign-ins per ten seconds is right for a workshop and wrong
+            // for a suite that signs in on every test.
+            AUTH_RATE_LIMIT: 'off',
+            // `next start` also reads the developer's .env, which may say cloud.
+            // Self-hosted unlocks every feature, which is what a suite that
+            // exercises them needs; plan gates are a subject of their own.
+            TORQVOICE_MODE: 'self-hosted',
+            TZ: process.env.E2E_TZ ?? 'Europe/Oslo',
+            // Mail goes to the sink instead of a provider. The app's SMTP
+            // settings fall back to these when nothing is configured in the
+            // database, which is how the seeded workshop is left.
+            SMTP_HOST: '127.0.0.1',
+            SMTP_PORT: smtpPort,
+            SMTP_FROM_EMAIL: 'workshop@e2e.test',
+            SMTP_SECURE: 'false',
+          },
+        },
+      ],
+})

+ 36 - 30
prisma/seed_dummy_data.ts

@@ -268,6 +268,11 @@ async function seed() {
   await provisionDemoAccount();
   await cleanup();
 
+  // Every row the app scopes by organisation needs the column set, not just a
+  // parent that has it: work orders and reminders were created without it and
+  // the app, which reads `where: { organizationId }`, answered "Service record
+  // not found" for all 101 seeded jobs and listed none of the 28 reminders.
+
   // Populate vehicle images: prefer bundled assets → fall back to cached copy
   // in data volume → fall back to live download.
   console.log("Populating vehicle images...");
@@ -468,7 +473,7 @@ async function seed() {
   const serviceRecords = [];
   for (const sr of svcData) {
     const { partItems, laborItems, ...data } = sr;
-    const record = await prisma.serviceRecord.create({ data: { ...data, cost: data.totalAmount, partItems: { create: partItems }, laborItems: { create: laborItems } } });
+    const record = await prisma.serviceRecord.create({ data: { ...data, organizationId: ORG_ID, cost: data.totalAmount, partItems: { create: partItems }, laborItems: { create: laborItems } } });
     serviceRecords.push(record);
   }
   console.log(`  Created ${serviceRecords.length} service records`);
@@ -591,14 +596,14 @@ async function seed() {
   // -- Reminders --
   console.log("\nCreating reminders...");
   await Promise.all([
-    prisma.reminder.create({ data: { vehicleId: vehicles[0].id, title: "Next Oil Change", description: "Due at 25,000 mi or March 2026", dueDate: new Date("2026-03-15"), dueMileage: 25000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[4].id, title: "Brake Fluid Flush", description: "BMW recommends every 2 years", dueDate: new Date("2026-06-01") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[17].id, title: "Annual DOT Inspection", description: "Kenworth T680 - annual inspection", dueDate: new Date("2026-02-28") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[29].id, title: "Track Tension Check", description: "CAT D6 - check after 100 hours", dueMileage: 4500 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[22].id, title: "1000hr Service", description: "John Deere 6R 250 - scheduled service", dueMileage: 3500 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[23].id, title: "Spring Planting Prep", description: "Fendt 942 - full check before spring", dueDate: new Date("2026-04-01") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[18].id, title: "Volvo FH 640 - Brake Inspection", description: "Check brakes before next long-haul", dueDate: new Date("2026-03-10") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[13].id, title: "Porsche 911 - Track Day Prep", description: "Brake pads, fluid, tire pressure check before April track day", dueDate: new Date("2026-04-10") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[0].id, title: "Next Oil Change", description: "Due at 25,000 mi or March 2026", dueDate: new Date("2026-03-15"), dueMileage: 25000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[4].id, title: "Brake Fluid Flush", description: "BMW recommends every 2 years", dueDate: new Date("2026-06-01") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[17].id, title: "Annual DOT Inspection", description: "Kenworth T680 - annual inspection", dueDate: new Date("2026-02-28") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[29].id, title: "Track Tension Check", description: "CAT D6 - check after 100 hours", dueMileage: 4500 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[22].id, title: "1000hr Service", description: "John Deere 6R 250 - scheduled service", dueMileage: 3500 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[23].id, title: "Spring Planting Prep", description: "Fendt 942 - full check before spring", dueDate: new Date("2026-04-01") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[18].id, title: "Volvo FH 640 - Brake Inspection", description: "Check brakes before next long-haul", dueDate: new Date("2026-03-10") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[13].id, title: "Porsche 911 - Track Day Prep", description: "Brake pads, fluid, tire pressure check before April track day", dueDate: new Date("2026-04-10") } }),
   ]);
   console.log("  Created 8 reminders");
 
@@ -657,26 +662,26 @@ async function seed() {
   // -- Additional reminders --
   console.log("\nCreating additional reminders...");
   const additionalReminders = await Promise.all([
-    prisma.reminder.create({ data: { vehicleId: vehicles[1].id, title: "Timing belt replacement", description: "Interval-based - EcoBoost timing chain inspection due", dueMileage: 60000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[2].id, title: "Annual inspection", description: "Minnesota state safety inspection", dueDate: new Date("2026-05-15") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[5].id, title: "Brake fluid flush", description: "Honda recommends every 3 years", dueMileage: 72000, dueDate: new Date("2026-06-01") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[8].id, title: "Registration renewal", description: "Texas registration expires June 2026", dueDate: new Date("2026-06-30") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[9].id, title: "Next oil change", description: "Sprinter service B interval", dueMileage: 40000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[10].id, title: "Haldex service", description: "Quattro rear diff fluid change - 40K interval", dueMileage: 40000, isCompleted: true } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[11].id, title: "Front diff fluid", description: "After lift kit, recommend fluid change at 35K", dueMileage: 35000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[12].id, title: "Annual inspection", description: "Georgia annual safety inspection", dueDate: new Date("2026-08-22") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[14].id, title: "DSG service interval", description: "Next DSG fluid/filter at 78K", dueMileage: 78000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[17].id, title: "CVT fluid check", description: "Subaru CVT recommended drain/fill", dueMileage: 30000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[20].id, title: "Mixer drum inspection", description: "Annual drum wear/bolt check", dueDate: new Date("2026-07-01") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[25].id, title: "250hr service", description: "John Deere 8R scheduled interval", dueMileage: 1000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[28].id, title: "Pre-harvest prep", description: "X9 1100 combine full inspection before harvest", dueDate: new Date("2026-08-15") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[30].id, title: "Blade edge replacement", description: "Check wear on cutting edge", dueMileage: 6500, isCompleted: true } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[32].id, title: "Undercarriage inspection", description: "2000hr interval for track chain inspection", dueMileage: 2000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[36].id, title: "OSHA crane certification", description: "Annual load test and certification due", dueDate: new Date("2026-05-01") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[38].id, title: "Corvette annual service", description: "GM recommended annual service with performance inspection", dueDate: new Date("2027-01-22") } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[39].id, title: "Transfer case fluid", description: "Land Rover recommends 40K interval for transfer case fluid", dueMileage: 40000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[40].id, title: "Transmission fluid change", description: "8-speed auto - ZF recommends fluid change at 50K", dueMileage: 50000 } }),
-    prisma.reminder.create({ data: { vehicleId: vehicles[41].id, title: "Timing belt inspection", description: "FA24 engine - inspect timing chain tensioner at 60K", dueMileage: 60000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[1].id, title: "Timing belt replacement", description: "Interval-based - EcoBoost timing chain inspection due", dueMileage: 60000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[2].id, title: "Annual inspection", description: "Minnesota state safety inspection", dueDate: new Date("2026-05-15") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[5].id, title: "Brake fluid flush", description: "Honda recommends every 3 years", dueMileage: 72000, dueDate: new Date("2026-06-01") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[8].id, title: "Registration renewal", description: "Texas registration expires June 2026", dueDate: new Date("2026-06-30") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[9].id, title: "Next oil change", description: "Sprinter service B interval", dueMileage: 40000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[10].id, title: "Haldex service", description: "Quattro rear diff fluid change - 40K interval", dueMileage: 40000, isCompleted: true } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[11].id, title: "Front diff fluid", description: "After lift kit, recommend fluid change at 35K", dueMileage: 35000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[12].id, title: "Annual inspection", description: "Georgia annual safety inspection", dueDate: new Date("2026-08-22") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[14].id, title: "DSG service interval", description: "Next DSG fluid/filter at 78K", dueMileage: 78000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[17].id, title: "CVT fluid check", description: "Subaru CVT recommended drain/fill", dueMileage: 30000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[20].id, title: "Mixer drum inspection", description: "Annual drum wear/bolt check", dueDate: new Date("2026-07-01") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[25].id, title: "250hr service", description: "John Deere 8R scheduled interval", dueMileage: 1000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[28].id, title: "Pre-harvest prep", description: "X9 1100 combine full inspection before harvest", dueDate: new Date("2026-08-15") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[30].id, title: "Blade edge replacement", description: "Check wear on cutting edge", dueMileage: 6500, isCompleted: true } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[32].id, title: "Undercarriage inspection", description: "2000hr interval for track chain inspection", dueMileage: 2000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[36].id, title: "OSHA crane certification", description: "Annual load test and certification due", dueDate: new Date("2026-05-01") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[38].id, title: "Corvette annual service", description: "GM recommended annual service with performance inspection", dueDate: new Date("2027-01-22") } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[39].id, title: "Transfer case fluid", description: "Land Rover recommends 40K interval for transfer case fluid", dueMileage: 40000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[40].id, title: "Transmission fluid change", description: "8-speed auto - ZF recommends fluid change at 50K", dueMileage: 50000 } }),
+    prisma.reminder.create({ data: { organizationId: ORG_ID, vehicleId: vehicles[41].id, title: "Timing belt inspection", description: "FA24 engine - inspect timing chain tensioner at 60K", dueMileage: 60000 } }),
   ]);
   console.log(`  Created ${additionalReminders.length} additional reminders`);
 
@@ -777,6 +782,7 @@ async function seed() {
       serviceDate.setDate(serviceDate.getDate() + entry.offsetDays);
       const rec = await prisma.serviceRecord.create({
         data: {
+          organizationId: ORG_ID,
           vehicleId: hist.vehicleId,
           title: entry.title,
           description: entry.desc,
@@ -892,7 +898,7 @@ async function seed() {
   const sr = (base: Record<string, unknown>, parts: { name: string; partNumber: string; quantity: number; unitPrice: number; total: number }[], labor: { description: string; hours: number; rate: number; total: number }[], notes?: string) => {
     const subtotal = parts.reduce((s, p) => s + p.total, 0) + labor.reduce((s, l) => s + l.total, 0);
     const taxAmount = Math.round(subtotal * 0.08 * 100) / 100;
-    return prisma.serviceRecord.create({ data: { ...base, subtotal, taxRate: 8, taxAmount, totalAmount: subtotal + taxAmount, cost: subtotal + taxAmount, diagnosticNotes: notes || null, partItems: { create: parts }, laborItems: { create: labor } } as never });
+    return prisma.serviceRecord.create({ data: { ...base, organizationId: ORG_ID, subtotal, taxRate: 8, taxAmount, totalAmount: subtotal + taxAmount, cost: subtotal + taxAmount, diagnosticNotes: notes || null, partItems: { create: parts }, laborItems: { create: labor } } as never });
   };
 
   // Create service records for the board (assigned ones)

+ 234 - 0
src/__tests__/features/invoice-designer/every-block.test.ts

@@ -0,0 +1,234 @@
+/**
+ * Every block on the sheet, and whether it does what the designer says it
+ * will.
+ *
+ * The designer offers the same handful of switches for eighteen sections, so
+ * the risk is not that one switch is wrong: it is that one *section* ignores
+ * the switch every other section obeys. A section that quietly prints while
+ * hidden puts a customer's address on a sheet somebody took it off, and a
+ * heading that will not go away is the kind of thing nobody notices until a
+ * workshop asks why. So the visibility rule is asserted for all of them by
+ * name, from the list the designer itself reads.
+ */
+import { describe, expect, it } from 'vitest'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import { themeOf } from '@/features/invoice-designer/Components/designTheme'
+import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
+import {
+  BUILTIN_SECTIONS,
+  type InvoiceLayoutConfig,
+  type InvoiceSection,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const sample = () =>
+  buildSampleData(
+    {
+      name: 'Shop',
+      address: 'A road',
+      phone: '555',
+      email: 'shop@example.com',
+      logoUrl: null,
+    } as any,
+    [],
+    ((key: string) => key) as any,
+    {},
+    'invoice'
+  )
+
+/** The sheet a layout prints, with the given sections patched. */
+function specWith(patch: (section: InvoiceSection) => Partial<InvoiceSection> | undefined) {
+  const layout = mergeWithDefaults({}) as InvoiceLayoutConfig
+  layout.sections = layout.sections.map((section) => ({ ...section, ...patch(section) }))
+  return buildDocumentSpec(layout, themeOf({} as any, layout), sample()) as any
+}
+
+const printed = (spec: any): string[] => spec.blocks.map((block: any) => block.content?.id)
+
+function blockOf(spec: any, id: string) {
+  return spec.blocks.find((block: any) => block.content?.id === id)?.content
+}
+
+/** Every string a block prints, in order. */
+function textsOf(node: any): string[] {
+  const out: string[] = []
+  const walk = (n: any) => {
+    if (!n || typeof n !== 'object') return
+    if (n.kind === 'text' && n.text) out.push(String(n.text))
+    for (const child of n.children ?? []) walk(child.node ?? child)
+  }
+  walk(node)
+  return out
+}
+
+const DEFAULT_SHEET = printed(specWith(() => undefined))
+
+describe('the sections a default sheet prints', () => {
+  it('is the list the designer shows, minus the ones off by default', () => {
+    // If this list changes, the tests below are testing something else.
+    expect(DEFAULT_SHEET).toEqual([
+      'header',
+      'document_title',
+      'slogan',
+      'customer',
+      'vehicle',
+      'service',
+      'parts_table',
+      'labor_table',
+      'findings',
+      'totals',
+      'notes',
+      'attached_documents',
+      'warranty',
+      'bank_account',
+      'footer',
+    ])
+  })
+
+  it.each(DEFAULT_SHEET)('hides %s when its switch is off, and nothing else', (id) => {
+    const spec = specWith((section) => (section.id === id ? { visible: false } : undefined))
+    expect(printed(spec)).not.toContain(id)
+    expect(printed(spec)).toEqual(DEFAULT_SHEET.filter((other) => other !== id))
+  })
+
+  it('accounts for every section the designer offers', () => {
+    // The guard on the two lists above: a section added to the schema
+    // tomorrow is either on the default sheet, and so covered by the
+    // hides-it test, or one of the three known to start off. Neither, and
+    // this fails rather than quietly leaving a block untested.
+    const offByDefault = ['items_table', 'telegram_qr', 'general']
+    expect([...DEFAULT_SHEET, ...offByDefault].sort()).toEqual(
+      BUILTIN_SECTIONS.map((section) => section.id).sort()
+    )
+  })
+
+  it('brings the combined items table back when it is switched on', () => {
+    // Off by default because the parts and labour tables cover the same
+    // ground; the designer warns that they are exclusive.
+    expect(DEFAULT_SHEET).not.toContain('items_table')
+    const spec = specWith((section) =>
+      section.id === 'items_table' ? { visible: true } : undefined
+    )
+    expect(printed(spec)).toContain('items_table')
+  })
+
+  it.each(['telegram_qr', 'general'])('leaves %s off the sheet with nothing to print', (id) => {
+    // Visible and empty is a real state — a QR code with no bot connected, a
+    // custom-fields block with no fields — and the designer's rail says
+    // "empty" beside it rather than printing a blank panel.
+    const spec = specWith((section) => (section.id === id ? { visible: true } : undefined))
+    expect(printed(spec)).not.toContain(id)
+  })
+})
+
+describe('the switches a section carries', () => {
+  it('drops the heading and keeps what is under it', () => {
+    const on = textsOf(
+      blockOf(
+        specWith(() => undefined),
+        'customer'
+      )
+    )
+    expect(on[0]).toBe('Bill To')
+
+    const off = textsOf(
+      blockOf(
+        specWith((section) => (section.id === 'customer' ? { heading: false } : undefined)),
+        'customer'
+      )
+    )
+    expect(off).not.toContain('Bill To')
+    // The customer is still billed; only the words over the panel are gone.
+    expect(off[0]).toBe(on[1])
+  })
+
+  it('prints the name a workshop gives the document in place of its own', () => {
+    // A business registered for GST has to head the sheet "Tax Invoice"; one
+    // that is not registered must not. It cannot wait for a translation.
+    const spec = specWith((section) =>
+      section.id === 'document_title' ? { text: 'TAX INVOICE' } : undefined
+    )
+    const title = textsOf(blockOf(spec, 'document_title'))
+    expect(title[0]).toBe('TAX INVOICE')
+    expect(title).not.toContain('INVOICE')
+  })
+
+  it('takes the panel away from a section told not to draw one', () => {
+    const boxed = blockOf(
+      specWith(() => undefined),
+      'customer'
+    )
+    expect(boxed.style?.background).toBeTruthy()
+    expect(boxed.style?.borderWidth).toBeGreaterThan(0)
+
+    const bare = blockOf(
+      specWith((section) => (section.id === 'customer' ? { boxed: false } : undefined)),
+      'customer'
+    )
+    expect(bare.style?.background).toBeFalsy()
+    expect(bare.style?.borderWidth).toBe(0)
+  })
+
+  it('moves a section where its order puts it', () => {
+    // The rail is a running order: dragging a section up has to move it on
+    // the sheet, not only in the list.
+    const spec = specWith((section) => (section.id === 'notes' ? { order: 0 } : undefined))
+    const order = printed(spec)
+    expect(order.indexOf('notes')).toBeLessThan(order.indexOf('document_title'))
+  })
+
+  it('pairs two sections into one row when both are given a side', () => {
+    const spec = specWith((section) =>
+      section.id === 'notes'
+        ? { column: 'right' }
+        : section.id === 'warranty'
+          ? { column: 'left' }
+          : undefined
+    )
+    const notes = spec.blocks.find((b: any) => b.content?.id === 'notes')
+    const warranty = spec.blocks.find((b: any) => b.content?.id === 'warranty')
+    expect(notes.placement.column).toBe('right')
+    expect(warranty.placement.column).toBe('left')
+  })
+
+  it('lifts a section out of the flow when it is placed by hand', () => {
+    const layout = mergeWithDefaults({}) as InvoiceLayoutConfig
+    layout.anchors = { totals: { x: 300, y: 500, width: 200 } }
+    const spec = buildDocumentSpec(layout, themeOf({} as any, layout), sample()) as any
+    const totals = spec.blocks.find((b: any) => b.content?.id === 'totals')
+    expect(totals.placement.mode).toBe('anchored')
+    expect(totals.placement.anchor).toMatchObject({ x: 300, y: 500, width: 200 })
+  })
+})
+
+describe('the fields inside a section', () => {
+  const customerFieldsOff = (offId: string) => {
+    const layout = mergeWithDefaults({}) as InvoiceLayoutConfig
+    layout.sections = layout.sections.map((section) =>
+      section.id === 'customer'
+        ? {
+            ...section,
+            fields: (section.fields ?? []).map((field) =>
+              field.id === offId ? { ...field, visible: false } : field
+            ),
+          }
+        : section
+    )
+    return textsOf(
+      blockOf(buildDocumentSpec(layout, themeOf({} as any, layout), sample()) as any, 'customer')
+    )
+  }
+
+  it('drops the one line switched off and keeps its neighbours', () => {
+    const all = customerFieldsOff('nothing_is_off')
+    expect(all).toContain('alex@example.com')
+    expect(all).toContain('+1 555 0134')
+
+    const withoutEmail = customerFieldsOff('customer_email')
+    expect(withoutEmail).not.toContain('alex@example.com')
+    expect(withoutEmail).toContain('+1 555 0134')
+    expect(withoutEmail).toContain('Alex Carter')
+  })
+})

+ 114 - 0
src/__tests__/features/invoice-designer/field-order.test.ts

@@ -3,6 +3,11 @@
  * Two blocks used to read it as a set and print their rows in the order they
  * happened to build them, so dragging a row in the inspector moved nothing on
  * the sheet.
+ *
+ * Those two are pinned in detail below. The first suite asks the same question
+ * of every section that has a field list, because "dragging does nothing" is
+ * a bug that lives in one block at a time: it was fixed in the payment panel
+ * and the title strip, and nothing was keeping the other five honest.
  */
 import { describe, expect, it } from 'vitest'
 import { buildSampleData } from '@/features/invoice-designer/Components/sample'
@@ -110,3 +115,112 @@ describe('document title strip order', () => {
     expect(printed.indexOf('Date')).toBeLessThan(printed.indexOf('Due'))
   })
 })
+
+/**
+ * Every section whose fields the inspector lets a workshop drag.
+ *
+ * One named pair per section, because a section's list is not always one run:
+ * the header joins its details into a single line, the title strip draws its
+ * own word above the cells, and the footer prints its note under everything.
+ * The pair is named; what the pair prints is discovered, so the test does not
+ * depend on the sample data's wording.
+ */
+describe('every section with a field list follows its order', () => {
+  const sectionsWithFields = mergeWithDefaults({}).sections.filter(
+    (section) => (section.fields ?? []).length > 1
+  )
+
+  /**
+   * A workshop with its details filled in. The sample above deliberately
+   * leaves them blank, and a header with no address, telephone or address to
+   * print says nothing about the order it would print them in.
+   */
+  const filled = () =>
+    buildSampleData(
+      {
+        name: 'Shop',
+        address: 'A road',
+        phone: '555',
+        email: 'shop@example.com',
+        logoUrl: null,
+      } as any,
+      [],
+      ((key: string) => key) as any,
+      {},
+      'invoice'
+    )
+
+  const specOf = (sectionId: string, fields: InvoiceFieldConfig[]) => {
+    const layout = mergeWithDefaults({})
+    layout.sections = layout.sections.map((section) =>
+      section.id === sectionId ? { ...section, fields } : section
+    )
+    return buildDocumentSpec(layout, themeOf({} as any, layout), filled())
+  }
+
+  /** Two fields of each section that share one running order. */
+  const ORDERED_PAIRS: Record<string, [string, string]> = {
+    header: ['company_address', 'company_phone'],
+    document_title: ['invoice_number', 'date'],
+    customer: ['customer_name', 'customer_company'],
+    vehicle: ['vehicle_name', 'vin'],
+    service: ['service_title', 'service_type'],
+    bank_account: ['bank_account', 'org_number'],
+    footer: ['company_name', 'company_address'],
+  }
+
+  it('has a pair named for every section that offers a list', () => {
+    // A section that gains a field list has to be given a pair here, rather
+    // than quietly going untested.
+    expect(sectionsWithFields.map((section) => section.id).sort()).toEqual(
+      Object.keys(ORDERED_PAIRS).sort()
+    )
+  })
+
+  /** Everything a section prints, as one string: some sections join their rows. */
+  const printedRun = (sectionId: string, order: string[]) =>
+    texts(
+      blockContent(
+        specOf(
+          sectionId,
+          order.map((id) => ({ id, visible: true }))
+        ),
+        sectionId
+      )
+    ).join('\u0000')
+
+  for (const section of sectionsWithFields) {
+    it(`moves what ${section.id} prints when two of its rows swap`, () => {
+      const all = (section.fields ?? []).map((field) => field.id)
+      const [a, b] = ORDERED_PAIRS[section.id]
+
+      // What each of the two prints, found by leaving the other one out: a
+      // section with a single field switched on can collapse to nothing,
+      // which tells us about neither.
+      const without = (id: string) =>
+        texts(
+          blockContent(
+            specOf(
+              section.id,
+              all.map((field) => ({ id: field, visible: field !== id }))
+            ),
+            section.id
+          )
+        )
+      const withoutB = without(b)
+      const withoutA = without(a)
+      const aWord = withoutB.find((text) => !withoutA.includes(text)) as string
+      const bWord = withoutA.find((text) => !withoutB.includes(text)) as string
+      expect(aWord, `${a} prints something of its own`).toBeTruthy()
+      expect(bWord, `${b} prints something of its own`).toBeTruthy()
+
+      const rest = all.filter((id) => id !== a && id !== b)
+      const asListed = printedRun(section.id, [a, b, ...rest])
+      const swapped = printedRun(section.id, [b, a, ...rest])
+
+      expect(asListed.indexOf(aWord)).toBeLessThan(asListed.indexOf(bWord))
+      // Dragged past each other in the inspector, they print the other way round.
+      expect(swapped.indexOf(bWord)).toBeLessThan(swapped.indexOf(aWord))
+    })
+  }
+})

+ 116 - 0
src/__tests__/features/invoice-designer/fixed-slot-fields.test.ts

@@ -0,0 +1,116 @@
+/**
+ * The inspector offers a drag exactly where the sheet honours one.
+ *
+ * Most of a section's fields print in the order the list is in, and dragging a
+ * row moves it. A few do not: the company name sits above the header's
+ * details and the logo in its corner, the word the document calls itself is
+ * drawn over the title strip rather than among its cells, and the footer
+ * prints its portal line first and its closing note last. Dragging those did
+ * nothing whatever, which reads as a broken editor rather than as a slot that
+ * is fixed on purpose, so the inspector no longer offers it.
+ *
+ * Which fields those are is worked out here rather than trusted: every field
+ * of every section is printed first and then last, and one that lands in the
+ * same place either way is a fixed slot. `FIXED_SLOT_FIELDS` has to name
+ * exactly that set — if the sheet changes its mind about a field, this fails
+ * and the inspector gets corrected with it.
+ */
+import { describe, expect, it } from 'vitest'
+import { themeOf } from '@/features/invoice-designer/Components/designTheme'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
+import {
+  FIXED_SLOT_FIELDS,
+  fieldHasFixedSlot,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * A workshop with every detail filled in. A field with nothing behind it
+ * prints nothing, and a field that prints nothing cannot be seen to move —
+ * it would be mistaken for a fixed slot.
+ */
+const sample = () =>
+  buildSampleData(
+    {
+      name: 'Shop',
+      address: 'A road',
+      phone: '555',
+      email: 'shop@example.com',
+      slogan: 'Slogan',
+      orgNumber: '123 456 789',
+      paymentTerms: 'Net 14',
+      logoUrl: '/logo.png',
+    } as any,
+    [],
+    ((key: string) => key) as any,
+    {},
+    'invoice'
+  )
+
+function printedWith(sectionId: string, order: string[]): string {
+  const layout = mergeWithDefaults({})
+  layout.sections = layout.sections.map((section) =>
+    section.id === sectionId
+      ? { ...section, fields: order.map((id) => ({ id, visible: true })) }
+      : section
+  )
+  const spec = buildDocumentSpec(layout, themeOf({} as any, layout), sample()) as any
+  const content = spec.blocks.find((block: any) => block.content?.id === sectionId)?.content
+
+  // Images count too: a logo prints no words and still has a place.
+  const out: string[] = []
+  const walk = (node: any) => {
+    if (!node || typeof node !== 'object') return
+    if (node.kind === 'text' && node.text) out.push(String(node.text))
+    if (node.kind === 'image') out.push('[image]')
+    for (const child of node.children ?? []) walk(child.node ?? child)
+  }
+  walk(content)
+  return out.join(' | ')
+}
+
+/** Fields whose position the sheet ignores, found by moving each one. */
+function fixedSlotsOf(sectionId: string, fields: string[]): string[] {
+  return fields.filter((field) => {
+    const rest = fields.filter((id) => id !== field)
+    return printedWith(sectionId, [field, ...rest]) === printedWith(sectionId, [...rest, field])
+  })
+}
+
+const sectionsWithFields = mergeWithDefaults({}).sections.filter(
+  (section) => (section.fields ?? []).length > 1
+)
+
+describe('fields that print in a place of their own', () => {
+  it.each(
+    sectionsWithFields.map((section) => section.id)
+  )('%s names exactly the fields that ignore their position', (sectionId) => {
+    const section = sectionsWithFields.find((candidate) => candidate.id === sectionId)
+    const fields = (section?.fields ?? []).map((field) => field.id)
+    expect(fixedSlotsOf(sectionId, fields).sort()).toEqual(
+      [...(FIXED_SLOT_FIELDS[sectionId] ?? [])].sort()
+    )
+  })
+
+  it('is the six the sheet actually has', () => {
+    // Named, so that gaining or losing one is a decision rather than a drift.
+    expect(FIXED_SLOT_FIELDS).toEqual({
+      header: ['logo', 'company_name'],
+      document_title: ['title'],
+      footer: ['footer_note', 'portal_link', 'logo'],
+    })
+  })
+
+  it('answers for one field at a time', () => {
+    expect(fieldHasFixedSlot('footer', 'footer_note')).toBe(true)
+    expect(fieldHasFixedSlot('footer', 'company_name')).toBe(false)
+    // The same field can be fixed in one section and free in another: the
+    // header's name is drawn above its details, the footer's is one of a run.
+    expect(fieldHasFixedSlot('header', 'company_name')).toBe(true)
+    expect(fieldHasFixedSlot('customer', 'customer_name')).toBe(false)
+    expect(fieldHasFixedSlot('vehicle', 'anything_at_all')).toBe(false)
+  })
+})

+ 72 - 0
src/__tests__/features/invoice-designer/multiline-line-items.test.ts

@@ -0,0 +1,72 @@
+/**
+ * A part described over several lines makes its table row taller, and the
+ * estimate the PDF lays the page out with has to know that.
+ *
+ * The row height used to be the whole cell's text width divided by the column
+ * width, which counts three short lines as one. The table then measured
+ * shorter than it printed, and whatever the layout engine placed after it —
+ * the totals, the notes, the footer — was put where the last rows would end
+ * up. The counterpart for a customer address is in multiline-address.test.ts.
+ */
+import { describe, expect, it } from 'vitest'
+import { estimateBlockHeights } from '@/features/invoice-designer/Pdf/estimateHeights'
+import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/** One table block on an A4 page, holding a single row with the given description. */
+function specWithDescription(desc: string): DocumentSpec {
+  return {
+    // A4 in points, as the spec builder writes it. Without a width the
+    // content width is NaN and no wrap is ever detected.
+    page: {
+      width: 595,
+      height: 842,
+      margin: { top: 40, right: 40, bottom: 40, left: 40 },
+      fontFamily: 'Helvetica',
+      fontSize: 9,
+    },
+    frame: undefined,
+    blocks: [
+      {
+        id: 'items',
+        placement: { mode: 'flow', order: 1 },
+        content: {
+          id: 'items',
+          kind: 'table',
+          columns: [
+            { key: 'desc', width: 'flex', align: 'left' },
+            { key: 'total', width: 60, align: 'right' },
+          ],
+          rows: [{ desc, total: '1 450.00' }],
+          rowPadding: 5,
+        },
+      },
+    ],
+  } as unknown as DocumentSpec
+}
+
+const heightOf = (desc: string) => estimateBlockHeights(specWithDescription(desc)).get('items') ?? 0
+
+describe('a line item described over several lines', () => {
+  it('is taller than the same text on one line', () => {
+    const oneLine = heightOf('Timing belt kit')
+    const threeLines = heightOf('Timing belt kit\nGates K015603XS\nincludes tensioner')
+    expect(threeLines).toBeGreaterThan(oneLine)
+  })
+
+  it('grows by roughly a line per break, not by a fraction', () => {
+    const one = heightOf('Timing belt kit')
+    const two = heightOf('Timing belt kit\nGates K015603XS')
+    const three = heightOf('Timing belt kit\nGates K015603XS\nincludes tensioner')
+    // Each break adds the same line, so the two steps are the same size.
+    expect(three - two).toBeCloseTo(two - one, 5)
+    expect(two - one).toBeGreaterThan(9)
+  })
+
+  it('counts a run that has to wrap on its own as well', () => {
+    // No breaks typed: the estimate still has to see more than one line.
+    const long = heightOf('Harbour Road '.repeat(20))
+    expect(long).toBeGreaterThan(heightOf('Harbour Road'))
+  })
+})

+ 178 - 0
src/__tests__/features/invoice-designer/renderer-parity.test.tsx

@@ -0,0 +1,178 @@
+// @vitest-environment node
+/**
+ * The sheet the designer draws, the sheet the customer opens and the PDF are
+ * one document.
+ *
+ * They are drawn by three different renderers. `SpecCanvas` (the designer) and
+ * `SpecSheet` (the share page) both put their content through `RenderNode`, so
+ * those two agree by construction; `SpecPdf` is the one that reads the same
+ * spec through its own eyes. That is where a switch can be honoured on screen
+ * and ignored on paper, or the other way round, and a workshop hears about it
+ * from a customer.
+ *
+ * So one spec is built with the designer's switches deliberately set: a
+ * section hidden, a heading off, the document renamed, a field switched off
+ * and two fields dragged past each other. Then both renderers are asked what
+ * they printed.
+ */
+import { Document, renderToBuffer } from '@react-pdf/renderer'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { extractText } from 'unpdf'
+import { describe, expect, it } from 'vitest'
+import '@/features/vehicles/Components/invoice-pdf/fonts'
+import { themeOf } from '@/features/invoice-designer/Components/designTheme'
+import { buildSampleData } from '@/features/invoice-designer/Components/sample'
+import { SpecPdfPage } from '@/features/invoice-designer/Pdf/SpecPdf'
+import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
+import { buildDocumentSpec } from '@/features/invoice-designer/Spec/buildSpec'
+import {
+  type InvoiceLayoutConfig,
+  mergeWithDefaults,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+const TITLE = 'TAX INVOICE'
+
+/** The designer's switches, set to something worth checking on both sides. */
+function designedLayout(): InvoiceLayoutConfig {
+  const layout = mergeWithDefaults({}) as InvoiceLayoutConfig
+  layout.sections = layout.sections.map((section) => {
+    if (section.id === 'service') return { ...section, visible: false }
+    if (section.id === 'document_title') return { ...section, text: TITLE }
+    if (section.id === 'customer') {
+      return {
+        ...section,
+        heading: false,
+        fields: (section.fields ?? []).map((field) =>
+          field.id === 'customer_email' ? { ...field, visible: false } : field
+        ),
+      }
+    }
+    if (section.id === 'vehicle') {
+      // The plate dragged above the VIN, which both renderers have to follow.
+      return {
+        ...section,
+        fields: ['vehicle_name', 'license_plate', 'vin', 'mileage'].map((id) => ({
+          id,
+          visible: true,
+        })),
+      }
+    }
+    return section
+  })
+  return layout
+}
+
+const sample = () =>
+  buildSampleData(
+    {
+      name: 'Shop',
+      address: 'A road',
+      phone: '555',
+      email: 'shop@example.com',
+      logoUrl: null,
+    } as any,
+    [],
+    ((key: string) => key) as any,
+    {},
+    'invoice'
+  )
+
+/** Text out of the HTML sheet: tags dropped, entities decoded, spacing folded. */
+function textFromHtml(html: string): string {
+  return html
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&amp;/g, '&')
+    .replace(/&#x27;|&apos;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/\s+/g, ' ')
+}
+
+async function bothRenderings() {
+  const layout = designedLayout()
+  const spec = buildDocumentSpec(layout, themeOf({} as any, layout), sample())
+
+  const html = textFromHtml(renderToStaticMarkup((<SpecSheet spec={spec} />) as any))
+
+  const pdf = await renderToBuffer(
+    (
+      <Document>
+        <SpecPdfPage spec={spec} />
+      </Document>
+    ) as any
+  )
+  const { text } = await extractText(new Uint8Array(pdf), { mergePages: true })
+
+  // Both folded to one case: the PDF renderer applies `textTransform` for
+  // real, so a heading the stylesheet only draws in capitals comes out of the
+  // PDF in capitals and out of the HTML as it was written.
+  return {
+    html: html.toLowerCase(),
+    pdf: String(text).replace(/\s+/g, ' ').toLowerCase(),
+  }
+}
+
+describe('the share sheet and the PDF print the same designed document', () => {
+  it('agree on what the designer asked for', async () => {
+    const { html, pdf } = await bothRenderings()
+
+    // Renamed by the workshop, on both.
+    expect(html).toContain(TITLE.toLowerCase())
+    expect(pdf).toContain(TITLE.toLowerCase())
+
+    // A section switched off is off both.
+    expect(html).not.toContain('jamie lee')
+    expect(pdf).not.toContain('jamie lee')
+
+    // A heading switched off is gone from both, and what was under it stays.
+    expect(html).not.toContain('bill to')
+    expect(pdf).not.toContain('bill to')
+    expect(html).toContain('alex carter')
+    expect(pdf).toContain('alex carter')
+
+    // One field switched off, and only that one.
+    expect(html).not.toContain('alex@example.com')
+    expect(pdf).not.toContain('alex@example.com')
+    expect(html).toContain('+1 555 0134')
+    expect(pdf).toContain('+1 555 0134')
+
+    // Two fields dragged past each other print in the new order on both.
+    for (const [name, printed] of Object.entries({ html, pdf })) {
+      const plate = printed.indexOf('ab 12345')
+      const vin = printed.indexOf('yv1aa0000l0000000')
+      expect(plate, `the plate is on the ${name}`).toBeGreaterThanOrEqual(0)
+      expect(vin, `the VIN is on the ${name}`).toBeGreaterThanOrEqual(0)
+      expect(plate, `the plate prints above the VIN on the ${name}`).toBeLessThan(vin)
+    }
+  })
+
+  it('put the blocks in the same order', async () => {
+    const { html, pdf } = await bothRenderings()
+
+    // One marker per block that prints, in the order the layout puts them.
+    // Each has to be a word only its own block prints: "Total" would find the
+    // items table's column heading long before the totals panel.
+    const markers = [
+      'shop',
+      TITLE.toLowerCase(),
+      'alex carter',
+      '2020 volvo v60',
+      'subtotal',
+      'payment information',
+    ]
+
+    for (const [name, printed] of Object.entries({ html, pdf })) {
+      const found = markers.map((marker) => printed.indexOf(marker))
+      expect(
+        found.every((at) => at >= 0),
+        `every marker is on the ${name}`
+      ).toBe(true)
+      // Ascending, which is to say: the same running order in both.
+      expect(found, `${name} keeps the order`).toEqual([...found].sort((a, b) => a - b))
+    }
+  })
+})

+ 96 - 0
src/__tests__/features/invoices/printable-image.test.ts

@@ -0,0 +1,96 @@
+import { deflateSync } from 'node:zlib'
+import { crc32 } from 'node:zlib'
+import { describe, expect, it } from 'vitest'
+import { isPrintableImage } from '@/features/invoices/Lib/printableImage'
+
+/**
+ * Which attachments may be drawn into an invoice.
+ *
+ * The renderer decodes images inside its own stream, so one it cannot read
+ * throws where the route cannot catch it: the PDF request never answers and
+ * the process logs an uncaught `Z_DATA_ERROR`. A single truncated photograph
+ * on a job made that job's invoice unobtainable, preview included. These are
+ * the shapes that get past the door.
+ */
+
+function pngChunk(type: string, data: Buffer): Buffer {
+  const head = Buffer.alloc(4)
+  head.writeUInt32BE(data.length)
+  const typed = Buffer.concat([Buffer.from(type, 'latin1'), data])
+  const tail = Buffer.alloc(4)
+  tail.writeUInt32BE(crc32(typed))
+  return Buffer.concat([head, typed, tail])
+}
+
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+/** A 1×1 truecolour PNG, built the way a PNG is built. */
+function onePixelPng(): Buffer {
+  const ihdr = Buffer.alloc(13)
+  ihdr.writeUInt32BE(1, 0)
+  ihdr.writeUInt32BE(1, 4)
+  ihdr[8] = 8 // bit depth
+  ihdr[9] = 2 // truecolour
+  return Buffer.concat([
+    PNG_SIGNATURE,
+    pngChunk('IHDR', ihdr),
+    pngChunk('IDAT', deflateSync(Buffer.from([0x00, 0xff, 0x00, 0x00]))),
+    pngChunk('IEND', Buffer.alloc(0)),
+  ])
+}
+
+const jpeg = (body: Buffer = Buffer.alloc(8)) =>
+  Buffer.concat([Buffer.from([0xff, 0xd8]), body, Buffer.from([0xff, 0xd9])])
+
+describe('what may be drawn into an invoice', () => {
+  it('takes a PNG that holds together', () => {
+    expect(isPrintableImage(onePixelPng(), 'image/png')).toBe(true)
+  })
+
+  it('refuses a PNG whose pixels are damaged', () => {
+    // The checksum is what catches it, which is what a half-finished upload
+    // looks like: the header parses and the rest does not.
+    const png = onePixelPng()
+    png[png.length - 10] ^= 0xff
+    expect(isPrintableImage(png, 'image/png')).toBe(false)
+  })
+
+  it('refuses a PNG that stops early', () => {
+    expect(isPrintableImage(onePixelPng().subarray(0, 40), 'image/png')).toBe(false)
+  })
+
+  it('refuses something that only says PNG', () => {
+    const liar = Buffer.concat([PNG_SIGNATURE, Buffer.from('not a chunk at all, honestly')])
+    expect(isPrintableImage(liar, 'image/png')).toBe(false)
+  })
+
+  it('refuses a chunk that claims to be longer than the file', () => {
+    const png = onePixelPng()
+    png.writeUInt32BE(0xffff_fff0, 8)
+    expect(isPrintableImage(png, 'image/png')).toBe(false)
+  })
+
+  it('takes a JPEG with both its markers', () => {
+    expect(isPrintableImage(jpeg(), 'image/jpeg')).toBe(true)
+    expect(isPrintableImage(jpeg(), 'image/jpg')).toBe(true)
+  })
+
+  it('refuses a JPEG that was cut off before the end', () => {
+    expect(isPrintableImage(Buffer.from([0xff, 0xd8, 0x01, 0x02, 0x03]), 'image/jpeg')).toBe(false)
+  })
+
+  it('refuses WEBP, which uploads accept and the renderer cannot read', () => {
+    const webp = Buffer.concat([
+      Buffer.from('RIFF'),
+      Buffer.alloc(4),
+      Buffer.from('WEBPVP8 '),
+      Buffer.alloc(16),
+    ])
+    expect(isPrintableImage(webp, 'image/webp')).toBe(false)
+  })
+
+  it('refuses anything that is not an image at all', () => {
+    expect(isPrintableImage(Buffer.from('%PDF-1.7'), 'application/pdf')).toBe(false)
+    expect(isPrintableImage(Buffer.alloc(0), 'image/png')).toBe(false)
+  })
+})

+ 141 - 0
src/__tests__/features/workorders/validate-service-form.test.ts

@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest'
+import {
+  findServiceFormProblem,
+  type ServiceFormInput,
+} from '@/features/vehicles/Lib/validateServiceForm'
+
+/**
+ * What the work order editor refuses to save, and why it has to be the one
+ * doing the refusing.
+ *
+ * The editor draws every field twice, once per breakpoint. Native form
+ * validation then objects to whichever copy the screen is not using, will not
+ * open a message on a hidden control, and abandons the submit in silence: a
+ * Save button that does nothing at all, with an "invalid form control is not
+ * focusable" line in a console nobody has open. So the form runs unvalidated
+ * and these rules stand in its place.
+ *
+ * The two row rules are here because of what they replaced. A priced row with
+ * no name used to be filtered out of the payload on the way to the server, so
+ * the save succeeded, said "Saved", and quietly left the money off the
+ * customer's invoice.
+ */
+
+const empty: ServiceFormInput = { title: 'Front brakes', partItems: [], laborItems: [] }
+
+const part = (over: Partial<ServiceFormInput['partItems'][number]> = {}) => ({
+  name: 'Brake pads',
+  quantity: 1,
+  unitCost: 0,
+  unitPrice: 100,
+  markupPercent: 0,
+  ...over,
+})
+
+const labor = (over: Partial<ServiceFormInput['laborItems'][number]> = {}) => ({
+  description: 'Replace pads',
+  hours: 1,
+  rate: 800,
+  ...over,
+})
+
+describe('what a work order must have before it saves', () => {
+  it('lets a complete job through', () => {
+    expect(
+      findServiceFormProblem({ ...empty, partItems: [part()], laborItems: [labor()] })
+    ).toBeNull()
+  })
+
+  it('needs a title', () => {
+    expect(findServiceFormProblem({ ...empty, title: '' })).toBe('title')
+    expect(findServiceFormProblem({ ...empty, title: '   ' })).toBe('title')
+  })
+
+  it('refuses a part that has a price but no name', () => {
+    expect(findServiceFormProblem({ ...empty, partItems: [part({ name: '' })] })).toBe('partName')
+    // Whitespace is not a name either: it saved, and printed a blank line on
+    // the invoice.
+    expect(findServiceFormProblem({ ...empty, partItems: [part({ name: '  ' })] })).toBe('partName')
+  })
+
+  it('refuses a part that has a number or a cost but no name', () => {
+    expect(
+      findServiceFormProblem({
+        ...empty,
+        partItems: [part({ name: '', unitPrice: 0, partNumber: '8K0-698-451' })],
+      })
+    ).toBe('partName')
+    expect(
+      findServiceFormProblem({
+        ...empty,
+        partItems: [part({ name: '', unitPrice: 0, unitCost: 40 })],
+      })
+    ).toBe('partName')
+  })
+
+  it('leaves the blank row the editor keeps ready alone', () => {
+    // Every list ends in an empty row waiting to be typed into. It is not a
+    // mistake, and it is dropped on save without a word.
+    expect(
+      findServiceFormProblem({
+        ...empty,
+        partItems: [part(), part({ name: '', unitPrice: 0, quantity: 1 })],
+        laborItems: [labor(), labor({ description: '', hours: 0, rate: 0 })],
+      })
+    ).toBeNull()
+  })
+
+  it('refuses labour with hours or a rate but nothing said about it', () => {
+    expect(findServiceFormProblem({ ...empty, laborItems: [labor({ description: '' })] })).toBe(
+      'laborDescription'
+    )
+    expect(
+      findServiceFormProblem({ ...empty, laborItems: [labor({ description: '', hours: 0 })] })
+    ).toBe('laborDescription')
+  })
+
+  it('refuses negative money and negative time', () => {
+    for (const over of [{ quantity: -1 }, { unitPrice: -1 }, { unitCost: -5 }]) {
+      expect(findServiceFormProblem({ ...empty, partItems: [part(over)] })).toBe('negative')
+    }
+    for (const over of [{ hours: -2 }, { rate: -100 }]) {
+      expect(findServiceFormProblem({ ...empty, laborItems: [labor(over)] })).toBe('negative')
+    }
+  })
+
+  it('allows a markup down to giving the part away, and no further', () => {
+    // Selling below cost is a real decision; a price below nothing is not.
+    expect(
+      findServiceFormProblem({ ...empty, partItems: [part({ markupPercent: -100 })] })
+    ).toBeNull()
+    expect(findServiceFormProblem({ ...empty, partItems: [part({ markupPercent: -101 })] })).toBe(
+      'negative'
+    )
+  })
+
+  it('reads the numbers the editor holds as strings', () => {
+    // Every number in the editor comes from an input, so it arrives as text.
+    expect(
+      findServiceFormProblem({
+        ...empty,
+        partItems: [part({ quantity: '2', unitPrice: '150.50' })],
+        laborItems: [labor({ hours: '1.5', rate: '400' })],
+      })
+    ).toBeNull()
+    expect(findServiceFormProblem({ ...empty, partItems: [part({ quantity: '-2' })] })).toBe(
+      'negative'
+    )
+  })
+
+  it('says the most fundamental thing first', () => {
+    // A form with three problems gets one sentence, and fixing it brings the
+    // next one up.
+    expect(
+      findServiceFormProblem({
+        title: '',
+        partItems: [part({ name: '', quantity: -1 })],
+        laborItems: [labor({ description: '' })],
+      })
+    ).toBe('title')
+  })
+})

+ 88 - 0
src/__tests__/lib/upload-root.test.ts

@@ -0,0 +1,88 @@
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import { dataRoot, orgUploadDir, uploadsRoot, uploadsRoots } from '@/lib/upload-root'
+
+/**
+ * Where uploaded files live, and why moving them loses nothing.
+ *
+ * `DATA_ROOT` moves the data directory for a self-hosted install whose spare
+ * disk is not the one the app was unpacked on. The seed script has honoured it
+ * since it was written and the app never did, so anyone who set it had their
+ * files written to the default regardless. That is the whole risk in turning
+ * it on, and the reason a read tries the configured root and then the old
+ * default: a file already on disk keeps resolving, wherever it was put.
+ */
+
+const ORIGINAL = process.env.DATA_ROOT
+
+afterEach(() => {
+  if (ORIGINAL === undefined) delete process.env.DATA_ROOT
+  else process.env.DATA_ROOT = ORIGINAL
+})
+
+describe('the data directory', () => {
+  it('is data beside the app when nothing says otherwise', () => {
+    delete process.env.DATA_ROOT
+    expect(dataRoot()).toBe(path.join(process.cwd(), 'data'))
+    expect(uploadsRoot()).toBe(path.join(process.cwd(), 'data', 'uploads'))
+    // One root, so nothing is looked for twice.
+    expect(uploadsRoots()).toEqual([path.join(process.cwd(), 'data', 'uploads')])
+  })
+
+  it('is wherever the installation says', () => {
+    process.env.DATA_ROOT = '/var/lib/torqvoice'
+    expect(uploadsRoot()).toBe('/var/lib/torqvoice/uploads')
+    expect(orgUploadDir('org_1', 'services')).toBe('/var/lib/torqvoice/uploads/org_1/services')
+  })
+
+  it('still knows where files were written before it read the variable', () => {
+    process.env.DATA_ROOT = '/var/lib/torqvoice'
+    expect(uploadsRoots()).toEqual([
+      '/var/lib/torqvoice/uploads',
+      path.join(process.cwd(), 'data', 'uploads'),
+    ])
+  })
+})
+
+describe('resolving a stored file after the data directory moves', () => {
+  it('finds one that was written before the move', () => {
+    // The old default is the working directory's, so this is written there.
+    const relative = path.join('org_upgrade', 'services', 'before.pdf')
+    const legacy = path.join(process.cwd(), 'data', 'uploads', relative)
+    mkdirSync(path.dirname(legacy), { recursive: true })
+    writeFileSync(legacy, 'old')
+
+    process.env.DATA_ROOT = mkdtempSync(path.join(tmpdir(), 'torqvoice-data-'))
+    expect(resolveUploadPath(`/api/protected/files/${relative}`)).toBe(legacy)
+  })
+
+  it('prefers the configured root when the file is there', () => {
+    const root = mkdtempSync(path.join(tmpdir(), 'torqvoice-data-'))
+    process.env.DATA_ROOT = root
+    const relative = path.join('org_moved', 'services', 'after.pdf')
+    const moved = path.join(root, 'uploads', relative)
+    mkdirSync(path.dirname(moved), { recursive: true })
+    writeFileSync(moved, 'new')
+
+    expect(resolveUploadPath(`/api/protected/files/${relative}`)).toBe(moved)
+  })
+
+  it('names the configured root for a file that is in neither', () => {
+    // So the caller's own read reports a missing file, rather than this
+    // reporting a path nobody asked about.
+    process.env.DATA_ROOT = '/var/lib/torqvoice'
+    expect(resolveUploadPath('/api/protected/files/org_x/services/gone.pdf')).toBe(
+      '/var/lib/torqvoice/uploads/org_x/services/gone.pdf'
+    )
+  })
+
+  it('refuses a stored path that would climb out of either root', () => {
+    // A stored value is something somebody typed at some point, and `..` in it
+    // must never reach readFile or unlink.
+    process.env.DATA_ROOT = '/var/lib/torqvoice'
+    expect(() => resolveUploadPath('/api/protected/files/../../etc/passwd')).toThrow()
+  })
+})

+ 93 - 0
src/__tests__/lib/zoned-wall-clock.test.ts

@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+import {
+  fromZonedWallClock,
+  toZonedWallClock,
+  zonedDateInput,
+  zonedParts,
+  zonedTimeInput,
+} from '@/lib/timezone'
+import { parseWorkshopDateTime } from '@/lib/workshop-datetime'
+
+/**
+ * The stand-in a local-time widget is handed.
+ *
+ * A booking made at half past ten in the workshop has to read half past ten
+ * on every screen the workshop looks at, including the pickers that only know
+ * how to work in the browser's own clock. These two functions are the whole
+ * of that: down into the widget, and back out again unchanged.
+ */
+
+/** The harness's own zone, so a round trip is checkable from either side. */
+const HERE = Intl.DateTimeFormat().resolvedOptions().timeZone
+
+describe('a local stand-in for a workshop wall clock', () => {
+  it('reads locally what the instant reads in the workshop', () => {
+    const instant = new Date('2026-09-10T22:30:00Z')
+    const shown = toZonedWallClock(instant, 'Pacific/Auckland')
+    const there = zonedParts(instant, 'Pacific/Auckland')
+
+    expect(shown.getHours()).toBe(there.hour)
+    expect(shown.getMinutes()).toBe(there.minute)
+    expect(shown.getDate()).toBe(there.day)
+    // And it is not the instant itself, unless the two zones happen to agree.
+    if (HERE !== 'Pacific/Auckland') expect(shown.getTime()).not.toBe(instant.getTime())
+  })
+
+  it('comes back as the instant it stood for', () => {
+    for (const zone of ['Pacific/Auckland', 'America/Los_Angeles', 'UTC', 'Asia/Kolkata']) {
+      const instant = new Date('2026-09-10T22:30:00Z')
+      const round = fromZonedWallClock(toZonedWallClock(instant, zone), zone)
+      expect(round.getTime(), zone).toBe(instant.getTime())
+    }
+  })
+
+  it('reads a typed time as the workshop typed it', () => {
+    // Ten thirty on the widget's face, in a workshop thirteen hours ahead:
+    // the instant is the one Auckland calls 10:30, whatever this box thinks.
+    const typed = new Date(2026, 8, 11, 10, 30, 0, 0)
+    const instant = fromZonedWallClock(typed, 'Pacific/Auckland')
+    expect(instant.toISOString()).toBe('2026-09-10T22:30:00.000Z')
+  })
+
+  it('survives the hour a workshop skips going into summer time', () => {
+    // 02:30 on the last Sunday of March does not exist in Oslo. The picker
+    // must still resolve it to a real instant rather than an invalid date.
+    const instant = fromZonedWallClock(new Date(2026, 2, 29, 2, 30, 0, 0), 'Europe/Oslo')
+    expect(Number.isNaN(instant.getTime())).toBe(false)
+    expect(zonedParts(instant, 'Europe/Oslo').day).toBe(29)
+  })
+})
+
+describe('what a date field and a time field are filled with', () => {
+  it("is the workshop's day and clock, not the reader's", () => {
+    // 22:30 UTC is the 11th at 10:30 in Auckland and still the 10th at 15:30
+    // in Los Angeles. Both readings are of the same reminder.
+    const instant = new Date('2026-09-10T22:30:00Z')
+    expect(zonedDateInput(instant, 'Pacific/Auckland')).toBe('2026-09-11')
+    expect(zonedTimeInput(instant, 'Pacific/Auckland')).toBe('10:30')
+    expect(zonedDateInput(instant, 'America/Los_Angeles')).toBe('2026-09-10')
+    expect(zonedTimeInput(instant, 'America/Los_Angeles')).toBe('15:30')
+  })
+
+  it('pads both, so the strings are what the inputs accept', () => {
+    const instant = new Date('2026-01-02T03:04:00Z')
+    expect(zonedDateInput(instant, 'UTC')).toBe('2026-01-02')
+    expect(zonedTimeInput(instant, 'UTC')).toBe('03:04')
+  })
+
+  it('falls back to the browser for a workshop that has never chosen a zone', () => {
+    const instant = new Date(2026, 8, 11, 10, 30, 0, 0)
+    expect(zonedDateInput(instant, '')).toBe('2026-09-11')
+    expect(zonedTimeInput(instant, '')).toBe('10:30')
+  })
+
+  it('round trips through the wall clock the server reads back', () => {
+    // What the form does: fill the two fields, hand them back as one string,
+    // and let the server read that string in the workshop's zone. The
+    // instant has to survive it untouched, which is the whole bug.
+    const zone = 'Pacific/Auckland'
+    const stored = new Date('2026-09-10T22:30:00Z')
+    const typed = `${zonedDateInput(stored, zone)}T${zonedTimeInput(stored, zone)}`
+    expect(parseWorkshopDateTime(typed, zone).getTime()).toBe(stored.getTime())
+  })
+})

+ 19 - 2
src/app/(authenticated)/layout.tsx

@@ -103,8 +103,25 @@ export default async function DashboardLayout({ children }: { children: React.Re
 
   if (!isOwnerOrAdmin) {
     const membership = await getCachedMembership(data.userId)
-    // Members without a custom role have full access
-    if (membership?.roleId) {
+    if (!membership?.roleId) {
+      /**
+       * A member with no role at all.
+       *
+       * `withAuth` refuses every permissioned action for these accounts, so
+       * offering the whole application would be the sidebar of refusals this
+       * screen was built to replace — and the team page already promises the
+       * opposite in as many words: "Without a role, this member cannot do
+       * anything." An invitation may still be sent without a role, so this
+       * account can be created at any time.
+       *
+       * On an install that already holds one, that person now lands here
+       * instead of on a broken-looking app, and an owner or an admin gives
+       * them a role. That is the same end state the action layer arrived at.
+       */
+      visibleSubjects = []
+      canCreateVehicles = false
+      hasAnyAccess = false
+    } else {
       const userPermissions = membership?.customRole?.permissions ?? []
       visibleSubjects = allSubjects.filter((subject) =>
         hasPermission(userPermissions, {

+ 10 - 7
src/app/(authenticated)/settings/localization/localization-settings.tsx

@@ -427,9 +427,9 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
         <ReadOnlyWrapper>
           <div className="space-y-6">
             <div className="space-y-2">
-              <Label>{t('appearance.dateFormat')}</Label>
+              <Label htmlFor="date-format">{t('appearance.dateFormat')}</Label>
               <Select value={dateFormat} onValueChange={setDateFormat}>
-                <SelectTrigger className="w-64">
+                <SelectTrigger id="date-format" className="w-64">
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
@@ -443,9 +443,9 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
             </div>
 
             <div className="space-y-2">
-              <Label>{t('appearance.timeFormat')}</Label>
+              <Label htmlFor="time-format">{t('appearance.timeFormat')}</Label>
               <Select value={timeFormat} onValueChange={setTimeFormat}>
-                <SelectTrigger className="w-48">
+                <SelectTrigger id="time-format" className="w-48">
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
@@ -456,9 +456,9 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
             </div>
 
             <div className="space-y-2">
-              <Label>{t('workshop.weekStartDay')}</Label>
+              <Label htmlFor="week-start-day">{t('workshop.weekStartDay')}</Label>
               <Select value={weekStartDay} onValueChange={setWeekStartDay}>
-                <SelectTrigger className="w-48">
+                <SelectTrigger id="week-start-day" className="w-48">
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
@@ -472,10 +472,13 @@ export function LocalizationSettings({ settings }: { settings: Record<string, st
             </div>
 
             <div className="space-y-2">
-              <Label>{t('appearance.timezone')}</Label>
+              {/* Named, so the control announces itself as the timezone and not
+                  as whatever zone it currently holds. */}
+              <Label htmlFor="timezone">{t('appearance.timezone')}</Label>
               <Popover open={timezoneOpen} onOpenChange={setTimezoneOpen}>
                 <PopoverTrigger asChild>
                   <Button
+                    id="timezone"
                     variant="outline"
                     role="combobox"
                     aria-expanded={timezoneOpen}

+ 2 - 1
src/app/api/protected/backup/export/route.ts

@@ -7,6 +7,7 @@ import { isDemoMode } from '@/lib/demo'
 import { UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { readdir, readFile, stat } from 'fs/promises'
 import path from 'path'
+import { uploadsRoot } from '@/lib/upload-root'
 
 export const maxDuration = 300
 
@@ -432,7 +433,7 @@ export async function POST(request: NextRequest) {
 
   // Add uploaded files if requested
   if (options.files) {
-    const uploadsDir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId)
+    const uploadsDir = path.join(uploadsRoot(), ctx.organizationId)
 
     const categories = UPLOAD_CATEGORIES
 

+ 2 - 1
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -12,6 +12,7 @@ import path from 'path'
 import os from 'os'
 import JSZip from 'jszip'
 import { resolveWithinDir } from '@/lib/safe-path'
+import { uploadsRoot } from '@/lib/upload-root'
 
 // Allow up to 5 minutes for large imports
 export const maxDuration = 300
@@ -314,7 +315,7 @@ export async function POST(request: NextRequest) {
     }
 
     // Uploads directory for this org
-    const uploadsBase = path.join(process.cwd(), 'data', 'uploads', organizationId)
+    const uploadsBase = path.join(uploadsRoot(), organizationId)
 
     // Maps: IN hashed_id → Torqvoice ID
     const clientIdMap = new Map<string, string>()

+ 2 - 1
src/app/api/protected/backup/import-lubelog/route.ts

@@ -14,6 +14,7 @@ import os from 'os'
 import { BSON } from 'bson'
 import JSZip from 'jszip'
 import { resolveWithinDir } from '@/lib/safe-path'
+import { uploadsRoot } from '@/lib/upload-root'
 
 // Allow up to 5 minutes for large imports
 export const maxDuration = 300
@@ -293,7 +294,7 @@ export async function POST(request: NextRequest) {
     }
 
     // Uploads directory for this org
-    const uploadsBase = path.join(process.cwd(), 'data', 'uploads', organizationId)
+    const uploadsBase = path.join(uploadsRoot(), organizationId)
 
     // ── Copy a file from the backup to the uploads directory ──────────────
     async function copyFile(

+ 2 - 1
src/app/api/protected/backup/import/route.ts

@@ -14,6 +14,7 @@ import { Prisma } from '@/generated/prisma/client'
 import JSZip from 'jszip'
 import { mkdir, rm, writeFile } from 'fs/promises'
 import path from 'path'
+import { uploadsRoot } from '@/lib/upload-root'
 
 // Zip magic bytes: PK\x03\x04
 const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]
@@ -309,7 +310,7 @@ async function restoreRows(
 }
 
 async function restoreFiles(zip: JSZip, organizationId: string) {
-  const uploadsDir = path.join(process.cwd(), 'data', 'uploads', organizationId)
+  const uploadsDir = path.join(uploadsRoot(), organizationId)
 
   const fileEntries = Object.keys(zip.files).filter(
     (name) => !zip.files[name].dir && (name.startsWith('files/') || name.startsWith('uploads/'))

+ 2 - 1
src/app/api/protected/files/[...path]/route.ts

@@ -3,6 +3,7 @@ import { svgDownloadHeaders } from '@/lib/upload-url'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { readFile, stat } from 'fs/promises'
 import path from 'path'
+import { uploadsRoot } from '@/lib/upload-root'
 
 const MIME_TYPES: Record<string, string> = {
   jpg: 'image/jpeg',
@@ -63,7 +64,7 @@ export async function GET(
     return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })
   }
 
-  const filePath = path.join(process.cwd(), 'data', 'uploads', orgId, category, filename)
+  const filePath = path.join(uploadsRoot(), orgId, category, filename)
 
   try {
     await stat(filePath)

+ 15 - 252
src/app/api/protected/quotes/[id]/pdf/route.ts

@@ -1,21 +1,14 @@
-import { withOrgNumberLabel } from '@/features/invoice-designer/Lib/labelOverrides'
-import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
-import { NextResponse } from 'next/server'
-import { renderToBuffer } from '@react-pdf/renderer'
-import '@/features/vehicles/Components/invoice-pdf/fonts'
 import { cookies } from 'next/headers'
+import { NextResponse } from 'next/server'
+import { buildQuotePdfBuffer } from '@/features/quotes/Pdf/buildQuotePdfBuffer'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { db } from '@/lib/db'
-import { QuotePDF } from '@/features/quotes/Components/QuotePDF'
-import React from 'react'
-import { readFile } from 'fs/promises'
-import { PDFDocument } from 'pdf-lib'
-import { resolveUploadPath } from '@/lib/resolve-upload-path'
-import { getFeatures } from '@/lib/features'
-import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
-import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
-import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 
+/**
+ * The workshop's own copy of a quote, and the preview dialog behind it.
+ *
+ * The document itself is built by `buildQuotePdfBuffer`, which the public
+ * share link and the emailed copy also go through: three surfaces, one sheet.
+ */
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
     const ctx = await getAuthContext()
@@ -23,250 +16,20 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
-    // Load locale-based PDF translations
-    const cookieStore = await cookies()
-    const locale = cookieStore.get('locale')?.value || 'en'
-    let pdfMessages: Record<string, Record<string, string>>
-    try {
-      pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default
-    } catch {
-      pdfMessages = (await import(`../../../../../../../messages/en/pdf.json`)).default
-    }
-    // The quote sheet shares its builder, and so its column heads and panel
-    // titles, with the invoice. Layering quote wording over the invoice's keeps
-    // every shared label translated instead of falling back to English.
-    const labels = {
-      ...pdfMessages.invoice,
-      ...pdfMessages.quote,
-      ...pdfMessages.common,
-    }
-
     const { id } = await params
+    // The workshop reads its own copy in its own language; a customer's copy
+    // follows the customer's.
+    const locale = (await cookies()).get('locale')?.value || 'en'
 
-    const [quote, settings, org] = await Promise.all([
-      db.quote.findFirst({
-        where: { id, organizationId: ctx.organizationId },
-        include: {
-          partItems: true,
-          laborItems: true,
-          attachments: true,
-          customer: {
-            select: {
-              name: true,
-              email: true,
-              phone: true,
-              address: true,
-              company: true,
-              taxId: true,
-            },
-          },
-          vehicle: {
-            select: { make: true, model: true, year: true, vin: true, licensePlate: true },
-          },
-        },
-      }),
-      db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }),
-      db.organization.findUnique({
-        where: { id: ctx.organizationId },
-        select: { name: true },
-      }),
-    ])
-
-    if (!quote) {
+    const pdf = await buildQuotePdfBuffer(id, ctx.organizationId, locale)
+    if (!pdf) {
       return NextResponse.json({ error: 'Quote not found' }, { status: 404 })
     }
 
-    const settingsMap: Record<string, string> = {}
-    for (const s of settings) settingsMap[s.key] = s.value
-
-    // Override labels for marine service type
-    const serviceType = settingsMap['workshop.serviceType'] || 'automotive'
-    if (serviceType === 'marine') {
-      if (pdfMessages.quote.vinMarine) labels.vin = pdfMessages.quote.vinMarine
-      if (pdfMessages.quote.plateMarine) labels.plate = pdfMessages.quote.plateMarine
-      if (pdfMessages.quote.vehicleMarine) labels.vehicle = pdfMessages.quote.vehicleMarine
-    }
-
-    // Custom tax label override (e.g. "VAT", "MVA", "GST", "MwSt.")
-    const customTaxLabel = settingsMap['workshop.taxLabel']?.trim()
-    if (customTaxLabel) {
-      labels.tax = `${customTaxLabel} ({rate}%)`
-    }
-    Object.assign(labels, withOrgNumberLabel(labels, settingsMap['workshop.orgNumberLabel']))
-
-    let logoDataUri: string | undefined
-    const logoPath = documentLogoPath(settingsMap, 'quote')
-    if (logoPath) {
-      try {
-        const fullPath = resolveUploadPath(logoPath)
-        const logoBuffer = await readFile(fullPath)
-        const ext = logoPath.split('.').pop()?.toLowerCase() || 'png'
-        const mimeMap: Record<string, string> = {
-          png: 'image/png',
-          jpg: 'image/jpeg',
-          jpeg: 'image/jpeg',
-          webp: 'image/webp',
-          svg: 'image/svg+xml',
-        }
-        const mime = mimeMap[ext] || 'image/png'
-        logoDataUri = `data:${mime};base64,${logoBuffer.toString('base64')}`
-      } catch {
-        // Skip
-      }
-    }
-
-    // Process attachments for PDF
-    const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = []
-    const otherAttachments: { fileName: string; fileType: string }[] = []
-    const pdfAttachments: { fileName: string; buffer: Buffer }[] = []
-
-    const seenNames = new Set<string>()
-    const uniqueAttachments = (quote.attachments || [])
-      .filter((att) => att.includeInInvoice !== false)
-      .filter((att) => {
-        if (seenNames.has(att.fileName)) return false
-        seenNames.add(att.fileName)
-        return true
-      })
-
-    for (const att of uniqueAttachments) {
-      if (att.fileType.startsWith('image/')) {
-        try {
-          const filePath = resolveUploadPath(att.fileUrl)
-          const buffer = await readFile(filePath)
-          const base64 = buffer.toString('base64')
-          imageAttachments.push({
-            fileName: att.fileName,
-            dataUri: `data:${att.fileType};base64,${base64}`,
-            description: att.description || undefined,
-          })
-        } catch {
-          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType })
-        }
-      } else if (att.fileType === 'application/pdf') {
-        try {
-          const filePath = resolveUploadPath(att.fileUrl)
-          const buffer = await readFile(filePath)
-          pdfAttachments.push({ fileName: att.fileName, buffer })
-        } catch {
-          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType })
-        }
-      } else {
-        otherAttachments.push({ fileName: att.fileName, fileType: att.fileType })
-      }
-    }
-
-    // Fetch custom field values for the quote
-    const customFields = await getCustomFieldsForPrint(ctx.organizationId, quote.id, 'quote')
-
-    // Check if Torqvoice branding should be shown
-    const features = await getFeatures(ctx.organizationId)
-    let torqvoiceLogoDataUri: string | undefined
-    if (!features.brandingRemoved) {
-      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
-    }
-
-    // Fetch layout config for quotes
-    const layoutConfigSetting = await db.appSetting.findUnique({
-      where: {
-        organizationId_key: { organizationId: ctx.organizationId, key: 'quote.layoutConfig' },
-      },
-    })
-    const layoutConfig = mergeWithDefaults(
-      layoutConfigSetting?.value ? JSON.parse(layoutConfigSetting.value) : {}
-    )
-
-    const template = {
-      primaryColor:
-        settingsMap['quote.primaryColor'] || settingsMap['invoice.primaryColor'] || '#d97706',
-      backgroundColor:
-        settingsMap['quote.backgroundColor'] || settingsMap['invoice.backgroundColor'] || undefined,
-      textColor: settingsMap['quote.textColor'] || settingsMap['invoice.textColor'] || undefined,
-      companyTextColor:
-        settingsMap['quote.companyTextColor'] ||
-        settingsMap['invoice.companyTextColor'] ||
-        undefined,
-      frameBorderColor:
-        settingsMap['quote.frameBorderColor'] ||
-        settingsMap['invoice.frameBorderColor'] ||
-        undefined,
-      frameShadow: settingsMap['quote.frameShadow'] ?? settingsMap['invoice.frameShadow'],
-      frameRadius:
-        Number(settingsMap['quote.frameRadius'] ?? settingsMap['invoice.frameRadius']) || 0,
-      frameSide: ((settingsMap['quote.frameSide'] ?? settingsMap['invoice.frameSide']) === 'right'
-        ? 'right'
-        : 'left') as 'left' | 'right',
-      fontFamily:
-        settingsMap['quote.fontFamily'] || settingsMap['invoice.fontFamily'] || 'Helvetica',
-      showLogo: settingsMap['invoice.showLogo'] !== 'false',
-      showCompanyName: settingsMap['invoice.showCompanyName'] !== 'false',
-      headerStyle:
-        settingsMap['quote.headerStyle'] || settingsMap['invoice.headerStyle'] || 'standard',
-      logoSize: Number(settingsMap['quote.logoSize']) || 100,
-    }
-
-    const element = React.createElement(QuotePDF, {
-      lineItemsInclTax: settingsMap['invoice.lineItemsInclTax'] === 'true',
-      data: quote,
-      workshop: {
-        name: org?.name || '',
-        address: settingsMap['workshop.address'] || '',
-        phone: settingsMap['workshop.phone'] || '',
-        email: settingsMap['workshop.email'] || '',
-        slogan: settingsMap['workshop.slogan'] || undefined,
-      },
-      currencyCode: settingsMap['workshop.currencyCode'] || 'USD',
-      currencyFormat: (settingsMap['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol') as
-        | 'symbol'
-        | 'code',
-      logoDataUri,
-      torqvoiceLogoDataUri,
-      dateFormat: settingsMap['workshop.dateFormat'] || undefined,
-      timezone: settingsMap['workshop.timezone'] || undefined,
-      template,
-      imageAttachments,
-      otherAttachments,
-      pdfAttachmentNames: pdfAttachments.map((a) => a.fileName),
-      customFields,
-      labels,
-      layoutConfig,
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    }) as any
-    const quoteBuffer = await renderToBuffer(element)
-
-    const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`
-
-    // Merge attached PDFs into the quote PDF
-    let finalBuffer: ArrayBuffer
-    if (pdfAttachments.length > 0) {
-      const mergedPdf = await PDFDocument.load(quoteBuffer)
-      for (const att of pdfAttachments) {
-        try {
-          const attachedPdf = await PDFDocument.load(att.buffer)
-          const pages = await mergedPdf.copyPages(attachedPdf, attachedPdf.getPageIndices())
-          for (const page of pages) {
-            mergedPdf.addPage(page)
-          }
-        } catch {
-          // Skip corrupted/unreadable PDFs silently
-        }
-      }
-      const saved = await mergedPdf.save()
-      finalBuffer = saved.buffer.slice(
-        saved.byteOffset,
-        saved.byteOffset + saved.byteLength
-      ) as ArrayBuffer
-    } else {
-      finalBuffer = quoteBuffer.buffer.slice(
-        quoteBuffer.byteOffset,
-        quoteBuffer.byteOffset + quoteBuffer.byteLength
-      ) as ArrayBuffer
-    }
-
-    return new NextResponse(finalBuffer, {
+    return new NextResponse(pdf.buffer as unknown as BodyInit, {
       headers: {
         'Content-Type': 'application/pdf',
-        'Content-Disposition': `attachment; filename="${quoteNum}.pdf"`,
+        'Content-Disposition': `attachment; filename="${pdf.filename}"`,
       },
     })
   } catch (error) {

+ 19 - 50
src/app/api/protected/services/[id]/pdf/route.ts

@@ -1,21 +1,14 @@
 import { NextResponse } from 'next/server'
-import { renderToBuffer } from '@react-pdf/renderer'
-import '@/features/vehicles/Components/invoice-pdf/fonts'
 import { cookies } from 'next/headers'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
-import { InvoicePDF } from '@/features/vehicles/Components/InvoicePDF'
-import React from 'react'
 import { readFile } from 'fs/promises'
 import { PDFDocument } from 'pdf-lib'
 import { resolveUploadPath } from '@/lib/resolve-upload-path'
-import { getFeatures } from '@/lib/features'
-import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
-import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
-import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
-import { getAppBaseUrl } from '@/lib/app-url'
+import { renderInvoicePdf } from '@/features/invoices/Pdf/buildInvoicePdfBuffer'
+import { isPrintableImage } from '@/features/invoices/Lib/printableImage'
 
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
@@ -41,16 +34,16 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     if (!assembly) {
       return NextResponse.json({ error: 'Record not found' }, { status: 404 })
     }
-    const { record, settingsMap, org, layoutConfig } = assembly
+    const { record } = assembly
 
     // Getting-started checklist: a downloaded invoice leaves no other trace
     // in the data, so record it here. Best-effort, never blocks the PDF.
     void markInvoiceIssued(ctx.organizationId, ctx.userId, record.id)
 
-    // Load locale-based PDF translations
+    // The workshop reads its own copy in its own language; the customer's
+    // copy follows the customer's, which the shared renderer resolves.
     const cookieStore = await cookies()
     const locale = cookieStore.get('locale')?.value || 'en'
-    const labels = await loadPrintLabels(locale, assembly.labelSettings)
 
     // Load image attachments as base64 data URIs for PDF embedding
     const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = []
@@ -72,13 +65,18 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
         try {
           const filePath = resolveUploadPath(att.fileUrl)
           const buffer = await readFile(filePath)
-          const base64 = buffer.toString('base64')
-          const mimeType = att.fileType
-          imageAttachments.push({
-            fileName: att.fileName,
-            dataUri: `data:${mimeType};base64,${base64}`,
-            description: att.description || undefined,
-          })
+          // A file the renderer cannot decode throws inside its own stream,
+          // where this try cannot reach it: the request then never answers at
+          // all. Checked first, and listed rather than drawn if it fails.
+          if (!isPrintableImage(buffer, att.fileType)) {
+            otherAttachments.push({ fileName: att.fileName, fileType: att.fileType })
+          } else {
+            imageAttachments.push({
+              fileName: att.fileName,
+              dataUri: `data:${att.fileType};base64,${buffer.toString('base64')}`,
+              description: att.description || undefined,
+            })
+          }
         } catch {
           otherAttachments.push({ fileName: att.fileName, fileType: att.fileType })
         }
@@ -95,40 +93,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       }
     }
 
-    // Check if Torqvoice branding should be shown
-    const features = await getFeatures(ctx.organizationId)
-    let torqvoiceLogoDataUri: string | undefined
-    if (!features.brandingRemoved) {
-      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
-    }
-
-    const appUrl = getAppBaseUrl()
-    const portalSlug = org?.portalSlug
-    const portalEnabled = settingsMap['portal.enabled'] === 'true'
-    const portalUrl = portalEnabled
-      ? `${appUrl}/portal/${portalSlug || ctx.organizationId}`
-      : undefined
-
-    const telegramQr = await telegramQrForPrint(ctx.organizationId, layoutConfig)
-
-    const element = React.createElement(InvoicePDF, {
-      data: assembly.data,
-      workshop: assembly.workshop,
-      invoiceSettings: assembly.invoiceSettings,
-      paymentSummary: assembly.paymentSummary,
+    const invoiceBuffer = await renderInvoicePdf(assembly, locale, {
       imageAttachments,
       otherAttachments,
       pdfAttachmentNames: pdfAttachments.map((a) => a.fileName),
-      logoDataUri: assembly.logoDataUri,
-      template: assembly.template,
-      torqvoiceLogoDataUri,
-      portalUrl,
-      telegramQrDataUri: telegramQr?.dataUri,
-      telegramLabel: labels?.telegramConnect || 'Chat with us on Telegram',
-      labels,
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    }) as any
-    const invoiceBuffer = await renderToBuffer(element)
+    })
 
     const invoiceNum = invoiceNumberOf(record)
 

+ 2 - 1
src/app/api/protected/upload/email-logo/route.ts

@@ -5,6 +5,7 @@ import path from 'path'
 import sharp from 'sharp'
 import { EMAIL_LOGO_CATEGORY, EMAIL_LOGO_MAX_WIDTH } from '@/features/email/Lib/emailTemplate'
 import { guardEmailUpload } from '@/features/email/Lib/emailUploadAccess.server'
+import { uploadsRoot } from '@/lib/upload-root'
 
 /**
  * A logo for the email templates, uploaded on its own rather than borrowed
@@ -57,7 +58,7 @@ export async function POST(request: Request) {
       .toBuffer({ resolveWithObject: true })
 
     const fileName = `${randomUUID()}.png`
-    const dir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId, EMAIL_LOGO_CATEGORY)
+    const dir = path.join(uploadsRoot(), ctx.organizationId, EMAIL_LOGO_CATEGORY)
     await mkdir(dir, { recursive: true })
     await writeFile(path.join(dir, fileName), png.data)
 

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