52 KiB
phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | quick_id | description | date | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261002-fm5 | 01 | execute | 1 | 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung (kantine-datev) und Handelsware (handelsware-datev) | 2026-10-02 |
|
true |
|
|
|
Purpose: the finance team uses Tessera instead of a separate desktop tool; access via the existing activation + ModuleGrants; no company-specific defaults (Tessera is a multi-tenant product). Output: two API modules with pure, tested processing functions, two Prisma migrations with RLS, two web module pages, i18n de/en, docs + CHANGELOG, local stack rebuilt. No push.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Source of business rules (read, do not copy UI/Tauri code):
@user-files/headflow/app/src/modules/datev/parser.ts
@user-files/headflow/app/src/modules/datev/validator.ts
@user-files/headflow/app/src/modules/datev/transformer.ts
@user-files/headflow/app/src/modules/datev/types.ts
@user-files/headflow/app/src/modules/handelsware/handelswareService.ts
@user-files/headflow/app/src/modules/handelsware/handelswareTypes.ts
Download filename rules live in the source UI: datev/ui/DatevPreview.tsx (downloadFileName, lines ~33-35) and handelsware/ui/HandelswarePage.tsx (getExportFilename, lines ~30-34).
Tessera analogs (patterns to follow):
- Module seed + module class:
apps/api/src/cert-manager/cert-manager.seed.ts,apps/api/src/cert-manager/cert-manager.module.ts - Controller with
@UseModule,@Roles(Role.ADMIN, Role.SUPER_ADMIN),requireTenantId:apps/api/src/proxmox/proxmox.controller.ts - Multer upload (memory, 5 MB limit),
UploadedFileLike(buffer,originalname,size):apps/api/src/cert-manager/cert-manager.controller.ts - Tenant-bound Prisma:
forTenant(assignment formconst tenantPrisma = forTenant(this.prisma, tenantId)) andwithTenantTransaction(this.prisma, tenantId, async (tx) => ...)inapps/api/src/prisma/prisma-tenant.extension.ts(header comment explains why never$transactionon a bound client) - Singleton-per-tenant config model:
DkvModuleConfiginapps/api/prisma/schema.prisma; RLS migration header + SQL form:apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql - RLS gates:
apps/api/src/prisma/rls-coverage.spec.ts,apps/api/src/prisma/rls-access-inventory.spec.ts(+ Fundstellentabelle indocs/mandantentrennung-zugriffsklassifikation.md) - Route-order test:
apps/api/src/custom-modules/custom-modules.controller.spec.ts(describe "Routen-Reihenfolge") - Web: module page + PageHeader + tabs
apps/web/src/app/(portal)/modules/cert-manager/page.tsx; layout gateapps/web/src/app/(portal)/modules/cert-manager/layout.tsx; drop areaapps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx(uses certManager texts, so build a module-local equivalent instead of importing it); blob downloaddownloadBase64inapps/web/src/app/(portal)/modules/cert-manager/actions.ts; API client styleapps/web/src/lib/custom-modules-api.ts; admin detectionuseAuthStoreinapps/web/src/app/(portal)/modules/proxmox/page.tsx; page test with realNextIntlClientProvider+ mocked api client + mocked auth storeapps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx - Registries:
apps/web/src/lib/module-loader.ts(MODULE_REGISTRY),apps/web/src/lib/module-identity.ts(ICONS + ModuleIconId),apps/web/src/components/modules/module-tile.tsx(GLYPHS),apps/web/src/lib/stores/nav-store.ts(MODULE_TITLE_KEYS),packages/shared/src/index.ts(MODULE_CATEGORIES),apps/web/src/messages/{de,en}.json(moduleCategories),apps/web/src/app/(portal)/modules/module-layouts.test.tsx
Project memory that applies: NestJS static routes before :id (unit tests do not catch shadowing); db container has no host port (use container IP); plain docker compose up does not rebuild; no customer-specific defaults; app texts use formal "Sie"; do not push.
<coverage_audit> Sources: the orchestrator task description (GOAL) only — no ROADMAP phase, REQUIREMENTS, RESEARCH.md or CONTEXT.md for this quick task.
| Source item | Covered by |
|---|---|
New category accounting, de "Finanzbuchhaltung" / en "Financial accounting" |
Task 1 |
| Kantine: CSV upload, UTF-8 / cp1252 detection | Task 1 |
| Kantine: validation exactly as parser.ts/validator.ts, month from "bis", multi-month warning | Task 1 |
| Kantine: preview (rows, month, total, row errors with lines, warnings) | Task 1 |
| Kantine: DATEV Lohn ASCII per transformer.ts + quality check | Task 1 |
| Kantine: Berater/Mandant/Lohnart as admin settings per tenant, empty default, blocked hint, numeric | Task 1 |
| Kantine: no persistence of uploaded data; pure functions + Vitest (cp1252 umlaut, CRLF/LF, multi-month, invalid rows) | Task 1 |
| Handelsware: XLSX B1 header, A/B rows, number or German string | Task 2 |
| Handelsware: Prisma model per tenant with RLS, unique name per tenant, migration | Task 2 |
| Handelsware: preview columns, auto Gegenkonto (max+1 / Startwert), "neu" marker | Task 2 (API) + Task 3 (UI) |
| Handelsware: persist new accounts only on export, one transaction, conflict re-check | Task 2 (API) + Task 3 (UI) |
| Handelsware: Buchungsdatum from filename MMYY, editable, TTMM validation | Task 2 (API) + Task 3 (UI) |
Handelsware: TXT format, CRLF, filename <Prefix>_<MMYY>.txt, UTF-8 + open question in SUMMARY |
Task 2 + Task 3 |
| Handelsware: Konten tab CRUD, CSV import replace-all with confirmation, CSV export | Task 2 (API) + Task 3 (UI) |
| Handelsware: settings Standard-Erloeskonto / Startwert Gegenkonto, empty, blocked hint | Task 2 + Task 3 |
| ModuleGrants access, de (Sie) + en texts, existing upload/download patterns, desktop-safe downloads | Tasks 1-3 |
Static routes before :id + declaration-order test |
Task 2 |
| Tests api + web, tsc, biome | Tasks 1-3 |
Local migration via container IP, docker compose up -d --build api web |
Tasks 1-3 |
| Browser check (Playwright, dark mode) or hand-off note in SUMMARY | Task 3 |
| Atomic commit per task, no push | Tasks 1-3 |
| </coverage_audit> |
Prisma + migration. Add model KantineDatevConfig to apps/api/prisma/schema.prisma following DkvModuleConfig: id uuid, tenantId String @unique, beraterNr String?, mandantNr String?, lohnart String?, createdAt, updatedAt, @@index([tenantId]). Strings (not Int) so leading zeros survive; no @default on the three fields — the colleague's hardcoded header/Lohnart constants from source transformer.ts (KOPF_SPALTE_1, KOPF_SPALTE_2, DETAIL_SPALTE_4) must not appear anywhere as defaults, examples, placeholders or test values (use neutral test values such as 1234567 / 12345 / 1111). Hand-write apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql in the form of 20260923140000_proxmox_server: German header comment (purpose, RLS without user dimension, no system_read_policy because there is no scheduler, grants via ALTER DEFAULT PRIVILEGES, switch note), CREATE TABLE, unique index KantineDatevConfig_tenantId_key, index on tenantId, ENABLE + FORCE ROW LEVEL SECURITY, CREATE POLICY tenant_isolation_policy ... USING ("tenantId" = current_tenant_id()). Run pnpm --filter @tessera/api exec prisma generate. Apply locally: get the IP with docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1, then DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy and confirm with prisma migrate status (same env).
Shared decoder. apps/api/src/accounting/decode-csv-text.ts exports decodeCsvText(buffer: Buffer): string: try new TextDecoder('utf-8', { fatal: true }) (drops BOM by default); on TypeError fall back to new TextDecoder('windows-1252'). (Source also sniffed CP850 for the Konten CSV — not required here; Excel CSV is UTF-8 or cp1252.) Reused by Task 2.
Pure functions (port, keep source German messages). kantine-datev.types.ts ports source types.ts and adds a stable code to every error/warning (codes: headerMissing, headerColumns, columnCount, personalNrMissing, personalNrNotNumeric, betragMissing, betragFormat, vonMissing, vonFormat, bisMissing, bisFormat, multiMonthRange, noRows; warning multipleMonths with params.months) so the web can translate while message keeps the source text. kantine-csv.parser.ts = source parser.ts (input already decoded string). kantine-csv.validator.ts = source validator.ts, rules and regexes unchanged. kantine-datev.transformer.ts = source transformer.ts with the three constants replaced by a KantineDatevSettings { beraterNr, mandantNr, lohnart } argument to generateDatevOutput; qualityCheck unchanged; add buildKantineExportFilename(settings, month) per source DatevPreview rule. kantine-datev.pipeline.ts: processKantineCsv(buffer, settings) = decode → parse → validate → totals (sum Betrag in integer cents from the German string, only for rows that passed Betrag validation) → preview object; buildKantineExport(buffer, settings) = same steps, throws a typed error when errors exist / no rows / settings missing, generates output, runs qualityCheck, throws when it fails, returns { filename, content (base64 of the ASCII output), mimeType: 'text/plain' } — same FileResponse shape as cert-manager. Never log row content or names.
Service/DTO/controller. dto/kantine-datev-settings.dto.ts: three required @IsString() @Matches(/^\d{1,10}$/) fields with German messages ("… darf nur Ziffern enthalten"). kantine-datev.service.ts: getSettings(tenantId) (findUnique by tenantId via const tenantPrisma = forTenant(this.prisma, tenantId)), saveSettings(tenantId, dto) (upsert on tenantId), preview(tenantId, buffer), export(tenantId, buffer) (load settings, call pipeline, map typed errors to BadRequestException({ code, message, errors }) / quality failure to UnprocessableEntityException). kantine-datev.controller.ts: @Controller('modules/kantine-datev') @UseModule('kantine-datev'), requireTenantId like ProxmoxController; GET settings, PUT settings with @Roles(Role.ADMIN, Role.SUPER_ADMIN), POST preview and POST export with FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }), 400 when no file. No :id routes here. Uploaded buffers stay in memory (multer memory storage default) and are never persisted. kantine-datev.seed.ts: slug kantine-datev, name Kantinenabrechnung, version 1.0.0, category: 'accounting', description de "Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren" / en "Check canteen CSV files and export them as a DATEV payroll ASCII file", isSystem: true. kantine-datev.module.ts like CertManagerModule (imports ModuleRegistryModule, seeds in onModuleInit with try/catch + logger). Register KantineDatevModule in apps/api/src/app.module.ts.
Access inventory. Run pnpm --filter @tessera/api exec vitest run rls-access-inventory rls-coverage; add the Fundstellen row for apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | … (German justification: Mandanten-Einstellung, no user dimension, migration 20261002120000) and a new area row kantine-datev plus an updated Summe row in docs/mandantentrennung-zugriffsklassifikation.md, measured with the gate loop like the previous entries (not copied).
Web. apps/web/src/lib/download-base64.ts: same body as cert-manager's downloadBase64 (Blob + object URL + anchor with download + click + revoke) — this blob mechanism is what the desktop client saves since 1.9.2, so no Tauri-specific code. apps/web/src/lib/kantine-datev-api.ts in the style of custom-modules-api.ts (credentials: 'include', NEXT_PUBLIC_API_URL, error class carrying status + code + message): getKantineSettings, saveKantineSettings, previewKantineCsv(file), exportKantineCsv(file) (multipart field file). Module dir apps/web/src/app/(portal)/modules/kantine-datev/: layout.tsx = ModuleAccessGate with moduleSlug="kantine-datev"; page.tsx ('use client', default export) with PageHeader moduleSlug="kantine-datev", tabs "Abrechnung" and (admins only, via useAuthStore role ADMIN/SUPER_ADMIN) "Einstellungen"; Abrechnung: module-local drop area (accept .csv,text/csv), note "Die hochgeladenen Daten werden nicht gespeichert.", summary (Zeilen, Abrechnungsmonat, Gesamtbetrag formatted de-DE EUR from totalCents), warnings, error table (Zeile, Feld, Meldung translated via kantineDatev.errors.<code>, falling back to message), download button "DATEV-Datei herunterladen" (disabled while errors or not configured; keeps the File in state and re-sends it to export). Not configured: admin sees hint + link to the settings tab, others see "Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen." Einstellungen: three numeric inputs (inputMode numeric, client-side digits check, empty by default, no placeholders with real numbers), save with success/error feedback. All texts under namespace kantineDatev in de.json (formal "Sie") and en.json. Registries: module-loader.ts entry 'kantine-datev' (dynamic import, ssr false); module-identity.ts new ModuleIconId 'utensils' mapped from kantine-datev; module-tile.tsx GLYPHS entry utensils with the Lucide "utensils" stroke paths; nav-store.ts MODULE_TITLE_KEYS 'kantine-datev': 'kantineDatev.title'; module-layouts.test.tsx adds ['kantine-datev', KantineDatevLayout]. Write kantine-datev.test.tsx per the behavior list (real NextIntlClientProvider with de.json, mocked @/lib/kantine-datev-api, mocked auth store, mocked @/lib/download-base64).
Finish. Biome lint the touched files (pnpm exec biome lint <files> from repo root, fix findings in new files), type-check both apps, run the verify command, commit atomically (German subject, e.g. feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung, attribution line). Do not push.
pnpm --filter @tessera/api exec vitest run src/accounting src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev module-layouts module-categories && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -rnE '1387819|\b10001\b|\b9005\b' apps/api/src/kantine-datev 'apps/web/src/app/(portal)/modules/kantine-datev' apps/web/src/lib/kantine-datev-api.ts apps/api/prisma/migrations/20261002120000_kantine_datev_config)"
Migration applied locally (prisma migrate status up to date); all listed api and web tests green; both type-checks clean; biome clean on new files; no colleague-specific numbers in Kantine code/tests/migration; one commit on main, not pushed.
Pure functions (port of source handelswareService.ts, write tests first). handelsware-datev.types.ts: ImportRow {line, buchungstext, umsatz:number}, PreviewRow {line, buchungstext, umsatz:string, sollHaben, gegenkonto, erloeskonto, isNew}, NewAccount {name, gegenkonto, erloeskonto}, RowError {line, code, message}, settings type, FileResponse {filename, content, mimeType}. handelsware-xlsx.ts: parseHandelswareXlsx(buffer) with XLSX.read(buffer, { type: 'buffer', cellFormula: false, cellHTML: false, cellStyles: false, sheetRows: MAX_ROWS + 1 }) (MAX_ROWS = 10 000 data rows; first sheet only, cells B1 and A/B from row 2 — source loop), plus parseUmsatz(value) (number → as is; string → trim, drop spaces and thousands dots, comma → dot, must match an optional minus + digits + optional decimals, else null). Improvement over the source (which silently used 0): invalid Umsatz becomes a row error. handelsware-transform.ts: calculateBuchungsdatum(filename) (source logic + month 1-12 guard), isValidBuchungsdatum(ttmm) (4 digits, month 1-12, day 1..days of that month, Feb up to 29), formatAmount (source), assignAccounts(importRows, accounts, settings) (exact, case-sensitive name match on the trimmed text as in the source; next free = max existing gegenkonto + 1, or settings.startGegenkonto when the list is empty — a documented choice: "Startwert" is the first number handed out), generateTxt(headerText, rows, buchungsdatum) (source format, rows take the edited date), getExportFilename(importFilename) (source regex and fallback). handelsware-konten-csv.ts: parseKontenCsv(buffer, defaultErloeskonto) using decodeCsvText from apps/api/src/accounting/decode-csv-text.ts, and generateKontenCsv(accounts).
DTOs, service, controller, seed, module. dto/handelsware-settings.dto.ts: erloeskonto, startGegenkonto both @IsInt() @Min(1) @Max(999999999) with German messages. dto/handelsware-account.dto.ts: create/update with name (@IsString, trimmed, length 1-120), gegenkonto, erloeskonto (@IsInt 1-999999999). handelsware-datev.service.ts: settings get/upsert via const tenantPrisma = forTenant(this.prisma, tenantId); listAccounts (orderBy name), createAccount, updateAccount (findFirst by id within tenant → 404), deleteAccount, exportAccountsCsv (FileResponse Konten.csv, text/csv;charset=utf-8), importAccountsCsv(tenantId, buffer) (parse all first, abort with 400 + line errors, else withTenantTransaction(this.prisma, tenantId, async (tx) => …) deleteMany + createMany, return count); preview(tenantId, file) → { headerText, suggestedBuchungsdatum, exportFilename, rows, newAccounts, rowErrors }; export(tenantId, file, buchungsdatum, submittedNewAccounts) → validate TTMM, re-parse the file, then inside one withTenantTransaction load settings + accounts via tx, recompute with assignAccounts, compare to the submitted list (409 accountsChanged, German message "Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren."), createMany the new accounts (P2002 → 409 too), build TXT, return { ...FileResponse (UTF-8 bytes base64, text/plain;charset=utf-8), createdCount }. Design note (record in SUMMARY): the export re-sends the original XLSX as multipart plus buchungsdatum and newAccounts (JSON string of the preview's new accounts) instead of a JSON body with all rows — the server re-derives every row from the same file, so the TXT cannot diverge from the workbook, and Express's 100 kB JSON body default does not cap large lists. handelsware-datev.controller.ts: @Controller('modules/handelsware-datev') @UseModule('handelsware-datev'), requireTenantId; declare in this order: GET settings, PUT settings (@Roles(Role.ADMIN, Role.SUPER_ADMIN)), POST preview, POST export (both FileInterceptor('file', 5 MB)), GET accounts, POST accounts, GET accounts/export-csv, POST accounts/import-csv (FileInterceptor, 1 MB), then PUT accounts/:id and DELETE accounts/:id. Parse the newAccounts field defensively (JSON.parse in try/catch, array of objects with string name and integer gegenkonto, at most 10 000 entries, else 400). Multer decodes originalname as latin1 — convert with Buffer.from(name, 'latin1').toString('utf8') before deriving date/filename. handelsware-datev.seed.ts: slug handelsware-datev, name Handelsware, category: 'accounting', description de "Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei exportieren" / en "Map merchandise sales from Excel to revenue accounts and export a DATEV booking file", isSystem: true; module class like Task 1; register in apps/api/src/app.module.ts.
Access inventory. Run the two RLS specs; add Fundstellen rows for apps/api/src/handelsware-datev/handelsware-datev.service.ts × handelswareDatevConfig and × handelswareKonto with the Stand the spec measures (bound client + tx of withTenantTransaction), plus area row handelsware-datev and updated Summe, measured with the gate loop.
Tests. Specs per the behavior list; service spec with a fake PrismaService/tx (pattern: apps/api/src/favorites/favorites.service.spec.ts for withTenantTransaction fakes) covering conflict 409, createMany only on export, preview not writing, replace-import atomicity; controller spec for roles metadata, path, file-missing 400 and the declaration-order describe "Routen-Reihenfolge (statisch vor :id)". Biome lint touched files, tsc --noEmit, commit atomically (e.g. feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export). Do not push.
pnpm --filter @tessera/api exec vitest run src/handelsware-datev src/accounting rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && test -z "$(grep -rn '8000' apps/api/src/handelsware-datev apps/api/prisma/migrations/20261002130000_handelsware_datev)"
Migration applied locally and prisma migrate status up to date; handelsware + accounting + RLS specs green (including the route declaration-order test); api type-check clean; no default or sample value equal to the colleague's Erlöskonto in API code/tests/migration; one commit, not pushed.
Module UI. layout.tsx = ModuleAccessGate with moduleSlug="handelsware-datev". page.tsx ('use client', default export): PageHeader moduleSlug="handelsware-datev", tabs "Import", "Konten", and "Einstellungen" (admins only via useAuthStore), tab pattern from cert-manager. components/ImportTab.tsx: module-local drop area (accept .xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet), header text display, Buchungsdatum input (maxLength 4, inputMode numeric, label "Buchungsdatum (TTMM)"), export filename display, preview table per behavior with "neu" badge (accent token, readable in dark mode), totals of S and H, row errors, download button "Buchungsdatei herunterladen"; after a successful export notify the Konten tab to reload (shared state in page or a reload key). components/AccountsTab.tsx: table Name / Gegenkonto / Erlöskonto, inline add row, edit (inline or small modal following existing modal patterns), delete with confirm, "CSV importieren" (file input → confirmation dialog naming the current count and the replace effect → import → show count or line errors), "CSV exportieren" via downloadBase64 from apps/web/src/lib/download-base64.ts. components/SettingsTab.tsx: "Standard-Erlöskonto" and "Startwert Gegenkonto" numeric inputs, empty by default, short explanations ("wird neuen Konten zugeordnet" / "erste Nummer, wenn die Kontenliste leer ist"), save feedback; no placeholder showing a real account number. All texts under namespace handelswareDatev in de.json (formal "Sie") and en.json, error codes translated via handelswareDatev.errors.<code> with server message fallback.
Registries. module-loader.ts entry 'handelsware-datev'; module-identity.ts new ModuleIconId 'shopping-bag' mapped from handelsware-datev; module-tile.tsx GLYPHS entry with the Lucide "shopping-bag" stroke paths; nav-store.ts 'handelsware-datev': 'handelswareDatev.title'; module-layouts.test.tsx adds ['handelsware-datev', HandelswareDatevLayout].
Tests. handelsware-datev.test.tsx per the behavior list (real NextIntlClientProvider with de.json, mocked api client, auth store and download helper).
Docs. CHANGELOG.md under "## Unveröffentlicht" add "### Neu" with two plain-language entries (Kantinenabrechnung: CSV der Kantine prüfen, Fehler mit Zeilennummer, DATEV-Lohndatei herunterladen, Nummern einmalig vom Administrator hinterlegen, hochgeladene Daten werden nicht gespeichert; Handelsware: Excel-Liste hochladen, Konten automatisch zuordnen, neue Konten markiert und erst beim Herunterladen gespeichert, Buchungsdatum aus dem Dateinamen, Kontenliste pflegen und als CSV ein- und auslesen; both under the new group „Finanzbuchhaltung“; activation via Marktplatz + Freigabe). docs/anleitung-anwender.md: add "### Kantinenabrechnung" and "### Handelsware" under "## Die Module" plus table-of-contents entries, same tone as the existing module sections.
Local stack + smoke. Rebuild with docker compose up -d --build api web; check docker compose logs api --tail 80 for both seed log lines and no migration error. Generate fictitious test files into the scratch directory and copy them to .planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/testdata/ for the browser check: a canteen CSV encoded Windows-1252 with CRLF, umlaut names, one invalid row and two billing months; a valid UTF-8 canteen CSV; an XLSX named like "HWA 0326 Test.xlsx" (B1 header, mixed numeric and German-string amounts, one negative, one unknown product), built with the api's xlsx package via pnpm --filter @tessera/api exec node -e …; a Konten CSV Name;Gegenkonto;Konto. If the Playwright MCP tool is available: in dark mode (theme button), activate both modules in the Marketplace, grant access, verify sidebar group "Finanzbuchhaltung", run each flow and confirm the downloaded files' content (11 columns + CRLF; TXT format; new accounts appear in Konten only after download). If Playwright is not available, state in the SUMMARY that the browser check is left to the orchestrator and list the testdata paths.
SUMMARY must name, in plain words: open question Handelsware TXT encoding (kept UTF-8 like the source; DATEV imports often expect Windows-1252/ANSI — umlauts in product names may need it); the export re-send design; the "Startwert" semantics; that only administrators change settings while all granted users maintain the Konten list; that the api's xlsx 0.18.5 is used for reading uploads (see threat model); browser check status.
Run the full api and web test suites, both type-checks, biome lint on touched files; commit atomically (e.g. feat(handelsware-datev): Modulseite mit Import, Konten und Einstellungen) and a separate docs commit if preferred. Do not push.
pnpm --filter @tessera/web exec vitest run handelsware-datev kantine-datev module-layouts module-categories && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && test -z "$(grep -rn '8000' 'apps/web/src/app/(portal)/modules/handelsware-datev' apps/web/src/lib/handelsware-datev-api.ts)" && docker compose logs api --tail 200 | grep -ciE 'kantine|handelsware'
Web tests (new + full suite) and api full suite green; type-checks clean; local stack rebuilt and both modules seeded; testdata files exist; CHANGELOG and user guide updated; browser check done or explicitly handed off in SUMMARY; commits made, nothing pushed.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| browser/desktop → API | Authenticated, granted module users upload CSV/XLSX files and send account data |
| API → PostgreSQL | Tenant-bound access to config and account tables under RLS |
| uploaded file → parser | Untrusted file content parsed in memory (TextDecoder, xlsx) |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-FM5-01 | Information disclosure | Kantine upload (names, personnel numbers) | high | mitigate | Pure in-memory processing (multer memory storage, no DB write, no file write); no logging of row content; preview returns only counts, month, total, row errors (field + line, no names) |
| T-FM5-02 | Elevation of privilege | Settings endpoints of both modules | medium | mitigate | PUT settings carries @Roles(Role.ADMIN, Role.SUPER_ADMIN); all routes under @UseModule(...) (activation + grant); controller specs assert the metadata |
| T-FM5-03 | Information disclosure / Tampering | KantineDatevConfig, HandelswareDatevConfig, HandelswareKonto | high | mitigate | tenantId on every table, ENABLE + FORCE RLS + tenant_isolation_policy; all access via forTenant or withTenantTransaction; rls-coverage + rls-access-inventory specs updated and green |
| T-FM5-04 | Denial of service | Upload endpoints | medium | mitigate | Multer fileSize 5 MB (CSV import 1 MB), XLSX sheetRows cap (10 000 data rows), newAccounts array capped and parsed defensively |
| T-FM5-05 | Tampering | xlsx 0.18.5 reading crafted workbooks (known prototype-pollution/ReDoS advisories fixed in later SheetJS builds that are not on the npm registry) |
medium | accept | Only authenticated, admin-granted internal users can upload; formulas/HTML/styles disabled, only first sheet cells A/B read, size and row caps; noted in SUMMARY. Upgrading the library is a separate decision outside this task |
| T-FM5-06 | Tampering | Handelsware export (client-submitted new accounts) | medium | mitigate | Server re-parses the uploaded file and recomputes the mapping inside one tenant-bound transaction; mismatch → 409; unique (tenantId, name) catches races (P2002 → 409) |
| T-FM5-07 | Tampering | CSV formula injection in Konten CSV export opened in Excel | low | mitigate | Account names starting with =, +, -, @ are prefixed with an apostrophe in generateKontenCsv (covered by a test) |
| T-FM5-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs in this plan (xlsx already a dependency of apps/api) |
| </threat_model> |
<success_criteria>
- Sidebar shows group "Finanzbuchhaltung" with "Kantinenabrechnung" and "Handelsware" for granted users
- Canteen CSV (UTF-8 or Windows-1252) → correct preview and a DATEV Lohn ASCII file that passes the source quality check, using tenant settings
- Handelsware XLSX → preview with "neu" markers and editable date → TXT in the source format; new accounts saved only on download, atomically, with conflict detection
- Konten tab fully usable incl. CSV import (replace with confirmation) and export
- No company-specific defaults; settings empty until an administrator sets them
- Three atomic commits (plus optional docs commit) on main, not pushed </success_criteria>