Bernt Christian Egeland 3 недель назад
Родитель
Сommit
4b3de63543
100 измененных файлов с 11615 добавлено и 260 удалено
  1. 16 0
      .env.example
  2. 18 0
      .github/workflows/ci.yml
  3. 9 1
      .github/workflows/deploy-prod.yml
  4. 7 1
      .github/workflows/deploy-staging.yml
  5. 202 0
      .github/workflows/e2e.yml
  6. 8 0
      .gitignore
  7. 11 0
      Dockerfile
  8. 145 143
      biome.json
  9. 219 0
      e2e/README.md
  10. 49 0
      e2e/auth.setup.ts
  11. BIN
      e2e/fixtures/email-logo.png
  12. 10 0
      e2e/global-setup.ts
  13. 36 0
      e2e/google-standin-preload.mjs
  14. 193 0
      e2e/google-standin.ts
  15. 132 0
      e2e/mail-sink.ts
  16. 371 0
      e2e/payment-sink.ts
  17. 95 0
      e2e/prepare-db.ts
  18. 129 0
      e2e/specs/auth/account.spec.ts
  19. 61 0
      e2e/specs/auth/pages.spec.ts
  20. 114 0
      e2e/specs/auth/roles.spec.ts
  21. 162 0
      e2e/specs/auth/sign-in.spec.ts
  22. 63 0
      e2e/specs/auth/sign-up.spec.ts
  23. 182 0
      e2e/specs/auth/tenancy.spec.ts
  24. 183 0
      e2e/specs/calendar/booking.spec.ts
  25. 84 0
      e2e/specs/cloud/auth-pages.spec.ts
  26. 166 0
      e2e/specs/cloud/google-sign-in.spec.ts
  27. 196 0
      e2e/specs/cloud/plan-gates.spec.ts
  28. 299 0
      e2e/specs/email/designer.spec.ts
  29. 294 0
      e2e/specs/email/sending.spec.ts
  30. 181 0
      e2e/specs/inventory/movements.spec.ts
  31. 310 0
      e2e/specs/invoices/designer-drag.spec.ts
  32. 262 0
      e2e/specs/invoices/designer.spec.ts
  33. 86 0
      e2e/specs/invoices/numbering.spec.ts
  34. 196 0
      e2e/specs/invoices/payments.spec.ts
  35. 159 0
      e2e/specs/invoices/pdf-attachments.spec.ts
  36. 183 0
      e2e/specs/invoices/pdf-parity.spec.ts
  37. 139 0
      e2e/specs/payments/booked-once.spec.ts
  38. 328 0
      e2e/specs/payments/checkout.spec.ts
  39. 171 0
      e2e/specs/quotes/document.spec.ts
  40. 242 0
      e2e/specs/reminders/due-time.spec.ts
  41. 225 0
      e2e/specs/security/admin-only.spec.ts
  42. 278 0
      e2e/specs/security/files.spec.ts
  43. 187 0
      e2e/specs/security/payment-attribution.spec.ts
  44. 111 0
      e2e/specs/security/sms-webhook.spec.ts
  45. 329 0
      e2e/specs/settings/marine.spec.ts
  46. 41 0
      e2e/specs/smoke/app.spec.ts
  47. 403 0
      e2e/specs/tech/api.spec.ts
  48. 56 0
      e2e/specs/vehicles/search.spec.ts
  49. 175 0
      e2e/specs/work-orders/layout.spec.ts
  50. 184 0
      e2e/specs/work-orders/lifecycle.spec.ts
  51. 169 0
      e2e/specs/work-orders/pricing.spec.ts
  52. 143 0
      e2e/specs/work-orders/quote-to-invoice.spec.ts
  53. 245 0
      e2e/specs/work-orders/split-tax.spec.ts
  54. 158 0
      e2e/specs/work-orders/title-template.spec.ts
  55. 142 0
      e2e/specs/work-orders/validation.spec.ts
  56. 33 0
      e2e/support/attachments.ts
  57. 120 0
      e2e/support/cloud.ts
  58. 941 0
      e2e/support/db.ts
  59. 75 0
      e2e/support/email-designer.ts
  60. 27 0
      e2e/support/hydration.ts
  61. 40 0
      e2e/support/inventory.ts
  62. 85 0
      e2e/support/mail.ts
  63. 77 0
      e2e/support/payments.ts
  64. 73 0
      e2e/support/pdf.ts
  65. 126 0
      e2e/support/quote.ts
  66. 179 0
      e2e/support/settings.ts
  67. 17 0
      e2e/support/totp.ts
  68. 50 0
      e2e/support/webhooks.ts
  69. 208 0
      e2e/support/work-order.ts
  70. 17 2
      messages/de/audit.json
  71. 44 0
      messages/de/auth.json
  72. 7 1
      messages/de/billing.json
  73. 11 1
      messages/de/common.json
  74. 4 0
      messages/de/customers.json
  75. 2 1
      messages/de/dashboard.json
  76. 60 0
      messages/de/email.json
  77. 5 0
      messages/de/featureHints.json
  78. 11 3
      messages/de/integrations.json
  79. 8 0
      messages/de/navigation.json
  80. 9 13
      messages/de/onboarding.json
  81. 13 1
      messages/de/pdf.json
  82. 1 0
      messages/de/permissions.json
  83. 3 1
      messages/de/portal.json
  84. 2 2
      messages/de/quotes.json
  85. 21 2
      messages/de/reports.json
  86. 11 4
      messages/de/service.json
  87. 449 63
      messages/de/settings.json
  88. 141 0
      messages/de/timeTracking.json
  89. 10 0
      messages/de/workOrders.json
  90. 17 2
      messages/en/audit.json
  91. 44 0
      messages/en/auth.json
  92. 7 1
      messages/en/billing.json
  93. 11 1
      messages/en/common.json
  94. 4 0
      messages/en/customers.json
  95. 2 1
      messages/en/dashboard.json
  96. 60 0
      messages/en/email.json
  97. 5 0
      messages/en/featureHints.json
  98. 11 3
      messages/en/integrations.json
  99. 8 0
      messages/en/navigation.json
  100. 9 13
      messages/en/onboarding.json

+ 16 - 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).
@@ -28,6 +38,12 @@ INTEGRATIONS_ENCRYPTION_KEY=""
 # self-hosted install to have each workshop enter its own app credentials.
 GOOGLE_INTEGRATION_CLIENT_ID=""
 GOOGLE_INTEGRATION_CLIENT_SECRET=""
+
+# Google sign-in (cloud mode only). A web OAuth client in the Google Cloud console
+# with this authorised redirect URI: <NEXT_PUBLIC_APP_URL>/api/public/auth/callback/google
+# Separate from the calendar connector client above, which requests calendar scopes.
+GOOGLE_AUTH_CLIENT_ID=""
+GOOGLE_AUTH_CLIENT_SECRET=""
 MICROSOFT_INTEGRATION_CLIENT_ID=""
 MICROSOFT_INTEGRATION_CLIENT_SECRET=""
 ZOOM_INTEGRATION_CLIENT_ID=""

+ 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

+ 9 - 1
.github/workflows/deploy-prod.yml

@@ -51,6 +51,8 @@ jobs:
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
+          GOOGLE_AUTH_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_ID }}
+          GOOGLE_AUTH_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_SECRET }}
           MICROSOFT_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_ID }}
           MICROSOFT_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_MICROSOFT_INTEGRATION_CLIENT_SECRET }}
           ZOOM_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_ID }}
@@ -61,7 +63,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -85,6 +87,10 @@ jobs:
                 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
                 NEXT_PUBLIC_APP_URL: ${APP_URL}
                 TORQVOICE_MODE: cloud
+                # Production sits behind Cloudflare and the origin only accepts its
+                # edges, so the visitor address is the one Cloudflare says it is.
+                # Without this every visitor shares the edge's rate-limit bucket.
+                TRUST_CF_CONNECTING_IP: "true"
                 STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
                 STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
                 STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID}
@@ -95,6 +101,8 @@ jobs:
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
+                GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
+                GOOGLE_AUTH_CLIENT_SECRET: ${GOOGLE_AUTH_CLIENT_SECRET}
                 MICROSOFT_INTEGRATION_CLIENT_ID: ${MICROSOFT_INTEGRATION_CLIENT_ID}
                 MICROSOFT_INTEGRATION_CLIENT_SECRET: ${MICROSOFT_INTEGRATION_CLIENT_SECRET}
                 ZOOM_INTEGRATION_CLIENT_ID: ${ZOOM_INTEGRATION_CLIENT_ID}

+ 7 - 1
.github/workflows/deploy-staging.yml

@@ -35,6 +35,8 @@ jobs:
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_SECRET }}
+          GOOGLE_AUTH_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_AUTH_CLIENT_ID }}
+          GOOGLE_AUTH_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_AUTH_CLIENT_SECRET }}
           MICROSOFT_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_MICROSOFT_INTEGRATION_CLIENT_ID }}
           MICROSOFT_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_MICROSOFT_INTEGRATION_CLIENT_SECRET }}
           ZOOM_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_ZOOM_INTEGRATION_CLIENT_ID }}
@@ -45,7 +47,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -69,6 +71,8 @@ jobs:
                 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
                 NEXT_PUBLIC_APP_URL: ${APP_URL}
                 TORQVOICE_MODE: cloud
+                # Staging sits behind Cloudflare like production; see deploy-prod.yml.
+                TRUST_CF_CONNECTING_IP: "true"
                 STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
                 STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
                 STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID}
@@ -78,6 +82,8 @@ jobs:
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
+                GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
+                GOOGLE_AUTH_CLIENT_SECRET: ${GOOGLE_AUTH_CLIENT_SECRET}
                 MICROSOFT_INTEGRATION_CLIENT_ID: ${MICROSOFT_INTEGRATION_CLIENT_ID}
                 MICROSOFT_INTEGRATION_CLIENT_SECRET: ${MICROSOFT_INTEGRATION_CLIENT_SECRET}
                 ZOOM_INTEGRATION_CLIENT_ID: ${ZOOM_INTEGRATION_CLIENT_ID}

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

@@ -0,0 +1,202 @@
+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
+
+# The suite runs one test at a time against one seeded database, so more
+# workers would only trip over each other. More databases do not: resetting
+# and seeding one takes about fifteen seconds. So the specs are split across
+# shards, each on its own runner with its own database, and the cloud specs
+# run beside them. The last job merges what they found into one report and is
+# the one check a pull request waits on.
+
+jobs:
+  e2e:
+    name: Playwright (${{ matrix.name }})
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+
+    strategy:
+      # A failure in one shard says nothing about the others; let them finish.
+      fail-fast: false
+      matrix:
+        include:
+          - { id: shard-1, name: shard 1 of 4, shard: 1/4 }
+          - { id: shard-2, name: shard 2 of 4, shard: 2/4 }
+          - { id: shard-3, name: shard 3 of 4, shard: 3/4 }
+          - { id: shard-4, name: shard 4 of 4, shard: 4/4 }
+          # The same build started in cloud mode: plan limits, the sign-up
+          # pitch and Google sign-in only exist there.
+          - { id: cloud, name: cloud, mode: cloud }
+
+    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
+      E2E_MODE: ${{ matrix.mode }}
+
+    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
+        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.
+      # Every job reads it; only the first shard writes it back, so five jobs
+      # do not race to save the same entry.
+      - name: Restore the build cache
+        uses: actions/cache/restore@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: Save the build cache
+        if: ${{ matrix.id == 'shard-1' }}
+        uses: actions/cache/save@v4
+        with:
+          path: .next/cache
+          key: next-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
+
+      - name: Run the specs
+        run: npx playwright test ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
+
+      # The blob carries the results with their traces, screenshots and videos;
+      # the report job turns every job's blob into one HTML report.
+      - name: Upload the blob report
+        if: ${{ !cancelled() }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: blob-report-${{ matrix.id }}
+          path: blob-report/
+          retention-days: 1
+          if-no-files-found: ignore
+
+  report:
+    # Named as the single job used to be, so a required check keeps its name.
+    name: Playwright
+    needs: e2e
+    if: ${{ !cancelled() }}
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 22
+          cache: npm
+
+      - run: npm ci
+
+      - name: Download the blob reports
+        uses: actions/download-artifact@v4
+        with:
+          pattern: blob-report-*
+          path: all-blob-reports
+
+      # Each job's blob lands in a folder of its own. The merge wants them side
+      # by side, and two jobs may give their file the same name.
+      - name: Gather the blobs
+        run: |
+          mkdir -p blob-reports
+          for dir in all-blob-reports/*/; do
+            [ -d "$dir" ] || continue
+            job=$(basename "$dir")
+            for file in "$dir"*.zip; do
+              [ -e "$file" ] || continue
+              mv "$file" "blob-reports/${job}-$(basename "$file")"
+            done
+          done
+          ls -la blob-reports
+
+      - name: Merge the reports
+        run: npx playwright merge-reports --reporter html ./blob-reports
+
+      - name: Upload the report
+        uses: actions/upload-artifact@v4
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 7
+          if-no-files-found: ignore
+
+      # Green only when every shard and the cloud specs are.
+      - name: Every job passed
+        if: ${{ always() }}
+        run: |
+          if [ "${{ needs.e2e.result }}" != "success" ]; then
+            echo "The e2e jobs finished as: ${{ needs.e2e.result }}"
+            exit 1
+          fi

+ 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

+ 11 - 0
Dockerfile

@@ -8,6 +8,10 @@ WORKDIR /app
 COPY package.json package-lock.json* ./
 COPY prisma ./prisma/
 COPY prisma.config.ts ./prisma.config.ts
+# npm ci runs the prepare script, which patches next-ws and then applies our
+# own follow-up patch from scripts/. The source tree is not copied until the
+# builder stage, so that one file has to come along here.
+COPY scripts/patch-next-ws-first-upgrade.mjs ./scripts/
 RUN npm ci
 
 # Rebuild the source code only when needed
@@ -93,6 +97,13 @@ RUN rm -rf \
 # resolve the binary against the platform the image will actually run on.
 RUN npx next-ws patch --yes
 
+# next-ws reads a route module's exports the moment an upgrade arrives, and
+# Next 16.3 loads route modules lazily, so the first WebSocket connection after
+# every boot died with "The lazy module is still loading" until the fix below
+# is applied to the fresh copy the install above pulled in.
+COPY --from=builder --chown=nextjs:nodejs /app/scripts/patch-next-ws-first-upgrade.mjs ./scripts/patch-next-ws-first-upgrade.mjs
+RUN node scripts/patch-next-ws-first-upgrade.mjs
+
 # Fail the build here rather than at the first certificate download: a native
 # module that cannot be loaded throws while the route module is being
 # evaluated, which reaches the browser as an empty HTTP 500 with nothing in it

+ 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"
+          }
+        }
+      }
+    }
+  ]
+}

+ 219 - 0
e2e/README.md

@@ -0,0 +1,219 @@
+# 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
+  payment-sink.ts      Stripe and PayPal as far as the app can tell; no money moves
+  google-standin.ts    Google's account chooser and token endpoint, for the cloud run
+  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
+    email/             the email template designer, and the mail it sends
+    payments/          a customer pays online, and the payment is booked once
+    security/          the doors the September 2026 audit found open: admin-only
+                       actions, file paths, payment attribution, webhook signatures
+    cloud/             run with E2E_MODE=cloud: plan limits, Google sign-in, the sign-up pitch
+    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. The specs are split into four shards
+(`--shard=1/4` and so on) plus a job for the cloud specs, all at once. Each job
+has its own `postgres:16-alpine` service holding `torqvoice_e2e`, installs
+Chromium, builds with `NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100`, and seeds its
+own database, so the one-test-at-a-time rule still holds inside every job.
+A spec that only passes because another file ran before it will fail here.
+
+To run one shard the way CI does:
+
+```bash
+npx playwright test --shard=2/4
+```
+
+On CI every job writes a blob report, and the last job, `Playwright`, merges them
+into one HTML report uploaded as the `playwright-report` artifact. It is green
+only when every shard and the cloud job are. Open a downloaded report 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.

+ 49 - 0
e2e/auth.setup.ts

@@ -0,0 +1,49 @@
+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)
+
+  // A workshop that predates a feature is greeted with an announcement card
+  // on its first page ("Your emails have a new look"). It is a non-modal
+  // dialog, so a spec looking for "the dialog" finds it as well as its own.
+  // Closed here through its own button, which the app records for the whole
+  // workshop, so no spec starts with one on screen. Announcements queue one
+  // at a time, hence the loop.
+  const gotIt = page.getByRole('button', { name: 'Got it', exact: true })
+  for (let shown = 0; shown < 5; shown++) {
+    const open = await gotIt
+      .first()
+      .waitFor({ state: 'visible', timeout: 3_000 })
+      .then(() => true)
+      .catch(() => false)
+    if (!open) break
+    await gotIt.first().click()
+    await expect(gotIt).toHaveCount(0, { timeout: 10_000 })
+  }
+
+  // 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 })
+})

BIN
e2e/fixtures/email-logo.png


+ 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()
+}

+ 36 - 0
e2e/google-standin-preload.mjs

@@ -0,0 +1,36 @@
+/**
+ * Points the app server's calls to Google at the stand-in, and nothing else.
+ *
+ * Loaded into the app under test through NODE_OPTIONS=--import, and only in
+ * the cloud-mode run: better-auth's Google provider has its endpoints written
+ * into it, so there is no setting to change, and the app's own code is left
+ * exactly as it ships. Every other request goes where it was going.
+ *
+ * Inert unless E2E_GOOGLE_STANDIN_URL is set.
+ */
+
+const standin = process.env.E2E_GOOGLE_STANDIN_URL?.replace(/\/+$/, '')
+
+if (standin) {
+  const rewrites = [
+    ['https://oauth2.googleapis.com/token', `${standin}/token`],
+    ['https://www.googleapis.com/oauth2/v3/certs', `${standin}/oauth2/v3/certs`],
+  ]
+  const original = globalThis.fetch
+
+  globalThis.fetch = function fetchThroughStandin(input, init) {
+    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url
+    for (const [from, to] of rewrites) {
+      if (url?.startsWith(from)) {
+        const target = to + url.slice(from.length)
+        if (typeof input === 'string' || input instanceof URL) {
+          return original.call(this, target, init)
+        }
+        return original.call(this, new Request(target, input), init)
+      }
+    }
+    return original.call(this, input, init)
+  }
+
+  console.log(`[google-standin] Google token calls from this server go to ${standin}`)
+}

+ 193 - 0
e2e/google-standin.ts

@@ -0,0 +1,193 @@
+import { randomBytes } from 'node:crypto'
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
+
+/**
+ * Google, as far as a sign-in needs it, with nobody's real account in it.
+ *
+ * Google sign-in cannot run from a test: it needs a real Google account, a
+ * consent screen that is Google's to change, and a callback Google would only
+ * send to an address it knows. So this plays the two parts of Google the app
+ * touches in a sign-in. The browser is sent to its account chooser (a spec
+ * routes `accounts.google.com` here), and the app's server exchanges the code
+ * at its token endpoint (e2e/google-standin-preload.mjs points it here).
+ *
+ * The accounts it offers are the ones a spec registers, each with its own
+ * `email_verified`, because what an account-linking rule does with an address
+ * Google has not verified is the question most worth a test. The ID token it
+ * returns is unsigned: in the redirect flow better-auth decodes the token it
+ * receives straight from the token endpoint and does not check a signature.
+ */
+
+const PORT = Number(process.env.E2E_GOOGLE_PORT ?? 8027)
+
+interface Account {
+  sub: string
+  email: string
+  email_verified: boolean
+  name: string
+}
+
+interface Grant {
+  account: Account
+  clientId: string
+  redirectUri: string
+}
+
+const state = {
+  accounts: [] as Account[],
+  /** The query of every trip to the chooser, oldest first. */
+  authorizeRequests: [] as Record<string, string>[],
+  /** What the app sent to the token endpoint, oldest first. */
+  tokenExchanges: [] as { clientId: string; code: string; hadVerifier: boolean }[],
+}
+const grants = new Map<string, Grant>()
+
+function json(res: ServerResponse, status: number, body: unknown): void {
+  res.writeHead(status, { 'content-type': 'application/json' })
+  res.end(JSON.stringify(body))
+}
+
+function redirect(res: ServerResponse, to: string): void {
+  res.writeHead(303, { location: to })
+  res.end()
+}
+
+async function readBody(req: IncomingMessage): Promise<string> {
+  const chunks: Buffer[] = []
+  for await (const chunk of req) chunks.push(chunk as Buffer)
+  return Buffer.concat(chunks).toString('utf8')
+}
+
+function escapeHtml(value: string): string {
+  return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
+}
+
+function base64url(value: string): string {
+  return Buffer.from(value).toString('base64url')
+}
+
+/** An ID token shaped like Google's. Unsigned; see the note at the top. */
+function idToken(account: Account, clientId: string): string {
+  const now = Math.floor(Date.now() / 1000)
+  const header = base64url(JSON.stringify({ alg: 'RS256', kid: 'e2e-standin', typ: 'JWT' }))
+  const payload = base64url(
+    JSON.stringify({
+      iss: 'https://accounts.google.com',
+      azp: clientId,
+      aud: clientId,
+      sub: account.sub,
+      email: account.email,
+      email_verified: account.email_verified,
+      name: account.name,
+      iat: now,
+      exp: now + 3600,
+    })
+  )
+  return `${header}.${payload}.${base64url('e2e-standin-signature')}`
+}
+
+function chooser(query: URLSearchParams): string {
+  const hidden = ['redirect_uri', 'state', 'client_id']
+    .map((k) => `<input type="hidden" name="${k}" value="${escapeHtml(query.get(k) ?? '')}">`)
+    .join('')
+  const accounts = state.accounts
+    .map(
+      (a) =>
+        `<form method="post" action="/o/oauth2/v2/auth/choose">${hidden}` +
+        `<input type="hidden" name="sub" value="${escapeHtml(a.sub)}">` +
+        `<button type="submit">${escapeHtml(a.email)}</button></form>`
+    )
+    .join('')
+  const cancel = `${query.get('redirect_uri') ?? ''}?error=access_denied&state=${encodeURIComponent(query.get('state') ?? '')}`
+  return (
+    `<!doctype html><html><head><meta charset="utf-8"><title>Sign in - Google Accounts</title></head><body>` +
+    `<h1>Choose an account</h1>${accounts}<a href="${escapeHtml(cancel)}">Cancel</a></body></html>`
+  )
+}
+
+const server = createServer(async (req, res) => {
+  const url = new URL(req.url ?? '/', `http://127.0.0.1:${PORT}`)
+  try {
+    if (url.pathname === '/health') return json(res, 200, { ok: true })
+
+    if (url.pathname === '/state') {
+      if (req.method === 'DELETE') {
+        state.accounts.length = 0
+        state.authorizeRequests.length = 0
+        state.tokenExchanges.length = 0
+        grants.clear()
+        return json(res, 200, { cleared: true })
+      }
+      return json(res, 200, state)
+    }
+
+    if (req.method === 'POST' && url.pathname === '/accounts') {
+      const body = JSON.parse((await readBody(req)) || '{}') as Partial<Account>
+      if (!body.email) return json(res, 400, { error: 'email is required' })
+      const account: Account = {
+        sub: body.sub ?? `e2e-${randomBytes(8).toString('hex')}`,
+        email: body.email,
+        email_verified: body.email_verified !== false,
+        name: body.name ?? body.email,
+      }
+      state.accounts.push(account)
+      return json(res, 200, account)
+    }
+
+    if (req.method === 'GET' && url.pathname === '/o/oauth2/v2/auth') {
+      state.authorizeRequests.push(Object.fromEntries(url.searchParams))
+      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
+      res.end(chooser(url.searchParams))
+      return
+    }
+
+    if (req.method === 'POST' && url.pathname === '/o/oauth2/v2/auth/choose') {
+      const form = new URLSearchParams(await readBody(req))
+      const account = state.accounts.find((a) => a.sub === form.get('sub'))
+      const redirectUri = form.get('redirect_uri') ?? ''
+      if (!account || !redirectUri) return json(res, 400, { error: 'unknown account' })
+      const code = randomBytes(16).toString('hex')
+      grants.set(code, { account, clientId: form.get('client_id') ?? '', redirectUri })
+      const back = new URL(redirectUri)
+      back.searchParams.set('code', code)
+      back.searchParams.set('state', form.get('state') ?? '')
+      back.searchParams.set('scope', 'email profile openid')
+      return redirect(res, back.toString())
+    }
+
+    if (req.method === 'POST' && url.pathname === '/token') {
+      const form = new URLSearchParams(await readBody(req))
+      // The client may authenticate in the body or with Basic, as Google allows.
+      const basic = (req.headers.authorization ?? '').replace(/^Basic\s+/i, '')
+      const [basicId] = basic ? Buffer.from(basic, 'base64').toString('utf8').split(':') : []
+      const clientId = form.get('client_id') ?? decodeURIComponent(basicId ?? '')
+      const code = form.get('code') ?? ''
+      state.tokenExchanges.push({ clientId, code, hadVerifier: form.has('code_verifier') })
+
+      const grant = grants.get(code)
+      if (!grant) return json(res, 400, { error: 'invalid_grant' })
+      // A code is good once, as Google's are.
+      grants.delete(code)
+      return json(res, 200, {
+        access_token: `e2e-google-access-${code}`,
+        expires_in: 3599,
+        scope:
+          'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
+        token_type: 'Bearer',
+        id_token: idToken(grant.account, grant.clientId || clientId),
+      })
+    }
+
+    if (req.method === 'GET' && url.pathname === '/oauth2/v3/certs') {
+      return json(res, 200, { keys: [] })
+    }
+
+    json(res, 404, { error: `google stand-in has nothing at ${url.pathname}` })
+  } catch (error) {
+    json(res, 500, { error: error instanceof Error ? error.message : String(error) })
+  }
+})
+
+server.listen(PORT, '127.0.0.1', () => {
+  console.log(`[google-standin] account chooser and token endpoint on http://127.0.0.1:${PORT}`)
+})

+ 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)))
+  })
+}

+ 371 - 0
e2e/payment-sink.ts

@@ -0,0 +1,371 @@
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
+
+/**
+ * A payment vendor that moves no money.
+ *
+ * Stripe and PayPal are reached over the network with a workshop's own keys,
+ * and neither can be used from a test run: no account to charge, and a hosted
+ * checkout page that is theirs to change. So this stands in for both, speaking
+ * just the calls the app makes, with the same shapes the vendors answer with,
+ * and a checkout page of its own where a spec clicks "Pay" the way a customer
+ * would. The app is pointed here by STRIPE_API_BASE_URL and
+ * PAYPAL_API_BASE_URL, which only the environment can set.
+ *
+ * It keeps what it is given in memory and hands it back over /state, so a spec
+ * can check that the amount a customer was charged is the amount the invoice
+ * showed. A key or secret containing "wrong" is refused, the way a vendor
+ * refuses one it does not know. Run on its own with `npx tsx e2e/payment-sink.ts`.
+ */
+
+const PORT = Number(process.env.E2E_PAYMENT_PORT ?? 8026)
+const SELF = `http://127.0.0.1:${PORT}`
+
+interface StripeSession {
+  id: string
+  object: 'checkout.session'
+  mode: 'payment'
+  status: 'open' | 'complete' | 'expired'
+  payment_status: 'unpaid' | 'paid'
+  amount_total: number
+  currency: string
+  metadata: Record<string, string>
+  success_url: string
+  cancel_url: string
+  url: string
+}
+
+interface PayPalOrder {
+  id: string
+  intent: 'CAPTURE'
+  status: 'PAYER_ACTION_REQUIRED' | 'APPROVED' | 'COMPLETED'
+  amount: { currency_code: string; value: string }
+  custom_id: string
+  invoice_id: string
+  return_url: string
+  cancel_url: string
+}
+
+const state = {
+  stripe: [] as StripeSession[],
+  paypal: [] as PayPalOrder[],
+  /** Every request the app made, oldest first: "POST /v1/checkout/sessions". */
+  calls: [] as string[],
+}
+let counter = 0
+/**
+ * Stamped into every id, because the counter starts again with each run and
+ * the database does not. A session called `cs_test_e2e_5` in this run is not
+ * the one of that name an earlier run paid, and the app keys payments on
+ * these ids exactly as it keys them on Stripe's and PayPal's.
+ */
+const RUN = Date.now().toString(36)
+
+function json(res: ServerResponse, status: number, body: unknown): void {
+  res.writeHead(status, { 'content-type': 'application/json' })
+  res.end(JSON.stringify(body))
+}
+
+function html(res: ServerResponse, body: string): void {
+  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
+  res.end(
+    `<!doctype html><html><head><meta charset="utf-8"><title>E2E checkout</title></head><body>${body}</body></html>`
+  )
+}
+
+function redirect(res: ServerResponse, to: string): void {
+  res.writeHead(303, { location: to })
+  res.end()
+}
+
+async function readBody(req: IncomingMessage): Promise<string> {
+  const chunks: Buffer[] = []
+  for await (const chunk of req) chunks.push(chunk as Buffer)
+  return Buffer.concat(chunks).toString('utf8')
+}
+
+/**
+ * Stripe's form encoding, `metadata[orgId]=…&line_items[0][price_data][unit_amount]=…`,
+ * as the nested object it describes.
+ */
+function parseStripeForm(body: string): Record<string, unknown> {
+  const out: Record<string, unknown> = {}
+  for (const [key, value] of new URLSearchParams(body)) {
+    const path = key.split(/[[\]]+/).filter(Boolean)
+    let node = out
+    path.forEach((segment, i) => {
+      if (i === path.length - 1) {
+        node[segment] = value
+      } else {
+        node[segment] = (node[segment] as Record<string, unknown>) ?? {}
+        node = node[segment] as Record<string, unknown>
+      }
+    })
+  }
+  return out
+}
+
+function stripeRefuses(req: IncomingMessage): boolean {
+  const key = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
+  return !key || key.includes('wrong')
+}
+
+function money(cents: number, currency: string): string {
+  return `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`
+}
+
+async function handleStripe(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
+  if (!url.pathname.startsWith('/v1/') || url.pathname.startsWith('/v1/oauth2')) return false
+
+  if (stripeRefuses(req)) {
+    json(res, 401, {
+      error: {
+        type: 'invalid_request_error',
+        code: 'api_key_invalid',
+        message: 'Invalid API Key provided',
+      },
+    })
+    return true
+  }
+
+  if (req.method === 'GET' && url.pathname === '/v1/account') {
+    json(res, 200, {
+      id: 'acct_e2e',
+      object: 'account',
+      email: 'payments@e2e.test',
+      settings: { dashboard: { display_name: 'E2E Stripe account' } },
+    })
+    return true
+  }
+
+  if (req.method === 'POST' && url.pathname === '/v1/checkout/sessions') {
+    const form = parseStripeForm(await readBody(req)) as {
+      line_items?: Record<string, { price_data?: { currency?: string; unit_amount?: string } }>
+      metadata?: Record<string, string>
+      success_url?: string
+      cancel_url?: string
+    }
+    const line = form.line_items?.['0']?.price_data
+    const id = `cs_test_e2e_${RUN}_${++counter}`
+    const session: StripeSession = {
+      id,
+      object: 'checkout.session',
+      mode: 'payment',
+      status: 'open',
+      payment_status: 'unpaid',
+      amount_total: Number(line?.unit_amount ?? 0),
+      currency: line?.currency ?? 'usd',
+      metadata: form.metadata ?? {},
+      success_url: form.success_url ?? '',
+      cancel_url: form.cancel_url ?? '',
+      url: `${SELF}/pay/stripe/${id}`,
+    }
+    state.stripe.push(session)
+    json(res, 200, session)
+    return true
+  }
+
+  const retrieve = url.pathname.match(/^\/v1\/checkout\/sessions\/([^/]+)$/)
+  if (req.method === 'GET' && retrieve) {
+    const session = state.stripe.find((s) => s.id === retrieve[1])
+    if (!session) {
+      json(res, 404, {
+        error: {
+          type: 'invalid_request_error',
+          message: `No such checkout.session: '${retrieve[1]}'`,
+        },
+      })
+    } else {
+      json(res, 200, session)
+    }
+    return true
+  }
+
+  json(res, 404, { error: { type: 'invalid_request_error', message: `Unknown ${url.pathname}` } })
+  return true
+}
+
+function paypalOrderBody(order: PayPalOrder) {
+  const captures =
+    order.status === 'COMPLETED'
+      ? [
+          {
+            id: `CAP-${order.id}`,
+            status: 'COMPLETED',
+            amount: order.amount,
+            custom_id: order.custom_id,
+          },
+        ]
+      : undefined
+  return {
+    id: order.id,
+    intent: order.intent,
+    status: order.status,
+    purchase_units: [
+      {
+        reference_id: 'default',
+        custom_id: order.custom_id,
+        invoice_id: order.invoice_id,
+        amount: order.amount,
+        ...(captures ? { payments: { captures } } : {}),
+      },
+    ],
+  }
+}
+
+async function handlePayPal(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
+  if (req.method === 'POST' && url.pathname === '/v1/oauth2/token') {
+    const basic = (req.headers.authorization ?? '').replace(/^Basic\s+/i, '')
+    const [clientId, secret] = Buffer.from(basic, 'base64').toString('utf8').split(':')
+    if (!clientId || !secret || secret.includes('wrong')) {
+      json(res, 401, { error: 'invalid_client', error_description: 'Client Authentication failed' })
+    } else {
+      json(res, 200, { access_token: 'E2E-ACCESS-TOKEN', token_type: 'Bearer', expires_in: 32400 })
+    }
+    return true
+  }
+
+  if (!url.pathname.startsWith('/v2/checkout/orders')) return false
+
+  if (req.headers.authorization !== 'Bearer E2E-ACCESS-TOKEN') {
+    json(res, 401, { name: 'AUTHENTICATION_FAILURE', message: 'Authentication failed' })
+    return true
+  }
+
+  if (req.method === 'POST' && url.pathname === '/v2/checkout/orders') {
+    const body = JSON.parse((await readBody(req)) || '{}')
+    const unit = body.purchase_units?.[0] ?? {}
+    const context = body.payment_source?.paypal?.experience_context ?? {}
+    const id = `E2EORDER${RUN.toUpperCase()}${++counter}`
+    const order: PayPalOrder = {
+      id,
+      intent: 'CAPTURE',
+      status: 'PAYER_ACTION_REQUIRED',
+      amount: unit.amount ?? { currency_code: 'USD', value: '0.00' },
+      custom_id: unit.custom_id ?? '',
+      invoice_id: unit.invoice_id ?? '',
+      return_url: context.return_url ?? '',
+      cancel_url: context.cancel_url ?? '',
+    }
+    state.paypal.push(order)
+    json(res, 200, {
+      id,
+      status: order.status,
+      links: [
+        { rel: 'self', href: `${SELF}/v2/checkout/orders/${id}`, method: 'GET' },
+        { rel: 'payer-action', href: `${SELF}/pay/paypal/${id}`, method: 'GET' },
+      ],
+    })
+    return true
+  }
+
+  const capture = url.pathname.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/)
+  if (req.method === 'POST' && capture) {
+    const order = state.paypal.find((o) => o.id === capture[1])
+    if (!order) {
+      json(res, 404, { name: 'RESOURCE_NOT_FOUND' })
+    } else if (order.status === 'COMPLETED') {
+      json(res, 422, {
+        name: 'UNPROCESSABLE_ENTITY',
+        details: [{ issue: 'ORDER_ALREADY_CAPTURED' }],
+      })
+    } else if (order.status !== 'APPROVED') {
+      json(res, 422, { name: 'UNPROCESSABLE_ENTITY', details: [{ issue: 'ORDER_NOT_APPROVED' }] })
+    } else {
+      order.status = 'COMPLETED'
+      const body = paypalOrderBody(order)
+      // A capture answer carries the capture, not the unit's own custom_id.
+      json(res, 201, {
+        id: body.id,
+        status: body.status,
+        purchase_units: [{ reference_id: 'default', payments: body.purchase_units[0].payments }],
+      })
+    }
+    return true
+  }
+
+  const show = url.pathname.match(/^\/v2\/checkout\/orders\/([^/]+)$/)
+  if (req.method === 'GET' && show) {
+    const order = state.paypal.find((o) => o.id === show[1])
+    if (!order) json(res, 404, { name: 'RESOURCE_NOT_FOUND' })
+    else json(res, 200, paypalOrderBody(order))
+    return true
+  }
+
+  json(res, 404, { name: 'RESOURCE_NOT_FOUND', message: `Unknown ${url.pathname}` })
+  return true
+}
+
+/** The page a customer lands on at the vendor, with the one decision a customer makes there. */
+async function handleCheckoutPage(
+  req: IncomingMessage,
+  res: ServerResponse,
+  url: URL
+): Promise<boolean> {
+  const stripe = url.pathname.match(/^\/pay\/stripe\/([^/]+)$/)
+  if (stripe) {
+    const session = state.stripe.find((s) => s.id === stripe[1])
+    if (!session) return json(res, 404, { error: 'no such session' }), true
+    if (req.method === 'POST') {
+      session.status = 'complete'
+      session.payment_status = 'paid'
+      redirect(res, session.success_url.replace('{CHECKOUT_SESSION_ID}', session.id))
+      return true
+    }
+    html(
+      res,
+      `<h1>Stripe checkout</h1><p id="amount">${money(session.amount_total, session.currency)}</p>` +
+        `<form method="post"><button type="submit">Pay</button></form>` +
+        `<a href="${session.cancel_url}">Cancel</a>`
+    )
+    return true
+  }
+
+  const paypal = url.pathname.match(/^\/pay\/paypal\/([^/]+)$/)
+  if (paypal) {
+    const order = state.paypal.find((o) => o.id === paypal[1])
+    if (!order) return json(res, 404, { error: 'no such order' }), true
+    if (req.method === 'POST') {
+      order.status = 'APPROVED'
+      // PayPal appends its own token and PayerID to whatever return URL it was given.
+      const joiner = order.return_url.includes('?') ? '&' : '?'
+      redirect(res, `${order.return_url}${joiner}token=${order.id}&PayerID=E2EPAYER`)
+      return true
+    }
+    html(
+      res,
+      `<h1>PayPal checkout</h1><p id="amount">${order.amount.value} ${order.amount.currency_code}</p>` +
+        `<form method="post"><button type="submit">Pay</button></form>` +
+        `<a href="${order.cancel_url}">Cancel</a>`
+    )
+    return true
+  }
+  return false
+}
+
+const server = createServer(async (req, res) => {
+  const url = new URL(req.url ?? '/', SELF)
+  try {
+    if (url.pathname === '/health') return json(res, 200, { ok: true })
+    if (url.pathname === '/state') {
+      if (req.method === 'DELETE') {
+        state.stripe.length = 0
+        state.paypal.length = 0
+        state.calls.length = 0
+        return json(res, 200, { cleared: true })
+      }
+      return json(res, 200, state)
+    }
+
+    state.calls.push(`${req.method} ${url.pathname}`)
+    if (await handleCheckoutPage(req, res, url)) return
+    if (await handlePayPal(req, res, url)) return
+    if (await handleStripe(req, res, url)) return
+    json(res, 404, { error: `payment sink has nothing at ${url.pathname}` })
+  } catch (error) {
+    json(res, 500, { error: error instanceof Error ? error.message : String(error) })
+  }
+})
+
+server.listen(PORT, '127.0.0.1', () => {
+  console.log(`[payment-sink] Stripe and PayPal stand-in on ${SELF}`)
+})

+ 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()
+  })
+})

+ 61 - 0
e2e/specs/auth/pages.spec.ts

@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+
+/**
+ * The sign-in and sign-up pages on a self-hosted install.
+ *
+ * The pages were rebuilt around a cloud sign-up pitch, and a self-hosted
+ * install must not get it: its visitors are the workshop's own staff, and a
+ * "free plan, no credit card" banner or a Google button would be selling them
+ * a product they already run, through a Google client nobody configured. What
+ * they do keep is the way to an account above the form and the language
+ * switcher, which is theirs as much as anybody's.
+ */
+
+test.use({ storageState: { cookies: [], origins: [] } })
+
+test.describe('a self-hosted install', () => {
+  test('signs people up without a sales pitch or Google', async ({ page }) => {
+    await page.goto('/auth/sign-up')
+    await settle(page)
+
+    for (const field of ['#name', '#email', '#password']) {
+      await expect(page.locator(field)).toBeVisible()
+    }
+    await expect(page.getByText('Free plan, no credit card')).toHaveCount(0)
+    await expect(page.getByRole('button', { name: 'Continue with Google' })).toHaveCount(0)
+  })
+
+  test('puts the way to an account above the sign-in form', async ({ page }) => {
+    await page.goto('/auth/sign-in')
+    await settle(page)
+
+    const link = page.getByRole('link', { name: 'Create one' })
+    await expect(link).toHaveAttribute('href', /\/auth\/sign-up/)
+    const linkBox = await link.boundingBox()
+    const emailBox = await page.locator('#email').boundingBox()
+    expect(linkBox && emailBox && linkBox.y < emailBox.y, 'the link sits above the form').toBe(true)
+    await expect(page.getByRole('button', { name: 'Continue with Google' })).toHaveCount(0)
+  })
+
+  test('lets the language be changed before signing in', async ({ page, context }) => {
+    await page.goto('/auth/sign-in')
+    await settle(page)
+    await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible()
+
+    await expect(async () => {
+      await page.getByRole('combobox', { name: 'Language' }).click()
+      await expect(page.getByRole('option', { name: 'Norsk Bokmål' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('option', { name: 'Norsk Bokmål' }).click()
+
+    // The page re-renders in Norwegian and the choice is kept.
+    await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toHaveCount(0, {
+      timeout: 30_000,
+    })
+    const cookie = (await context.cookies()).find((c) => c.name === 'locale')
+    expect(cookie?.value).toBe('nb')
+  })
+})

+ 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')
+  })
+})

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

@@ -0,0 +1,183 @@
+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()
+}
+
+/**
+ * A day far enough ahead that nothing is booked on it.
+ *
+ * Today's column is whatever the seed and the other specs have put there, and
+ * a right-click that lands on a chip opens that job's menu instead of the
+ * empty-slot one. The date is a query the calendar already takes.
+ */
+const EMPTY_DAY = (() => {
+  const day = new Date(Date.now() + 45 * 86_400_000)
+  return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
+})()
+
+async function openDayView(page: Page) {
+  await page.goto(`/calendar?view=day&date=${EMPTY_DAY}`)
+  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()
+  })
+})

+ 84 - 0
e2e/specs/cloud/auth-pages.spec.ts

@@ -0,0 +1,84 @@
+import { expect, test } from '@playwright/test'
+import { signUpWithPassword } from '../../support/cloud'
+import { settle } from '../../support/hydration'
+
+/**
+ * The sign-up and sign-in pages as a stranger meets them, in cloud mode.
+ *
+ * Four in ten people who landed on sign-up left without touching the form, on
+ * a page that said nothing about what they were signing up for. The pages now
+ * make the case beside the form, point a newcomer who clicked "Login" at a
+ * free account before the form, and let a visitor change the language before
+ * they have an account to keep it on. The last is the one that can quietly
+ * break: a language picked on sign-up has to survive the sign-up itself.
+ */
+
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+
+test.describe('the sign-up page', () => {
+  test('makes its case beside the form', async ({ page }) => {
+    await page.goto('/auth/sign-up')
+    await settle(page)
+
+    await expect(page.getByRole('heading', { name: 'Start your free workshop' })).toBeVisible()
+    await expect(page.getByText('Ready in under a minute. No credit card required.')).toBeVisible()
+    await expect(page.getByRole('heading', { name: /Workshop management/ })).toBeVisible()
+    await expect(page.getByText('Free plan, no credit card')).toBeVisible()
+
+    // And the form is still the thing to do.
+    for (const field of ['#name', '#email', '#password']) {
+      await expect(page.locator(field)).toBeVisible()
+    }
+    await expect(page.getByRole('button', { name: 'Create free account' })).toBeVisible()
+  })
+
+  test('keeps a language picked before signing up, into onboarding', async ({ page, context }) => {
+    await page.goto('/auth/sign-up')
+    await settle(page)
+
+    await expect(async () => {
+      await page.getByRole('combobox', { name: 'Language' }).click()
+      await expect(page.getByRole('option', { name: 'Norsk Bokmål' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('option', { name: 'Norsk Bokmål' }).click()
+
+    await expect(page.getByRole('heading', { name: 'Start ditt gratis verksted' })).toBeVisible({
+      timeout: 30_000,
+    })
+    const cookie = (await context.cookies()).find((c) => c.name === 'locale')
+    expect(cookie?.value, 'kept for the visits after this one').toBe('nb')
+
+    // Signed up in Norwegian, and onboarding speaks it too.
+    await signUpWithPassword(page, {
+      name: 'E2E Norsk',
+      email: `e2e-norsk-${stamp}@example.com`,
+      password: `E2e-pass-${stamp}`,
+    })
+    await expect(page.getByRole('heading', { name: 'Sett opp verkstedet ditt' })).toBeVisible()
+  })
+})
+
+test.describe('the sign-in page', () => {
+  test('points a newcomer at a free account before the form', async ({ page }) => {
+    await page.goto('/auth/sign-in')
+    await settle(page)
+
+    await expect(page.getByText('New to Torqvoice?')).toBeVisible()
+    const link = page.getByRole('link', { name: 'Create a free account' })
+    await expect(link).toHaveAttribute('href', /\/auth\/sign-up/)
+
+    // Above the form, where a newcomer who clicked "Login" reads, rather than
+    // under it.
+    const linkBox = await link.boundingBox()
+    const emailBox = await page.locator('#email').boundingBox()
+    expect(linkBox && emailBox && linkBox.y < emailBox.y, 'the link sits above the form').toBe(true)
+
+    await link.click()
+    await page.waitForURL(/\/auth\/sign-up/)
+    await expect(page.getByRole('heading', { name: 'Start your free workshop' })).toBeVisible()
+  })
+})

+ 166 - 0
e2e/specs/cloud/google-sign-in.spec.ts

@@ -0,0 +1,166 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  clearGoogleStandin,
+  completeOnboarding,
+  googleStandin,
+  registerGoogleAccount,
+  routeGoogleToStandin,
+  signUpWithPassword,
+} from '../../support/cloud'
+import { personWithEmail } from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * Signing in with Google, and who a Google account is allowed to become.
+ *
+ * Google here is `e2e/google-standin.ts`: its account chooser takes the
+ * browser's trip to accounts.google.com, and its token endpoint takes the
+ * server's code exchange. Each test offers the accounts it needs, with the
+ * `email_verified` it needs.
+ *
+ * The rule most worth a test is account linking. A Google sign-in whose
+ * address matches an existing password account is attached to that account,
+ * which is what a returning customer expects. But an address Google has not
+ * verified proves nothing about who is signing in: anyone can create a Google
+ * account with somebody else's address on it. Attached to the account that
+ * owns the address, that is a way into another person's workshop.
+ */
+
+// Signing up and onboarding a workshop in a hook takes longer than a test.
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+// A stranger's browser, every time.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const NEWCOMER = `e2e-google-new-${stamp}@example.com`
+const RETURNING = `e2e-google-password-${stamp}@example.com`
+const OWNER = `e2e-google-owner-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+test.beforeAll(async ({ browser }) => {
+  await clearGoogleStandin()
+
+  // Two people who signed up with a password before Google was offered, each
+  // with a workshop of their own.
+  for (const [email, workshop] of [
+    [RETURNING, `E2E Returning Garage ${stamp}`],
+    [OWNER, `E2E Owner Garage ${stamp}`],
+  ]) {
+    const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+    const page = await context.newPage()
+    await signUpWithPassword(page, { name: 'E2E Password Person', email, password: PASSWORD })
+    await completeOnboarding(page, workshop, { sampleData: false })
+    await context.close()
+  }
+})
+
+test.beforeEach(async ({ context }) => {
+  await routeGoogleToStandin(context)
+})
+
+/** Presses "Continue with Google" and picks an account in the chooser. */
+async function continueWithGoogle(page: Page, from: string, email: string): Promise<void> {
+  await page.goto(from)
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Continue with Google' }).click()
+    await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
+      timeout: 5_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('button', { name: email, exact: true }).click()
+}
+
+test.describe('Google sign-in', () => {
+  test('is offered on the sign-in and the sign-up page', async ({ page }) => {
+    for (const path of ['/auth/sign-in', '/auth/sign-up']) {
+      await page.goto(path)
+      await expect(
+        page.getByRole('button', { name: 'Continue with Google' }),
+        `${path} offers it`
+      ).toBeVisible()
+    }
+  })
+
+  test('takes a newcomer to setting up a workshop', async ({ page }) => {
+    await registerGoogleAccount({ email: NEWCOMER, name: 'E2E Google Newcomer' })
+    await continueWithGoogle(page, '/auth/sign-up', NEWCOMER)
+
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+    await expect(page.getByRole('heading', { name: 'Set up your workshop' })).toBeVisible()
+
+    const person = await personWithEmail(NEWCOMER)
+    expect(person).toEqual({ users: 1, providers: ['google'], emailVerified: true })
+
+    // What was asked of Google: this app's client, the chooser every time
+    // (a workshop laptop is shared), and a code only this browser can redeem.
+    const { authorizeRequests, tokenExchanges } = await googleStandin()
+    const asked = authorizeRequests.at(-1)
+    expect(asked?.client_id).toBe('e2e-google-client')
+    expect(asked?.prompt).toBe('select_account')
+    expect(asked?.code_challenge, 'PKCE').toBeTruthy()
+    expect(tokenExchanges.at(-1)?.hadVerifier, 'the verifier came with the code').toBe(true)
+
+    await completeOnboarding(page, `E2E Google Garage ${stamp}`, { sampleData: false })
+  })
+
+  test('brings the same person back to their workshop next time', async ({ page }) => {
+    await continueWithGoogle(page, '/auth/sign-in', NEWCOMER)
+
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+    await expect(page.getByText(`E2E Google Garage ${stamp}`).first()).toBeVisible()
+    expect((await personWithEmail(NEWCOMER)).users, 'still one person').toBe(1)
+  })
+
+  test('joins a password account whose address Google has verified', async ({ page }) => {
+    await registerGoogleAccount({ email: RETURNING, emailVerified: true })
+    await continueWithGoogle(page, '/auth/sign-in', RETURNING)
+
+    // Into the workshop they already had, not into a second onboarding.
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+    await expect(page.getByText(`E2E Returning Garage ${stamp}`).first()).toBeVisible()
+
+    const person = await personWithEmail(RETURNING)
+    expect(person.users, 'one person, not two with the same address').toBe(1)
+    expect(person.providers).toEqual(['credential', 'google'])
+  })
+
+  test('does not hand an account to a Google address nobody verified', async ({ page }) => {
+    // Somebody made a Google account with the owner's address on it. Google
+    // says so: the address is not verified.
+    await registerGoogleAccount({ email: OWNER, name: 'Not the owner', emailVerified: false })
+    await continueWithGoogle(page, '/auth/sign-in', OWNER)
+
+    // Wherever the attempt ends up, it must not be inside the owner's workshop.
+    await page.waitForLoadState('networkidle')
+    await expect(
+      page.getByText(`E2E Owner Garage ${stamp}`),
+      'the owner workshop is not opened'
+    ).toHaveCount(0)
+    await expect(page).toHaveURL(/\/auth\//)
+
+    const person = await personWithEmail(OWNER)
+    expect(person.providers, 'no Google account attached to the owner').toEqual(['credential'])
+    expect(person.users).toBe(1)
+  })
+
+  test('comes back to sign-in, with a way forward, when the person turns back at Google', async ({
+    page,
+  }) => {
+    await page.goto('/auth/sign-in')
+    await settle(page)
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Continue with Google' }).click()
+      await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
+        timeout: 5_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('link', { name: 'Cancel' }).click()
+
+    await page.waitForURL(/\/auth\/sign-in/, { timeout: 30_000 })
+    await expect(page.getByText(/Google sign-in did not complete/)).toBeVisible()
+    // And the password form is still there to use.
+    await expect(page.locator('#email')).toBeVisible()
+  })
+})

+ 196 - 0
e2e/specs/cloud/plan-gates.spec.ts

@@ -0,0 +1,196 @@
+import { expect, test } from '@playwright/test'
+import { completeOnboarding, expectUpgradeOffered, signUpWithPassword } from '../../support/cloud'
+import {
+  customerRows,
+  giveProPlan,
+  insertCustomers,
+  organizationIdFor,
+  removePlan,
+  teamInvitations,
+  userIdFor,
+  vehicleRows,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * What the free plan allows, and what happens at its edge.
+ *
+ * Only in cloud mode: a self-hosted install has every feature, which is why
+ * the rest of the suite never reaches a limit. Two things are pinned here.
+ * The limit counts what the workshop made and not the sample data onboarding
+ * seeds, because counted, the samples ate three of the old five slots and a
+ * new workshop was refused on its third real customer. And reaching a limit
+ * offers an upgrade rather than an error: a red "Customer limit reached" with
+ * no way forward is how a workshop that was ready to pay left instead.
+ *
+ * A workshop of its own is opened here by signing up, so nothing about the
+ * seeded one changes.
+ */
+
+// Signing up and onboarding a workshop in a hook takes longer than a test.
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+const stamp = Date.now()
+const EMAIL = `e2e-free-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+const WORKSHOP = `E2E Free Garage ${stamp}`
+const STATE = `e2e/.auth/cloud-free-${stamp}.json`
+/** The free plan's customer allowance, from PLAN_FEATURES.free in src/lib/features.ts. */
+const FREE_CUSTOMERS = 20
+
+let organizationId = ''
+let planId = ''
+
+test.use({ storageState: STATE })
+
+test.beforeAll(async ({ browser }) => {
+  // Signed up the way a stranger does, sample data and all, and the session
+  // kept for the tests below.
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signUpWithPassword(page, { name: 'E2E Free Owner', email: EMAIL, password: PASSWORD })
+  await completeOnboarding(page, WORKSHOP, { sampleData: true })
+  await context.storageState({ path: STATE })
+  await context.close()
+
+  organizationId = await organizationIdFor(EMAIL)
+})
+
+test.afterAll(async () => {
+  if (planId) await removePlan(organizationId, planId)
+})
+
+/** Opens the customer form and saves a customer with just a name. */
+async function addCustomer(page: import('@playwright/test').Page, name: string): Promise<void> {
+  await page.goto('/customers')
+  await settle(page)
+  const dialog = page.getByRole('dialog', { name: 'Add New Customer' })
+  await expect(async () => {
+    await page
+      .getByRole('button', { name: 'Add Customer' })
+      .filter({ visible: true })
+      .first()
+      .click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await dialog.locator('#name').fill(name)
+  await dialog.locator('button[type="submit"]').click()
+}
+
+test.describe('the free plan', () => {
+  test('counts only the customers the workshop made, not the samples', async ({ page }) => {
+    // Onboarding seeded its sample customers. Real ones up to one short of
+    // the allowance go in directly; the last one goes through the form.
+    const samples = await customerRows(organizationId)
+    expect(samples, 'onboarding left sample customers to look around with').toBeGreaterThan(0)
+    await insertCustomers(organizationId, await userIdFor(EMAIL), FREE_CUSTOMERS - 1, 'E2E Real')
+
+    // Twenty real customers is inside the plan, however many rows the
+    // samples add on top.
+    await addCustomer(page, `E2E Twentieth ${stamp}`)
+    await expect(page.getByText('Customer created')).toBeVisible({ timeout: 30_000 })
+    expect(await customerRows(organizationId)).toBe(samples + FREE_CUSTOMERS)
+  })
+
+  test('offers an upgrade at the customer after that, instead of an error', async ({ page }) => {
+    const before = await customerRows(organizationId)
+
+    await addCustomer(page, `E2E Twenty-first ${stamp}`)
+    await expectUpgradeOffered(page, new RegExp(`up to ${FREE_CUSTOMERS} customers`))
+
+    // Refused on the server, not just hidden: nothing was written.
+    expect(await customerRows(organizationId)).toBe(before)
+    await expect(page.getByText('Failed to save customer')).toHaveCount(0)
+  })
+
+  test('offers the same upgrade when a new work order creates the customer', async ({ page }) => {
+    // The flow the getting-started checklist sends a new workshop to: vehicle,
+    // customer and job from one dialog. Still at the limit, the customer is
+    // refused, and with it the vehicle that would have belonged to them.
+    const customers = await customerRows(organizationId)
+    const vehicles = await vehicleRows(organizationId)
+
+    await page.goto('/work-orders?new=1')
+    await settle(page)
+    await expect(async () => {
+      await page
+        .getByRole('dialog', { name: 'Select Vehicle' })
+        .getByRole('button', { name: 'Add New Vehicle' })
+        .first()
+        .click()
+      await expect(page.locator('#new-make')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await page.locator('#new-make').fill('Volvo')
+    await page.locator('#new-model').fill('V70')
+    await page.locator('#new-year').fill('2015')
+    await page.getByRole('combobox').filter({ hasText: 'Select a customer (optional)' }).click()
+    await page.getByRole('option', { name: 'Create new customer' }).click()
+    await page.locator('#new-customer-name').fill(`E2E Work order customer ${stamp}`)
+    await page.getByRole('button', { name: 'Create & Continue' }).click()
+
+    await expectUpgradeOffered(page, new RegExp(`up to ${FREE_CUSTOMERS} customers`))
+    expect(await customerRows(organizationId), 'no customer written').toBe(customers)
+    expect(await vehicleRows(organizationId), 'and no vehicle without an owner').toBe(vehicles)
+    // Not taken on to a work order that has nobody to belong to.
+    await expect(page).toHaveURL(/\/work-orders/)
+  })
+
+  test('offers an upgrade when inviting a colleague', async ({ page }) => {
+    // The free plan is one person, so the first invitation is the limit.
+    await page.goto('/settings/team')
+    await settle(page)
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add', exact: true }).first().click()
+      await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByText('Someone in the office').click()
+    await page.locator('#member-email').fill(`e2e-colleague-${stamp}@example.com`)
+    await page.getByRole('button', { name: 'Invite', exact: true }).click()
+
+    await expectUpgradeOffered(page, /one team member/)
+    expect(await teamInvitations(organizationId), 'no invitation went out').toBe(0)
+  })
+
+  test('keeps online payments locked, with the way to a plan', async ({ page }) => {
+    // One of the settings pages gated by feature rather than by count: the
+    // notice names Stripe and points at the plans, and the connection behind
+    // it cannot be set up.
+    await page.goto('/settings/integrations/stripe')
+    await settle(page)
+
+    await expect(page.getByText('Subscription required')).toBeVisible({ timeout: 30_000 })
+    await expect(page.getByRole('heading', { name: 'Stripe' })).toBeVisible()
+    // In terms of payments, not the calendars and video calls other
+    // integrations are about.
+    await expect(page.getByText(/pay their invoices online/)).toBeVisible()
+    await expect(page.getByText(/calendars, video calls/)).toHaveCount(0)
+    await expect(page.getByRole('link', { name: 'View plans' })).toHaveAttribute(
+      'href',
+      '/settings/subscription'
+    )
+    // Nothing to type keys into, in front of the notice or behind it.
+    await expect(page.locator('input[name="stripe-secretKey"]')).toHaveCount(0)
+    await expect(page.getByRole('button', { name: 'Connect', exact: true })).toHaveCount(0)
+  })
+})
+
+test.describe('a paid plan', () => {
+  test('is not stopped where the free plan was', async ({ page }) => {
+    planId = await giveProPlan(organizationId)
+    const before = await customerRows(organizationId)
+
+    await addCustomer(page, `E2E On Pro ${stamp}`)
+    await expect(page.getByText('Customer created')).toBeVisible({ timeout: 30_000 })
+    expect(await customerRows(organizationId)).toBe(before + 1)
+  })
+
+  test('opens online payments for connecting', async ({ page }) => {
+    await page.goto('/settings/integrations/stripe')
+    await settle(page)
+
+    await expect(page.locator('input[name="stripe-secretKey"]')).toBeEditable({ timeout: 30_000 })
+    await expect(page.getByRole('button', { name: 'Connect', exact: true })).toBeVisible()
+    await expect(page.getByText('Subscription required')).toHaveCount(0)
+  })
+})

+ 299 - 0
e2e/specs/email/designer.spec.ts

@@ -0,0 +1,299 @@
+import { expect, test } from '@playwright/test'
+import { emailTemplateNames, forgetEmailTemplates } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  openPreset,
+  openSubjectAndTheme,
+  preview,
+  railBlock,
+  railOrder,
+  saveDesign,
+  subjectField,
+} from '../../support/email-designer'
+
+/**
+ * The email designer, from the gallery to a saved template.
+ *
+ * Every mail a workshop sends is built from one of these, so the failure
+ * modes are worth naming: a block hidden in the designer that still prints in
+ * the mail, a misspelt tag saved and then sent to a customer as
+ * "{custmer_name}", or a template saved under a name that already exists and
+ * quietly replacing the other one.
+ *
+ * The preview is a real iframe of the rendered mail, which is why it can be
+ * asserted against at all: what the frame holds is what the mail client gets.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TEMPLATE = `E2E invoice mail ${stamp}`
+const SECOND = `E2E second mail ${stamp}`
+/** A sentence nothing else in the app says, to find in the preview and the mail. */
+const SENTENCE = `Your invoice is ready, ${stamp}`
+
+test.afterAll(async () => {
+  // No test's design is left sending the workshop's mail.
+  await forgetEmailTemplates('E2E ')
+})
+
+test.describe('the gallery', () => {
+  test('offers every kind of mail, each on its built-in template', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    // The kinds come from EMAIL_KINDS; the page groups them, and each group
+    // names the kinds under it.
+    for (const kind of [
+      'Invoice sent',
+      'Quote sent',
+      'Inspection report sent',
+      'Customer message',
+      'Portal sign-in',
+    ]) {
+      await expect(
+        page.getByRole('heading', { name: kind, exact: true }),
+        `${kind} has a section`
+      ).toBeVisible()
+    }
+
+    // Nothing is designed yet, so every kind sends with its preset and says so.
+    const builtIn = page.getByText('Built-in', { exact: true })
+    expect(await builtIn.count(), 'a built-in card per kind').toBeGreaterThanOrEqual(5)
+    expect(await page.getByText('In use', { exact: true }).count()).toBeGreaterThanOrEqual(5)
+  })
+
+  test('opens the designer in a tab of its own', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    // A card is a link, and it opens beside the settings page rather than
+    // taking the workshop out of it.
+    const card = page.getByRole('link').filter({ hasText: 'Built-in' }).first()
+    await expect(card).toHaveAttribute('target', '_blank')
+    await expect(card).toHaveAttribute('href', /\/email-designer\?kind=\w+&preset=1/)
+  })
+})
+
+test.describe('the designer', () => {
+  test('shows the mail it is designing, block by block', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    // The rail and the mail point at each other through these marks, which is
+    // how a click in one selects in the other.
+    const blocks = await railOrder(page)
+    expect(blocks, 'the preset is the mail a workshop expects').toEqual([
+      'header',
+      'heading',
+      'intro',
+      'message',
+      'summary',
+      'cta',
+      'attachment',
+      'outro',
+      'divider',
+      'footer',
+    ])
+
+    for (const id of blocks.filter((block) => block !== 'divider')) {
+      await expect(
+        preview(page).locator(`[data-block="${id}"]`),
+        `${id} is in the mail`
+      ).toBeAttached()
+    }
+
+    // The exception, and it is deliberate: the footer is lifted out of the
+    // card, and a rule immediately above it would underline nothing, so it
+    // goes with it. Moving the footer up brings the rule back.
+    await expect(
+      preview(page).locator('[data-block="divider"]'),
+      'a rule at the foot of the card is dropped with the footer'
+    ).toHaveCount(0)
+  })
+
+  test('a block clicked in the rail is the one the inspector edits', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+    await expect(async () => {
+      await railBlock(page, 'intro').click()
+      await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'true', {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    // The inspector swaps from "Subject and theme" to that block's own fields.
+    await expect(page.getByLabel('Text', { exact: true })).toBeVisible()
+    await expect(subjectField(page), 'the subject is put away while a block is open').toHaveCount(0)
+
+    // And the way back, which is how a workshop reaches the subject again.
+    await openSubjectAndTheme(page)
+    await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'false')
+  })
+
+  test('line and paragraph spacing set in the theme reach the mail', async ({ page }) => {
+    // Every line of body text used to sit at one fixed height and every
+    // paragraph at one fixed gap; a workshop that found the mail airy had
+    // nothing to turn. The theme has two steps for it now.
+    await openPreset(page, 'invoice_sent')
+    await openSubjectAndTheme(page)
+    const intro = preview(page).locator('[data-block="intro"] td').first()
+    await expect(intro).toHaveAttribute('style', /line-height:1\.6;/)
+
+    await page.getByRole('combobox', { name: 'Line spacing' }).click()
+    await page.getByRole('option', { name: 'Relaxed', exact: true }).click()
+    await expect(intro, 'the body lines open up').toHaveAttribute('style', /line-height:1\.8;/)
+
+    await page.getByRole('combobox', { name: 'Paragraph spacing' }).click()
+    await page.getByRole('option', { name: 'Tight', exact: true }).click()
+    await expect(intro, 'the text blocks close ranks').toHaveAttribute(
+      'style',
+      /padding:0 0 10px 0;/
+    )
+
+    await page.getByRole('combobox', { name: 'Line spacing' }).click()
+    await page.getByRole('option', { name: 'Compact', exact: true }).click()
+    await expect(intro).toHaveAttribute('style', /line-height:1\.4;/)
+  })
+
+  test('a block hidden in the rail leaves the mail', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+    const row = railBlock(page, 'outro')
+
+    await expect(preview(page).locator('[data-block="outro"]')).toBeAttached()
+    await expect(async () => {
+      await row.getByRole('button', { name: /Shown in the email/ }).click()
+      await expect(
+        preview(page).locator('[data-block="outro"]'),
+        'the hidden block is not in the mail'
+      ).toHaveCount(0, { timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+
+    // And the row says what it is now, so the eye is not a mystery toggle.
+    await expect(row.getByRole('button', { name: /Hidden from the email/ })).toBeVisible()
+  })
+
+  test('the plain text half says the same as the mail', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    // Every mail goes out with both halves; a client that shows text only
+    // must still be able to read it, and follow the button.
+    await page.getByRole('button', { name: 'Show the plain-text version' }).click()
+    const text = await page.locator('pre').first().innerText()
+    expect(text.length, 'the text half has words in it').toBeGreaterThan(40)
+    // A button cannot be clicked in plain text, so it is spelled out.
+    expect(text).toMatch(/https?:\/\//)
+  })
+
+  test('a misspelt tag is a problem, and the problem blocks the save', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    const subject = subjectField(page)
+    await expect(subject).toBeVisible()
+    await subject.fill('Invoice for {custmer_name}')
+
+    // The badge counts what is wrong and the tooltip names the tag; either
+    // way Save is out of reach until it is fixed.
+    await expect(page.getByText(/problem/)).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
+
+    // Spelled correctly, it is a tag again and the mail fills it in.
+    await subject.fill('Invoice for {customer_name}')
+    await expect(page.getByText(/problem/)).toHaveCount(0)
+    await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled()
+  })
+
+  test('an invalid colour blocks the save and says why', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    const hex = page.getByLabel('Primary as hex')
+    await hex.fill('#zzz')
+    await expect(page.getByText('A colour is not a valid hex value')).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
+
+    await hex.fill('#1d4ed8')
+    await expect(page.getByText('A colour is not a valid hex value')).toHaveCount(0)
+  })
+})
+
+test.describe('saving a design', () => {
+  test('the first save asks for a name and takes over the kind', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    // A sentence of the workshop's own, so the saved template is recognisable
+    // in the gallery and in the mail that goes out later.
+    await expect(async () => {
+      await railBlock(page, 'intro').click()
+      await expect(page.getByLabel('Text', { exact: true })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByLabel('Text', { exact: true }).fill(SENTENCE)
+    // In the block it was typed into. It is also in the mail's preheader, the
+    // hidden line an inbox shows beside the subject, which is drawn from the
+    // first paragraph.
+    await expect(preview(page).locator('[data-block="intro"]').getByText(SENTENCE)).toBeVisible()
+
+    await saveDesign(page, TEMPLATE)
+
+    // The tab is now on the saved template rather than the preset it started
+    // from: saving again updates it instead of making a second one.
+    await expect(page).toHaveURL(/[?&]template=/)
+    // The button is named for what it would do, so "Saved" is also the check
+    // that there is nothing left unsaved.
+    await expect(page.getByRole('button', { name: 'Saved', exact: true })).toBeVisible()
+    expect(await emailTemplateNames('invoice_sent')).toContain(TEMPLATE)
+  })
+
+  test('the gallery shows it in use, and offers the way back', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    const card = page
+      .locator('div')
+      .filter({ has: page.getByText(TEMPLATE, { exact: true }) })
+      .last()
+    await expect(card.getByText('In use', { exact: true })).toBeVisible()
+    // The built-in card is no longer the one sending, so it offers itself.
+    await expect(page.getByRole('button', { name: 'Use built-in' }).first()).toBeVisible()
+  })
+
+  test('a second template cannot take the first one’s name', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+    await subjectField(page).fill(`Second ${stamp}`)
+
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Save this template' })
+    await dialog.getByPlaceholder('Template name').fill(TEMPLATE)
+
+    // Refused on the spot, before the save is even attempted: a workshop with
+    // two templates called the same thing cannot tell them apart afterwards.
+    await expect(dialog.getByRole('alert')).toHaveText(/already exists/)
+    await expect(dialog.getByRole('button', { name: 'Save template', exact: true })).toBeDisabled()
+
+    // Under its own name it saves.
+    await dialog.getByPlaceholder('Template name').fill(SECOND)
+    await dialog.getByRole('button', { name: 'Save template', exact: true }).click()
+    await expect(
+      page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
+    ).toBeVisible({ timeout: 30_000 })
+    expect(await emailTemplateNames('invoice_sent')).toEqual(
+      expect.arrayContaining([TEMPLATE, SECOND])
+    )
+  })
+
+  test('deleting the one in use puts the kind back on its preset', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    // The second save took the kind over, so this is the one in use.
+    const card = page
+      .locator('div')
+      .filter({ has: page.getByText(SECOND, { exact: true }) })
+      .last()
+    await expect(async () => {
+      await card.getByRole('button', { name: 'Delete' }).click()
+      await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click()
+
+    await expect(page.getByText(SECOND, { exact: true })).toHaveCount(0, { timeout: 30_000 })
+    expect(await emailTemplateNames('invoice_sent')).not.toContain(SECOND)
+  })
+})

+ 294 - 0
e2e/specs/email/sending.spec.ts

@@ -0,0 +1,294 @@
+import { expect, type Page, test } from '@playwright/test'
+import { customerOfVehicle, forgetEmailTemplates, ownerOrganizationId } from '../../support/db'
+import { openPreset, preview, railBlock, saveDesign } from '../../support/email-designer'
+import { settle } from '../../support/hydration'
+import { clearMailbox, waitForMail } from '../../support/mail'
+import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * What the workshop designed is what the customer receives.
+ *
+ * The designer is only worth anything if the mail that goes out is the one on
+ * screen, so this file designs a template with a sentence nothing else says,
+ * makes it the one the workshop sends invoices with, and then sends real mail
+ * and reads it out of the sink.
+ *
+ * Three things are asserted of every mail, because each has its own way of
+ * going wrong: the words came from the active template rather than the
+ * built-in preset, every tag was filled (a literal "{customer_name}" in a
+ * customer's inbox is the failure this feature can produce), and the
+ * plain-text half is there, since a mail with only an HTML part is what a
+ * spam filter looks for.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TEMPLATE = `E2E sent invoice ${stamp}`
+/** The workshop's own words, so a preset cannot pass for them. */
+const SENTENCE = `Thank you for your custom, ${stamp}.`
+const RECIPIENT = `e2e-mail-${stamp}@example.com`
+
+let jobUrl = ''
+let invoiceNumber = ''
+let customer: { name: string; email: string }
+
+/** Every literal tag left in a mail, which should be none. */
+function leftoverTags(...parts: string[]): string[] {
+  return parts.flatMap((part) => part.match(/\{[a-z_]+\}/g) ?? [])
+}
+
+/** Opens the email dialog on the work order and sends it. */
+async function emailInvoice(page: Page, to: string, attachPdf: boolean): Promise<void> {
+  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(to)
+  const attach = page.locator('#attach-pdf-send')
+  await expect(attach).toBeVisible()
+  // The workshop's default answers this, so it is set rather than clicked.
+  if ((await attach.getAttribute('data-state')) !== (attachPdf ? 'checked' : 'unchecked')) {
+    await attach.click()
+  }
+  await expect(attach).toHaveAttribute('data-state', attachPdf ? 'checked' : 'unchecked')
+
+  await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+
+  // A job worth a real invoice: one part, saved, so the mail has a number, a
+  // vehicle and a total to talk about.
+  const vehicleUrl = await seededVehicleUrl(page)
+  customer = await customerOfVehicle(vehicleUrl.split('/').pop() ?? '')
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E mail job ${stamp}`)
+  await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 2, unitPrice: 250 })
+  await saveWorkOrder(page)
+  invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+
+  // The workshop's own invoice mail: the preset's words replaced by one
+  // sentence and the tags a customer's mail has to fill.
+  await openPreset(page, 'invoice_sent')
+  await expect(async () => {
+    await railBlock(page, 'intro').click()
+    await expect(page.getByLabel('Text', { exact: true })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page
+    .getByLabel('Text', { exact: true })
+    .fill(`${SENTENCE} {customer_name}, your {vehicle} is done. Total {total}.`)
+  await expect(preview(page).locator('[data-block="intro"]').getByText(SENTENCE)).toBeVisible()
+  await saveDesign(page, TEMPLATE)
+
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // Back to the built-in preset, or every other spec's mail is this one.
+  await forgetEmailTemplates('E2E ')
+})
+
+test.describe('a test send from the designer', () => {
+  test('arrives marked as a test, in both halves, with nothing left unfilled', async ({ page }) => {
+    await clearMailbox()
+    await page.goto(`/settings/email-templates`)
+    await settle(page)
+
+    // The designer's own Send test, on the template that is now in use.
+    await openPreset(page, 'quote_sent')
+    await page.getByRole('button', { name: 'Send test' }).click()
+    const dialog = page.getByRole('dialog', { name: 'Send a test email' })
+    await dialog.getByPlaceholder('Email address').fill(RECIPIENT)
+    await dialog.getByRole('button', { name: 'Send', exact: true }).click()
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    // Marked, so a test sent to a customer by accident says what it is.
+    expect(mail.subject).toContain('[Test]')
+    expect(mail.html.length, 'the HTML half').toBeGreaterThan(200)
+    expect(mail.text.length, 'the plain-text half').toBeGreaterThan(40)
+    // Sample data fills the tags: a test mail full of braces tells a workshop
+    // nothing about what a customer will see.
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+  })
+})
+
+test.describe('an invoice a customer is sent', () => {
+  test('is written with the workshop’s template, not the built-in one', async ({ page }) => {
+    await clearMailbox()
+    await emailInvoice(page, RECIPIENT, true)
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    expect(mail.html, 'the words the workshop designed').toContain(SENTENCE)
+    expect(mail.text, 'and in the plain-text half too').toContain(SENTENCE)
+
+    // The tags filled from this job, not from sample data.
+    expect(mail.html).toContain(invoiceNumber)
+    expect(mail.html).toContain(customer.name)
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+
+    // With the PDF attached, the mail says so.
+    expect(mail.attachments.map((file) => file.filename).join(' ')).toContain('.pdf')
+    expect(mail.html, 'the attachment note').toContain('attached')
+  })
+
+  test('says nothing about an attachment when it is sent as a link', async ({ page }) => {
+    await clearMailbox()
+    await emailInvoice(page, RECIPIENT, false)
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    expect(mail.attachments, 'no PDF rode along').toHaveLength(0)
+    expect(mail.html, 'and no note about one').not.toContain('A PDF copy is attached')
+    // The link instead, which is the whole point of sending it this way.
+    expect(mail.html).toMatch(/\/share\/invoice\//)
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+  })
+})
+
+test.describe('a message to a customer', () => {
+  test('is wrapped in the workshop’s mail, and the words are shown as words', async ({ page }) => {
+    await clearMailbox()
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // Typed by a service adviser, including something that looks like markup:
+    // it has to reach the customer as text, not as formatting.
+    const typed = `Ready for collection <b>today</b> ${stamp}`
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Notify' }).click()
+      await expect(page.getByRole('dialog', { name: /^Notify / })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    const dialog = page.getByRole('dialog', { name: /^Notify / })
+    await dialog.getByRole('textbox').first().fill(typed)
+    const email = dialog.locator('#notify-email')
+    if ((await email.getAttribute('data-state')) !== 'checked') await email.click()
+    await dialog.getByRole('button', { name: 'Send', exact: true }).click()
+
+    const mail = await waitForMail(customer.email, { timeout: 30_000 })
+    // The workshop's message template around the adviser's words.
+    expect(mail.text).toContain(`Ready for collection <b>today</b> ${stamp}`)
+    expect(mail.html, 'the markup is escaped, not rendered').not.toContain('<b>today</b>')
+    expect(mail.html).toContain('&lt;b&gt;today&lt;/b&gt;')
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+  })
+})
+
+test.describe('a customer asking for a sign-in link', () => {
+  /** The portal switch saves as it is turned, so there is no Save to press. */
+  async function setPortal(page: Page, open: boolean): Promise<void> {
+    await page.goto('/settings/customer-portal')
+    await settle(page)
+    const toggle = page.locator('#portal-enabled')
+    await expect(toggle).toBeVisible()
+    if ((await toggle.getAttribute('aria-checked')) === String(open)) return
+    await expect(async () => {
+      await toggle.click()
+      await expect(toggle).toHaveAttribute('aria-checked', String(open), { timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+  }
+
+  let wasOpen = false
+
+  test('is sent the portal template, with a link that signs them in', async ({ page, request }) => {
+    await page.goto('/settings/customer-portal')
+    await settle(page)
+    wasOpen = (await page.locator('#portal-enabled').getAttribute('aria-checked')) === 'true'
+    await setPortal(page, true)
+
+    await clearMailbox()
+    const organizationId = await ownerOrganizationId()
+    // Asked the way the portal's own sign-in form asks, which is a public
+    // route: no session, and a workshop id in the path.
+    const asked = await request.post(`/api/public/portal/${organizationId}/auth/request`, {
+      data: { email: customer.email },
+    })
+    expect(asked.ok(), 'the workshop accepted the request').toBe(true)
+
+    const mail = await waitForMail(customer.email, { timeout: 30_000 })
+    const link = (mail.html.match(/https?:\/\/[^"'\s]*\/auth\/verify\?token=[^"'\s&]+/) ?? [])[0]
+    expect(link, 'the mail carries a sign-in link').toBeTruthy()
+    if (!link) throw new Error(`no sign-in link in "${mail.subject}"`)
+    expect(mail.text, 'and spells it out for a text-only client').toMatch(/https?:\/\//)
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+
+    // Following it is the only proof that the mail is worth sending.
+    const customerPage = await page
+      .context()
+      .browser()
+      ?.newPage({
+        storageState: { cookies: [], origins: [] },
+      })
+    if (!customerPage) throw new Error('no browser to open the link with')
+    await customerPage.goto(link)
+    await expect(customerPage, 'the link lands the customer inside the portal').toHaveURL(
+      /\/portal\//,
+      { timeout: 30_000 }
+    )
+    await expect(customerPage.getByText(/verify|invalid|expired/i)).toHaveCount(0)
+    await customerPage.close()
+  })
+
+  test.afterAll(async ({ browser }) => {
+    // Left as it was found: the portal is off in a seeded workshop.
+    const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await setPortal(page, wasOpen)
+    await page.close()
+  })
+})
+
+test.describe('a logo in the mail', () => {
+  test('is uploaded, prepared, and fetchable by a mail client with no session', async ({
+    page,
+    playwright,
+  }) => {
+    await openPreset(page, 'invoice_sent')
+    await expect(async () => {
+      await railBlock(page, 'header').click()
+      await expect(page.getByRole('button', { name: 'Upload logo' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    // The workshop's mark, with transparent margins, as one arrives from a
+    // designer. The route trims it and prepares it for mail.
+    await page.locator('input[type="file"]').first().setInputFiles('e2e/fixtures/email-logo.png')
+    const logo = preview(page).locator('[data-block="header"] img')
+    await expect(logo, 'the header draws the logo instead of the name').toBeVisible({
+      timeout: 30_000,
+    })
+
+    await clearMailbox()
+    await page.getByRole('button', { name: 'Send test' }).click()
+    const dialog = page.getByRole('dialog', { name: 'Send a test email' })
+    await dialog.getByPlaceholder('Email address').fill(RECIPIENT)
+    await dialog.getByRole('button', { name: 'Send', exact: true }).click()
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    const src = (mail.html.match(/<img[^>]+src="([^"]+)"/) ?? [])[1]
+    expect(src, 'the mail carries the logo').toBeTruthy()
+    if (!src) throw new Error('no image in the test mail')
+
+    // A mail client has no cookies, so a logo behind the app's session is a
+    // broken picture in every customer's inbox. The address in the mail has
+    // to be the public one, and it has to answer.
+    //
+    // The empty storage state is spelled out because a request context made
+    // from the fixture inherits the project's, and the owner's cookie would
+    // answer for a mail client that has none.
+    expect(src, 'served from the public route').toContain('/api/public/email-')
+    const stranger = await playwright.request.newContext({
+      baseURL: new URL(src).origin,
+      storageState: { cookies: [], origins: [] },
+    })
+    const fetched = await stranger.get(src)
+    expect(fetched.status(), `${src} answers a mail client`).toBe(200)
+    expect(fetched.headers()['content-type']).toContain('image/')
+    expect(Number(fetched.headers()['content-length'] ?? '0'), 'small enough to mail').toBeLessThan(
+      1_000_000
+    )
+    await stranger.dispose()
+  })
+})

+ 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)
+  })
+})

+ 139 - 0
e2e/specs/payments/booked-once.spec.ts

@@ -0,0 +1,139 @@
+import { expect, test } from '@playwright/test'
+import Stripe from 'stripe'
+import {
+  deleteVendorPaymentRows,
+  forgetConnections,
+  insertVendorPaymentRow,
+  vendorPaymentRows,
+} from '../../support/db'
+import { connectVendor, expectConnection, paymentSink } from '../../support/payments'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * One payment, one row, however many times it is reported at once.
+ *
+ * A customer who pays is reported twice by design: their browser comes back
+ * to the invoice and asks for the payment to be checked, and the vendor sends
+ * a notification of its own, often in the same second. Stripe retries its
+ * notifications as well. Every one of those paths used to ask "is this
+ * payment recorded yet?" and then record it, as two separate steps, so two
+ * reports arriving together could both see nothing and both write a row: the
+ * invoice then showed twice the money the customer had paid. It happened two
+ * times in five when this was first tried against the running app.
+ *
+ * The sequential case is in `checkout.spec.ts`. This file is the concurrent
+ * one, and it has to be: a race is only caught by racing.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const SECRET = `whsec_once_${stamp}`
+const ROUNDS = 6
+/** The browser coming back, plus Stripe's notification and two of its retries. */
+const WEBHOOKS_PER_ROUND = 3
+
+let jobId = ''
+let org = ''
+let token = ''
+
+test.beforeAll(async ({ browser }) => {
+  await forgetConnections(['stripe', 'paypal'])
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await connectVendor(page, 'stripe', { secretKey: `sk_test_once_${stamp}`, webhookSecret: SECRET })
+  await expectConnection('stripe', 'active')
+
+  const jobUrl = await newWorkOrder(page, await seededVehicleUrl(page), `E2E booked once ${stamp}`)
+  jobId = jobUrl.split('/').pop() ?? ''
+  await addPart(page, { name: `E2E once part ${stamp}`, quantity: 1, unitPrice: 800 })
+  await saveWorkOrder(page)
+  await page.goto(jobUrl)
+  ;[org, token] = new URL(await shareLink(page)).pathname.split('/').slice(-2)
+  await page.close()
+})
+
+test.afterAll(async () => {
+  await forgetConnections(['stripe', 'paypal'])
+})
+
+test('the database refuses a second row for a payment it already holds', async () => {
+  // Not timing, so not luck: whatever the app does, the table itself must
+  // not hold the same vendor payment against the same invoice twice.
+  const externalId = `cs_test_constraint_${stamp}`
+  try {
+    const first = await insertVendorPaymentRow({
+      serviceRecordId: jobId,
+      provider: 'stripe',
+      externalId,
+      amount: 1,
+    })
+    expect(first, 'the first row goes in').toBeNull()
+
+    const second = await insertVendorPaymentRow({
+      serviceRecordId: jobId,
+      provider: 'stripe',
+      externalId,
+      amount: 1,
+    })
+    expect(second, 'the second is refused as a unique violation').toBe('23505')
+    expect(await vendorPaymentRows(jobId, externalId)).toBe(1)
+  } finally {
+    await deleteVendorPaymentRows(externalId)
+  }
+})
+
+test('a payment reported by the browser and by Stripe at the same moment is booked once', async ({
+  request,
+}) => {
+  const rows: number[] = []
+
+  for (let round = 0; round < ROUNDS; round++) {
+    const checkout = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'stripe', amount: 10 },
+    })
+    expect(checkout.ok(), `checkout in round ${round}`).toBe(true)
+    const { externalId } = (await checkout.json()) as { externalId: string }
+
+    // The customer pays at the vendor.
+    await request.post(`http://127.0.0.1:8026/pay/stripe/${externalId}`, { maxRedirects: 0 })
+    const session = (await paymentSink()).stripe.find((s) => s.id === externalId)
+    expect(session?.payment_status, `paid at the vendor in round ${round}`).toBe('paid')
+
+    const payload = JSON.stringify({
+      id: `evt_once_${stamp}_${round}`,
+      object: 'event',
+      type: 'checkout.session.completed',
+      data: { object: session },
+    })
+    const signature = new Stripe('sk_test_unused').webhooks.generateTestHeaderString({
+      payload,
+      secret: SECRET,
+    })
+    const notify = () =>
+      request.post('/api/webhooks/stripe', {
+        data: payload,
+        headers: { 'content-type': 'application/json', 'stripe-signature': signature },
+      })
+
+    // Everything at once, the way it arrives when it goes wrong.
+    const answers = await Promise.all([
+      request.post(`/api/public/share/invoice/${org}/${token}/verify`, {
+        data: { provider: 'stripe', externalId },
+      }),
+      ...Array.from({ length: WEBHOOKS_PER_ROUND }, notify),
+    ])
+    for (const answer of answers) {
+      expect(answer.status(), 'no report is turned away with an error').toBeLessThan(500)
+    }
+
+    rows.push(await vendorPaymentRows(jobId, externalId))
+  }
+
+  expect(rows, `rows per payment across ${ROUNDS} rounds`).toEqual(Array(ROUNDS).fill(1))
+})

+ 328 - 0
e2e/specs/payments/checkout.spec.ts

@@ -0,0 +1,328 @@
+import { expect, type Page, test } from '@playwright/test'
+import Stripe from 'stripe'
+import { forgetConnections, ownerOrganizationId, paymentsFor } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  clearPaymentSink,
+  connectVendor,
+  expectConnection,
+  paymentSink,
+} from '../../support/payments'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * A customer pays an invoice online, and the workshop's books follow.
+ *
+ * The chain this file walks is the one a workshop depends on without ever
+ * seeing it: keys typed into Settings → Integrations, a pay button on the
+ * shared invoice for exactly what is owed, the customer sent to the vendor
+ * and back, and a payment recorded once, against the right invoice, however
+ * many times the vendor or the browser reports it. Getting any link wrong
+ * either loses money quietly or books money twice.
+ *
+ * Stripe and PayPal are played by `e2e/payment-sink.ts`, which answers the
+ * calls the app makes with the shapes the vendors use and has a checkout page
+ * a spec pays on. What it records is how the amount charged is checked against
+ * the amount the invoice showed.
+ *
+ * Pinned on the seeded workshop's 25% exclusive tax in dollars: one part at
+ * 800 makes a total of 1,000.00. 400 is paid by card, then 600 through PayPal.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const STRIPE_KEY = `sk_test_e2e_${stamp}`
+const WEBHOOK_SECRET = `whsec_e2e_${stamp}`
+
+let jobUrl = ''
+let jobId = ''
+let invoiceUrl = ''
+let organizationId = ''
+
+/** The shared invoice, hydrated, as the customer opens it. */
+async function openInvoice(page: Page, url = invoiceUrl): Promise<void> {
+  await page.goto(url)
+  await settle(page)
+}
+
+/** The badge the work order's payments panel shows: Unpaid, Partial or Paid. */
+async function expectWorkOrderPaymentState(
+  page: Page,
+  state: 'Unpaid' | 'Partial' | 'Paid'
+): Promise<void> {
+  await page.goto(jobUrl)
+  await settle(page)
+  const panel = page
+    .getByRole('heading', { name: 'Payments', exact: true })
+    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
+  await expect(
+    panel.getByText(state, { exact: true }),
+    `the work order reads ${state}`
+  ).toBeVisible()
+}
+
+/** Starts a payment of `amount` with a vendor, and lands on its checkout page. */
+async function startPayment(page: Page, vendor: 'Card' | 'PayPal', amount: string): Promise<void> {
+  await openInvoice(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
+    await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.locator('#payAmount').fill(amount)
+  await page.getByRole('button', { name: new RegExp(`with ${vendor}$`) }).click()
+  await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
+}
+
+/** A Stripe notification, signed with the workshop's webhook secret unless told otherwise. */
+function stripeNotification(session: unknown, secret = WEBHOOK_SECRET) {
+  const payload = JSON.stringify({
+    id: `evt_e2e_${Date.now()}`,
+    object: 'event',
+    type: 'checkout.session.completed',
+    data: { object: session },
+  })
+  const signature = new Stripe('sk_test_unused').webhooks.generateTestHeaderString({
+    payload,
+    secret,
+  })
+  return { payload, signature }
+}
+
+test.beforeAll(async ({ browser }) => {
+  await clearPaymentSink()
+  // A spec that failed halfway must not leave a connection behind, and this
+  // one must start from none.
+  await forgetConnections(['stripe', 'paypal'])
+  organizationId = await ownerOrganizationId()
+
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E paid online ${stamp}`)
+  jobId = jobUrl.split('/').pop() ?? ''
+  await addPart(page, { name: `E2E alternator ${stamp}`, quantity: 1, unitPrice: 800 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // Every other spec shares invoices, and a connected vendor would put a pay
+  // button on all of them.
+  await forgetConnections(['stripe', 'paypal'])
+})
+
+test.describe('connecting a vendor', () => {
+  test('refuses a Stripe key that Stripe does not accept', async ({ page }) => {
+    await connectVendor(page, 'stripe', { secretKey: 'sk_test_wrong' })
+
+    // Checked against the vendor before anything is stored as live, and the
+    // workshop is told why, where they typed it.
+    await expect(page.getByText('Stripe rejected the secret key')).toBeVisible({ timeout: 30_000 })
+    await expectConnection('stripe', 'error')
+  })
+
+  test('connects Stripe with a key it does accept', async ({ page }) => {
+    await connectVendor(page, 'stripe', { secretKey: STRIPE_KEY, webhookSecret: WEBHOOK_SECRET })
+    await expectConnection('stripe', 'active')
+
+    // And shows where Stripe must send its notifications, which is the step a
+    // workshop most often misses.
+    await page.goto('/settings/integrations/stripe')
+    await settle(page)
+    await expect(page.getByText('Inbound webhook URL')).toBeVisible()
+    await expect(page.getByText(/\/api\/webhooks\/stripe/).first()).toBeVisible()
+  })
+
+  test('refuses a PayPal secret that PayPal does not accept', async ({ page }) => {
+    await connectVendor(page, 'paypal', { clientId: 'e2e-client', clientSecret: 'wrong-secret' })
+    await expect(page.getByText(/PayPal auth failed/)).toBeVisible({ timeout: 30_000 })
+    await expectConnection('paypal', 'error')
+  })
+
+  test('connects PayPal with an id and secret it does accept', async ({ page }) => {
+    await connectVendor(page, 'paypal', {
+      clientId: `e2e-client-${stamp}`,
+      clientSecret: `e2e-secret-${stamp}`,
+    })
+    await expectConnection('paypal', 'active')
+  })
+})
+
+test.describe('the invoice a customer is sent', () => {
+  test('shows what is owed, and offers both vendors for exactly that', async ({ page }) => {
+    await page.goto(jobUrl)
+    invoiceUrl = await shareLink(page)
+
+    await openInvoice(page)
+    await expect(page.getByText('Balance Due').first()).toBeVisible()
+    await expect(page.getByText(/\$1,?000\.00/).first(), 'the total the job came to').toBeVisible()
+    await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with Card/ })).toBeVisible()
+    await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with PayPal/ })).toBeVisible()
+  })
+
+  test('refuses to charge more than is owed', async ({ request }) => {
+    const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
+    const before = (await paymentSink()).stripe.length
+
+    const response = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'stripe', amount: 1000.5 },
+    })
+    expect(response.status(), 'more than the balance').toBe(400)
+    // Refused before the vendor was asked for anything.
+    expect((await paymentSink()).stripe.length).toBe(before)
+  })
+})
+
+test.describe('paying part of it by card', () => {
+  test('charges what the customer chose, for this invoice', async ({ page }) => {
+    await startPayment(page, 'Card', '400')
+
+    // The vendor was asked for exactly that, in cents, and told whose invoice
+    // it is: the metadata is what the notification is matched on later.
+    const session = (await paymentSink()).stripe.at(-1)
+    expect(session?.amount_total, 'the amount sent to Stripe').toBe(40000)
+    expect(session?.currency).toBe('usd')
+    expect(session?.metadata.serviceRecordId).toBe(jobId)
+    expect(session?.metadata.orgId).toBe(organizationId)
+    await expect(page.locator('#amount')).toHaveText('400.00 USD')
+  })
+
+  test('is recorded when the customer comes back, and the invoice says what is left', async ({
+    page,
+  }) => {
+    await startPayment(page, 'Card', '400')
+    await page.getByRole('button', { name: 'Pay', exact: true }).click()
+
+    // Back on the invoice, which checks with Stripe before believing it.
+    await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
+    await expect(page.getByText(/\$400\.00 has been applied/)).toBeVisible()
+
+    const recorded = await paymentsFor(jobId)
+    expect(recorded.map((p) => [p.provider, p.amount])).toEqual([['stripe', 400]])
+
+    await expectWorkOrderPaymentState(page, 'Partial')
+
+    // The customer's copy owes the rest, and offers it.
+    await openInvoice(page)
+    await expect(page.getByRole('button', { name: /Pay \$600\.00 with Card/ })).toBeVisible()
+  })
+
+  test('is not counted twice when the same payment is reported again', async ({
+    page,
+    request,
+  }) => {
+    const paid = (await paymentSink()).stripe.filter((s) => s.payment_status === 'paid')
+    const session = paid.at(-1)
+    expect(session, 'a paid session from the test before').toBeTruthy()
+
+    // The customer reloads the page Stripe sent them back to.
+    await page.goto(`${invoiceUrl}?session_id=${session?.id}`)
+    await settle(page)
+    await expect(page.getByText(/Payment received!|could not be verified/)).toBeVisible({
+      timeout: 30_000,
+    })
+
+    // And Stripe's own notification for the same session arrives afterwards,
+    // as it always does in real life: two reports of one payment.
+    const { payload, signature } = stripeNotification(session)
+    const notified = await request.post('/api/webhooks/stripe', {
+      data: payload,
+      headers: { 'content-type': 'application/json', 'stripe-signature': signature },
+    })
+    expect(notified.status()).toBe(200)
+
+    expect(
+      (await paymentsFor(jobId)).map((p) => [p.provider, p.amount]),
+      'still one payment of 400'
+    ).toEqual([['stripe', 400]])
+  })
+
+  test('ignores a notification that Stripe did not sign', async ({ request }) => {
+    // A forged "this invoice is paid", which is what the signature is for.
+    const forged = {
+      id: `cs_test_forged_${stamp}`,
+      object: 'checkout.session',
+      payment_status: 'paid',
+      amount_total: 60000,
+      metadata: { serviceRecordId: jobId, orgId: organizationId },
+    }
+    const { payload, signature } = stripeNotification(forged, 'whsec_not_the_workshops')
+    const response = await request.post('/api/webhooks/stripe', {
+      data: payload,
+      headers: { 'content-type': 'application/json', 'stripe-signature': signature },
+    })
+    expect(response.status(), 'the signature does not verify').toBe(400)
+    expect((await paymentsFor(jobId)).length, 'nothing recorded').toBe(1)
+  })
+})
+
+test.describe('a customer who changes their mind at the vendor', () => {
+  test('pays nothing, and nothing is recorded', async ({ page }) => {
+    await startPayment(page, 'PayPal', '600')
+    await page.getByRole('link', { name: 'Cancel' }).click()
+
+    // Back on the invoice with the same balance, and no payment on the books.
+    await expect(page).toHaveURL(new RegExp(new URL(invoiceUrl).pathname))
+    await settle(page)
+    await expect(page.getByRole('button', { name: /Pay \$600\.00 with PayPal/ })).toBeVisible()
+    expect((await paymentsFor(jobId)).length).toBe(1)
+  })
+})
+
+test.describe('paying the rest through PayPal', () => {
+  test('settles the invoice', async ({ page }) => {
+    await startPayment(page, 'PayPal', '600')
+    const order = (await paymentSink()).paypal.at(-1)
+    expect(order?.amount, 'the amount sent to PayPal').toEqual({
+      currency_code: 'USD',
+      value: '600.00',
+    })
+    expect(order?.custom_id).toBe(`${jobId}:${organizationId}`)
+
+    await page.getByRole('button', { name: 'Pay', exact: true }).click()
+    await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
+
+    expect((await paymentsFor(jobId)).map((p) => [p.provider, p.amount])).toEqual([
+      ['stripe', 400],
+      ['paypal', 600],
+    ])
+    await expectWorkOrderPaymentState(page, 'Paid')
+
+    // Nothing is owed, so the customer is offered nothing to pay.
+    await openInvoice(page)
+    await expect(page.getByRole('button', { name: /with Card|with PayPal/ })).toHaveCount(0)
+  })
+
+  test('is not counted twice when PayPal reports it too', async ({ request }) => {
+    const order = (await paymentSink()).paypal.find((o) => o.status === 'COMPLETED')
+    expect(order, 'the order paid in the test before').toBeTruthy()
+
+    const response = await request.post('/api/webhooks/paypal', {
+      data: {
+        event_type: 'PAYMENT.CAPTURE.COMPLETED',
+        resource: {
+          id: `CAP-${order?.id}`,
+          custom_id: order?.custom_id,
+          supplementary_data: { related_ids: { order_id: order?.id } },
+        },
+      },
+    })
+    expect(response.status()).toBe(200)
+    expect((await paymentsFor(jobId)).length, 'still two payments').toBe(2)
+  })
+
+  test('refuses more money on an invoice that is paid in full', async ({ request }) => {
+    const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
+    const response = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'paypal', amount: 1 },
+    })
+    expect(response.status()).toBe(400)
+    expect(await response.json()).toMatchObject({ error: 'Invoice is already paid in full' })
+  })
+})

+ 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()
+  })
+})

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

@@ -0,0 +1,242 @@
+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 }) => {
+    // A day nothing is booked on: a right-click that lands on a chip opens
+    // that job's menu, not the empty slot's.
+    const day = new Date(Date.now() + 45 * 86_400_000)
+    const empty = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
+    await page.goto(`/calendar?view=day&date=${empty}`)
+    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)
+  })
+})

+ 225 - 0
e2e/specs/security/admin-only.spec.ts

@@ -0,0 +1,225 @@
+import { expect, type Browser, type Page, test } from '@playwright/test'
+import {
+  contentCounts,
+  createRoleWithEveryPermission,
+  invitationTokenFor,
+  ownerOrganizationId,
+  setMembership,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { linkIn, waitForMail } from '../../support/mail'
+
+/**
+ * Logged in is not allowed.
+ *
+ * The September 2026 audit found a class of actions and routes that checked
+ * for a session and a permission, and nothing more: any member could wipe the
+ * workshop's records, export the whole organisation or replace it with an
+ * empty backup, change the plan and charge the card, and a settings manager
+ * could invite a second address of their own as admin. All of them are
+ * owner-or-admin decisions now, whatever permissions a custom role carries.
+ *
+ * So a colleague is given a role with every permission the app knows and no
+ * admin standing, which is the member every permission check waves through,
+ * and is then pointed at each of those doors.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// The colleague starts as a stranger with no session.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const MANAGER = `e2e-manager-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+const SECRET_INVITEE = `e2e-secret-${stamp}@example.com`
+const ADMIN_INVITEE = `e2e-admin-${stamp}@example.com`
+const MEMBER_INVITEE = `e2e-member-${stamp}@example.com`
+
+let organizationId = ''
+let roleId = ''
+
+async function signIn(page: Page, email: string, password: string) {
+  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()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+/**
+ * Opens the team page's Add dialog and sends an invitation to `email` as
+ * "someone in the office", optionally as an Admin. The dialog is left open so
+ * the caller can read what it said.
+ */
+async function invite(page: Page, email: string, role?: 'Admin') {
+  await page.goto('/settings/team')
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add', exact: true }).first().click()
+    await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByText('Someone in the office').click()
+  await page.locator('#member-email').fill(email)
+  if (role) {
+    const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
+    await dialog.getByRole('combobox').click()
+    await page.getByRole('option', { name: role, exact: true }).click()
+  }
+  await page.getByRole('button', { name: 'Invite', exact: true }).click()
+}
+
+/** The owner invites an address and sees it listed as pending. */
+async function ownerInvites(browser: Browser, email: string) {
+  const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+  const page = await owner.newPage()
+  await invite(page, email)
+  await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
+  await owner.close()
+}
+
+/** The API routes are written to, so the request carries the app's own origin. */
+const sameOrigin = { origin: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100' }
+
+test.afterAll(async () => {
+  // The last test makes the manager an admin; the workshop is left with one
+  // more ordinary member, not one more admin.
+  if (organizationId && roleId) {
+    await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
+  }
+})
+
+test.describe('a member with every permission and no admin standing', () => {
+  test('is invited by the owner, signs up, and is given the role', async ({ page, browser }) => {
+    await ownerInvites(browser, MANAGER)
+
+    const invitation = await waitForMail(MANAGER)
+    await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
+    await page.locator('#name').fill('E2E Settings Manager')
+    await page.locator('#email').fill(MANAGER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.getByRole('button', { name: /create account/i }).click()
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+
+    organizationId = await ownerOrganizationId()
+    roleId = await createRoleWithEveryPermission(organizationId, `E2E Everything ${stamp}`)
+    await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
+
+    // The role opens the whole application to them, which is what makes the
+    // refusals below worth anything: they are not a roleless member being
+    // turned away at the door.
+    await signIn(page, MANAGER, PASSWORD)
+    await page.goto('/settings/team')
+    await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
+    await expect(page.getByText(MANAGER).first()).toBeVisible()
+  })
+
+  test('cannot export the workshop, or replace it from a backup', async ({ page }) => {
+    await signIn(page, MANAGER, PASSWORD)
+
+    for (const route of [
+      'backup/export',
+      'backup/import',
+      'backup/import-lubelog',
+      'backup/import-invoice-ninja',
+    ]) {
+      // Refused before the body is looked at: an import that got as far as
+      // parsing would already be past the check that matters.
+      const response = await page.request.post(`/api/protected/${route}`, {
+        data: { version: 2, data: {} },
+        headers: sameOrigin,
+      })
+      expect(response.status(), `${route} is refused`).toBe(403)
+      expect(await response.json()).toEqual({ error: 'Forbidden' })
+    }
+  })
+
+  test('cannot wipe the workshop’s records from the data page', async ({ page }) => {
+    await signIn(page, MANAGER, PASSWORD)
+    const before = await contentCounts(organizationId)
+
+    // The page offers the button to anyone who can open it; the action is
+    // what has to say no.
+    await page.goto('/settings/data')
+    await settle(page)
+    const dialog = page.getByRole('dialog', { name: 'Delete Content' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Delete Content', exact: true }).first().click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await dialog.getByRole('checkbox', { disabled: false }).first().click()
+    await dialog.getByPlaceholder('delete my data').fill('delete my data')
+    // The confirm button counts what it would delete: "Delete 1 selected".
+    await dialog.getByRole('button', { name: /^Delete \d+ selected$/ }).click()
+
+    await expect(page.getByText('Only an owner or admin can delete workshop content')).toBeVisible({
+      timeout: 30_000,
+    })
+    expect(await contentCounts(organizationId), 'nothing was deleted').toEqual(before)
+  })
+
+  test('cannot change the plan or reach the card', async ({ page }) => {
+    await signIn(page, MANAGER, PASSWORD)
+
+    for (const route of ['upgrade', 'checkout', 'upgrade-preview', 'billing-portal']) {
+      const response = await page.request.post(`/api/protected/subscription/${route}`, {
+        data: { plan: 'enterprise' },
+        headers: sameOrigin,
+      })
+      expect(response.status(), `${route} is refused`).toBe(403)
+      expect(await response.json()).toEqual({ error: 'Forbidden' })
+    }
+  })
+
+  test('is not offered a way to bring people in', async ({ page }) => {
+    // Inviting is an admin's call, and the rule sits in the action
+    // (`canInvite`, with its own unit tests). The page agrees with it: the
+    // button is not there for a member, however wide their role.
+    await signIn(page, MANAGER, PASSWORD)
+    await page.goto('/settings/team')
+    await settle(page)
+    await expect(page.getByText(MANAGER).first()).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Add', exact: true })).toHaveCount(0)
+  })
+})
+
+test.describe('an invitation', () => {
+  test('keeps its token in the invitee’s inbox and off the team page', async ({ browser }) => {
+    // The token is the credential that lets whoever holds it join as the
+    // invitee. It used to be returned to everyone who could read the team
+    // page, which let a member read the token for the invited boss's address
+    // and sign up with it.
+    await ownerInvites(browser, SECRET_INVITEE)
+    const token = await invitationTokenFor(SECRET_INVITEE, organizationId)
+    expect(token, 'the invitation exists').toBeTruthy()
+
+    const mail = await waitForMail(SECRET_INVITEE)
+    expect(`${mail.html}\n${mail.text}`, 'the invitee is sent the token').toContain(token)
+
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const html = await (await owner.request.get('/settings/team')).text()
+    await owner.close()
+    expect(html, 'the team page lists the invitation').toContain(SECRET_INVITEE)
+    expect(html, 'without its token').not.toContain(token as string)
+  })
+
+  test('as admin can only come from the owner', async ({ page }) => {
+    // The manager is made an admin: they may bring people in now, and may
+    // still not hand out admin, which is how a settings manager once walked
+    // in as one.
+    await setMembership(MANAGER, organizationId, { roleId, role: 'admin' })
+    await signIn(page, MANAGER, PASSWORD)
+
+    await invite(page, ADMIN_INVITEE, 'Admin')
+    await expect(page.getByText('Only the owner can invite admins')).toBeVisible({
+      timeout: 30_000,
+    })
+    expect(await invitationTokenFor(ADMIN_INVITEE, organizationId), 'nothing was sent').toBeNull()
+
+    await invite(page, MEMBER_INVITEE)
+    await expect(page.getByText(MEMBER_INVITEE).first()).toBeVisible({ timeout: 30_000 })
+    expect(await invitationTokenFor(MEMBER_INVITEE, organizationId)).toBeTruthy()
+  })
+})

+ 278 - 0
e2e/specs/security/files.spec.ts

@@ -0,0 +1,278 @@
+import { existsSync } from 'node:fs'
+import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { expect, type Page, test } from '@playwright/test'
+import {
+  deleteServiceAttachments,
+  insertServiceAttachment,
+  ownerOrganizationId,
+  serviceAttachmentsNamed,
+} from '../../support/db'
+import { pdfContent, TINY_PNG } from '../../support/pdf'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * A stored file URL is data somebody typed at some point.
+ *
+ * Every file a workshop uploads is kept as a URL on a record, and that URL is
+ * later turned into a path on disk and read, copied or unlinked. The audit
+ * found it joined onto the upload folder with no containment check, so a
+ * record carrying `../../.env` read the server's own files into a PDF. Three
+ * layers stand in the way now: the file routes refuse a path with a dot pair,
+ * the actions refuse to store a URL that is not one of this workshop's own
+ * uploads, and the path resolver refuses to leave the folder for whatever is
+ * stored already.
+ *
+ * And an SVG is a document with scripts in it: uploaded as a logo it ran for
+ * every visitor on the app's origin. Uploads are decoded and re-encoded now,
+ * and a stored SVG is offered as a download inside a sandbox.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+/** A real picture of some size, so drawing it is visible in the PDF's bytes. */
+const LOGO = path.join('public', 'torqvoice_app_logo.png')
+
+let organizationId = ''
+let jobUrl = ''
+let jobId = ''
+/** The share token of the job's invoice, for the public file route. */
+let shareToken = ''
+
+async function workshopCopy(page: Page) {
+  const response = await page.request.get(`/api/protected/services/${jobId}/pdf`, {
+    timeout: 60_000,
+  })
+  expect(response.status(), 'the workshop can always get its invoice').toBe(200)
+  const body = await response.body()
+  return { bytes: body.length, ...(await pdfContent(body)) }
+}
+
+/** Where the app writes uploads, when the suite shares a disk with it. */
+function uploadDir(...segments: string[]): string {
+  return path.join('data', 'uploads', organizationId, ...segments)
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E file safety ${stamp}`)
+  jobId = jobUrl.split('/').pop() ?? ''
+  await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 1, unitPrice: 100 })
+  await saveWorkOrder(page)
+  shareToken = new URL(await shareLink(page)).pathname.split('/').pop() ?? ''
+  await page.close()
+})
+
+test.describe('the file routes', () => {
+  test('refuse a path that climbs out of the upload folder', async ({ page }) => {
+    // Encoded, because a browser would fold a literal `..` away before the
+    // request left it; a client that wants the traversal does not.
+    const climbs = [
+      '..%2F..%2F..%2F..%2Fpackage.json',
+      '%2E%2E%2F%2E%2E%2Fpackage.json',
+      '..%5C..%5Cpackage.json',
+    ]
+    for (const climb of climbs) {
+      for (const url of [
+        `/api/protected/files/${organizationId}/services/${climb}`,
+        `/api/public/files/${shareToken}/services/${climb}`,
+      ]) {
+        const response = await page.request.get(url)
+        expect([400, 404], `${url} is refused`).toContain(response.status())
+        expect(await response.text(), 'and nothing of the file came back').not.toContain(
+          '"scripts"'
+        )
+      }
+    }
+  })
+})
+
+test.describe('a file URL on a record', () => {
+  test('is not stored unless it is one of this workshop’s own uploads', async ({ browser }) => {
+    // The client uploads the file, then hands the answer's URL to the action
+    // that puts it on the job. Here the answer is rewritten on its way back,
+    // which is what a client that wanted to would do.
+    const forged = [
+      { url: `/api/protected/files/${organizationId}/services/../../../../package.json` },
+      { url: '/api/files/../../.env' },
+      { url: `/api/protected/files/not-this-workshop/services/${stamp}.txt` },
+      { url: `https://example.com/${stamp}.txt` },
+    ]
+    const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await page.goto(jobUrl)
+
+    for (const [i, { url }] of forged.entries()) {
+      const name = `e2e-forged-${i}-${stamp}.txt`
+      await page.route('**/api/protected/upload/service-files', async (route) => {
+        const response = await route.fetch()
+        const json = (await response.json()) as Record<string, unknown>
+        await route.fulfill({ response, json: { ...json, url } })
+      })
+
+      await expect(async () => {
+        await page.getByRole('button', { name: /^Documents/ }).click()
+        await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+      await page
+        .locator('input[type="file"][accept=".pdf,.csv,.txt"]')
+        .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') })
+
+      await expect(
+        page.getByText(/not an upload of this workshop/i).first(),
+        `${url} is refused, and the page says why`
+      ).toBeVisible({ timeout: 30_000 })
+      await page.unroute('**/api/protected/upload/service-files')
+      expect(await serviceAttachmentsNamed(name), `${url} was not stored`).toBe(0)
+    }
+    await page.close()
+  })
+
+  test('that climbs out of the folder is listed on the invoice, never read', async ({ page }) => {
+    // Rows written straight to the database, as records from before the
+    // schema guard would be. The oracle is the printed document: a picture
+    // that is drawn gets a "Service Images" page of its own, one that is
+    // only listed adds its name to the invoice and nothing else. (Byte size
+    // would not do: a flat-colour logo deflates to a kilobyte once the
+    // renderer re-encodes it.) The picture goes up through the upload route
+    // rather than the images tab, which re-encodes what it is given.
+    const uploaded = await page.request.post('/api/protected/upload/service-files', {
+      multipart: {
+        file: {
+          name: `e2e-real-${stamp}.png`,
+          mimeType: 'image/png',
+          buffer: await readFile(LOGO),
+        },
+      },
+    })
+    expect(uploaded.status()).toBe(200)
+    const realFile = ((await uploaded.json()) as { url: string }).url.split('/').pop()
+    const bare = await workshopCopy(page)
+
+    const withRow = async (fileName: string, fileUrl: string) => {
+      const id = await insertServiceAttachment({
+        serviceRecordId: jobId,
+        fileName,
+        fileUrl,
+        fileType: 'image/png',
+      })
+      try {
+        return await workshopCopy(page)
+      } finally {
+        await deleteServiceAttachments([id])
+      }
+    }
+
+    // The control: the uploaded file, reached by climbing out of the folder
+    // and straight back in. It stays inside, so it is drawn, which proves the
+    // climb below starts where the app's upload folder is.
+    const control = await withRow(
+      `e2e-control-${stamp}.png`,
+      `/api/protected/files/${organizationId}/services/../../../../data/uploads/${organizationId}/services/${realFile}`
+    )
+    expect(control.pages, 'a path that stays inside the folder is drawn').toBe(bare.pages + 1)
+    expect(control.flat).toContain('Service Images')
+    expect(control.flat).toContain(`e2e-control-${stamp}.png`)
+
+    // The escape: the same climb, ending in a real picture outside the
+    // folder. Listed by name, and not one pixel of it in the document.
+    const escaped = await withRow(
+      `e2e-escape-${stamp}.png`,
+      `/api/protected/files/${organizationId}/services/../../../../${LOGO}`
+    )
+    expect(escaped.flat, 'the invoice still names the file').toContain(`e2e-escape-${stamp}.png`)
+    expect(escaped.pages, 'but did not draw it').toBe(bare.pages)
+    expect(escaped.flat).not.toContain('Service Images')
+  })
+})
+
+test.describe('an SVG', () => {
+  test('is not accepted as a logo or a portal background, whatever it is called', async ({
+    page,
+  }) => {
+    const svg = Buffer.from(
+      '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
+    )
+    for (const route of ['logo', 'portal-background']) {
+      const url = `/api/protected/upload/${route}`
+      const declared = await page.request.post(url, {
+        multipart: { file: { name: 'logo.svg', mimeType: 'image/svg+xml', buffer: svg } },
+      })
+      expect(declared.status(), `${route}: an SVG declared as one`).toBe(400)
+
+      // Declared as a PNG, which is what a client that wanted it stored would
+      // say. The bytes are decoded before anything is written, and these do
+      // not decode.
+      const disguised = await page.request.post(url, {
+        multipart: { file: { name: 'logo.png', mimeType: 'image/png', buffer: svg } },
+      })
+      expect(disguised.status(), `${route}: an SVG declared as a PNG`).toBe(400)
+    }
+  })
+
+  test('already on disk is a download inside a sandbox, never a page on the app’s origin', async ({
+    page,
+  }) => {
+    // Written straight into the upload folder, as a file from before the
+    // upload routes re-encoded would be. Only possible when the suite shares
+    // a disk with the server, which it does locally and on CI.
+    test.skip(!existsSync(uploadDir()), 'the suite does not share a disk with the app server')
+
+    const name = `e2e-${stamp}.svg`
+    await mkdir(uploadDir('logos'), { recursive: true })
+    await writeFile(
+      uploadDir('logos', name),
+      '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
+    )
+    try {
+      for (const url of [
+        `/api/protected/files/${organizationId}/logos/${name}`,
+        `/api/public/files/${shareToken}/logos/${name}`,
+      ]) {
+        const response = await page.request.get(url)
+        expect(response.status(), `${url} is served`).toBe(200)
+        const headers = response.headers()
+        expect(headers['content-disposition'], `${url} is a download`).toBe('attachment')
+        expect(headers['content-security-policy'], `${url} is sandboxed`).toContain('sandbox')
+        expect(headers['x-content-type-options']).toBe('nosniff')
+      }
+    } finally {
+      await unlink(uploadDir('logos', name))
+    }
+  })
+
+  test('is refused where a picture is expected, and a picture is stored as what it is', async ({
+    page,
+  }) => {
+    // The stored file's extension is what the bytes turned out to be, not
+    // what the name said; a PNG called .svg is a .png on disk, and is served
+    // as one. The portal background is the route without a side effect on
+    // the workshop's current logo.
+    const response = await page.request.post('/api/protected/upload/portal-background', {
+      multipart: { file: { name: 'picture.svg', mimeType: 'image/png', buffer: TINY_PNG } },
+    })
+    expect(response.status()).toBe(200)
+    const { url } = (await response.json()) as { url: string }
+    expect(url).toMatch(
+      new RegExp(`^/api/protected/files/${organizationId}/portal/[0-9a-f-]+\\.png$`)
+    )
+
+    const served = await page.request.get(url)
+    expect(served.status()).toBe(200)
+    expect(served.headers()['content-type']).toBe('image/png')
+
+    // Tidied away when the suite shares a disk with the server; otherwise the
+    // stray background stays where every other spec's uploads do.
+    if (existsSync(uploadDir('portal'))) {
+      await unlink(uploadDir('portal', url.split('/').pop() ?? ''))
+    }
+  })
+})

+ 187 - 0
e2e/specs/security/payment-attribution.spec.ts

@@ -0,0 +1,187 @@
+import { expect, type Page, test } from '@playwright/test'
+import { forgetConnections, ownerOrganizationId, paymentsFor } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  clearPaymentSink,
+  connectVendor,
+  expectConnection,
+  paymentSink,
+  type SinkPayPalOrder,
+} from '../../support/payments'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * A payment settles the invoice it was made for, and no other.
+ *
+ * The return page and the vendor's notification both name an order and an
+ * invoice, and the audit found the app checked that the order was paid but
+ * never that it was created for that invoice. Paying one unit on your own
+ * invoice and posting the order against somebody else's marked theirs as
+ * paid, and the idempotency key then blocked the real payment. The vendor's
+ * own record of who the order was for is compared now, as Stripe's always
+ * was.
+ *
+ * Two invoices, one payment on the first, and the paid order pointed at the
+ * second through both doors.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const sinkUrl = process.env.E2E_PAYMENT_SINK ?? 'http://127.0.0.1:8026'
+
+let organizationId = ''
+/** The invoice that is paid, and the one the payment is pointed at. */
+let paidJobId = ''
+let paidInvoiceUrl = ''
+let otherJobId = ''
+let otherInvoiceUrl = ''
+/** The PayPal order paid on the first invoice. */
+let order: SinkPayPalOrder | undefined
+
+async function makeSharedInvoice(page: Page, title: string) {
+  const vehicleUrl = await seededVehicleUrl(page)
+  const jobUrl = await newWorkOrder(page, vehicleUrl, title)
+  await addPart(page, { name: `E2E belt ${stamp}`, quantity: 1, unitPrice: 800 })
+  await saveWorkOrder(page)
+  return { jobId: jobUrl.split('/').pop() ?? '', invoiceUrl: await shareLink(page) }
+}
+
+function shareParts(invoiceUrl: string) {
+  const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
+  return { org, token }
+}
+
+test.beforeAll(async ({ browser }) => {
+  await clearPaymentSink()
+  await forgetConnections(['paypal'])
+  organizationId = await ownerOrganizationId()
+
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await connectVendor(page, 'paypal', {
+    clientId: `e2e-client-${stamp}`,
+    clientSecret: `e2e-secret-${stamp}`,
+  })
+  await expectConnection('paypal', 'active')
+  ;({ jobId: paidJobId, invoiceUrl: paidInvoiceUrl } = await makeSharedInvoice(
+    page,
+    `E2E paid invoice ${stamp}`
+  ))
+  ;({ jobId: otherJobId, invoiceUrl: otherInvoiceUrl } = await makeSharedInvoice(
+    page,
+    `E2E other invoice ${stamp}`
+  ))
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // A connected vendor puts a pay button on every shared invoice.
+  await forgetConnections(['paypal'])
+})
+
+test.describe('a PayPal order', () => {
+  test('paid on one invoice is booked on that invoice', async ({ page }) => {
+    await page.goto(paidInvoiceUrl)
+    await settle(page)
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
+      await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.locator('#payAmount').fill('100')
+    await page.getByRole('button', { name: /with PayPal$/ }).click()
+    await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
+    await page.getByRole('button', { name: 'Pay', exact: true }).click()
+    await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
+
+    order = (await paymentSink()).paypal.find(
+      (o) => o.custom_id === `${paidJobId}:${organizationId}` && o.status === 'COMPLETED'
+    )
+    expect(order, 'PayPal holds a completed order for the first invoice').toBeTruthy()
+    expect((await paymentsFor(paidJobId)).map((p) => [p.provider, p.amount])).toEqual([
+      ['paypal', 100],
+    ])
+    expect(await paymentsFor(otherJobId), 'and nothing on the other').toEqual([])
+  })
+
+  test('is refused by another invoice’s return page', async ({ request }) => {
+    // The customer's browser, back from PayPal, with the other invoice's
+    // link and the paid order's id.
+    const { org, token } = shareParts(otherInvoiceUrl)
+    const response = await request.post(`/api/public/share/invoice/${org}/${token}/verify`, {
+      data: { provider: 'paypal', externalId: order?.id },
+    })
+    expect(response.status()).toBe(400)
+    expect(await response.json()).toEqual({ error: 'Payment does not belong to this invoice' })
+    expect(await paymentsFor(otherJobId), 'nothing was booked').toEqual([])
+  })
+
+  test('is refused by a notification that names another invoice', async ({ request }) => {
+    // An order already on the books is answered without another look, so the
+    // forgery has to be an order the app has not seen: created for the first
+    // invoice, approved and captured at the vendor, and never brought back
+    // to the app. That is what a notification that arrives first looks like.
+    const { org, token } = shareParts(paidInvoiceUrl)
+    const checkout = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'paypal', amount: 50 },
+    })
+    expect(checkout.status()).toBe(200)
+    const fresh = (await paymentSink()).paypal.find(
+      (o) =>
+        o.custom_id === `${paidJobId}:${organizationId}` && o.status === 'PAYER_ACTION_REQUIRED'
+    )
+    expect(fresh, 'PayPal holds the new order').toBeTruthy()
+    await fetch(`${sinkUrl}/pay/paypal/${fresh?.id}`, { method: 'POST', redirect: 'manual' })
+    const captured = await fetch(`${sinkUrl}/v2/checkout/orders/${fresh?.id}/capture`, {
+      method: 'POST',
+      headers: { authorization: 'Bearer E2E-ACCESS-TOKEN' },
+    })
+    expect(captured.status, 'the order is paid at the vendor').toBe(201)
+
+    // PayPal's notification carries the invoice in `custom_id`; here it is
+    // rewritten to the other invoice while the order stays the paid one.
+    const notify = (customId: string) =>
+      request.post('/api/webhooks/paypal', {
+        data: {
+          event_type: 'PAYMENT.CAPTURE.COMPLETED',
+          resource: {
+            id: `CAP-${fresh?.id}`,
+            custom_id: customId,
+            supplementary_data: { related_ids: { order_id: fresh?.id } },
+          },
+        },
+      })
+
+    const forged = await notify(`${otherJobId}:${organizationId}`)
+    expect(forged.status()).toBe(400)
+    expect(await forged.json()).toEqual({ error: 'Order does not belong to this record' })
+    expect(await paymentsFor(otherJobId), 'nothing was booked').toEqual([])
+
+    // The other invoice's return page is refused the same order.
+    const other = shareParts(otherInvoiceUrl)
+    const verify = await request.post(
+      `/api/public/share/invoice/${other.org}/${other.token}/verify`,
+      { data: { provider: 'paypal', externalId: fresh?.id } }
+    )
+    expect(verify.status()).toBe(400)
+    expect(await paymentsFor(otherJobId), 'still nothing').toEqual([])
+
+    // And the genuine notification books it where it belongs, once.
+    const genuine = await notify(`${paidJobId}:${organizationId}`)
+    expect(genuine.status()).toBe(200)
+    expect((await paymentsFor(paidJobId)).map((p) => [p.provider, p.amount])).toEqual([
+      ['paypal', 100],
+      ['paypal', 50],
+    ])
+  })
+
+  test('leaves the other invoice untouched', async () => {
+    expect((await paymentsFor(paidJobId)).length).toBe(2)
+    expect(await paymentsFor(otherJobId)).toEqual([])
+  })
+})

+ 111 - 0
e2e/specs/security/sms-webhook.spec.ts

@@ -0,0 +1,111 @@
+import { expect, test } from '@playwright/test'
+import {
+  deleteInboundSms,
+  forgetConnections,
+  inboundSmsCount,
+  insertConnection,
+  ownerOrganizationId,
+  userIdFor,
+} from '../../support/db'
+import { sealCredentials, twilioSignature, webhookSecretHash } from '../../support/webhooks'
+
+/**
+ * An inbound text message has to come from the vendor.
+ *
+ * The SMS webhooks authenticated on a secret in the URL and nothing else, and
+ * a URL is something a vendor's dashboard, a log line or a support ticket
+ * shows to people. Anyone who had seen it could post a message attributed to
+ * any customer. Twilio signs every delivery with the account's auth token,
+ * and the route checks that signature now; the secret in the URL only says
+ * which workshop the call is for.
+ *
+ * The connection is planted with sealed keys rather than connected through
+ * the page, because connecting tests the keys against Twilio.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const AUTH_TOKEN = `e2e-twilio-token-${stamp}`
+const URL_SECRET = `e2e-url-secret-${stamp}`
+const FROM = '+15551230000'
+const BODY = `E2E inbound ${stamp}`
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+const webhook = `${baseURL}/api/webhooks/sms/twilio?org_secret=${URL_SECRET}`
+
+let organizationId = ''
+
+/** What Twilio posts: the message as form fields. */
+function message(body = BODY): Record<string, string> {
+  return { From: FROM, To: '+15550009999', Body: body, MessageSid: `SM${stamp}` }
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  await forgetConnections(['twilio-sms'])
+  await insertConnection({
+    organizationId,
+    connectorId: 'twilio-sms',
+    credentials: sealCredentials({
+      accountSid: 'ACe2e',
+      authToken: AUTH_TOKEN,
+      webhookSecret: URL_SECRET,
+    }),
+    settings: { webhookSecretHash: webhookSecretHash(URL_SECRET) },
+    createdById: await userIdFor('demo@torqvoice.com'),
+  })
+})
+
+test.afterAll(async () => {
+  await forgetConnections(['twilio-sms'])
+  await deleteInboundSms(organizationId, BODY)
+})
+
+test.describe('a text message posted to the Twilio webhook', () => {
+  test('with the wrong URL secret is for nobody', async ({ request }) => {
+    const response = await request.post(`${baseURL}/api/webhooks/sms/twilio?org_secret=wrong`, {
+      form: message(),
+    })
+    expect(response.status()).toBe(403)
+    expect(await response.json()).toEqual({ error: 'Invalid org_secret' })
+  })
+
+  test('without Twilio’s signature is dropped', async ({ request }) => {
+    const response = await request.post(webhook, { form: message() })
+    expect(response.status()).toBe(403)
+    expect(await response.json()).toEqual({ error: 'Invalid signature' })
+    expect(await inboundSmsCount(organizationId, BODY), 'nothing was filed').toBe(0)
+  })
+
+  test('with a signature made with the wrong token is dropped', async ({ request }) => {
+    const response = await request.post(webhook, {
+      form: message(),
+      headers: { 'x-twilio-signature': twilioSignature('not-the-token', webhook, message()) },
+    })
+    expect(response.status()).toBe(403)
+    expect(await inboundSmsCount(organizationId, BODY), 'nothing was filed').toBe(0)
+  })
+
+  test('with a signature Twilio would make is received', async ({ request }) => {
+    const response = await request.post(webhook, {
+      form: message(),
+      headers: { 'x-twilio-signature': twilioSignature(AUTH_TOKEN, webhook, message()) },
+    })
+    expect(response.status()).toBe(200)
+    expect(response.headers()['content-type']).toContain('text/xml')
+    expect(await inboundSmsCount(organizationId, BODY), 'the message is filed once').toBe(1)
+  })
+
+  test('with a body that was changed after signing is dropped', async ({ request }) => {
+    // The signature covers every field, so a message cannot be altered in
+    // flight either.
+    const signed = message()
+    const response = await request.post(webhook, {
+      form: message(`${BODY} tampered`),
+      headers: { 'x-twilio-signature': twilioSignature(AUTH_TOKEN, webhook, signed) },
+    })
+    expect(response.status()).toBe(403)
+    expect(await inboundSmsCount(organizationId, `${BODY} tampered`)).toBe(0)
+  })
+})

+ 329 - 0
e2e/specs/settings/marine.spec.ts

@@ -0,0 +1,329 @@
+import { expect, type Locator, type Page, test } from '@playwright/test'
+import { completeOnboarding, signUpWithPassword } from '../../support/cloud'
+import {
+  connectRegistry,
+  customerIdNamed,
+  disconnectRegistry,
+  organizationIdFor,
+  userIdFor,
+  workshopSetting,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { pdfContent } from '../../support/pdf'
+import { newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * A marine workshop, and the words the app uses for it.
+ *
+ * Settings → Workshop → Service Type is one choice with a long reach. Picked
+ * Marine, the workshop services vessels: a make is a manufacturer, the
+ * odometer is engine hours, a VIN is a HIN, a plate is a registration number,
+ * and a transmission is an outboard or inboard engine. Each place that is
+ * meant to switch is pinned here, and so is the way back, because a workshop
+ * that picked Marine by mistake has to get its car words back.
+ *
+ * A workshop of its own is opened by signing up. The seeded one is shared by
+ * the whole run, and a failure halfway would leave every later spec reading
+ * about vessels.
+ */
+
+// Signing up and onboarding a workshop in a hook takes longer than a test.
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+const stamp = Date.now()
+const EMAIL = `e2e-marine-${stamp}@example.com`
+const STATE = `e2e/.auth/marine-${stamp}.json`
+const SERVICE_TYPE = 'workshop.serviceType'
+// The model carries the run in letters and digits: the list's search reads an
+// all-digit word as a number, and the stamp is too big for one.
+const VESSEL = {
+  make: 'Boston Whaler',
+  model: `Montauk ${stamp.toString(36)}`,
+  year: '2021',
+  hours: '1234',
+}
+
+const SKIPPER = `E2E Skipper ${stamp}`
+/** A plate registry (the Dutch RDW): enough for the header to offer a lookup. */
+const REGISTRY = 'rdw'
+
+let organizationId = ''
+let vesselUrl = ''
+
+test.use({ storageState: STATE })
+
+test.beforeAll(async ({ browser }) => {
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signUpWithPassword(page, {
+    name: 'E2E Marine Owner',
+    email: EMAIL,
+    password: `E2e-pass-${stamp}`,
+  })
+  await completeOnboarding(page, `E2E Marina ${stamp}`, { sampleData: false })
+  await context.storageState({ path: STATE })
+  await context.close()
+
+  organizationId = await organizationIdFor(EMAIL)
+})
+
+test.afterAll(async () => {
+  if (organizationId) await disconnectRegistry(organizationId, REGISTRY)
+})
+
+function sidebar(page: Page): Locator {
+  return page.locator('[data-sidebar="sidebar"]')
+}
+
+function serviceTypeSelect(page: Page): Locator {
+  return page.getByRole('combobox').filter({ hasText: /^(Automotive|Marine)$/ })
+}
+
+/** Settings → Workshop → Service Type, saved, and checked as stored. */
+async function setServiceType(page: Page, type: 'Automotive' | 'Marine'): Promise<void> {
+  await page.goto('/settings/workshop')
+  await settle(page)
+  const select = serviceTypeSelect(page)
+  await expect(async () => {
+    if ((await select.textContent())?.trim() !== type) {
+      await select.click()
+      await page.getByRole('option', { name: type, exact: true }).click({ timeout: 2_000 })
+    }
+    await expect(select).toHaveText(type, { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('button', { name: 'Save Workshop Settings' }).click()
+  await expect(page.getByText('Workshop settings saved')).toBeVisible()
+  // The page shows its toast whatever the save returned, so what was stored
+  // is what counts.
+  await expect
+    .poll(() => workshopSetting(organizationId, SERVICE_TYPE), { timeout: 15_000 })
+    .toBe(type.toLowerCase())
+}
+
+/** The vehicle list's add form, open. */
+async function openAddVehicle(page: Page): Promise<Locator> {
+  await page.goto('/vehicles')
+  await settle(page)
+  const dialog = page.getByRole('dialog', { name: 'Add New Vehicle' })
+  await expect(async () => {
+    await page
+      .getByRole('button', { name: 'Add Vehicle' })
+      .filter({ visible: true })
+      .first()
+      .click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  return dialog
+}
+
+function label(dialog: Locator, field: string): Locator {
+  return dialog.locator(`label[for="${field}"]`)
+}
+
+/** The select drawn after a label. Its trigger carries no id of its own. */
+function selectAfter(dialog: Locator, field: string): Locator {
+  return dialog.locator(`label[for="${field}"] ~ [role="combobox"]`)
+}
+
+test.describe('a workshop that picks Marine', () => {
+  test('starts out as automotive', async ({ page }) => {
+    await page.goto('/settings/workshop')
+    await settle(page)
+    await expect(serviceTypeSelect(page)).toHaveText('Automotive')
+    expect(await workshopSetting(organizationId, SERVICE_TYPE), 'nothing stored yet').toBeNull()
+    await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toBeVisible()
+  })
+
+  test('keeps the choice once saved', async ({ page }) => {
+    await setServiceType(page, 'Marine')
+
+    await page.reload()
+    await settle(page)
+    await expect(serviceTypeSelect(page)).toHaveText('Marine')
+  })
+
+  test('lists vessels in the sidebar', async ({ page }) => {
+    await page.goto('/vehicles')
+    await settle(page)
+    await expect(sidebar(page).getByRole('link', { name: /^Vessels/ })).toHaveAttribute(
+      'href',
+      '/vehicles'
+    )
+    await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toHaveCount(0)
+  })
+
+  test('asks for what a boat has when one is added', async ({ page }) => {
+    const dialog = await openAddVehicle(page)
+
+    await expect(label(dialog, 'make')).toHaveText('Manufacturer *')
+    await expect(label(dialog, 'mileage')).toHaveText('Engine Hours')
+    await expect(label(dialog, 'vin')).toHaveText('HIN')
+    await expect(label(dialog, 'licensePlate')).toContainText('Registration Number')
+    await expect(label(dialog, 'transmission')).toHaveText('Engine Type')
+    await expect(label(dialog, 'engineSize')).toHaveText('Engine')
+    // A periodic roadworthiness inspection is a road vehicle's.
+    await expect(dialog.locator('#inspectionDueAt')).toHaveCount(0)
+
+    // Two-stroke instead of electric and hybrid.
+    await selectAfter(dialog, 'fuelType').click()
+    await expect(page.getByRole('option')).toHaveText(['Gasoline', 'Diesel', 'Two-Stroke', 'Other'])
+    await page.getByRole('option', { name: 'Two-Stroke' }).click()
+
+    // Outboard or inboard, outboard unless told otherwise.
+    const engineType = selectAfter(dialog, 'transmission')
+    await expect(engineType).toHaveText('Outboard')
+    await engineType.click()
+    await expect(page.getByRole('option')).toHaveText(['Outboard', 'Inboard'])
+    await page.getByRole('option', { name: 'Inboard' }).click()
+
+    await dialog.locator('#make').fill(VESSEL.make)
+    await dialog.locator('#model').fill(VESSEL.model)
+    await dialog.locator('#year').fill(VESSEL.year)
+    await dialog.locator('#mileage').fill(VESSEL.hours)
+    await dialog.locator('#vin').fill(`BWC${stamp}`.slice(0, 12))
+    await dialog.locator('#licensePlate').fill('NB-4521-E')
+    await dialog.getByRole('button', { name: 'Add New Vehicle' }).click()
+    await expect(page.getByText('Vehicle added')).toBeVisible({ timeout: 30_000 })
+  })
+
+  test('counts a vessel in engine hours, from its page to its invoice', async ({ page }) => {
+    vesselUrl = await seededVehicleUrl(page, VESSEL.model)
+    await settle(page)
+    await expect(page.getByText('1,234', { exact: true }).first()).toBeVisible()
+    await expect(page.getByText('hrs', { exact: true }).first()).toBeVisible()
+
+    const jobUrl = await newWorkOrder(page, vesselUrl, `E2E Vessel service ${stamp}`)
+    const hours = page.locator('#mileage')
+    await expect(page.locator('label[for="mileage"]')).toHaveText('Engine Hours')
+    await hours.fill('1250')
+    await saveWorkOrder(page)
+
+    const id = new URL(jobUrl).pathname.split('/').pop()
+    const response = await page.request.get(`/api/protected/services/${id}/pdf`)
+    expect(response.ok(), 'the invoice PDF is served').toBe(true)
+    const pdf = await pdfContent(await response.body())
+    expect(pdf.flat).toMatch(/Engine Hours:?\s*1,250 hrs/)
+    expect(pdf.flat).not.toContain('Mileage')
+    // The vessel's own numbers under a vessel's names.
+    expect(pdf.flat).toContain(`HIN: ${`BWC${stamp}`.slice(0, 12)}`)
+    expect(pdf.flat).toContain('Registration: NB-4521-E')
+    expect(pdf.flat).not.toMatch(/\bVIN:|\bPlate:/)
+  })
+
+  test('names vessels in the breadcrumbs', async ({ page }) => {
+    const breadcrumb = page.getByRole('navigation', { name: 'breadcrumb' })
+
+    await page.goto('/vehicles')
+    await settle(page)
+    await expect(breadcrumb).toContainText('Vessels')
+    await expect(breadcrumb).toContainText('All Vessels')
+    await expect(breadcrumb).not.toContainText('Vehicle')
+
+    await page.goto(vesselUrl)
+    await settle(page)
+    await expect(breadcrumb).toContainText('Vessel Details')
+    await expect(breadcrumb).not.toContainText('Vehicle')
+  })
+
+  test('names vessels in the bottom bar on a phone', async ({ page }) => {
+    await page.setViewportSize({ width: 390, height: 844 })
+    await page.goto('/vehicles')
+    await settle(page)
+
+    const vessels = page
+      .getByRole('link', { name: 'Vessels', exact: true })
+      .filter({ visible: true })
+    await expect(vessels).toHaveAttribute('href', '/vehicles')
+    await expect(
+      page.getByRole('link', { name: 'Vehicles', exact: true }).filter({ visible: true })
+    ).toHaveCount(0)
+  })
+
+  test('starts a work order on a vessel, with a registration number', async ({ page }) => {
+    await page.goto('/work-orders?new=1')
+    await settle(page)
+    const picker = page.getByRole('dialog', { name: 'Select Vessel for Work Order' })
+    await expect(picker).toBeVisible({ timeout: 30_000 })
+    await expect(picker.getByPlaceholder('Search vessels...')).toBeVisible()
+
+    await expect(async () => {
+      await picker.getByRole('button', { name: 'Add New Vessel' }).first().click()
+      await expect(page.locator('#new-make')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    const create = page.getByRole('dialog', { name: 'Add New Vessel' })
+    await expect(create.getByText('Manufacturer *', { exact: true })).toBeVisible()
+    await expect(create.getByText('Registration Number', { exact: true })).toBeVisible()
+    await expect(create.getByText('License Plate', { exact: true })).toHaveCount(0)
+
+    // A vessel and its owner, made in one go, for the customer page below.
+    await page.locator('#new-make').fill('Yamaha')
+    await page.locator('#new-model').fill(`242X ${stamp.toString(36)}`)
+    await page.locator('#new-year').fill('2019')
+    await page.getByRole('combobox').filter({ hasText: 'Select a customer (optional)' }).click()
+    await page.getByRole('option', { name: 'Create new customer' }).click()
+    await page.locator('#new-customer-name').fill(SKIPPER)
+    await page.getByRole('button', { name: 'Create & Continue' }).click()
+    await page.waitForURL(/\/vehicles\/[^/]+\/service\//, { timeout: 30_000 })
+  })
+
+  test("shows a customer's vessels in engine hours", async ({ page }) => {
+    await page.goto(`/customers/${await customerIdNamed(organizationId, SKIPPER)}`)
+    await settle(page)
+
+    await expect(page.getByText('Engine Hours', { exact: true }).first()).toBeVisible()
+    // The list is drawn twice, cards for a phone and a table for a desk, so
+    // only the one on screen counts; neither may say km or mi.
+    await expect(
+      page
+        .getByText(/^\d[\d,]* hrs$/)
+        .filter({ visible: true })
+        .first()
+    ).toBeVisible()
+    await expect(page.getByText(/^\d[\d,]* (km|mi)$/)).toHaveCount(0)
+  })
+
+  test('does not offer a plate lookup, even with a registry connected', async ({ page }) => {
+    // Registries answer for road vehicles. Connected, the header would offer
+    // one to an automotive workshop; the last test below proves it does.
+    await connectRegistry(organizationId, await userIdFor(EMAIL), REGISTRY)
+
+    await page.goto('/vehicles')
+    await settle(page)
+    await expect(page.getByRole('heading', { name: /./ }).first()).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Plate lookup' })).toHaveCount(0)
+  })
+
+  test('gets its car words back when switched to Automotive', async ({ page }) => {
+    await setServiceType(page, 'Automotive')
+
+    const dialog = await openAddVehicle(page)
+    await expect(label(dialog, 'make')).toHaveText('Make *')
+    await expect(label(dialog, 'mileage')).toHaveText('Mileage')
+    await expect(label(dialog, 'vin')).toHaveText('VIN')
+    await expect(label(dialog, 'licensePlate')).toContainText('License Plate')
+    await expect(label(dialog, 'transmission')).toHaveText('Transmission')
+    await expect(dialog.locator('#inspectionDueAt')).toBeVisible()
+
+    await selectAfter(dialog, 'fuelType').click()
+    await expect(page.getByRole('option')).toHaveText([
+      'Gasoline',
+      'Diesel',
+      'Electric',
+      'Hybrid',
+      'Other',
+    ])
+    await page.keyboard.press('Escape')
+    await dialog.getByRole('button', { name: 'Cancel' }).click()
+
+    await expect(sidebar(page).getByRole('link', { name: /^Vehicles/ })).toBeVisible()
+    await expect(sidebar(page).getByRole('link', { name: /^Vessels/ })).toHaveCount(0)
+
+    // The same pages, back in car words, and the registry connected earlier is
+    // offered again.
+    await page.goto('/vehicles')
+    await settle(page)
+    const breadcrumb = page.getByRole('navigation', { name: 'breadcrumb' })
+    await expect(breadcrumb).toContainText('All Vehicles')
+    await expect(page.getByRole('button', { name: 'Plate lookup' }).first()).toBeVisible()
+  })
+})

+ 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.
+   */
+})

+ 56 - 0
e2e/specs/vehicles/search.spec.ts

@@ -0,0 +1,56 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+
+/**
+ * Searching the vehicle list with a number.
+ *
+ * A four-digit word is also tried as a model year, which is how "2023" finds
+ * the 2023 cars. Every all-digit word used to be tried that way, and a phone
+ * number with its country code is past what the year column holds: the
+ * database refused the query and the list was replaced by the raw error, query
+ * code and server paths included.
+ */
+
+/** Words that belong to the database, never to a page a workshop reads. */
+const LEAKED = /Invalid `|invocation|out of range for type|prisma|\.next\/|TURBOPACK/i
+
+async function expectNoLeak(page: Page): Promise<void> {
+  await expect(page.getByText(LEAKED)).toHaveCount(0)
+}
+
+test('a long number finds nothing, instead of breaking the list', async ({ page }) => {
+  await page.goto('/vehicles?search=4791234567')
+  await settle(page)
+
+  await expect(page.getByText('No vehicles match your search.')).toBeVisible()
+  await expect(page.getByText('Failed to load vehicles')).toHaveCount(0)
+  await expectNoLeak(page)
+})
+
+test('a full phone number finds its customer in the global search', async ({ page }) => {
+  // James Mitchell is seeded with +1 (555) 201-3344. Eleven digits were tried
+  // as a model year too, and that one failed query took every other result of
+  // the search down with it, the customer whose number it is included.
+  await page.goto('/vehicles')
+  await settle(page)
+  const input = page.getByPlaceholder('Search by name, plate, phone, VIN, invoice…')
+  await expect(async () => {
+    await page.keyboard.press('Control+k')
+    await expect(input).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  await input.fill('15552013344')
+  await expect(page.getByRole('option', { name: /James Mitchell/ }).first()).toBeVisible({
+    timeout: 15_000,
+  })
+  await expectNoLeak(page)
+})
+
+test('a year still finds the vehicles from that year', async ({ page }) => {
+  // The seeded Camry XSE is a 2023.
+  await page.goto('/vehicles?search=2023')
+  await settle(page)
+
+  await expect(page.getByRole('link', { name: /Camry/ }).first()).toBeVisible()
+  await expectNoLeak(page)
+})

+ 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)
+  })
+})

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

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

+ 158 - 0
e2e/specs/work-orders/title-template.spec.ts

@@ -0,0 +1,158 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  forgetWorkshopSetting,
+  ownerOrganizationId,
+  serviceRecordNames,
+  vehicleFacts,
+  workshopSetting,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * A new work order opens with a title the workshop chose.
+ *
+ * Every job used to start as "New Service Record" and wait for somebody to
+ * type over it. A workshop asked for the title to fill itself in from the
+ * job's number, the plate or the customer, so Settings → Workshop now holds a
+ * template of tags, resolved on the server the moment the draft is made and
+ * its number is known. The unit tests in
+ * `src/__tests__/features/vehicles/work-order-title.test.ts` pin the
+ * resolution rules; this file proves the chain from the settings page to the
+ * title field of a job opened from a vehicle.
+ *
+ * The seeded workshop has never set a template, so the default applies until
+ * this file saves one, and the setting is removed again at the end.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const KEY = 'workshop.workOrderTitleTemplate'
+
+let organizationId = ''
+let vehicleUrl = ''
+let vehicleId = ''
+let facts: Awaited<ReturnType<typeof vehicleFacts>>
+
+/** Types a template into Settings → Workshop and saves it. */
+async function saveTemplate(page: Page, template: string): Promise<void> {
+  await page.goto('/settings/workshop')
+  await settle(page)
+  const field = page.locator('#workOrderTitleTemplate')
+  await expect(field).toBeVisible()
+  await field.fill(template)
+  await page.getByRole('button', { name: 'Save Workshop Settings', exact: true }).click()
+  await expect(page.getByText('Workshop settings saved', { exact: true })).toBeVisible()
+  await expect.poll(() => workshopSetting(organizationId, KEY)).toBe(template)
+}
+
+/** When the last job was opened: `/service/new` reuses an untouched draft younger than five seconds. */
+let lastOpenedAt = 0
+
+/** Opens a fresh job on the seeded vehicle and returns what it was called and numbered. */
+async function openNewJob(page: Page) {
+  const wait = lastOpenedAt + 5_500 - Date.now()
+  if (wait > 0) await page.waitForTimeout(wait)
+  lastOpenedAt = Date.now()
+  await page.goto(`${vehicleUrl}/service/new`)
+  await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
+  await settle(page)
+  const id = page.url().split('/').pop() ?? ''
+  const row = await serviceRecordNames(id)
+  return { id, ...row, field: page.locator('input[name="title"]') }
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  await forgetWorkshopSetting(organizationId, KEY)
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  vehicleId = vehicleUrl.split('/').pop() ?? ''
+  facts = await vehicleFacts(vehicleId)
+  expect(facts.licensePlate, 'the seeded vehicle has a plate to print').toBeTruthy()
+  expect(facts.customerName, 'and an owner').toBeTruthy()
+  await page.close()
+})
+
+test.afterAll(async () => {
+  await forgetWorkshopSetting(organizationId, KEY)
+})
+
+test.describe('the default, before the workshop has chosen anything', () => {
+  test('names a new job after its number and the plate', async ({ page }) => {
+    const job = await openNewJob(page)
+    expect(job.invoiceNumber, 'the job was numbered').toBeTruthy()
+    expect(job.title).toBe(`${job.invoiceNumber} - ${facts.licensePlate}`)
+    // And that is what the editor shows, ready to be typed over.
+    await expect(job.field).toHaveValue(job.title)
+  })
+
+  test('is what the settings page offers to edit', async ({ page }) => {
+    await page.goto('/settings/workshop')
+    await settle(page)
+    await expect(page.locator('#workOrderTitleTemplate')).toHaveValue(
+      '{order_number} - {license_plate}'
+    )
+    await expect(page.getByTestId('work-order-title-preview')).toHaveText(
+      'Example: 2026-1042 - AB 12345'
+    )
+  })
+})
+
+test.describe('a template the workshop wrote', () => {
+  test('is previewed as it is typed, tag by tag', async ({ page }) => {
+    await page.goto('/settings/workshop')
+    await settle(page)
+    const field = page.locator('#workOrderTitleTemplate')
+    await field.fill('')
+    await page.getByTestId('work-order-title-tag-customer_name').click()
+    await page.getByTestId('work-order-title-tag-vehicle').click()
+    // Two tags added back to back are kept apart by a space.
+    await expect(field).toHaveValue('{customer_name} {vehicle}')
+    await expect(page.getByTestId('work-order-title-preview')).toHaveText(
+      'Example: Jane Cooper 2021 Toyota Camry'
+    )
+
+    // A tag nobody knows is pointed out, and left out of the example.
+    await field.fill('{order_number} - {nope}')
+    await expect(page.getByTestId('work-order-title-unknown')).toHaveText(
+      'Unknown tags are left out: {nope}'
+    )
+    await expect(page.getByTestId('work-order-title-preview')).toHaveText('Example: 2026-1042')
+  })
+
+  test('names every new job with the customer, the car and the number', async ({ page }) => {
+    await saveTemplate(page, '{customer_name} · {license_plate} · WO#{order_number}')
+    const job = await openNewJob(page)
+    expect(job.title).toBe(
+      `${facts.customerName} · ${facts.licensePlate} · WO#${job.invoiceNumber}`
+    )
+    await expect(job.field).toHaveValue(job.title)
+  })
+
+  test('leaves out a tag nobody knows, with its separator', async ({ page }) => {
+    await saveTemplate(page, '{order_number} - {nope} - {license_plate}')
+    const job = await openNewJob(page)
+    expect(job.title).toBe(`${job.invoiceNumber} - ${facts.licensePlate}`)
+  })
+
+  test('prints the car as year, make and model', async ({ page }) => {
+    await saveTemplate(page, '{vehicle} ({order_number})')
+    const job = await openNewJob(page)
+    expect(job.title).toBe(`${facts.year} ${facts.make} ${facts.model} (${job.invoiceNumber})`)
+  })
+
+  test('saved empty brings back the plain name', async ({ page }) => {
+    await saveTemplate(page, '')
+    const job = await openNewJob(page)
+    expect(job.title).toBe('New Service Record')
+  })
+
+  test('can still be typed over on the job', async ({ page }) => {
+    await saveTemplate(page, '{order_number} - {license_plate}')
+    const job = await openNewJob(page)
+    await job.field.fill('Brake pads, front')
+    await saveWorkOrder(page)
+    expect((await serviceRecordNames(job.id)).title).toBe('Brake pads, front')
+  })
+})

+ 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 })
+}

+ 120 - 0
e2e/support/cloud.ts

@@ -0,0 +1,120 @@
+import { type BrowserContext, expect, type Page } from '@playwright/test'
+import { settle } from './hydration'
+
+/**
+ * Driving the app in cloud mode, as a stranger arrives at it.
+ *
+ * Cloud mode is where the free plan's limits bite, where the sign-up page
+ * makes its case, and where Google sign-in exists. The specs under
+ * `specs/cloud` run against the same build started with TORQVOICE_MODE=cloud
+ * (see `E2E_MODE` in playwright.config.ts), and each opens a workshop of its
+ * own rather than touching the seeded one.
+ */
+
+const standin = process.env.E2E_GOOGLE_STANDIN ?? 'http://127.0.0.1:8027'
+
+export interface GoogleAccount {
+  sub: string
+  email: string
+  email_verified: boolean
+  name: string
+}
+
+/** Offers an account in the stand-in's chooser. Unverified only when asked. */
+export async function registerGoogleAccount(account: {
+  email: string
+  name?: string
+  emailVerified?: boolean
+}): Promise<GoogleAccount> {
+  const response = await fetch(`${standin}/accounts`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({
+      email: account.email,
+      name: account.name ?? account.email,
+      email_verified: account.emailVerified ?? true,
+    }),
+  })
+  if (!response.ok) {
+    throw new Error(
+      `the Google stand-in answered ${response.status}. Is e2e/google-standin.ts running?`
+    )
+  }
+  return response.json()
+}
+
+export interface GoogleStandinState {
+  accounts: GoogleAccount[]
+  authorizeRequests: Record<string, string>[]
+  tokenExchanges: { clientId: string; code: string; hadVerifier: boolean }[]
+}
+
+export async function googleStandin(): Promise<GoogleStandinState> {
+  return (await fetch(`${standin}/state`)).json()
+}
+
+export async function clearGoogleStandin(): Promise<void> {
+  await fetch(`${standin}/state`, { method: 'DELETE' })
+}
+
+/**
+ * Sends the browser's trip to Google to the stand-in instead. The app builds
+ * the real `accounts.google.com` address, as it would for a customer; only
+ * where the browser lands changes.
+ */
+export async function routeGoogleToStandin(context: BrowserContext): Promise<void> {
+  await context.route('https://accounts.google.com/**', async (route) => {
+    const url = new URL(route.request().url())
+    await route.fulfill({
+      status: 302,
+      headers: { location: `${standin}${url.pathname}${url.search}` },
+    })
+  })
+}
+
+/** Signs up with a password on the cloud sign-up page, landing in onboarding. */
+export async function signUpWithPassword(
+  page: Page,
+  person: { name: string; email: string; password: string }
+): Promise<void> {
+  await page.goto('/auth/sign-up')
+  await settle(page)
+  await page.locator('#name').fill(person.name)
+  await page.locator('#email').fill(person.email)
+  await page.locator('#password').fill(person.password)
+  await page.locator('#terms').click()
+  // By type, not by name: the button's wording is the page's pitch, and a
+  // spec that signs up in another language must still find it.
+  await page.locator('form button[type="submit"]').click()
+  await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+}
+
+/** Names the workshop and finishes onboarding, with or without the sample data. */
+export async function completeOnboarding(
+  page: Page,
+  workshopName: string,
+  { sampleData }: { sampleData: boolean }
+): Promise<void> {
+  await settle(page)
+  await page.locator('#workshopName').fill(workshopName)
+  const sample = page.locator('#loadSampleData')
+  const checked =
+    (await sample.getAttribute('aria-checked')) === 'true' ||
+    (await sample.isChecked().catch(() => false))
+  if (checked !== sampleData) await sample.click()
+  await page.locator('form button[type="submit"]').click()
+  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 60_000 })
+}
+
+/** The dialog a plan limit opens, in place of an error. */
+export function upgradeDialog(page: Page) {
+  return page.getByRole('dialog', { name: 'Upgrade to keep going' })
+}
+
+/** Waits for the upgrade dialog and checks it offers the way to a plan. */
+export async function expectUpgradeOffered(page: Page, reason: RegExp): Promise<void> {
+  const dialog = upgradeDialog(page)
+  await expect(dialog, 'the plan limit is offered as an upgrade').toBeVisible({ timeout: 30_000 })
+  await expect(dialog.getByText(reason)).toBeVisible()
+  await expect(dialog.getByRole('link')).toHaveAttribute('href', '/settings/subscription')
+}

+ 941 - 0
e2e/support/db.ts

@@ -0,0 +1,941 @@
+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
+  })
+}
+
+/**
+ * Backdates a job's scheduled start to an hour ago. A new work order is
+ * booked into the shop's next free slot, often tomorrow, and the financial
+ * reports run up to the present moment, so a job made by a spec is not in
+ * this year's tax report until it is moved into the past.
+ */
+export async function scheduleServiceRecordInThePast(serviceRecordId: string): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `update service_records set "startDateTime" = now() - interval '1 hour' where id = $1`,
+      [serviceRecordId]
+    )
+  )
+}
+
+/**
+ * Email templates a spec made, gone again, and every kind back on its
+ * built-in preset.
+ *
+ * The gallery's own delete is what a workshop uses and one test walks it, but
+ * a file that fails halfway must not leave the workshop sending mail designed
+ * by a test: the pointer is an `email.template.<kind>` setting, and a
+ * template row it names is what the resolver prefers over the preset.
+ */
+export async function forgetEmailTemplates(namePrefix: string): Promise<void> {
+  await withDb(async (db) => {
+    await db.query(`delete from email_templates where name like $1`, [`${namePrefix}%`])
+    await db.query(
+      `delete from app_settings
+        where key like 'email.template.%'
+          and value not in (select 'design:' || id from email_templates)`
+    )
+  })
+}
+
+/** The names of the templates saved for one kind of mail. */
+export async function emailTemplateNames(kind: string): Promise<string[]> {
+  return withDb(async (db) => {
+    const result = await db.query<{ name: string }>(
+      `select name from email_templates where kind = $1 order by "createdAt"`,
+      [kind]
+    )
+    return result.rows.map((row) => row.name)
+  })
+}
+
+/**
+ * Whose car it is, and where to write to them.
+ *
+ * The customer of the vehicle a spec is working on, not the first customer in
+ * the workshop: a message sent from a job goes to the owner of that car, so a
+ * spec waiting on another customer's mailbox waits forever.
+ */
+export async function customerOfVehicle(
+  vehicleId: string
+): Promise<{ name: string; email: string }> {
+  return withDb(async (db) => {
+    const result = await db.query<{ name: string; email: string }>(
+      `select c.name, c.email
+         from vehicles v
+         join customers c on c.id = v."customerId"
+        where v.id = $1`,
+      [vehicleId]
+    )
+    const customer = result.rows[0]
+    if (!customer?.email) throw new Error(`vehicle ${vehicleId} has no customer with an email`)
+    return customer
+  })
+}
+
+/** Where a workshop's connection to a vendor stands: active, pending, error, or none at all. */
+export async function connectionStatus(connectorId: string): Promise<string | null> {
+  const organizationId = await ownerOrganizationId()
+  return withDb(async (db) => {
+    const result = await db.query<{ status: string }>(
+      `select status from integration_connections
+        where "organizationId" = $1 and "connectorId" = $2`,
+      [organizationId, connectorId]
+    )
+    return result.rows[0]?.status ?? null
+  })
+}
+
+/**
+ * Every connection a spec made to a vendor, gone. The payment specs connect
+ * Stripe and PayPal to the seeded workshop, and a connection left behind puts
+ * pay buttons on every invoice the rest of the suite shares.
+ */
+export async function forgetConnections(connectorIds: string[]): Promise<void> {
+  const organizationId = await ownerOrganizationId()
+  await withDb((db) =>
+    db.query(
+      `delete from integration_connections
+        where "organizationId" = $1 and "connectorId" = any($2::text[])`,
+      [organizationId, connectorIds]
+    )
+  )
+}
+
+export interface RecordedPayment {
+  amount: number
+  provider: string | null
+  method: string
+  externalId: string | null
+}
+
+/** The money recorded against one work order, oldest first. */
+export async function paymentsFor(serviceRecordId: string): Promise<RecordedPayment[]> {
+  return withDb(async (db) => {
+    const result = await db.query<RecordedPayment>(
+      `select amount, provider, method, "externalId" from payments
+        where "serviceRecordId" = $1
+        order by "createdAt", id`,
+      [serviceRecordId]
+    )
+    return result.rows.map((row) => ({ ...row, amount: Number(row.amount) }))
+  })
+}
+
+/**
+ * Writes a vendor payment row straight into the table, bypassing the app.
+ *
+ * For the one question only the database can answer: whether it refuses a
+ * second row for a payment it already holds. Returns the Postgres error code
+ * when the insert is refused, or null when it went in.
+ */
+export async function insertVendorPaymentRow(row: {
+  serviceRecordId: string
+  provider: string
+  externalId: string
+  amount: number
+}): Promise<string | null> {
+  return withDb(async (db) => {
+    try {
+      await db.query(
+        `insert into payments (id, amount, method, provider, "externalId", "serviceRecordId", "updatedAt")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $2, $3, $4, now())`,
+        [row.amount, row.provider, row.externalId, row.serviceRecordId]
+      )
+      return null
+    } catch (error) {
+      return (error as { code?: string }).code ?? 'unknown'
+    }
+  })
+}
+
+/**
+ * How many rows one invoice holds for one vendor payment.
+ *
+ * Counted against the invoice as well as the id: a vendor's id means one
+ * payment on one invoice, and a count across the whole table also finds any
+ * other invoice that happens to carry the same id, which is not a duplicate.
+ */
+export async function vendorPaymentRows(
+  serviceRecordId: string,
+  externalId: string
+): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: number }>(
+      `select count(*)::int as n from payments
+        where "serviceRecordId" = $1 and "externalId" = $2`,
+      [serviceRecordId, externalId]
+    )
+    return result.rows[0]?.n ?? 0
+  })
+}
+
+/** Removes the rows a spec wrote for one vendor payment. */
+export async function deleteVendorPaymentRows(externalId: string): Promise<void> {
+  await withDb((db) => db.query(`delete from payments where "externalId" = $1`, [externalId]))
+}
+
+/** The id of the user signed up with an address. */
+export async function userIdFor(email: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `select id from users where lower(email) = lower($1)`,
+      [email]
+    )
+    const id = result.rows[0]?.id
+    if (!id) throw new Error(`no user with ${email}`)
+    return id
+  })
+}
+
+/**
+ * Writes customers straight into a workshop, as if it had typed them in.
+ *
+ * For reaching a plan limit without twenty trips through a form: what is
+ * under test is the one customer past the limit, and that one goes through
+ * the app. These are real customers, not sample ones, so they count.
+ */
+export async function insertCustomers(
+  organizationId: string,
+  userId: string,
+  count: number,
+  prefix: string
+): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `insert into customers (id, name, "userId", "organizationId", "updatedAt")
+       select md5(random()::text || clock_timestamp()::text || n), $3 || ' ' || n, $2, $1, now()
+       from generate_series(1, $4::int) as n`,
+      [organizationId, userId, prefix, count]
+    )
+  )
+}
+
+/** Every customer row a workshop holds, sample ones included. */
+export async function customerRows(organizationId: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: number }>(
+      `select count(*)::int as n from customers where "organizationId" = $1`,
+      [organizationId]
+    )
+    return result.rows[0]?.n ?? 0
+  })
+}
+
+/** Team invitations a workshop has sent. */
+export async function teamInvitations(organizationId: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: number }>(
+      `select count(*)::int as n from team_invitations where "organizationId" = $1`,
+      [organizationId]
+    )
+    return result.rows[0]?.n ?? 0
+  })
+}
+
+/**
+ * Puts a workshop on an active Pro subscription, as a paid checkout would.
+ * Returns the plan's id so the spec can take it away again.
+ */
+export async function giveProPlan(organizationId: string): Promise<string> {
+  return withDb(async (db) => {
+    const plan = await db.query<{ id: string }>(
+      `insert into subscription_plans (id, name, price, "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), 'E2E Pro', 0, now())
+       returning id`
+    )
+    const planId = plan.rows[0].id
+    await db.query(
+      `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now())`,
+      [organizationId, planId]
+    )
+    return planId
+  })
+}
+
+/** Takes a subscription and its plan away again. */
+export async function removePlan(organizationId: string, planId: string): Promise<void> {
+  await withDb(async (db) => {
+    await db.query(`delete from subscriptions where "organizationId" = $1`, [organizationId])
+    await db.query(`delete from subscription_plans where id = $1`, [planId])
+  })
+}
+
+export interface PersonRecord {
+  /** How many users hold the address: more than one is two people where there should be one. */
+  users: number
+  /** How each of them can sign in: `credential` for a password, `google`. */
+  providers: string[]
+  emailVerified: boolean
+}
+
+/** Who holds an address, and how they can sign in. */
+export async function personWithEmail(email: string): Promise<PersonRecord> {
+  return withDb(async (db) => {
+    const users = await db.query<{ id: string; emailVerified: boolean }>(
+      `select id, "emailVerified" from users where lower(email) = lower($1)`,
+      [email]
+    )
+    const providers = await db.query<{ providerId: string }>(
+      `select a."providerId" from accounts a join users u on u.id = a."userId"
+        where lower(u.email) = lower($1) order by a."providerId"`,
+      [email]
+    )
+    return {
+      users: users.rows.length,
+      providers: providers.rows.map((row) => row.providerId),
+      emailVerified: users.rows.some((row) => row.emailVerified),
+    }
+  })
+}
+
+/** Every vehicle row a workshop holds. */
+export async function vehicleRows(organizationId: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: number }>(
+      `select count(*)::int as n from vehicles where "organizationId" = $1`,
+      [organizationId]
+    )
+    return result.rows[0]?.n ?? 0
+  })
+}
+
+/** One of a workshop's settings as stored, or null when it was never saved. */
+export async function workshopSetting(organizationId: string, key: string): Promise<string | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ value: string }>(
+      `select value from app_settings where "organizationId" = $1 and key = $2`,
+      [organizationId, key]
+    )
+    return result.rows[0]?.value ?? null
+  })
+}
+
+/**
+ * A vehicle registry connected to a workshop, active, the way the header's
+ * plate lookup looks for one. No keys: nothing is looked up, only offered.
+ */
+export async function connectRegistry(
+  organizationId: string,
+  userId: string,
+  connectorId: string
+): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `insert into integration_connections
+         (id, "organizationId", "connectorId", status, "createdById", "updatedAt")
+       values ($1, $2, $3, 'active', $4, now())`,
+      [`e2e-${connectorId}-${Date.now()}`, organizationId, connectorId, userId]
+    )
+  )
+}
+
+export async function disconnectRegistry(
+  organizationId: string,
+  connectorId: string
+): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `delete from integration_connections where "organizationId" = $1 and "connectorId" = $2`,
+      [organizationId, connectorId]
+    )
+  )
+}
+
+/** The id of a workshop's customer with exactly this name. */
+export async function customerIdNamed(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `select id from customers where "organizationId" = $1 and name = $2`,
+      [organizationId, name]
+    )
+    const id = result.rows[0]?.id
+    if (!id) throw new Error(`no customer named ${name}`)
+    return id
+  })
+}
+
+// ─── The security specs ──────────────────────────────────────────────────────
+
+/**
+ * A custom role carrying every action on every subject the app knows, and no
+ * admin standing. It is the sharpest test of "logged in is not allowed": a
+ * member with this role passes every `requiredPermissions` check there is,
+ * and the owner-only and admin-only actions have to refuse them anyway.
+ */
+export async function createRoleWithEveryPermission(
+  organizationId: string,
+  name: string
+): Promise<string> {
+  const subjects = [
+    'dashboard',
+    'vehicles',
+    'customers',
+    'work_orders',
+    'quotes',
+    'services',
+    'billing',
+    'inventory',
+    'labor_presets',
+    'inspections',
+    'tire_hotel',
+    'reports',
+    'settings',
+    'work_board',
+    'ai_assistant',
+    'time_tracking',
+  ]
+  const actions = ['create', 'read', 'update', 'delete', 'manage']
+  return withDb(async (db) => {
+    const role = await db.query<{ id: string }>(
+      `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, false, $2, now(), now())
+       returning id`,
+      [name, organizationId]
+    )
+    const roleId = role.rows[0].id
+    for (const subject of subjects) {
+      for (const action of actions) {
+        await db.query(
+          `insert into permissions (id, action, subject, "roleId")
+           values (gen_random_uuid()::text, $1, $2, $3)`,
+          [action, subject, roleId]
+        )
+      }
+    }
+    return roleId
+  })
+}
+
+/** Gives a member a custom role, and a built-in standing (member or admin) beside it. */
+export async function setMembership(
+  email: string,
+  organizationId: string,
+  membership: { roleId: string | null; role: 'member' | 'admin' }
+): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `update organization_members m
+          set "roleId" = $3, role = $4
+         from users u
+        where u.id = m."userId" and u.email = $1 and m."organizationId" = $2`,
+      [email, organizationId, membership.roleId, membership.role]
+    )
+  )
+}
+
+/** The credential in a pending invitation, or null when there is none for the address. */
+export async function invitationTokenFor(
+  email: string,
+  organizationId: string
+): Promise<string | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ token: string }>(
+      `select token from team_invitations
+        where email = $1 and "organizationId" = $2 and status = 'pending'`,
+      [email, organizationId]
+    )
+    return result.rows[0]?.token ?? null
+  })
+}
+
+/** How much of the workshop there is, for a test that must find it all still there. */
+export async function contentCounts(organizationId: string): Promise<Record<string, number>> {
+  return withDb(async (db) => {
+    const counts: Record<string, number> = {}
+    for (const table of ['vehicles', 'customers', 'quotes', 'inventory_parts', 'notifications']) {
+      const result = await db.query<{ n: string }>(
+        `select count(*)::text as n from ${table} where "organizationId" = $1`,
+        [organizationId]
+      )
+      counts[table] = Number(result.rows[0].n)
+    }
+    return counts
+  })
+}
+
+/**
+ * A file row written straight to the job, bypassing the schema that guards
+ * the action: what a record carried before the guard existed, or what a
+ * restore could bring in. The path resolver is the last line for these.
+ */
+export async function insertServiceAttachment(row: {
+  serviceRecordId: string
+  fileName: string
+  fileUrl: string
+  fileType: string
+}): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into service_attachments
+         (id, "fileName", "fileUrl", "fileType", "fileSize", category, "includeInInvoice", "serviceRecordId")
+       values (gen_random_uuid()::text, $1, $2, $3, 1, 'image', true, $4)
+       returning id`,
+      [row.fileName, row.fileUrl, row.fileType, row.serviceRecordId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteServiceAttachments(ids: string[]): Promise<void> {
+  await withDb((db) =>
+    db.query(`delete from service_attachments where id = any($1::text[])`, [ids])
+  )
+}
+
+/** How many file rows carry a name, on any job. */
+export async function serviceAttachmentsNamed(fileName: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from service_attachments where "fileName" = $1`,
+      [fileName]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/** A live connection to a vendor, planted with sealed keys; see `support/webhooks.ts`. */
+export async function insertConnection(row: {
+  organizationId: string
+  connectorId: string
+  credentials: string
+  settings: Record<string, unknown>
+  createdById: string
+}): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into integration_connections
+         (id, "organizationId", "connectorId", status, credentials, settings, "createdById", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, $2, 'active', $3, $4::jsonb, $5, now(), now())
+       returning id`,
+      [
+        row.organizationId,
+        row.connectorId,
+        row.credentials,
+        JSON.stringify(row.settings),
+        row.createdById,
+      ]
+    )
+    return result.rows[0].id
+  })
+}
+
+/** Inbound text messages with exactly this body, for a workshop. */
+export async function inboundSmsCount(organizationId: string, body: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from sms_messages
+        where "organizationId" = $1 and direction = 'inbound' and body = $2`,
+      [organizationId, body]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+export async function deleteInboundSms(organizationId: string, body: string): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `delete from sms_messages where "organizationId" = $1 and direction = 'inbound' and body = $2`,
+      [organizationId, body]
+    )
+  )
+}
+
+// ─── Work order titles ───────────────────────────────────────────────────────
+
+/** What a job is called and numbered, straight from its row. */
+export async function serviceRecordNames(
+  serviceRecordId: string
+): Promise<{ title: string; invoiceNumber: string | null }> {
+  return withDb(async (db) => {
+    const result = await db.query<{ title: string; invoiceNumber: string | null }>(
+      `select title, "invoiceNumber" from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no work order ${serviceRecordId}`)
+    return row
+  })
+}
+
+/** The words a title template can print about one vehicle and its owner. */
+export async function vehicleFacts(vehicleId: string): Promise<{
+  licensePlate: string | null
+  make: string
+  model: string
+  year: number
+  vin: string | null
+  customerName: string | null
+}> {
+  return withDb(async (db) => {
+    const result = await db.query<{
+      licensePlate: string | null
+      make: string
+      model: string
+      year: number
+      vin: string | null
+      customerName: string | null
+    }>(
+      `select v."licensePlate", v.make, v.model, v.year, v.vin, c.name as "customerName"
+         from vehicles v
+         left join customers c on c.id = v."customerId"
+        where v.id = $1`,
+      [vehicleId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no vehicle ${vehicleId}`)
+    return row
+  })
+}
+
+/** Removes a workshop setting so the app falls back to its default for it. */
+export async function forgetWorkshopSetting(organizationId: string, key: string): Promise<void> {
+  await withDb((db) =>
+    db.query(`delete from app_settings where "organizationId" = $1 and key = $2`, [
+      organizationId,
+      key,
+    ])
+  )
+}

+ 75 - 0
e2e/support/email-designer.ts

@@ -0,0 +1,75 @@
+import { expect, type FrameLocator, type Page } from '@playwright/test'
+import { settle } from './hydration'
+
+/**
+ * Driving the email designer.
+ *
+ * The designer opens in its own tab from the gallery, on a route that takes
+ * either a preset (`&preset=1`) or a saved template (`&template=<id>`), so a
+ * spec can go straight to the one it means. The preview is an iframe rendered
+ * from `srcDoc`: the mail as a mail client would see it, with a `data-block`
+ * mark on every row so the rail and the preview can point at each other.
+ */
+
+/** Opens the designer on a kind's built-in preset. */
+export async function openPreset(page: Page, kind: string): Promise<void> {
+  await page.goto(`/email-designer?kind=${kind}&preset=1`)
+  await expect(page.locator('[data-rail-block]').first()).toBeVisible({ timeout: 30_000 })
+  await settle(page)
+}
+
+/** The mail itself, inside the preview frame. */
+export function preview(page: Page): FrameLocator {
+  return page.frameLocator('iframe')
+}
+
+/**
+ * The subject and theme fields.
+ *
+ * The inspector shows one thing at a time: a block's own fields when a block
+ * is selected, and the subject with the theme when none is. The rail's first
+ * button is the way back to the whole mail.
+ */
+export async function openSubjectAndTheme(page: Page): Promise<void> {
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Subject and theme' }).click()
+    await expect(subjectField(page)).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+}
+
+/** The subject line. Its id carries the tag-field prefix; the label does not. */
+export function subjectField(page: Page) {
+  return page.getByLabel('Subject', { exact: true })
+}
+
+/** The rail row for a block. */
+export function railBlock(page: Page, id: string) {
+  return page.locator(`[data-rail-block="${id}"]`)
+}
+
+/** The ids of the blocks the mail is made of, in the order the rail lists them. */
+export async function railOrder(page: Page): Promise<string[]> {
+  return page
+    .locator('[data-rail-block]')
+    .evaluateAll((rows) => rows.map((row) => row.getAttribute('data-rail-block') ?? ''))
+}
+
+/**
+ * Saves the design, naming it when the designer asks.
+ *
+ * A preset has no name yet, so the first save opens a dialog; a saved
+ * template updates in place. The dialog stays open on a refusal, which is
+ * what the duplicate-name test reads.
+ */
+export async function saveDesign(page: Page, name?: string): Promise<void> {
+  await page.getByRole('button', { name: 'Save', exact: true }).click()
+  if (name !== undefined) {
+    const dialog = page.getByRole('dialog', { name: 'Save this template' })
+    await expect(dialog).toBeVisible({ timeout: 10_000 })
+    await dialog.getByPlaceholder('Template name').fill(name)
+    await dialog.getByRole('button', { name: 'Save template', exact: true }).click()
+  }
+  await expect(
+    page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
+  ).toBeVisible({ timeout: 30_000 })
+}

+ 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
+}

+ 77 - 0
e2e/support/payments.ts

@@ -0,0 +1,77 @@
+import { expect, type Page } from '@playwright/test'
+import { connectionStatus } from './db'
+import { settle } from './hydration'
+
+/**
+ * Online payments, from both ends.
+ *
+ * The workshop connects a vendor in Settings → Integrations; the customer pays
+ * from the shared invoice. Between them sits the stand-in vendor in
+ * `e2e/payment-sink.ts`, whose state a spec reads to check what the customer
+ * was actually charged.
+ */
+
+const sink = process.env.E2E_PAYMENT_SINK ?? 'http://127.0.0.1:8026'
+
+export interface SinkStripeSession {
+  id: string
+  payment_status: 'unpaid' | 'paid'
+  amount_total: number
+  currency: string
+  metadata: Record<string, string>
+}
+
+export interface SinkPayPalOrder {
+  id: string
+  status: string
+  amount: { currency_code: string; value: string }
+  custom_id: string
+}
+
+export interface SinkState {
+  stripe: SinkStripeSession[]
+  paypal: SinkPayPalOrder[]
+  calls: string[]
+}
+
+/** Everything the stand-in vendor was asked for so far. */
+export async function paymentSink(): Promise<SinkState> {
+  const response = await fetch(`${sink}/state`)
+  if (!response.ok) {
+    throw new Error(`the payment sink answered ${response.status}. Is e2e/payment-sink.ts running?`)
+  }
+  return response.json()
+}
+
+export async function clearPaymentSink(): Promise<void> {
+  await fetch(`${sink}/state`, { method: 'DELETE' })
+}
+
+/**
+ * Types a vendor's keys into its connection page and presses Connect.
+ *
+ * The page tests the keys against the vendor before it stores anything as
+ * live, so the outcome is read back as the connection's status rather than
+ * from a toast: `active` when the vendor accepted them, anything else when not.
+ */
+export async function connectVendor(
+  page: Page,
+  vendor: 'stripe' | 'paypal',
+  credentials: Record<string, string>
+): Promise<void> {
+  await page.goto(`/settings/integrations/${vendor}`)
+  await settle(page)
+  for (const [key, value] of Object.entries(credentials)) {
+    const field = page.locator(`input[name="${vendor}-${key}"]`)
+    await expect(field, `${vendor} asks for ${key}`).toBeVisible()
+    await field.fill(value)
+  }
+  await page.getByRole('button', { name: 'Connect', exact: true }).click()
+}
+
+/** Waits for the connection to settle into the status a spec expects. */
+export async function expectConnection(vendor: string, status: string): Promise<void> {
+  await expect
+    .poll(() => connectionStatus(vendor), { timeout: 30_000, message: `${vendor} is ${status}` })
+    .toBe(status)
+}

+ 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`
+}

+ 179 - 0
e2e/support/settings.ts

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

+ 50 - 0
e2e/support/webhooks.ts

@@ -0,0 +1,50 @@
+import { createCipheriv, createHash, createHmac, hkdfSync, randomBytes } from 'node:crypto'
+
+/**
+ * Standing in for a messaging vendor.
+ *
+ * A connection's keys are sealed before they reach the database, and the
+ * vault derives its key from `BETTER_AUTH_SECRET` when no dedicated one is
+ * set, which is how the suite's server runs. Sealing here, the same way,
+ * lets a spec plant a connection without a vendor to test the keys against;
+ * `src/features/integrations/Lib/vault.ts` is the original.
+ */
+
+/** The secret the app server signs sessions with; the config's fallback when unset. */
+const AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? 'k3Qb8vZ1hN7pXtR2yJm5Ls9CwD4gFa6UeH0iOoT+PbY='
+
+export function sealCredentials(value: Record<string, unknown>): string {
+  const key = Buffer.from(hkdfSync('sha256', AUTH_SECRET, 'torqvoice', 'integrations-vault', 32))
+  const iv = randomBytes(12)
+  const cipher = createCipheriv('aes-256-gcm', key, iv)
+  const encrypted = Buffer.concat([
+    cipher.update(Buffer.from(JSON.stringify(value), 'utf8')),
+    cipher.final(),
+  ])
+  return [
+    'v1',
+    iv.toString('base64url'),
+    cipher.getAuthTag().toString('base64url'),
+    encrypted.toString('base64url'),
+  ].join('.')
+}
+
+/** How the app files a connection's inbound URL secret, so the route can find the workshop. */
+export function webhookSecretHash(secret: string): string {
+  return createHash('sha256').update(secret).digest('hex')
+}
+
+/**
+ * What Twilio puts in `X-Twilio-Signature`: HMAC-SHA1 over the URL as
+ * registered in its console followed by every form field, sorted by name,
+ * keyed with the account's auth token.
+ */
+export function twilioSignature(
+  authToken: string,
+  url: string,
+  params: Record<string, string>
+): string {
+  let data = url
+  for (const key of Object.keys(params).sort()) data += key + params[key]
+  return createHmac('sha1', authToken).update(data, 'utf8').digest('base64')
+}

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

@@ -0,0 +1,208 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+import { settle } from './hydration'
+
+/**
+ * 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\/[^/]+$/)
+  // `/service/new` makes the draft and redirects to it, and for a moment the
+  // page being left and the page arriving are both in the document: two title
+  // fields, and a strict-mode error instead of a retry. Settled, there is one.
+  await settle(page)
+  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()
+  }
+
+  // By name: a workshop-wide announcement is a dialog too, and can be open on
+  // the same page.
+  const dialog = page.getByRole('dialog', { name: 'Share Invoice' })
+  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
+}

+ 17 - 2
messages/de/audit.json

@@ -50,6 +50,7 @@
     "team_invite": "Teammitglied eingeladen",
     "team_sendInvitation": "Einladung gesendet",
     "team_cancelInvitation": "Einladung storniert",
+    "team_resendInvitation": "Einladung erneut gesendet",
     "team_updateRole": "Mitgliedsrolle geändert",
     "team_removeMember": "Teammitglied entfernt",
     "role_create": "Rolle erstellt",
@@ -161,10 +162,16 @@
     "settings_deleteDocumentDesign": "Dokumentdesign gelöscht",
     "settings_applyDocumentDesign": "Standard-Dokumentdesign festgelegt",
     "settings_setDocumentDesignRule": "Verwendung eines Designs geändert",
+    "settings_saveEmailTemplate": "E-Mail-Vorlage gespeichert",
+    "settings_deleteEmailTemplate": "E-Mail-Vorlage gelöscht",
+    "settings_applyEmailTemplate": "Standard-E-Mail-Vorlage festgelegt",
     "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
     "inspection_reminders_send": "HU-Erinnerungen gesendet",
-    "service_videoCall_send": "Link zum Videoanruf gesendet"
+    "service_videoCall_send": "Link zum Videoanruf gesendet",
+    "timeEntry_create": "Zeiteintrag hinzugefügt",
+    "timeEntry_update": "Zeiteintrag korrigiert",
+    "timeEntry_delete": "Zeiteintrag gelöscht"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -243,6 +250,7 @@
     "subscription_cancel": "Abonnement zum Ende der Laufzeit gekündigt",
     "subscription_resume": "Abonnement fortgesetzt",
     "team_cancelInvitation": "Einladung für {email} zurückgezogen",
+    "team_resendInvitation": "Einladung für {email} erneut gesendet",
     "team_removeMember": "Teammitglied {id} entfernt",
     "team_sendInvitation": "{email} als {role, select, owner {Inhaber} admin {Administrator} member {Mitglied} other {{role}}} eingeladen",
     "team_updateRole": "Rolle eines Mitglieds geändert auf {role, select, owner {Inhaber} admin {Administrator} member {Mitglied} other {{role}}}",
@@ -298,8 +306,15 @@
     "settings_deleteDocumentDesign": "Hat das Dokumentdesign {name} gelöscht",
     "settings_applyDocumentDesign": "Hat {name} zum Standard-Dokumentdesign gemacht",
     "settings_setDocumentDesignRule": "Verwendung des Dokumentdesigns {name} geändert",
+    "settings_saveEmailTemplate": "Hat die E-Mail-Vorlage {name} gespeichert",
+    "settings_deleteEmailTemplate": "Hat die E-Mail-Vorlage {name} gelöscht",
+    "settings_applyEmailTemplate": "Hat {name} als Vorlage für {kind}-E-Mails festgelegt",
     "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
     "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet",
-    "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet"
+    "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet",
+    "timeEntry_create": "{minutes} Minuten für {technician} auf \"{job}\" hinzugefügt",
+    "timeEntry_update": "Zeiteintrag für {technician} auf \"{job}\" auf {minutes} Minuten korrigiert",
+    "timeEntry_stop": "Uhr für {technician} auf \"{job}\" bei {minutes} Minuten gestoppt",
+    "timeEntry_delete": "Zeiteintrag {id} gelöscht"
   }
 }

+ 44 - 0
messages/de/auth.json

@@ -6,6 +6,8 @@
     "forgotPassword": "Passwort vergessen?",
     "noAccount": "Noch kein Konto?",
     "createOne": "Jetzt erstellen",
+    "newHere": "Neu bei Torqvoice?",
+    "createFreeAccount": "Kostenloses Konto erstellen",
     "termsAgreement": "Durch die Nutzung dieses Dienstes stimmen Sie unseren",
     "or": "oder",
     "passkey": "Mit Passkey anmelden",
@@ -22,12 +24,16 @@
     "title": "Konto erstellen",
     "descriptionInvite": "Erstellen Sie Ihr Konto, um dem Team beizutreten",
     "descriptionDefault": "Richten Sie Ihre Werkstatt ein, um Kundenfahrzeuge zu verwalten",
+    "titleCloud": "Starten Sie Ihre kostenlose Werkstatt",
+    "descriptionCloud": "In unter einer Minute startklar. Keine Kreditkarte nötig.",
     "fullName": "Vollständiger Name",
     "fullNamePlaceholder": "Max Mustermann",
     "passwordPlaceholder": "Sicheres Passwort erstellen",
     "agreeToTerms": "Ich stimme den",
     "createAccountJoin": "Konto erstellen & Team beitreten",
     "createAccount": "Konto erstellen",
+    "createAccountCloud": "Kostenloses Konto erstellen",
+    "noCardNote": "Kostenloser Tarif. Upgrade nur, wenn Sie mehr brauchen.",
     "alreadyHaveAccount": "Bereits ein Konto?",
     "errors": {
       "couldNotCreate": "Konto konnte nicht erstellt werden",
@@ -39,6 +45,11 @@
     "passwordHint": "Mindestens 8 Zeichen",
     "signInInstead": "Stattdessen anmelden"
   },
+  "social": {
+    "google": "Mit Google fortfahren",
+    "orEmail": "oder mit E-Mail fortfahren",
+    "failed": "Die Anmeldung mit Google wurde nicht abgeschlossen. Versuchen Sie es erneut oder nutzen Sie E-Mail und Passwort."
+  },
   "forgotPassword": {
     "title": "Passwort zurücksetzen",
     "description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen",
@@ -88,5 +99,38 @@
     "errors": {
       "invalidCode": "Ungültiger Code"
     }
+  },
+  "shell": {
+    "language": "Sprache",
+    "about": "Über Torqvoice",
+    "docs": "Dokumentation",
+    "terms": "Nutzungsbedingungen"
+  },
+  "pitch": {
+    "badge": "Für unabhängige Werkstätten",
+    "headline": "Werkstattverwaltung",
+    "headlineHighlight": "ohne Papierkram",
+    "subheadline": "Angebote, Aufträge, Rechnungen und Kundennachrichten an einem Ort. Jeder Auftrag wird einmal erfasst, und nichts geht unter.",
+    "benefits": {
+      "flow": {
+        "title": "Vom Angebot zur bezahlten Rechnung in einem Ablauf",
+        "description": "Machen Sie aus einem Angebot einen Auftrag und eine Rechnung, ohne etwas neu einzutippen."
+      },
+      "history": {
+        "title": "Die komplette Historie jedes Fahrzeugs",
+        "description": "Arbeiten, Teile, Fotos und Kilometerstand auf einer Zeitachse, griffbereit, wenn der Kunde anruft."
+      },
+      "pay": {
+        "title": "Kunden zahlen per Link",
+        "description": "Rechnung senden und online bezahlt werden, mit Stripe, Vipps oder PayPal. Kein Kundenkonto nötig."
+      }
+    },
+    "proof": {
+      "free": "Kostenloser Tarif, keine Kreditkarte",
+      "minute": "In einer Minute startklar",
+      "languages": "12 Sprachen",
+      "openSource": "Open Source"
+    },
+    "screenshotAlt": "Das Torqvoice-Dashboard mit aktiven Aufträgen, Kennzahlen und der Checkliste für den Einstieg"
   }
 }

+ 7 - 1
messages/de/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Gesamt",
     "columnPaid": "Bezahlt",
     "columnBalance": "Saldo",
-    "noRecords": "Keine Abrechnungseinträge gefunden."
+    "noRecords": "Keine Abrechnungseinträge gefunden.",
+    "columnDelivery": "Zustellung",
+    "deliverySent": "Gesendet",
+    "deliveryViewed": "Geöffnet",
+    "deliveryViewedOn": "Zuletzt geöffnet {date}",
+    "filterUnviewed": "Nicht geöffnet",
+    "filterUnviewedHint": "Rechnungen, die gesendet, aber nie geöffnet wurden"
   },
   "recurring": {
     "title": "Wiederkehrend",

+ 11 - 1
messages/de/common.json

@@ -81,6 +81,14 @@
     "enterFullscreen": "Vollbild",
     "exitFullscreen": "Vollbild beenden"
   },
+  "upgrade": {
+    "title": "Upgrade, um weiterzumachen",
+    "maxCustomers": "Ihr Tarif umfasst bis zu {limit} Kunden, und diese Zahl ist erreicht. Mit einem Upgrade legen Sie so viele an, wie Sie brauchen.",
+    "maxUsers": "Ihr Tarif umfasst {limit, plural, one {ein Teammitglied} other {# Teammitglieder}}. Mit einem Upgrade laden Sie weitere Personen ein.",
+    "generic": "Diese Funktion ist in Ihrem aktuellen Tarif nicht enthalten.",
+    "featureNotIncluded": "{feature} ist in Ihrem aktuellen Tarif nicht enthalten.",
+    "notNow": "Später"
+  },
   "broadcast": {
     "dismiss": "Schließen",
     "title": "Hinweis an alle",
@@ -100,5 +108,7 @@
     "title": "PDF-Vorschau",
     "preview": "Vorschau",
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
-  }
+  },
+  "attachPdf": "PDF anhängen",
+  "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet."
 }

+ 4 - 0
messages/de/customers.json

@@ -17,6 +17,10 @@
     "deleteError": "Kunde konnte nicht gelöscht werden",
     "error": "Kunden konnten nicht geladen werden",
     "importCustomers": "Importieren",
+    "assignNumbers": "Nummern vergeben",
+    "assignNumbersTitle": "Kundennummern vergeben?",
+    "assignNumbersDescription": "{count, plural, one {# Kunde hat} other {# Kunden haben}} noch keine Nummer. Jeder erhält die nächste Nummer der Reihe nach, älteste zuerst. Kunden mit Nummer bleiben unverändert.",
+    "assignNumbersDone": "{count, plural, one {# Kunde} other {# Kunden}} nummeriert",
     "selectedCount": "{count} ausgewählt",
     "clearSelection": "Auswahl aufheben",
     "batchDelete": "Löschen ({count})",

+ 2 - 1
messages/de/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "{qty} zum Auftrag hinzufügen",
     "statusReport": "Statusbericht",
     "report": "Bericht",
-    "observation": "Beobachtung"
+    "observation": "Beobachtung",
+    "empty": "Dir sind derzeit keine Aufträge zugewiesen."
   },
   "recentActivity": "Letzte Aktivitäten",
   "noRecentActivity": "Keine aktuellen Aktivitäten",

+ 60 - 0
messages/de/email.json

@@ -0,0 +1,60 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Rechnung",
+      "subject": "Rechnung {document_number} von {workshop_name}",
+      "heading": "Rechnung {document_number}",
+      "intro": "Hallo {customer_name},\n\nvielen Dank, dass Sie sich für uns entschieden haben. Ihre Rechnung ist fertig.",
+      "button": "Rechnung ansehen",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "quote_sent": {
+      "name": "Angebot",
+      "subject": "Angebot {document_number} von {workshop_name}",
+      "heading": "Angebot {document_number}",
+      "intro": "Hallo {customer_name},\n\nhier ist das gewünschte Angebot. Geben Sie uns einfach Bescheid, wenn wir loslegen sollen.",
+      "button": "Angebot ansehen",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "inspection_sent": {
+      "name": "Prüfbericht",
+      "subject": "Prüfbericht von {workshop_name}",
+      "heading": "Fahrzeugprüfbericht",
+      "intro": "Hallo {customer_name},\n\nwir haben die Prüfung abgeschlossen. Der Bericht liegt für Sie bereit.",
+      "button": "Bericht ansehen",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "message": {
+      "name": "Kundennachricht",
+      "subject": "Nachricht von {workshop_name}",
+      "intro": "Hallo {customer_name},",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "portal_signin": {
+      "name": "Portal-Anmeldung",
+      "subject": "Anmelden bei {workshop_name}",
+      "heading": "Melden Sie sich in Ihrem Kundenportal an",
+      "intro": "Hallo {customer_name},\n\nüber die Schaltfläche unten melden Sie sich im Kundenportal von {workshop_name} an.",
+      "button": "Anmelden",
+      "linkFallback": "Falls die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:\n{signin_link}",
+      "outro": "Dieser Link ist 15 Minuten gültig. Falls Sie ihn nicht angefordert haben, können Sie diese E-Mail ignorieren."
+    },
+    "team_invitation": {
+      "name": "Team-Einladung",
+      "subject": "Sie sind eingeladen, {workshop_name} beizutreten",
+      "heading": "Werden Sie Teil von {workshop_name}",
+      "intro": "Sie wurden eingeladen, {workshop_name} auf Torqvoice als {role} beizutreten.\n\nKlicken Sie auf die Schaltfläche unten, um Ihr Konto zu erstellen und dem Team beizutreten.",
+      "button": "Einladung annehmen",
+      "linkFallback": "Wenn die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:\n{invite_link}",
+      "outro": "Diese Einladung gilt bis {invite_expires}. Wenn Sie sie nicht erwartet haben, können Sie diese E-Mail ignorieren."
+    }
+  },
+  "attachmentNote": "Eine PDF-Kopie ist dieser E-Mail beigefügt.",
+  "summary": {
+    "reference": "Referenz",
+    "vehicle": "Fahrzeug",
+    "total": "Gesamt",
+    "balance": "Offener Betrag",
+    "due": "Fällig"
+  }
+}

+ 5 - 0
messages/de/featureHints.json

@@ -11,5 +11,10 @@
     "title": "Rechnungen wurden überarbeitet",
     "body": "Legen Sie Layout, Farben und Schriften für Rechnungen und Angebote im neuen Designer fest, unter Vorlagen.",
     "cta": "Vorlagen öffnen"
+  },
+  "email-designer": {
+    "title": "Ihre E-Mails haben ein neues Aussehen",
+    "body": "Kunden-E-Mails gehen jetzt in einem klareren Design raus. Farben, Texte und Aufbau ändern Sie im neuen Designer unter E-Mail-Vorlagen.",
+    "cta": "E-Mail-Vorlagen öffnen"
   }
 }

+ 11 - 3
messages/de/integrations.json

@@ -126,6 +126,9 @@
     "leaveEmpty": "Leer lassen",
     "noLimit": "Keine Grenze",
     "planLocked": "Integrationen sind in Ihrem Tarif nicht enthalten.",
+    "lockedIntegrations": "Verbinden Sie Kalender, Videoanrufe und weitere Dienste mit Ihrer Werkstatt.",
+    "lockedPayments": "Lassen Sie Kunden ihre Rechnungen online bezahlen, und jede Zahlung wird auf der Rechnung verbucht.",
+    "lockedAi": "Verbinden Sie einen KI-Anbieter, um KI-Funktionen in Ihrer Werkstatt zu nutzen.",
     "ownAppTitle": "Diese Installation verwendet Ihre eigene App beim Anbieter",
     "redirectUri": "Beim Anbieter zu registrierende Redirect-URI:",
     "sendTestEmail": "Send test email",
@@ -266,7 +269,9 @@
       "description": "Send and receive text messages through a Vonage number.",
       "fields": {
         "apiKey": "API key",
-        "apiSecret": "API secret"
+        "apiSecret": "API secret",
+        "signatureSecret": "Signature secret",
+        "signatureSecretHelp": "From the Vonage dashboard, under API settings. When set, inbound messages are only accepted when Vonage signed them. Without it, only the secret in the webhook URL is checked."
       },
       "settings": {
         "phoneNumber": "Send from this number"
@@ -275,7 +280,9 @@
     "telnyx-sms": {
       "description": "Send and receive text messages through a Telnyx number.",
       "fields": {
-        "apiKey": "API key"
+        "apiKey": "API key",
+        "webhookPublicKey": "Webhook public key",
+        "webhookPublicKeyHelp": "From the Telnyx portal, under Account settings, Keys and credentials, Public key. When set, inbound messages are only accepted when Telnyx signed them. Without it, only the secret in the webhook URL is checked."
       },
       "settings": {
         "phoneNumber": "Send from this number"
@@ -288,7 +295,8 @@
         "accessToken": "Access token",
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
-        "apiVersion": "Graph API version"
+        "apiVersion": "Graph API version",
+        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 8 - 0
messages/de/navigation.json

@@ -2,8 +2,11 @@
   "breadcrumbs": {
     "dashboard": "Dashboard",
     "vehicles": "Fahrzeuge",
+    "vessels": "Wasserfahrzeuge",
     "allVehicles": "Alle Fahrzeuge",
+    "allVessels": "Alle Wasserfahrzeuge",
     "vehicleDetails": "Fahrzeugdetails",
+    "vesselDetails": "Wasserfahrzeugdetails",
     "customers": "Kunden",
     "allCustomers": "Alle Kunden",
     "customerDetails": "Kundendetails",
@@ -20,6 +23,7 @@
     "allParts": "Alle Teile",
     "laborPresets": "Arbeitsvorlagen",
     "reports": "Berichte",
+    "timesheets": "Stundenzettel",
     "workBoard": "Auftragsboard",
     "presenter": "Präsentation",
     "adminOverview": "Admin-Übersicht",
@@ -82,6 +86,9 @@
   "licenseExpiresTomorrow": "Ihre Lizenz läuft morgen ab.",
   "licenseExpiresDays": "Ihre Lizenz läuft in {days} Tagen ab.",
   "licenseRenew": "Verlängern",
+  "licenseUnverifiedDays": "Ihre Lizenz konnte nicht überprüft werden. Das Torqvoice-Branding kehrt in {days} Tagen zurück, sofern dieser Server torqvoice.com nicht erreichen kann.",
+  "licenseUnverifiedNow": "Ihre Lizenz konnte nicht überprüft werden und das Torqvoice-Branding ist zurückgekehrt.",
+  "licenseVerify": "Überprüfen",
   "sidebar": {
     "dashboard": "Dashboard",
     "clients": "Kunden",
@@ -102,6 +109,7 @@
     "inventory": "Lagerbestand",
     "laborPresets": "Arbeitsvorlagen",
     "reports": "Berichte",
+    "timesheets": "Stundenzettel",
     "settings": "Einstellungen",
     "superAdmin": "Super Admin",
     "adminPanel": "Admin-Panel",

+ 9 - 13
messages/de/onboarding.json

@@ -53,24 +53,20 @@
   },
   "checklist": {
     "title": "Erste Schritte",
-    "description": "Vier Schritte, um Ihre Werkstatt zum Laufen zu bringen",
+    "description": "Drei Schritte, und {workshop} läuft",
     "progress": "{done}/{total}",
     "steps": {
-      "customer": {
-        "title": "Kunden anlegen",
-        "description": "Legen Sie Ihren ersten Kunden an"
-      },
-      "vehicle": {
-        "title": "Fahrzeug anlegen",
-        "description": "Erfassen Sie ein Fahrzeug für einen Kunden"
-      },
       "workOrder": {
-        "title": "Auftrag erstellen",
-        "description": "Eröffnen Sie Ihren ersten Auftrag"
+        "title": "Legen Sie Ihren ersten Auftrag an",
+        "description": "Kunde, Fahrzeug und Auftrag in einem Dialog"
+      },
+      "company": {
+        "title": "Ergänzen Sie Ihre Werkstattdaten",
+        "description": "Adresse, Telefon und Registernummer für Ihre Rechnungen"
       },
       "invoice": {
-        "title": "Rechnung senden oder herunterladen",
-        "description": "Teilen Sie eine Rechnung mit einem Kunden oder laden Sie sie als PDF herunter"
+        "title": "Senden oder laden Sie Ihre erste Rechnung",
+        "description": "An den Kunden schicken oder als PDF speichern"
       }
     },
     "allDoneTitle": "Alles erledigt",

+ 13 - 1
messages/de/pdf.json

@@ -5,12 +5,16 @@
     "billTo": "Rechnungsempfänger",
     "customerTaxId": "USt-IdNr.",
     "vehicle": "Fahrzeug",
+    "vehicleMarine": "Wasserfahrzeug",
     "vin": "VIN: {vin}",
+    "vinMarine": "HIN: {vin}",
     "plate": "Kennzeichen: {plate}",
+    "plateMarine": "Registrierungsnummer: {plate}",
     "mileage": "Kilometerstand: {mileage}",
     "mileageMarine": "Betriebsstunden: {mileage}",
     "km": "km",
     "mi": "mi",
+    "mileageUnitMarine": "h",
     "service": "Service",
     "type": "Typ: {type}",
     "tech": "Techniker: {tech}",
@@ -38,6 +42,7 @@
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
+    "taxIncludedNamed": "Inkl. {name} ({rate}%)",
     "paymentsReceived": "Eingegangene Zahlungen",
     "paidInFull": "VOLLSTÄNDIG BEZAHLT",
     "amountDue": "Fälliger Betrag",
@@ -72,7 +77,8 @@
     "org": "Org: {org}",
     "qtyOrHours": "Unit / Hours",
     "customFieldsTitle": "Zusätzliche Informationen",
-    "telegramConnect": "Chatten Sie mit uns auf Telegram"
+    "telegramConnect": "Chatten Sie mit uns auf Telegram",
+    "taxRegistrationLabel": "{name}-Nr."
   },
   "quote": {
     "title": "ANGEBOT",
@@ -100,6 +106,7 @@
     "discountPercent": "Rabatt ({percent}%)",
     "tax": "MwSt. ({rate}%)",
     "taxIncluded": "Inkl. Steuer ({rate}%)",
+    "taxIncludedNamed": "Inkl. {name} ({rate}%)",
     "validUntil": "Gültig bis: {date}",
     "attachedDocuments": "Beigefügte Dokumente",
     "attached": "{name} (beigefügt)",
@@ -115,9 +122,13 @@
   },
   "inspection": {
     "title": "FAHRZEUGINSPEKTION",
+    "titleMarine": "BOOTSINSPEKTION",
     "vehicle": "Fahrzeug",
+    "vehicleMarine": "Wasserfahrzeug",
     "vin": "VIN: {vin}",
+    "vinMarine": "HIN: {vin}",
     "plate": "Kennzeichen: {plate}",
+    "plateMarine": "Registrierungsnummer: {plate}",
     "mileage": "Kilometerstand: {mileage}",
     "mileageMarine": "Betriebsstunden: {mileage}",
     "customer": "Kunde",
@@ -133,6 +144,7 @@
     "statusColumn": "Status",
     "notesColumn": "Hinweise",
     "footerText": "Fahrzeuginspektion — {shopName}",
+    "footerTextMarine": "Bootsinspektion · {shopName}",
     "viewPortal": "Ihr Portal ansehen: {url}",
     "tel": "Tel: {phone}",
     "euPass": "Kein Mangel",

+ 1 - 0
messages/de/permissions.json

@@ -12,6 +12,7 @@
     "inspections": "Prüfungen",
     "tire_hotel": "Reifenhotel",
     "reports": "Berichte",
+    "time_tracking": "Zeiterfassung",
     "work_board": "Auftragstafel",
     "ai_assistant": "KI-Assistent",
     "settings": "Einstellungen"

+ 3 - 1
messages/de/portal.json

@@ -105,7 +105,9 @@
     "download": "Herunterladen",
     "paid": "bezahlt",
     "partial": "teilweise",
-    "unpaid": "unbezahlt"
+    "unpaid": "unbezahlt",
+    "back": "Zurück zu den Rechnungen",
+    "openShared": "Freigegebene Kopie öffnen"
   },
   "quotes": {
     "title": "Angebote",

+ 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"
   },

+ 21 - 2
messages/de/reports.json

@@ -12,7 +12,9 @@
     "parts": "Teile",
     "jobAnalytics": "Auftragsanalyse",
     "retention": "Kundenbindung",
-    "vehicles": "Fahrzeuge"
+    "vehicles": "Fahrzeuge",
+    "techniciansOverview": "Übersicht",
+    "clockedTime": "Erfasste Zeit"
   },
   "dateRange": {
     "to": "bis",
@@ -138,6 +140,19 @@
       "avgRevenue": "Ø Umsatz",
       "totalHours": "Gesamtstunden",
       "avgHours": "Ø Stunden"
+    },
+    "time": {
+      "clockedHours": "Erfasste Stunden",
+      "billedHours": "Abgerechnete Stunden",
+      "efficiency": "Effizienz",
+      "techniciansClocked": "Techniker mit Zeit",
+      "chartTitle": "Erfasst vs. abgerechnet je Techniker",
+      "tableTitle": "Erfasst gegen abgerechnet",
+      "clocked": "Erfasst",
+      "billed": "Abgerechnet",
+      "hint": "Erfasste Stunden stammen von der Stempeluhr. Abgerechnete Stunden sind die Arbeitspositionen der dem Techniker zugewiesenen Aufträge, die im Zeitraum begonnen haben. Effizienz ist abgerechnet geteilt durch erfasst.",
+      "noClock": "Keine Zeit",
+      "jobsClocked": "Erfasste Aufträge"
     }
   },
   "parts": {
@@ -307,7 +322,11 @@
     "type": "Typ",
     "status": "Status",
     "partsUsed": "Teile",
-    "laborHours": "Arbeitsstunden"
+    "laborHours": "Arbeitsstunden",
+    "clockedHours": "Erfasste Stunden",
+    "billedHours": "Abgerechnete Stunden",
+    "efficiency": "Effizienz",
+    "jobsClocked": "Erfasste Aufträge"
   },
   "pdf": {
     "reportTitle": "Business Report",

+ 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",

+ 449 - 63
messages/de/settings.json

@@ -131,24 +131,27 @@
         "title": "Reifenhotel",
         "description": "Saisonale Reifeneinlagerung"
       },
-      "providers": {
-        "title": "Anbieter",
-        "description": "E-Mail, SMS, WhatsApp & Telegram"
-      },
       "inspectionReminders": {
         "title": "HU-Erinnerungen",
         "description": "Buchungslink, Dauer und Vorlauf"
+      },
+      "emailTemplates": {
+        "title": "E-Mail-Vorlagen",
+        "description": "Was Ihre E-Mails zu Rechnung, Angebot und Bericht sagen"
       }
     },
-    "pro": "PRO"
+    "pro": "PRO",
+    "new": "Neu"
   },
   "readOnly": {
     "banner": "Sie haben nur Lesezugriff auf die Einstellungen. Kontaktieren Sie einen Administrator, um Anderungen vorzunehmen."
   },
   "featureLocked": {
-    "requiresPro": "Erfordert eine Pro-Lizenz",
-    "upgradePlan": "Tarif upgraden",
-    "activateLicense": "Lizenz aktivieren"
+    "requiresPro": "Abonnement erforderlich",
+    "upgradePlan": "Tarife ansehen",
+    "activateLicense": "Lizenz aktivieren",
+    "requiresLicense": "White-Label-Lizenz erforderlich",
+    "previewHint": "Dies ist eine Vorschau. Alles auf dieser Seite wird mit einem Abonnement freigeschaltet."
   },
   "company": {
     "logoTitle": "Firmenlogo",
@@ -164,6 +167,9 @@
     "shopNamePlaceholder": "Meiers Autowerkstatt",
     "orgNumber": "Organisationsnummer",
     "orgNumberPlaceholder": "123 456 789",
+    "orgNumberLabel": "Bezeichnung der Organisationsnummer",
+    "orgNumberLabelPlaceholder": "Org.-Nr.",
+    "orgNumberLabelHint": "Wird auf Rechnungen und Angeboten neben der Nummer gedruckt. Leer lassen für die Standardbezeichnung in Ihrer Sprache, oder eintragen, wie sie bei Ihnen heißt, z. B. USt-IdNr., HRB oder UID.",
     "phone": "Telefon",
     "phonePlaceholder": "(555) 123-4567",
     "email": "E-Mail",
@@ -354,10 +360,11 @@
     "failedSendInvitation": "Einladung konnte nicht gesendet werden",
     "pendingInvitations": "Ausstehende Einladungen",
     "invitedAs": "Eingeladen als {role}",
+    "resendInvitation": "Einladung erneut senden",
+    "invitationResent": "Einladung erneut an {email} gesendet",
+    "failedResendInvitation": "Einladung konnte nicht erneut gesendet werden",
     "expires": "Lauft ab {date}",
     "pending": "Ausstehend",
-    "copyInviteLink": "Einladungslink kopieren",
-    "inviteLinkCopied": "Einladungslink in die Zwischenablage kopiert",
     "cancelInvitation": "Einladung stornieren",
     "cancelInvitationDescription": "Einladung an {email} stornieren? Der Link kann dann nicht mehr verwendet werden.",
     "invitationCancelled": "Einladung storniert",
@@ -512,6 +519,10 @@
   "invoice": {
     "title": "Rechnungslayout",
     "description": "Konfigurieren Sie, was auf generierten PDF-Rechnungen erscheint.",
+    "numberingTitle": "Nummerierung und Fristen",
+    "numberingDescription": "Wie Rechnungen und Angebote nummeriert werden, wann Rechnungen fällig sind und wie lange Angebote gelten.",
+    "documentsTitle": "Dokumente",
+    "documentsDescription": "Was jede Rechnung und jedes Angebot mitbringt.",
     "tabs": {
       "general": "Allgemein",
       "layout": "Layout",
@@ -542,12 +553,6 @@
     "customFooter": "Benutzerdefinierte Rechnungsfusszeile",
     "footerPlaceholder": "Vielen Dank für Ihren Auftrag!",
     "footerHint": "Dieser Text erscheint am Ende jeder Rechnung",
-    "partsMarkupTitle": "Teile-Aufschlag",
-    "partsMarkupDescription": "Aufschlag auf den Einkaufspreis beim Hinzufügen von Teilen. Nur intern — der Kunde sieht weder Kosten noch Aufschlag, nur den Endpreis.",
-    "defaultMarkupPercent": "Standard-Aufschlag %",
-    "defaultMarkupPercentHint": "Neue manuell hinzugefügte Teile werden mit diesem Aufschlag vorausgefüllt. Auf 0 lassen, um Preise manuell einzugeben.",
-    "markupAppliesToInventory": "Aufschlag auf Lagerteile anwenden",
-    "markupAppliesToInventoryHint": "Wenn aktiviert, verwenden aus dem Lager gewählte Teile ebenfalls Einkaufspreis + Standard-Aufschlag (überschreibt den Verkaufspreis des Lagers).",
     "saveInvoice": "Rechnungseinstellungen speichern",
     "saved": "Rechnungseinstellungen gespeichert",
     "layoutDescription": "Passen Sie die Reihenfolge und Sichtbarkeit der Abschnitte in Ihren Rechnungen und Angeboten an.",
@@ -557,13 +562,6 @@
     "layoutDocQuote": "Angebot",
     "saveLayout": "Layout speichern",
     "resetLayout": "Zurücksetzen",
-    "sectionCustomers": "Kunden",
-    "assignCustomerNumbers": "Kundennummern vergeben",
-    "assignCustomerNumbersHint": "{count} Kunden haben noch keine Nummer. Fortlaufende Nummern vergeben (bestehende bleiben erhalten).",
-    "customerNumbersAssigned": "{count} Kunden nummeriert",
-    "allCustomersNumbered": "Alle Kunden haben eine Nummer.",
-    "assignCustomerNumbersConfirmTitle": "Kundennummern vergeben?",
-    "assignCustomerNumbersConfirmDescription": "Die {count} Kunden ohne Nummer erhalten fortlaufende Nummern in der Reihenfolge ihrer Erstellung, beginnend nach der höchsten vorhandenen Nummer. Kunden mit Nummer bleiben unverändert. Nummern können danach bearbeitet werden.",
     "layoutColorsHint": "Farben, Schriften und der Briefkopf-Stil liegen unter Vorlagen.",
     "goToTemplates": "Vorlagen öffnen",
     "lockTitle": "Abgeschlossene Dokumente sperren",
@@ -571,6 +569,8 @@
     "lockWhatItDoes": "Gesperrt werden: Teile, Arbeit, Mengen, Preise, Rabatt, Steuer, Belegnummer und Datum. Auch das Löschen des Dokuments wird verhindert.",
     "lockWhatItAllows": "Weiterhin möglich: Zahlungen erfassen, Online-Kartenzahlung, Statusänderungen, Versand und erneuter Versand, Anhänge und interne Notizen. Eine gesperrte Rechnung kann immer bezahlt werden.",
     "lockUnlockNote": "Inhaber und Administratoren können ein einzelnes Dokument auf dessen eigener Seite entsperren, wenn wirklich etwas korrigiert werden muss. Jede Entsperrung wird im Audit-Log festgehalten.",
+    "lockDetailsShow": "Was das Sperren bewirkt",
+    "lockDetailsHide": "Ausblenden",
     "lockInvoicesLabel": "Rechnungen sperren",
     "lockTriggerSent": "Sobald die Rechnung versendet ist",
     "lockTriggerPaid": "Sobald die Rechnung vollständig bezahlt ist",
@@ -589,7 +589,10 @@
     "freezeConfirmBody": "Jede behält das Design, das Logo, die Werkstattangaben, den Kunden, das Fahrzeug und die Befunde, mit denen sie jetzt gedruckt wird. Das kann nicht rückgängig gemacht werden; die Rechnungen bleiben genau so bearbeitbar oder für die Bearbeitung gesperrt wie heute.",
     "freezeProgress": "{done} von {total} gesperrt",
     "freezeDone": "{count, plural, one {# Rechnung} other {# Rechnungen}} gesperrt",
-    "freezeFailed": "Rechnungen konnten nicht gesperrt werden"
+    "freezeFailed": "Rechnungen konnten nicht gesperrt werden",
+    "sectionSending": "Versand",
+    "attachPdfLabel": "PDF an E-Mails anhängen",
+    "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern."
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",
@@ -854,9 +857,33 @@
     "modeInclusive": "Inklusiv",
     "modeExclusiveExample": "Beispiel: 100 + {rate}% Steuer = 100 + Steuer obendrauf.",
     "modeInclusiveExample": "Beispiel: 100 enthält bereits {rate}% Steuer — der Steueranteil wird zurückgerechnet.",
+    "lineItemsInclTaxLabel": "Zeilenpreise auf Rechnungen und Angeboten inklusive Steuer anzeigen",
+    "lineItemsInclTaxHint": "Ändert nur, was auf Rechnungen und Angeboten gedruckt wird, einschließlich der PDFs und der Kopien, die Kunden über einen Link öffnen. Ein: Jede Teile- und Arbeitszeile sowie die Zwischensumme werden inklusive Steuer angezeigt, und die Steuerzeile nennt den enthaltenen Steueranteil. Aus: Preise vor Steuer, Steuer wird unten hinzugerechnet. Aufträge und die eingegebenen Preise bleiben unverändert.",
     "labelLabel": "Steuerbezeichnung",
     "labelHint": "Benutzerdefinierter Name, der auf Rechnungen und Angeboten angezeigt wird (z.B. MwSt., USt., VAT). Leer lassen für die übersetzte Standardbezeichnung.",
     "labelPlaceholder": "MwSt.",
+    "splitLabel": "Steuer in mehrere Sätze aufteilen",
+    "splitHint": "Für Regionen, in denen auf derselben Rechnung mehr als eine Steuer erhoben wird, etwa GST und QST in Québec oder CGST und SGST in Indien. Jede Steuer erscheint als eigene Zeile mit eigener Registrierungsnummer, und der Steuerbericht führt sie getrennt auf.",
+    "splitIncomplete": "Geben Sie jeder Steuer vor dem Speichern einen Namen und einen Satz.",
+    "presetLabel": "Mit einer Vorlage beginnen",
+    "presetPlaceholder": "Region wählen",
+    "presets": {
+      "caQc": "Québec, Kanada (GST + QST)",
+      "caBc": "British Columbia, Kanada (GST + PST)",
+      "caSk": "Saskatchewan, Kanada (GST + PST)",
+      "caMb": "Manitoba, Kanada (GST + RST)",
+      "inIntra": "Indien, gleicher Bundesstaat (CGST + SGST zu 18%)"
+    },
+    "componentName": "Name",
+    "componentNamePlaceholder": "GST",
+    "componentRate": "Satz",
+    "componentRegistration": "Registrierungsnummer",
+    "componentRegistrationPlaceholder": "123456789 RT0001",
+    "componentRemove": "Diese Steuer entfernen",
+    "registrationHint": "Wird auf Rechnungen unter Ihren Firmendaten gedruckt. Leer lassen bei einer Steuer, für die Sie nicht registriert sind.",
+    "addComponent": "Steuer hinzufügen",
+    "combinedRate": "Gesamtsatz",
+    "combinedRateHint": "Neue Arbeitsaufträge und Angebote werden mit der Summe der obigen Sätze besteuert.",
     "convertInclusiveLabel": "Bestehende Einträge in inklusiven Modus umwandeln",
     "convertInclusiveHint": "Skaliert jeden Zeilenpreis um den Steuersatz nach oben, sodass die Preise die Steuer enthalten. Kundenseitige Summen bleiben gleich. Berechtigt: {serviceRecords} Arbeitsaufträge, {quotes} Angebote.",
     "convertInclusiveButton": "In inklusiv umwandeln",
@@ -1039,6 +1066,12 @@
     "addNewTech": "+ Neu hinzufügen",
     "defaultLaborRate": "Standard-Stundensatz",
     "laborRatePlaceholder": "75,00",
+    "partsMarkupTitle": "Teile-Aufschlag",
+    "partsMarkupDescription": "Aufschlag auf den Einkaufspreis beim Hinzufügen von Teilen. Nur intern. Der Kunde sieht weder Kosten noch Aufschlag, nur den Endpreis.",
+    "defaultMarkupPercent": "Standard-Aufschlag %",
+    "defaultMarkupPercentHint": "Neue manuell hinzugefügte Teile werden mit diesem Aufschlag vorausgefüllt. Auf 0 lassen, um Preise manuell einzugeben.",
+    "markupAppliesToInventory": "Aufschlag auf Lagerteile anwenden",
+    "markupAppliesToInventoryHint": "Wenn aktiviert, verwenden aus dem Lager gewählte Teile ebenfalls Einkaufspreis + Standard-Aufschlag (überschreibt den Verkaufspreis des Lagers).",
     "unitsTitle": "Einheiten",
     "unitsDescription": "Wählen Sie zwischen metrisch (km, Liter) und imperial (Meilen, Gallonen).",
     "unitSystem": "Einheitensystem",
@@ -1064,6 +1097,25 @@
     "weekStartDay": "Wochenstart",
     "workDayStart": "Arbeitsbeginn",
     "workDayEnd": "Arbeitsende",
+    "titleTemplateTitle": "Titel des Arbeitsauftrags",
+    "titleTemplateDescription": "Wie ein neuer Arbeitsauftrag heißt, bevor jemand einen Titel eingibt. Setzen Sie ihn aus den Tags unten zusammen; jeder Auftrag öffnet mit ausgefüllten Tags, und der Titel lässt sich am Auftrag weiterhin ändern.",
+    "titleTemplate": "Titelvorlage",
+    "titleTemplatePlaceholder": "Leer lassen, damit jeder Auftrag als \"New Service Record\" beginnt",
+    "titleTemplateHint": "Klicken Sie auf ein Tag, um es einzufügen. Ein Tag ohne Inhalt, etwa ein Fahrzeug ohne Kennzeichen, entfällt samt Trennzeichen.",
+    "titleTemplatePreview": "Beispiel: {title}",
+    "titleTemplateUnknown": "Unbekannte Tags entfallen: {tags}",
+    "titleTemplateTags": {
+      "order_number": "Auftragsnummer",
+      "license_plate": "Kennzeichen",
+      "customer_name": "Kundenname",
+      "vehicle": "Fahrzeug (Jahr, Marke, Modell)",
+      "make": "Marke",
+      "model": "Modell",
+      "year": "Baujahr",
+      "vin": "FIN",
+      "technician": "Techniker",
+      "date": "Datum"
+    },
     "weekDays": {
       "0": "Sonntag",
       "1": "Montag",
@@ -1231,7 +1283,14 @@
     "validated": "Lizenz erfolgreich validiert",
     "invalid": "Ungültiger Lizenzschlüssel",
     "failedValidate": "Lizenz konnte nicht validiert werden",
-    "keyHint": "Jeder Lizenzschlüssel ist an diese Organisation gebunden und kann nicht für mehrere Organisationen verwendet werden."
+    "keyHint": "Jeder Lizenzschlüssel ist an diese Organisation gebunden und kann nicht für mehrere Organisationen verwendet werden.",
+    "expired": "Abgelaufen",
+    "unverified": "Nicht überprüft",
+    "expiresOn": "Läuft ab: {date}",
+    "unreachable": "torqvoice.com war nicht erreichbar. Die auf diesem Server gespeicherte Lizenz bleibt unverändert.",
+    "unverifiedHint": "Dieser Server konnte die Lizenz seit {days} Tagen nicht bei torqvoice.com bestätigen. Nach {max} Tagen ohne erfolgreiche Prüfung kehrt das Torqvoice-Branding zurück.",
+    "rejected": "torqvoice.com hat diesen Schlüssel nicht akzeptiert: {reason}",
+    "signedHint": "Die Validierung wird von torqvoice.com signiert und täglich erneuert, daher braucht der Server ausgehenden Zugriff auf torqvoice.com."
   },
   "about": {
     "title": "Uber Torqvoice",
@@ -1733,43 +1792,6 @@
     "prepHint": "Was jede Art von Vorbereitung kostet. Leer bedeutet, sie wird nie berechnet, was zu Betrieben passt, die das Waschen in der Lagergebühr enthalten haben. Bepreiste werden automatisch auf Angebot und Auftrag gesetzt.",
     "prepNotCharged": "nicht berechnet"
   },
-  "providers": {
-    "tabs": {
-      "email": "E-Mail",
-      "sms": "SMS",
-      "whatsapp": "WhatsApp",
-      "telegram": "Telegram"
-    },
-    "locked": {
-      "email": {
-        "feature": "E-Mail-Einstellungen",
-        "description": "SMTP-Versand einrichten, um Rechnungen, Angebote und Benachrichtigungen direkt aus der Werkstatt zu senden."
-      },
-      "sms": {
-        "feature": "SMS-Nachrichten",
-        "description": "SMS mit Ihren Kunden direkt aus Torqvoice senden und empfangen."
-      },
-      "whatsapp": {
-        "feature": "WhatsApp-Nachrichten",
-        "description": "WhatsApp-Nachrichten samt Fotos von Bauteilen direkt aus Torqvoice senden und empfangen."
-      },
-      "telegram": {
-        "feature": "Telegram-Nachrichten",
-        "description": "Telegram-Nachrichten mit Ihren Kunden direkt aus Torqvoice senden und empfangen."
-      }
-    },
-    "moved": {
-      "title": "These settings moved to Integrations",
-      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
-      "connectedTo": "Connected through {vendor}",
-      "notSetUp": "Not set up yet",
-      "manage": "Manage",
-      "notConnected": "Not connected",
-      "goToIntegrations": "Go to Integrations",
-      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
-      "onByDefault": "On by default"
-    }
-  },
   "designer": {
     "invoice": "Rechnung",
     "quote": "Angebot",
@@ -1964,8 +1986,14 @@
     "paymentTermsPlaceholderHint": "Es sind keine Zahlungsbedingungen hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
     "paymentTermsSetHint": "Ihre Zahlungsbedingungen stammen aus den Zahlungseinstellungen.",
     "paymentTermsLink": "Zahlungseinstellungen",
+    "telegramQr": "Telegram-Code",
+    "telegramQrConnectedHint": "Druckt einen Code, der {link} öffnet, Ihren verbundenen Telegram-Bot. Er erscheint nur auf Rechnungen, die nach dem Einschalten des Blocks ausgestellt wurden.",
+    "telegramQrPlaceholderHint": "Kein Telegram-Bot ist verbunden, daher zeigt das Blatt einen Beispielcode, der nicht gedruckt wird.",
+    "telegramQrLink": "Integrationen",
     "titleText": "Titeltext",
     "titleTextHint": "Wie sich dieses Dokument nennt. Leer gelassen, druckt es seinen eigenen Namen in der Sprache des Lesers.",
+    "orgNumberLabel": "Bezeichnung der Organisationsnummer",
+    "orgNumberLabelHint": "Wird auf Rechnungen und Angeboten neben Ihrer Organisationsnummer gedruckt. Leer lassen für die Standardbezeichnung in Ihrer Sprache, oder eintragen, wie sie bei Ihnen heißt, z. B. USt-IdNr., HRB oder UID. Gilt auch in den Unternehmenseinstellungen.",
     "placeholder": "Platzhalter",
     "leaveTitle": "Ohne Speichern verlassen?",
     "leaveBody": "Ihre Änderungen an diesem Design wurden nicht gespeichert und gehen verloren.",
@@ -1975,7 +2003,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",
@@ -2016,5 +2045,362 @@
     "timeZoneDetected": "Die Zeitzone steht unter Lokalisierung auf automatisch, daher nutzt der Server {zone}, die zuletzt in einem Browser gesehene Zone. Wählen Sie die Zone der Werkstatt ausdrücklich, damit Buchungszeiten nie davon abhängen, wer zuletzt gespeichert hat.",
     "timeZoneExplicit": "Buchungszeiten und Öffnungszeiten werden in {zone} gelesen.",
     "timeZoneChange": "Unter Lokalisierung ändern"
+  },
+  "emailTemplates": {
+    "title": "E-Mail-Vorlagen",
+    "description": "Was die E-Mails Ihrer Werkstatt sagen und wie sie aussehen. Jede Art von E-Mail hat eine eingebaute Vorlage; gestalten Sie Ihre eigene, um Wortlaut, Farben und die Blöcke zu ändern, aus denen sie besteht.",
+    "lockedDescription": "Gestalten Sie die E-Mails Ihrer Werkstatt: Ändern Sie den Wortlaut, fügen Sie Blöcke hinzu oder entfernen Sie sie, und passen Sie die Farben an Ihre Marke an.",
+    "groups": {
+      "documents": "Dokumente",
+      "messages": "Nachrichten",
+      "portal": "Kundenportal",
+      "team": "Team"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Rechnung gesendet",
+        "description": "Wird gesendet, wenn eine Rechnung per E-Mail an einen Kunden geht, mit angehängtem PDF oder einem Link zum Ansehen."
+      },
+      "quote_sent": {
+        "name": "Angebot gesendet",
+        "description": "Wird gesendet, wenn ein Angebot per E-Mail an einen Kunden geht."
+      },
+      "inspection_sent": {
+        "name": "Prüfbericht gesendet",
+        "description": "Wird gesendet, wenn ein Prüfbericht per E-Mail an einen Kunden geht."
+      },
+      "message": {
+        "name": "Kundennachricht",
+        "description": "Eine Nachricht an einen Kunden: eine Benachrichtigung aus einem Auftrag, ein Statusbericht, eine Videoanruf-Einladung oder eine geplante Erinnerung."
+      },
+      "portal_signin": {
+        "name": "Portal-Anmeldung",
+        "description": "Der Anmeldelink, den ein Kunde im Kundenportal anfordert."
+      },
+      "team_invitation": {
+        "name": "Team-Einladung",
+        "description": "Geht an eine Kollegin oder einen Kollegen, die Sie in die Werkstatt einladen, mit dem Link, der das Konto anlegt."
+      }
+    },
+    "blocks": {
+      "header": "Kopfbereich",
+      "heading": "Überschrift",
+      "paragraph": "Absatz",
+      "callout": "Hinweisfeld",
+      "button": "Schaltfläche",
+      "document_summary": "Dokumentübersicht",
+      "attachment_note": "Anhangshinweis",
+      "image": "Bild",
+      "divider": "Trennlinie",
+      "spacer": "Abstand",
+      "contact_footer": "Kontaktfußzeile"
+    },
+    "blockNotes": {
+      "header": "Ihr Logo oder der Werkstattname, wenn es kein Logo gibt oder es ausgeschaltet ist. Laden Sie das Logo hier hoch und legen Sie seine Größe fest; Farben und Schriften finden Sie unter Betreff und Design.",
+      "contact_footer": "Name, Adresse, Telefon und E-Mail Ihrer Werkstatt aus den Einstellungen unter Unternehmen. Tragen Sie sie dort ein, um zu ändern, was hier gedruckt wird.",
+      "divider": "Eine dünne Linie zwischen zwei Blöcken. Sie entfällt, wenn sie die E-Mail eröffnen oder abschließen würde.",
+      "callout": "Ein hervorgehobenes Feld mit farbigem Rand. Gut für die wichtigsten Worte, etwa die Nachricht selbst.",
+      "attachment_note": "Wird nur gedruckt, wenn der E-Mail ein PDF angehängt ist.",
+      "button": "Der Link ist meist ein Tag wie der Freigabelink. Eine Schaltfläche, deren Link keinen Wert hat, entfällt in der E-Mail.",
+      "document_summary": "Ein Feld mit den Eckdaten des Dokuments. Zeilen ohne Inhalt entfallen.",
+      "image": "Bilder werden beim Hochladen komprimiert und für E-Mails verkleinert. Nicht mehr verwendete Bilder werden beim Speichern der Vorlage entfernt.",
+      "summaryUnused": "Diese Art von E-Mail hat kein Dokument, die Übersicht druckt also nichts."
+    },
+    "tagGroups": {
+      "workshop": "Werkstatt",
+      "customer": "Kunde",
+      "vehicle": "Fahrzeug",
+      "document": "Dokument",
+      "message": "Nachricht",
+      "portal": "Portal",
+      "team": "Team"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Werkstattname",
+        "description": "Der Name Ihrer Werkstatt."
+      },
+      "workshop_phone": {
+        "label": "Werkstatt-Telefon",
+        "description": "Die Telefonnummer aus den Einstellungen unter Unternehmen."
+      },
+      "workshop_email": {
+        "label": "Werkstatt-E-Mail",
+        "description": "Die E-Mail-Adresse aus den Einstellungen unter Unternehmen."
+      },
+      "workshop_address": {
+        "label": "Werkstattadresse",
+        "description": "Die Adresse aus den Einstellungen unter Unternehmen, auf so vielen Zeilen, wie sie hat."
+      },
+      "current_user": {
+        "label": "Absendername",
+        "description": "Der Name der Person, die die E-Mail sendet."
+      },
+      "customer_name": {
+        "label": "Kundenname",
+        "description": "Der Name des Kunden, an den die E-Mail geht."
+      },
+      "vehicle": {
+        "label": "Fahrzeug",
+        "description": "Baujahr, Marke und Modell des Fahrzeugs."
+      },
+      "plate": {
+        "label": "Kennzeichen",
+        "description": "Das amtliche Kennzeichen des Fahrzeugs."
+      },
+      "mileage": {
+        "label": "Kilometerstand",
+        "description": "Der beim Fahrzeug erfasste Kilometerstand."
+      },
+      "document_number": {
+        "label": "Dokumentnummer",
+        "description": "Die Nummer der Rechnung oder des Angebots."
+      },
+      "document_title": {
+        "label": "Dokumenttitel",
+        "description": "Der Titel des Auftrags oder Dokuments."
+      },
+      "total": {
+        "label": "Gesamtbetrag",
+        "description": "Der Gesamtbetrag des Dokuments in seiner Währung."
+      },
+      "balance_due": {
+        "label": "Offener Betrag",
+        "description": "Was nach Zahlungen auf dem Dokument noch offen ist."
+      },
+      "due_date": {
+        "label": "Fälligkeitsdatum",
+        "description": "Das Datum, an dem die Zahlung fällig ist."
+      },
+      "share_link": {
+        "label": "Freigabelink",
+        "description": "Ein Link, über den der Kunde das Dokument online ansehen kann."
+      },
+      "message": {
+        "label": "Nachricht",
+        "description": "Die für diese E-Mail geschriebene Nachricht oder die dem Dokument hinzugefügte Notiz."
+      },
+      "signin_link": {
+        "label": "Anmeldelink",
+        "description": "Der Link, der den Kunden im Portal anmeldet. Er läuft nach kurzer Zeit ab."
+      },
+      "portal_link": {
+        "label": "Portallink",
+        "description": "Ein Link zum Kundenportal."
+      },
+      "invite_link": {
+        "label": "Einladungslink",
+        "description": "Der Link, der das Konto anlegt und dem Team beitritt. Er funktioniert nicht mehr, sobald die Einladung abläuft."
+      },
+      "role": {
+        "label": "Rolle",
+        "description": "Die Rolle, in die die Person eingeladen wird."
+      },
+      "invite_expires": {
+        "label": "Ablaufdatum",
+        "description": "Der letzte Tag, an dem der Einladungslink funktioniert."
+      }
+    },
+    "headerRule": "Linie unter der Kopfzeile",
+    "headerRuleHint": "Eine dünne Linie zwischen Logo oder Werkstattname und dem Rest der E-Mail.",
+    "optionalTag": "Nicht jede E-Mail hat einen Wert für dieses Tag. Bleibt es leer, werden die Worte darum herum bereinigt.",
+    "summaryRows": {
+      "reference": "Referenz",
+      "vehicle": "Fahrzeug",
+      "total": "Gesamtbetrag",
+      "balance": "Offener Betrag",
+      "due": "Fälligkeitsdatum"
+    },
+    "builtIn": "Eingebaut",
+    "inUse": "In Verwendung",
+    "useBuiltIn": "Eingebaute verwenden",
+    "setDefault": "Als Standard festlegen",
+    "defaultSet": "Wird jetzt mit „{name}“ gesendet",
+    "couldNotSetDefault": "Die verwendete Vorlage konnte nicht geändert werden",
+    "updatedOn": "Aktualisiert am {date}",
+    "delete": "Löschen",
+    "deleteTitle": "Vorlage löschen",
+    "deleteConfirm": "Die Vorlage „{name}“ löschen? Ist sie in Verwendung, greifen E-Mails wieder auf die eingebaute Vorlage zurück.",
+    "deleteTemplate": "Diese Vorlage löschen",
+    "deleted": "„{name}“ gelöscht",
+    "couldNotDelete": "Die Vorlage konnte nicht gelöscht werden",
+    "backToSettings": "Zurück zu den Einstellungen",
+    "unsaved": "(nicht gespeichert)",
+    "save": "Speichern",
+    "saving": "Wird gespeichert…",
+    "savedState": "Gespeichert",
+    "saved": "„{name}“ gespeichert",
+    "couldNotSave": "Speichern nicht möglich",
+    "saveAsNew": "Als neue speichern",
+    "discard": "Änderungen verwerfen",
+    "sendTest": "Test senden",
+    "cancel": "Abbrechen",
+    "problems": "{count, plural, one {# Problem} other {# Probleme}}",
+    "unknownTags": "Diese Tags gibt es für diese Art von E-Mail nicht: {tags}",
+    "missingTags": "Diese E-Mail muss {tags} enthalten",
+    "subjectRequired": "Der Betreff darf nicht leer sein",
+    "invalidTemplate": "Ein Text ist zu lang oder ein Feld hat einen ungültigen Wert.",
+    "leaveTitle": "Ohne Speichern verlassen?",
+    "leaveBody": "Ihre Änderungen an dieser Vorlage wurden nicht gespeichert. Sie gehen verloren.",
+    "leaveConfirm": "Verlassen",
+    "subjectAndTheme": "Betreff und Design",
+    "settingsSubtitle": "Gilt für die ganze E-Mail",
+    "blocksTitle": "Blöcke",
+    "addBlock": "Block hinzufügen",
+    "removeBlock": "Block entfernen",
+    "removeBlockLast": "Der letzte Block kann nicht entfernt werden",
+    "blockEmpty": "leer",
+    "blockEmptyHint": "Wird angezeigt, hat aber noch nichts zu drucken.",
+    "blockShownHint": "In der E-Mail sichtbar. Klicken, um ihn auszublenden.",
+    "blockHiddenHint": "Aus der E-Mail ausgeblendet. Klicken, um ihn anzuzeigen.",
+    "railHint": "Ziehen Sie einen Block an seinem Griff, um ihn zu verschieben. Die Linie zeigt, wo er landet. Klicken Sie einen Block an, um rechts seinen Text zu bearbeiten.",
+    "resizeHint": "Ziehen, um die Größe des Bereichs zu ändern. Doppelklick setzt sie zurück.",
+    "desktop": "Desktop",
+    "mobile": "Mobil",
+    "htmlView": "HTML",
+    "textView": "Nur Text",
+    "desktopTooltip": "Vorschau in Desktop-Breite",
+    "mobileTooltip": "Vorschau in Handy-Breite",
+    "htmlViewTooltip": "HTML-E-Mail anzeigen",
+    "previewWidth": "Vorschaubreite",
+    "previewView": "Vorschauansicht",
+    "textViewTooltip": "Nur-Text-Version anzeigen",
+    "inboxNow": "Jetzt",
+    "inboxNoSender": "Ihre Werkstatt",
+    "inboxNoSubject": "(kein Betreff)",
+    "subject": "Betreff",
+    "text": "Text",
+    "buttonLabel": "Schaltflächentext",
+    "buttonHref": "Link",
+    "spacerHeight": "Höhe",
+    "tagsTitle": "Tags",
+    "tagsHint": "Klicken Sie ein Tag an, um es an der Cursorposition in dem Feld einzufügen, in das Sie zuletzt getippt haben.",
+    "theme": {
+      "colors": "Farben",
+      "primaryColor": "Primär",
+      "textColor": "Text",
+      "mutedColor": "Gedämpfter Text",
+      "backgroundColor": "Hintergrund",
+      "panelColor": "Felder",
+      "invalidColor": "Eine Farbe ist kein gültiger Hex-Wert",
+      "hexLabel": "{label} als Hex",
+      "typography": "Typografie",
+      "font": "Schrift",
+      "fontHint": "Nur Schriften, die jedes Mailprogramm schon hat. Webfonts werden von den meisten nicht geladen.",
+      "fonts": {
+        "system": "System",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "lineSpacing": "Zeilenabstand",
+      "lineSpacings": {
+        "compact": "Kompakt",
+        "normal": "Normal",
+        "relaxed": "Locker"
+      },
+      "paragraphSpacing": "Absatzabstand",
+      "paragraphSpacings": {
+        "tight": "Eng",
+        "normal": "Normal",
+        "loose": "Weit"
+      },
+      "spacingHint": "Der Zeilenabstand ist die Höhe jeder Zeile; der Absatzabstand ist der Raum zwischen Absätzen und zwischen Textblöcken. Enter beginnt einen neuen Absatz, Umschalt+Enter eine neue Zeile darin.",
+      "shapes": "Formen",
+      "buttonRadius": "Schaltflächenecken",
+      "topBar": "Farbbalken oben",
+      "topBarHint": "Ein schmales Band in Ihrer Primärfarbe entlang der Oberkante der E-Mail.",
+      "showLogo": "Logo anzeigen",
+      "showLogoHint": "Ist das Logo aus, druckt der Kopfbereich stattdessen den Werkstattnamen."
+    },
+    "nameDialog": {
+      "title": "Diese Vorlage speichern",
+      "body": "Geben Sie der Vorlage einen Namen, unter dem Sie sie in der Galerie wiederfinden. Speichern macht sie zugleich zu der Vorlage, mit der diese Art von E-Mail gesendet wird.",
+      "placeholder": "Name der Vorlage",
+      "nameExists": "Eine Vorlage mit diesem Namen gibt es bereits. Wählen Sie einen anderen Namen.",
+      "save": "Vorlage speichern",
+      "update": "Vorlage aktualisieren"
+    },
+    "testDialog": {
+      "title": "Test-E-Mail senden",
+      "body": "Die E-Mail wird mit Beispieldaten gesendet, damit Sie sie in einem echten Mailprogramm sehen können.",
+      "placeholder": "E-Mail-Adresse",
+      "send": "Senden",
+      "sending": "Wird gesendet…",
+      "sentTitle": "Test-E-Mail gesendet",
+      "sentBody": "Prüfen Sie den Posteingang von {email}. Es kann eine Minute dauern, bis sie ankommt.",
+      "failedTitle": "Test-E-Mail konnte nicht gesendet werden",
+      "failedBody": "Prüfen Sie den E-Mail-Anbieter unter Anbieter und versuchen Sie es erneut."
+    },
+    "toolbar": {
+      "bold": "Fett",
+      "italic": "Kursiv",
+      "underline": "Unterstrichen",
+      "strike": "Durchgestrichen",
+      "link": "Link",
+      "linkUrl": "Linkadresse",
+      "linkPlaceholder": "https://... oder {share_link}",
+      "linkHint": "Eine Webadresse oder ein Tag wie {share_link}, das beim Senden der E-Mail ausgefüllt wird.",
+      "linkApply": "Übernehmen",
+      "linkRemove": "Link entfernen",
+      "bulletList": "Aufzählung",
+      "orderedList": "Nummerierte Liste",
+      "align": {
+        "left": "Linksbündig",
+        "center": "Zentriert",
+        "right": "Rechtsbündig"
+      },
+      "size": {
+        "label": "Textgröße",
+        "default": "Standardgröße",
+        "small": "Klein",
+        "normal": "Normal",
+        "large": "Groß",
+        "extraLarge": "Sehr groß"
+      },
+      "color": {
+        "label": "Textfarbe",
+        "text": "Textfarbe des Themas",
+        "muted": "Gedämpfte Farbe des Themas",
+        "primary": "Primärfarbe des Themas",
+        "default": "Standard",
+        "hex": "Hex-Farbe",
+        "apply": "Übernehmen"
+      },
+      "clear": "Formatierung entfernen"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Kein Logo hochgeladen. Der Kopfbereich druckt den Werkstattnamen.",
+      "upload": "Logo hochladen",
+      "replace": "Logo ersetzen",
+      "remove": "Entfernen",
+      "uploading": "Wird hochgeladen…",
+      "width": "Logobreite",
+      "hint": "Unabhängig vom Firmenlogo. Es wird beim Hochladen zugeschnitten und für E-Mails aufbereitet. PNG, JPEG oder WebP bis 4 MB.",
+      "position": "Position",
+      "tooLarge": "Das Bild muss kleiner als 4 MB sein",
+      "uploadFailed": "Logo konnte nicht hochgeladen werden"
+    },
+    "image": {
+      "empty": "Kein Bild hochgeladen. Der Block druckt nichts, solange keines vorhanden ist.",
+      "upload": "Bild hochladen",
+      "replace": "Bild ersetzen",
+      "remove": "Entfernen",
+      "uploading": "Wird hochgeladen…",
+      "alt": "Beschreibung",
+      "altHint": "Wird Lesern vorgelesen, die das Bild nicht sehen können, und während des Ladens angezeigt. PNG, JPEG oder WebP bis 10 MB.",
+      "width": "Breite",
+      "widthHint": "600 px ist die volle Breite der E-Mail. Schmalere Bilder stehen dort, wo es unter Position festgelegt ist.",
+      "link": "Link",
+      "linkHint": "Optional. Wird beim Klick auf das Bild geöffnet; ein Tag wie der Freigabelink funktioniert hier ebenfalls.",
+      "tooLarge": "Das Bild muss kleiner als 10 MB sein",
+      "uploadFailed": "Bild konnte nicht hochgeladen werden"
+    },
+    "dragHandle": "Zum Verschieben ziehen",
+    "dragHandleKeyboard": "Ziehen Sie diesen Block oder verschieben Sie ihn mit den Pfeiltasten",
+    "tagDoesNotFit": "Das Tag passt nicht in dieses Feld."
   }
 }

+ 141 - 0
messages/de/timeTracking.json

@@ -0,0 +1,141 @@
+{
+  "clock": {
+    "runningOn": "Uhr läuft für {job}",
+    "stop": "Uhr stoppen",
+    "stopped": "{minutes} erfasst",
+    "switched": "Vorherigen Auftrag bei {minutes} gestoppt",
+    "startFailed": "Uhr konnte nicht gestartet werden",
+    "stopFailed": "Uhr konnte nicht gestoppt werden"
+  },
+  "job": {
+    "title": "Erfasste Zeit",
+    "running": "läuft",
+    "clockIn": "Einstempeln",
+    "clockOut": "Ausstempeln",
+    "switchHere": "Zu diesem Auftrag wechseln",
+    "switchHint": "Stoppt die Uhr bei {job} und startet sie hier",
+    "addAsLabor": "{hours, number} h als Arbeit hinzufügen",
+    "addAsLaborHint": "Fügt eine Arbeitsposition mit {hours, number} Stunden zum Standardsatz hinzu. Die erfasste Zeit selbst bleibt unverändert.",
+    "laborDescription": "Arbeit (erfasste Zeit)",
+    "empty": "Für diesen Auftrag wurde noch keine Zeit erfasst. Techniker stempeln in der App oder unter „Meine aktiven Aufträge“ auf dem Dashboard ein.",
+    "showAll": "{count} weitere anzeigen",
+    "showFewer": "Weniger anzeigen",
+    "openTimesheets": "Stundenzettel öffnen"
+  },
+  "source": {
+    "app": "App",
+    "web": "Web",
+    "manual": "Manuell",
+    "editedBy": "Angepasst von {name}"
+  },
+  "page": {
+    "title": "Stundenzettel",
+    "description": "Wer wann gearbeitet hat, nach der Uhr der Werkstatt.",
+    "loadFailed": "Stundenzettel konnte nicht geladen werden",
+    "presets": {
+      "today": "Heute",
+      "yesterday": "Gestern",
+      "thisWeek": "Diese Woche",
+      "lastWeek": "Letzte Woche",
+      "thisMonth": "Dieser Monat",
+      "lastMonth": "Letzter Monat"
+    },
+    "customRange": "Eigener Zeitraum",
+    "apply": "Anwenden",
+    "allTechnicians": "Alle Techniker",
+    "export": "CSV exportieren",
+    "addEntry": "Eintrag hinzufügen",
+    "stats": {
+      "total": "Gesamtzeit",
+      "totalHint": "{days, plural, =1 {1 Tag} other {# Tage}} im Zeitraum",
+      "technicians": "Techniker mit Zeit",
+      "techniciansHint": "von {count} im Team",
+      "running": "Gerade eingestempelt",
+      "runningHint": "live",
+      "average": "Durchschnitt pro aktivem Tag",
+      "averageHint": "über {days, plural, =1 {1 Tag} other {# Tage}} mit Zeit"
+    },
+    "nowStrip": {
+      "title": "Gerade eingestempelt",
+      "since": "seit {time}",
+      "stop": "Stoppen"
+    },
+    "grid": {
+      "title": "Übersicht",
+      "description": "Stunden pro Techniker und Tag. Klicken Sie auf eine Zelle, um zu diesem Tag zu springen.",
+      "technician": "Techniker",
+      "total": "Gesamt",
+      "tooLong": "Die Übersicht zeigt bis zu 31 Tage. Grenzen Sie den Zeitraum ein, um sie zu sehen."
+    },
+    "table": {
+      "time": "Zeit",
+      "duration": "Dauer",
+      "job": "Auftrag",
+      "vehicle": "Fahrzeug",
+      "source": "Quelle",
+      "note": "Notiz",
+      "running": "Läuft",
+      "dayTotal": "Tagessumme",
+      "counterSale": "Thekenverkauf",
+      "edit": "Bearbeiten",
+      "stop": "Uhr stoppen",
+      "delete": "Löschen",
+      "actions": "Aktionen"
+    },
+    "empty": {
+      "title": "Keine Zeit in diesem Zeitraum erfasst",
+      "body": "Techniker stempeln in der Torqvoice Technicians App oder unter „Meine aktiven Aufträge“ auf dem Dashboard ein. Einträge erscheinen hier, sobald eine Uhr startet.",
+      "noTechnicians": "Noch niemand im Team ist als Techniker eingerichtet. Verknüpfen Sie Teammitglieder unter Einstellungen, Team mit Technikern.",
+      "teamLink": "Team-Einstellungen öffnen"
+    },
+    "deleteTitle": "Diesen Eintrag löschen?",
+    "deleteBody": "{duration} für {job} von {technician} wird aus dem Stundenzettel entfernt. Das kann nicht rückgängig gemacht werden.",
+    "deleteConfirm": "Löschen",
+    "deleted": "Eintrag gelöscht",
+    "stoppedByManager": "Uhr bei {minutes} gestoppt",
+    "csv": {
+      "technician": "Techniker",
+      "date": "Datum",
+      "start": "Beginn",
+      "end": "Ende",
+      "duration": "Dauer",
+      "hours": "Stunden",
+      "job": "Auftrag",
+      "vehicle": "Fahrzeug",
+      "plate": "Kennzeichen",
+      "source": "Quelle",
+      "editedBy": "Angepasst von",
+      "note": "Notiz"
+    },
+    "unlinked": "Kein Konto",
+    "unlinkedHint": "Nur auf der Plantafel, nicht mit einem Teammitglied verknüpft"
+  },
+  "dialog": {
+    "addTitle": "Zeiteintrag hinzufügen",
+    "addDescription": "Zeit, die ein Techniker gearbeitet, aber nicht erfasst hat. Sie wird als manuell eingetragen markiert.",
+    "editTitle": "Zeiteintrag korrigieren",
+    "editDescription": "Techniker und Auftrag bleiben unverändert. Wer die Änderung vorgenommen hat, wird protokolliert.",
+    "technician": "Techniker",
+    "pickTechnician": "Techniker wählen",
+    "job": "Auftrag",
+    "pickJob": "Auftrag wählen",
+    "searchJobs": "Nach Titel oder Kennzeichen suchen",
+    "searching": "Suche…",
+    "noJobs": "Keine Aufträge gefunden",
+    "start": "Beginn",
+    "end": "Ende",
+    "stillRunning": "Läuft noch",
+    "leaveEndEmpty": "Lassen Sie das Ende leer, damit die Uhr weiterläuft.",
+    "endBeforeStart": "Das Ende muss nach dem Beginn liegen.",
+    "duration": "Dauer: {duration}",
+    "timesInWorkshopZone": "Zeiten gelten nach der Uhr der Werkstatt ({zone}).",
+    "note": "Notiz",
+    "notePlaceholder": "Optional. Warum das hinzugefügt oder geändert wurde.",
+    "cancel": "Abbrechen",
+    "save": "Speichern",
+    "add": "Hinzufügen",
+    "added": "Eintrag hinzugefügt",
+    "updated": "Eintrag aktualisiert",
+    "saveFailed": "Eintrag konnte nicht gespeichert werden"
+  }
+}

+ 10 - 0
messages/de/workOrders.json

@@ -43,15 +43,23 @@
   },
   "vehiclePicker": {
     "title": "Fahrzeug für Arbeitsauftrag auswählen",
+    "titleMarine": "Wasserfahrzeug für Arbeitsauftrag auswählen",
     "searchPlaceholder": "Fahrzeuge durchsuchen...",
+    "searchPlaceholderMarine": "Wasserfahrzeuge durchsuchen...",
     "empty": "Keine Fahrzeuge gefunden.",
+    "emptyMarine": "Keine Wasserfahrzeuge gefunden.",
     "emptySearch": "Keine Fahrzeuge entsprechen Ihrer Suche.",
+    "emptySearchMarine": "Keine Wasserfahrzeuge entsprechen Ihrer Suche.",
     "addNewVehicle": "Neues Fahrzeug hinzufügen",
+    "addNewVehicleMarine": "Neues Wasserfahrzeug hinzufügen",
     "noPlate": "Kein Kennzeichen",
+    "noPlateMarine": "Keine Registrierungsnummer",
     "make": "Hersteller *",
+    "makeMarine": "Hersteller *",
     "model": "Modell *",
     "year": "Jahr *",
     "licensePlate": "Kennzeichen",
+    "licensePlateMarine": "Registrierungsnummer",
     "customer": "Kunde",
     "selectCustomer": "Wählen Sie einen Kunden (optional)",
     "createNewCustomer": "Neuen Kunden erstellen",
@@ -66,9 +74,11 @@
     "customerNameRequired": "Kundenname ist erforderlich",
     "failedCreateCustomer": "Kunde konnte nicht erstellt werden",
     "failedCreateVehicle": "Fahrzeug konnte nicht erstellt werden",
+    "failedCreateVehicleMarine": "Wasserfahrzeug konnte nicht erstellt werden",
     "somethingWentWrong": "Etwas ist schief gelaufen",
     "partsSale": "Reiner Teileverkauf",
     "partsSaleDescription": "Rechnung ohne Fahrzeug erstellen, für Teileverkauf über die Theke.",
+    "partsSaleDescriptionMarine": "Rechnung ohne Wasserfahrzeug erstellen, für Teileverkauf über die Theke.",
     "createSale": "Verkauf erstellen",
     "none": "Keiner"
   },

+ 17 - 2
messages/en/audit.json

@@ -50,6 +50,7 @@
     "team_invite": "Invited Team Member",
     "team_sendInvitation": "Sent Invitation",
     "team_cancelInvitation": "Cancelled Invitation",
+    "team_resendInvitation": "Resent Invitation",
     "team_updateRole": "Changed Member Role",
     "team_removeMember": "Removed Team Member",
     "role_create": "Created Role",
@@ -161,10 +162,16 @@
     "settings_deleteDocumentDesign": "Deleted Document Design",
     "settings_applyDocumentDesign": "Set Default Document Design",
     "settings_setDocumentDesignRule": "Changed When a Design Is Used",
+    "settings_saveEmailTemplate": "Saved Email Template",
+    "settings_deleteEmailTemplate": "Deleted Email Template",
+    "settings_applyEmailTemplate": "Set Default Email Template",
     "settings_freezeInvoices": "Locked Older Invoices",
     "inspection_reminders_sent": "Sent inspection reminders",
     "inspection_reminders_send": "Sent inspection reminders",
-    "service_videoCall_send": "Sent video call link"
+    "service_videoCall_send": "Sent video call link",
+    "timeEntry_create": "Added time entry",
+    "timeEntry_update": "Corrected time entry",
+    "timeEntry_delete": "Deleted time entry"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -243,6 +250,7 @@
     "subscription_cancel": "Cancelled the subscription, effective at the end of the period",
     "subscription_resume": "Resumed the subscription",
     "team_cancelInvitation": "Cancelled the invitation for {email}",
+    "team_resendInvitation": "Sent the invitation for {email} again",
     "team_removeMember": "Removed team member {id}",
     "team_sendInvitation": "Invited {email} as {role, select, owner {owner} admin {admin} member {member} other {{role}}}",
     "team_updateRole": "Changed a member's role to {role, select, owner {owner} admin {admin} member {member} other {{role}}}",
@@ -298,8 +306,15 @@
     "settings_deleteDocumentDesign": "Deleted the document design {name}",
     "settings_applyDocumentDesign": "Made {name} the default document design",
     "settings_setDocumentDesignRule": "Changed when the document design {name} is used",
+    "settings_saveEmailTemplate": "Saved the email template {name}",
+    "settings_deleteEmailTemplate": "Deleted the email template {name}",
+    "settings_applyEmailTemplate": "Made {name} the template for {kind} emails",
     "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
     "inspection_reminders_sent": "Sent {count} inspection reminders",
-    "service_videoCall_send": "Sent the video call link via {channels}"
+    "service_videoCall_send": "Sent the video call link via {channels}",
+    "timeEntry_create": "Added {minutes} minutes for {technician} on \"{job}\"",
+    "timeEntry_update": "Corrected time entry for {technician} on \"{job}\" to {minutes} minutes",
+    "timeEntry_stop": "Stopped the clock for {technician} on \"{job}\" at {minutes} minutes",
+    "timeEntry_delete": "Deleted time entry {id}"
   }
 }

+ 44 - 0
messages/en/auth.json

@@ -7,6 +7,8 @@
     "resetPasswordCta": "Reset it here",
     "noAccount": "Don't have an account?",
     "createOne": "Create one",
+    "newHere": "New to Torqvoice?",
+    "createFreeAccount": "Create a free account",
     "termsAgreement": "By using this service you agree to our",
     "or": "or",
     "passkey": "Sign in with Passkey",
@@ -22,6 +24,8 @@
     "title": "Create an account",
     "descriptionInvite": "Create your account to join the team",
     "descriptionDefault": "Set up your workshop to manage customer vehicles",
+    "titleCloud": "Start your free workshop",
+    "descriptionCloud": "Ready in under a minute. No credit card required.",
     "fullName": "Full Name",
     "fullNamePlaceholder": "John Doe",
     "passwordPlaceholder": "Create a strong password",
@@ -29,6 +33,8 @@
     "agreeToTerms": "I agree to the",
     "createAccountJoin": "Create Account & Join Team",
     "createAccount": "Create Account",
+    "createAccountCloud": "Create free account",
+    "noCardNote": "Free plan. Upgrade only if you outgrow it.",
     "alreadyHaveAccount": "Already have an account?",
     "signInInstead": "Sign in instead",
     "errors": {
@@ -39,6 +45,11 @@
       "emailExists": "An account with this email already exists."
     }
   },
+  "social": {
+    "google": "Continue with Google",
+    "orEmail": "or continue with email",
+    "failed": "Google sign-in did not complete. Try again, or use your email and password."
+  },
   "forgotPassword": {
     "title": "Reset your password",
     "description": "Enter your email and we'll send you a reset link",
@@ -88,5 +99,38 @@
     "errors": {
       "invalidCode": "Invalid code"
     }
+  },
+  "shell": {
+    "language": "Language",
+    "about": "About Torqvoice",
+    "docs": "Docs",
+    "terms": "Terms"
+  },
+  "pitch": {
+    "badge": "For independent repair shops",
+    "headline": "Workshop management",
+    "headlineHighlight": "without the paperwork",
+    "subheadline": "Quotes, work orders, invoices and customer messages in one place. Every job is entered once, and nothing slips through.",
+    "benefits": {
+      "flow": {
+        "title": "Quote to paid invoice in one flow",
+        "description": "Turn a quote into a work order and an invoice without retyping a thing."
+      },
+      "history": {
+        "title": "Every vehicle's full history",
+        "description": "Services, parts, photos and mileage on one timeline, ready when the customer calls."
+      },
+      "pay": {
+        "title": "Customers pay from a link",
+        "description": "Send the invoice and get paid online with Stripe, Vipps or PayPal. No customer account needed."
+      }
+    },
+    "proof": {
+      "free": "Free plan, no credit card",
+      "minute": "Ready in a minute",
+      "languages": "12 languages",
+      "openSource": "Open source"
+    },
+    "screenshotAlt": "The Torqvoice dashboard with active jobs, key numbers and the getting-started checklist"
   }
 }

+ 7 - 1
messages/en/billing.json

@@ -19,7 +19,13 @@
     "columnTotal": "Total",
     "columnPaid": "Paid",
     "columnBalance": "Balance",
-    "noRecords": "No billing records found."
+    "noRecords": "No billing records found.",
+    "columnDelivery": "Delivery",
+    "deliverySent": "Sent",
+    "deliveryViewed": "Viewed",
+    "deliveryViewedOn": "Last opened {date}",
+    "filterUnviewed": "Not viewed",
+    "filterUnviewedHint": "Invoices that were sent but have never been opened"
   },
   "recurring": {
     "title": "Recurring",

+ 11 - 1
messages/en/common.json

@@ -81,6 +81,14 @@
     "enterFullscreen": "Fullscreen",
     "exitFullscreen": "Exit fullscreen"
   },
+  "upgrade": {
+    "title": "Upgrade to keep going",
+    "maxCustomers": "Your plan includes up to {limit} customers, and you have reached that number. Upgrade to add as many as you need.",
+    "maxUsers": "Your plan includes {limit, plural, one {one team member} other {# team members}}. Upgrade to invite more people.",
+    "generic": "This feature is not included in your current plan.",
+    "featureNotIncluded": "{feature} is not included in your current plan.",
+    "notNow": "Not now"
+  },
   "broadcast": {
     "dismiss": "Dismiss",
     "title": "Notice to everyone",
@@ -100,5 +108,7 @@
     "title": "PDF preview",
     "preview": "Preview",
     "failed": "Could not generate the PDF preview."
-  }
+  },
+  "attachPdf": "Attach PDF",
+  "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it."
 }

+ 4 - 0
messages/en/customers.json

@@ -17,6 +17,10 @@
     "deleteError": "Failed to delete customer",
     "error": "Failed to load customers",
     "importCustomers": "Import",
+    "assignNumbers": "Assign numbers",
+    "assignNumbersTitle": "Assign customer numbers?",
+    "assignNumbersDescription": "{count, plural, one {# customer has} other {# customers have}} no number yet. Each gets the next number in sequence, oldest first. Customers that already have a number are not touched.",
+    "assignNumbersDone": "{count, plural, one {# customer} other {# customers}} numbered",
     "selectedCount": "{count} selected",
     "clearSelection": "Clear",
     "batchDelete": "Delete ({count})",

+ 2 - 1
messages/en/dashboard.json

@@ -19,7 +19,8 @@
     "addToJob": "Add {qty} to job",
     "statusReport": "Status Report",
     "report": "Report",
-    "observation": "Observation"
+    "observation": "Observation",
+    "empty": "No work orders are assigned to you right now."
   },
   "recentActivity": "Recent Activity",
   "noRecentActivity": "No recent activity",

+ 60 - 0
messages/en/email.json

@@ -0,0 +1,60 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Invoice",
+      "subject": "Invoice {document_number} from {workshop_name}",
+      "heading": "Invoice {document_number}",
+      "intro": "Hi {customer_name},\n\nThank you for choosing us. Your invoice is ready.",
+      "button": "View invoice",
+      "outro": "Any questions, just reply to this email."
+    },
+    "quote_sent": {
+      "name": "Quote",
+      "subject": "Quote {document_number} from {workshop_name}",
+      "heading": "Quote {document_number}",
+      "intro": "Hi {customer_name},\n\nHere is the quote you asked for. Let us know if you would like us to go ahead.",
+      "button": "View quote",
+      "outro": "Any questions, just reply to this email."
+    },
+    "inspection_sent": {
+      "name": "Inspection report",
+      "subject": "Inspection report from {workshop_name}",
+      "heading": "Vehicle inspection report",
+      "intro": "Hi {customer_name},\n\nWe have finished the inspection. The report is ready for you.",
+      "button": "View report",
+      "outro": "Any questions, just reply to this email."
+    },
+    "message": {
+      "name": "Customer message",
+      "subject": "Message from {workshop_name}",
+      "intro": "Hi {customer_name},",
+      "outro": "Any questions, just reply to this email."
+    },
+    "portal_signin": {
+      "name": "Portal sign-in",
+      "subject": "Sign in to {workshop_name}",
+      "heading": "Sign in to your customer portal",
+      "intro": "Hi {customer_name},\n\nUse the button below to sign in to the {workshop_name} customer portal.",
+      "button": "Sign in",
+      "linkFallback": "If the button does not work, copy this link into your browser:\n{signin_link}",
+      "outro": "This link expires in 15 minutes. If you did not ask for it, you can ignore this email."
+    },
+    "team_invitation": {
+      "name": "Team invitation",
+      "subject": "You're invited to join {workshop_name}",
+      "heading": "Join {workshop_name}",
+      "intro": "You have been invited to join {workshop_name} on Torqvoice as {role}.\n\nUse the button below to create your account and join the team.",
+      "button": "Accept invitation",
+      "linkFallback": "If the button does not work, copy this link into your browser:\n{invite_link}",
+      "outro": "This invitation is valid until {invite_expires}. If you were not expecting it, you can ignore this email."
+    }
+  },
+  "attachmentNote": "A PDF copy is attached to this email.",
+  "summary": {
+    "reference": "Reference",
+    "vehicle": "Vehicle",
+    "total": "Total",
+    "balance": "Balance due",
+    "due": "Due"
+  }
+}

+ 5 - 0
messages/en/featureHints.json

@@ -11,5 +11,10 @@
     "title": "Invoices have been overhauled",
     "body": "Set the layout, colours and fonts for your invoices and quotes in the new designer, under Templates.",
     "cta": "Open Templates"
+  },
+  "email-designer": {
+    "title": "Your emails have a new look",
+    "body": "Customer emails now go out on a cleaner design. Change colours, wording and layout in the new designer, under Email templates.",
+    "cta": "Open Email templates"
   }
 }

+ 11 - 3
messages/en/integrations.json

@@ -126,6 +126,9 @@
     "leaveEmpty": "Leave empty",
     "noLimit": "No limit",
     "planLocked": "Integrations are not included in your plan.",
+    "lockedIntegrations": "Connect calendars, video calls and other services to your workshop.",
+    "lockedPayments": "Let customers pay their invoices online, with each payment booked on the invoice.",
+    "lockedAi": "Connect an AI provider to use AI features in your workshop.",
     "ownAppTitle": "This install uses your own app with the provider",
     "redirectUri": "Redirect URI to register with the provider:",
     "sendTestEmail": "Send test email",
@@ -266,7 +269,9 @@
       "description": "Send and receive text messages through a Vonage number.",
       "fields": {
         "apiKey": "API key",
-        "apiSecret": "API secret"
+        "apiSecret": "API secret",
+        "signatureSecret": "Signature secret",
+        "signatureSecretHelp": "From the Vonage dashboard, under API settings. When set, inbound messages are only accepted when Vonage signed them. Without it, only the secret in the webhook URL is checked."
       },
       "settings": {
         "phoneNumber": "Send from this number"
@@ -275,7 +280,9 @@
     "telnyx-sms": {
       "description": "Send and receive text messages through a Telnyx number.",
       "fields": {
-        "apiKey": "API key"
+        "apiKey": "API key",
+        "webhookPublicKey": "Webhook public key",
+        "webhookPublicKeyHelp": "From the Telnyx portal, under Account settings, Keys and credentials, Public key. When set, inbound messages are only accepted when Telnyx signed them. Without it, only the secret in the webhook URL is checked."
       },
       "settings": {
         "phoneNumber": "Send from this number"
@@ -288,7 +295,8 @@
         "accessToken": "Access token",
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
-        "apiVersion": "Graph API version"
+        "apiVersion": "Graph API version",
+        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 8 - 0
messages/en/navigation.json

@@ -2,8 +2,11 @@
   "breadcrumbs": {
     "dashboard": "Dashboard",
     "vehicles": "Vehicles",
+    "vessels": "Vessels",
     "allVehicles": "All Vehicles",
+    "allVessels": "All Vessels",
     "vehicleDetails": "Vehicle Details",
+    "vesselDetails": "Vessel Details",
     "customers": "Customers",
     "allCustomers": "All Customers",
     "customerDetails": "Customer Details",
@@ -20,6 +23,7 @@
     "allParts": "All Parts",
     "laborPresets": "Labor Presets",
     "reports": "Reports",
+    "timesheets": "Timesheets",
     "workBoard": "Work Board",
     "presenter": "Presenter",
     "adminOverview": "Admin Overview",
@@ -82,6 +86,9 @@
   "licenseExpiresTomorrow": "Your license expires tomorrow.",
   "licenseExpiresDays": "Your license expires in {days} days.",
   "licenseRenew": "Renew",
+  "licenseUnverifiedDays": "Your license could not be verified. Torqvoice branding returns in {days} days unless this server can reach torqvoice.com.",
+  "licenseUnverifiedNow": "Your license could not be verified and Torqvoice branding has returned.",
+  "licenseVerify": "Verify",
   "sidebar": {
     "dashboard": "Dashboard",
     "clients": "Clients",
@@ -102,6 +109,7 @@
     "inventory": "Inventory",
     "laborPresets": "Labor Presets",
     "reports": "Reports",
+    "timesheets": "Timesheets",
     "settings": "Settings",
     "superAdmin": "Super Admin",
     "adminPanel": "Admin Panel",

+ 9 - 13
messages/en/onboarding.json

@@ -53,24 +53,20 @@
   },
   "checklist": {
     "title": "Getting started",
-    "description": "Four steps to get your workshop rolling",
+    "description": "Three steps to get {workshop} rolling",
     "progress": "{done}/{total}",
     "steps": {
-      "customer": {
-        "title": "Add a customer",
-        "description": "Create your first customer record"
-      },
-      "vehicle": {
-        "title": "Add a vehicle",
-        "description": "Register a vehicle for a customer"
-      },
       "workOrder": {
-        "title": "Create a work order",
-        "description": "Open your first job"
+        "title": "Create your first work order",
+        "description": "Customer, vehicle and job in one dialog"
+      },
+      "company": {
+        "title": "Add your workshop details",
+        "description": "Address, phone and registration number for your invoices"
       },
       "invoice": {
-        "title": "Send or download an invoice",
-        "description": "Share an invoice with a customer or download it as a PDF"
+        "title": "Send or download your first invoice",
+        "description": "Share it with the customer or save it as a PDF"
       }
     },
     "allDoneTitle": "You are all set",

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