From 44c1d4351ffd622fba7f69bffe679c8e6e702257 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 11:38:22 +0200 Subject: [PATCH] feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export - Prisma-Modelle HandelswareDatevConfig und HandelswareKonto mit Zeilenschutz (Migration 20261002130000) - XLSX lesen (B1 Kopf, A/B ab Zeile 2, Zahl oder deutscher Text), Konten zuordnen, TXT erzeugen - neue Konten werden nur beim Export in einer mandantengebundenen Transaktion gespeichert (409 bei geaenderter Liste) - Konten-CSV Import (alles ersetzen) und Export mit Schutz vor Formeleinschleusung - Einstellungen nur fuer Administratoren, statische Routen vor accounts/:id Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migration.sql | 70 ++++ apps/api/prisma/schema.prisma | 33 ++ .../accounting/decode-upload-filename.spec.ts | 21 + .../src/accounting/decode-upload-filename.ts | 14 + apps/api/src/app.module.ts | 2 + .../dto/handelsware-account.dto.ts | 25 ++ .../dto/handelsware-settings.dto.ts | 18 + .../handelsware-datev.controller.spec.ts | 196 +++++++++ .../handelsware-datev.controller.ts | 173 ++++++++ .../handelsware-datev.module.ts | 31 ++ .../handelsware-datev.seed.ts | 22 + .../handelsware-datev.service.spec.ts | 378 ++++++++++++++++++ .../handelsware-datev.service.ts | 340 ++++++++++++++++ .../handelsware-datev.types.ts | 83 ++++ .../handelsware-konten-csv.spec.ts | 97 +++++ .../handelsware-konten-csv.ts | 141 +++++++ .../handelsware-transform.spec.ts | 142 +++++++ .../handelsware-transform.ts | 115 ++++++ .../handelsware-xlsx.spec.ts | 146 +++++++ .../src/handelsware-datev/handelsware-xlsx.ts | 130 ++++++ ...andantentrennung-zugriffsklassifikation.md | 10 +- 21 files changed, 2186 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql create mode 100644 apps/api/src/accounting/decode-upload-filename.spec.ts create mode 100644 apps/api/src/accounting/decode-upload-filename.ts create mode 100644 apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts create mode 100644 apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.controller.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.module.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.seed.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.service.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-datev.types.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-konten-csv.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-transform.spec.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-transform.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts create mode 100644 apps/api/src/handelsware-datev/handelsware-xlsx.ts diff --git a/apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql b/apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql new file mode 100644 index 0000000..de87ea1 --- /dev/null +++ b/apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql @@ -0,0 +1,70 @@ +-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev). +-- +-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen +-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die +-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant +-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen +-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das +-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname +-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je +-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen +-- auf dasselbe Gegenkonto laufen). +-- +-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand +-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt. +-- +-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide +-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE +-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus +-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten +-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst, +-- der diese Tabellen ueber alle Mandanten liest. +-- +-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber +-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu +-- tun. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter +-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md). + +-- 1) HandelswareDatevConfig +CREATE TABLE "HandelswareDatevConfig" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "erloeskonto" INTEGER, + "startGegenkonto" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId"); +CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId"); + +ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig" + USING ("tenantId" = current_tenant_id()); + +-- 2) HandelswareKonto +CREATE TABLE "HandelswareKonto" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "gegenkonto" INTEGER NOT NULL, + "erloeskonto" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name"); +CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId"); + +ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "HandelswareKonto" + USING ("tenantId" = current_tenant_id()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 73eebbe..981e2e6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -361,6 +361,39 @@ model KantineDatevConfig { @@index([tenantId]) } +// quick-261002-fm5: Handelsware (Modul handelsware-datev). Einstellungen je +// Mandant (Singleton wie KantineDatevConfig): Standard-Erloeskonto fuer neue +// Konten und Startwert fuer die Gegenkonto-Vergabe bei leerer Kontenliste. +// Beide Zahlen haben bewusst KEINEN Standardwert — der Administrator hinterlegt +// sie einmalig, bis dahin ist die Verarbeitung gesperrt. +model HandelswareDatevConfig { + id String @id @default(uuid()) + tenantId String @unique + erloeskonto Int? + startGegenkonto Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId]) +} + +// quick-261002-fm5: Kontenliste der Handelsware (Produktname -> Gegenkonto, +// Erloeskonto). Der Name ist je Mandant eindeutig; das Gegenkonto bewusst +// NICHT (mehrere Produkte duerfen auf dasselbe Gegenkonto laufen, wie in der +// Desktop-Vorlage). Keine Relation zu Tenant, Zeilenschutz nach ProxmoxServer. +model HandelswareKonto { + id String @id @default(uuid()) + tenantId String + name String + gegenkonto Int + erloeskonto Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([tenantId, name]) + @@index([tenantId]) +} + // Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal- // alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly // (own tenantId @unique row, own encrypted creds — D-03: each module keeps diff --git a/apps/api/src/accounting/decode-upload-filename.spec.ts b/apps/api/src/accounting/decode-upload-filename.spec.ts new file mode 100644 index 0000000..3602d68 --- /dev/null +++ b/apps/api/src/accounting/decode-upload-filename.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { decodeUploadFilename } from './decode-upload-filename'; + +describe('decodeUploadFilename', () => { + it('laesst ASCII-Namen unveraendert', () => { + expect(decodeUploadFilename('HWA 0326 Test.xlsx')).toBe('HWA 0326 Test.xlsx'); + }); + + it('kehrt latin1-gelesenes UTF-8 um', () => { + const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1'); + expect(decodeUploadFilename(mojibake)).toBe('Käse 0326.xlsx'); + }); + + it('laesst einen schon richtigen Namen mit Umlaut stehen', () => { + expect(decodeUploadFilename('Käse.xlsx')).toBe('Käse.xlsx'); + }); + + it('laesst Namen mit Zeichen ueber 255 stehen', () => { + expect(decodeUploadFilename('Preis €.xlsx')).toBe('Preis €.xlsx'); + }); +}); diff --git a/apps/api/src/accounting/decode-upload-filename.ts b/apps/api/src/accounting/decode-upload-filename.ts new file mode 100644 index 0000000..5875d57 --- /dev/null +++ b/apps/api/src/accounting/decode-upload-filename.ts @@ -0,0 +1,14 @@ +/** + * Multer liefert `originalname` je nach Version als latin1-gelesene Bytes: ein + * UTF-8-Dateiname wie "Käse.xlsx" kommt als "Käse.xlsx" an. Diese Funktion + * kehrt das um, ohne einen schon richtigen Namen zu zerstoeren: ist der Name + * nicht aus latin1-Zeichen zusammengesetzt (Zeichen > 255) oder ergibt die + * Umkehrung kein gueltiges UTF-8, bleibt er unveraendert. + */ +export function decodeUploadFilename(name: string): string { + for (let i = 0; i < name.length; i++) { + if (name.charCodeAt(i) > 255) return name; + } + const converted = Buffer.from(name, 'latin1').toString('utf8'); + return converted.includes('�') ? name : converted; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 44550ad..4ff1a19 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard'; import { TenantModule } from './tenant/tenant.module'; import { TendersModule } from './tenders/tenders.module'; import { UserModule } from './user/user.module'; +import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module'; import { KantineDatevModule } from './kantine-datev/kantine-datev.module'; import { ProxmoxModule } from './proxmox/proxmox.module'; import { CustomModulesModule } from './custom-modules/custom-modules.module'; @@ -57,6 +58,7 @@ import { RemindersModule } from './reminders/reminders.module'; BugReportsModule, ProxmoxModule, KantineDatevModule, + HandelswareDatevModule, CustomModulesModule, RemindersModule, ], diff --git a/apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts b/apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts new file mode 100644 index 0000000..060d589 --- /dev/null +++ b/apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts @@ -0,0 +1,25 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsString, Length, Matches, Max, Min } from 'class-validator'; + +const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value); + +/** Anlegen und Aendern eines Kontos der Kontenliste (quick-261002-fm5). */ +export class HandelswareAccountDto { + @Transform(trim) + @IsString({ message: 'Der Name muss angegeben werden' }) + @Length(1, 120, { message: 'Der Name muss 1 bis 120 Zeichen lang sein' }) + @Matches(/^[^\t\r\n]*$/, { + message: 'Der Name darf keine Tabulatoren oder Zeilenumbrüche enthalten', + }) + name!: string; + + @IsInt({ message: 'Das Gegenkonto muss eine ganze Zahl sein' }) + @Min(1, { message: 'Das Gegenkonto muss mindestens 1 sein' }) + @Max(999999999, { message: 'Das Gegenkonto darf höchstens 999999999 sein' }) + gegenkonto!: number; + + @IsInt({ message: 'Das Erlöskonto muss eine ganze Zahl sein' }) + @Min(1, { message: 'Das Erlöskonto muss mindestens 1 sein' }) + @Max(999999999, { message: 'Das Erlöskonto darf höchstens 999999999 sein' }) + erloeskonto!: number; +} diff --git a/apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts b/apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts new file mode 100644 index 0000000..4a310fa --- /dev/null +++ b/apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts @@ -0,0 +1,18 @@ +import { IsInt, Max, Min } from 'class-validator'; + +/** + * Einstellungen der Handelsware (quick-261002-fm5): Standard-Erloeskonto fuer + * neue Konten und Startwert fuer die Gegenkonto-Vergabe. Ganze Zahlen, + * bewusst ohne Standardwert — der Administrator hinterlegt sie einmalig. + */ +export class HandelswareSettingsDto { + @IsInt({ message: 'Das Standard-Erlöskonto muss eine ganze Zahl sein' }) + @Min(1, { message: 'Das Standard-Erlöskonto muss mindestens 1 sein' }) + @Max(999999999, { message: 'Das Standard-Erlöskonto darf höchstens 999999999 sein' }) + erloeskonto!: number; + + @IsInt({ message: 'Der Startwert Gegenkonto muss eine ganze Zahl sein' }) + @Min(1, { message: 'Der Startwert Gegenkonto muss mindestens 1 sein' }) + @Max(999999999, { message: 'Der Startwert Gegenkonto darf höchstens 999999999 sein' }) + startGegenkonto!: number; +} diff --git a/apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts b/apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts new file mode 100644 index 0000000..07c0dd1 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts @@ -0,0 +1,196 @@ +import 'reflect-metadata'; +import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { describe, expect, it, vi } from 'vitest'; +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; +import { MODULE_SLUG_KEY } from '../module-registry/module.guard'; +import { HandelswareAccountDto } from './dto/handelsware-account.dto'; +import { HandelswareSettingsDto } from './dto/handelsware-settings.dto'; +import { HandelswareDatevController, parseNewAccountsField } from './handelsware-datev.controller'; + +const proto = HandelswareDatevController.prototype as any; +const req = (tenantId?: string) => ({ tenantId }) as any; + +function makeService() { + return { + getSettings: vi.fn(async (..._a: unknown[]) => ({})), + saveSettings: vi.fn(async (..._a: unknown[]) => ({})), + preview: vi.fn(async (..._a: unknown[]) => ({})), + export: vi.fn(async (..._a: unknown[]) => ({})), + listAccounts: vi.fn(async (..._a: unknown[]) => []), + createAccount: vi.fn(async (..._a: unknown[]) => ({})), + updateAccount: vi.fn(async (..._a: unknown[]) => ({})), + deleteAccount: vi.fn(async (..._a: unknown[]) => ({})), + exportAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})), + importAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})), + }; +} + +describe('HandelswareDatevController — Metadaten', () => { + it('haengt an modules/handelsware-datev und traegt @UseModule', () => { + expect(Reflect.getMetadata('path', HandelswareDatevController)).toBe( + 'modules/handelsware-datev', + ); + expect(Reflect.getMetadata(MODULE_SLUG_KEY, HandelswareDatevController)).toBe( + 'handelsware-datev', + ); + }); + + it('PUT settings verlangt ADMIN/SUPER_ADMIN, alles andere keine Routen-Rolle', () => { + expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([ + Role.ADMIN, + Role.SUPER_ADMIN, + ]); + for (const name of [ + 'getSettings', + 'preview', + 'export', + 'listAccounts', + 'createAccount', + 'exportAccountsCsv', + 'importAccountsCsv', + 'updateAccount', + 'deleteAccount', + ]) { + expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); + } + }); + + it('Pfade und Methoden', () => { + const route = (name: string) => [ + Reflect.getMetadata('method', proto[name]), + Reflect.getMetadata('path', proto[name]), + ]; + // RequestMethod: GET 0, POST 1, PUT 2, DELETE 3 + expect(route('getSettings')).toEqual([0, 'settings']); + expect(route('saveSettings')).toEqual([2, 'settings']); + expect(route('preview')).toEqual([1, 'preview']); + expect(route('export')).toEqual([1, 'export']); + expect(route('listAccounts')).toEqual([0, 'accounts']); + expect(route('createAccount')).toEqual([1, 'accounts']); + expect(route('exportAccountsCsv')).toEqual([0, 'accounts/export-csv']); + expect(route('importAccountsCsv')).toEqual([1, 'accounts/import-csv']); + expect(route('updateAccount')).toEqual([2, 'accounts/:id']); + expect(route('deleteAccount')).toEqual([3, 'accounts/:id']); + }); +}); + +describe('HandelswareDatevController — Routen-Reihenfolge (statisch vor :id)', () => { + it('deklariert alle statischen Konten-Routen vor accounts/:id', () => { + const methods = Object.getOwnPropertyNames(HandelswareDatevController.prototype); + const idx = (name: string) => { + const i = methods.indexOf(name); + expect(i, `${name} fehlt`).toBeGreaterThanOrEqual(0); + return i; + }; + const firstIdRoute = Math.min(idx('updateAccount'), idx('deleteAccount')); + for (const staticRoute of [ + 'listAccounts', + 'createAccount', + 'exportAccountsCsv', + 'importAccountsCsv', + ]) { + expect(idx(staticRoute), `${staticRoute} muss vor :id stehen`).toBeLessThan(firstIdRoute); + } + }); +}); + +describe('HandelswareDatevController — Verhalten', () => { + it('reicht req.tenantId weiter und decodiert den Dateinamen', async () => { + const service = makeService(); + const c = new HandelswareDatevController(service as any); + const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1'); + const buffer = Buffer.from('x'); + await c.preview(req('t1'), { buffer, originalname: mojibake } as any); + expect(service.preview).toHaveBeenCalledWith('t1', { buffer, originalname: 'Käse 0326.xlsx' }); + await c.export( + req('t1'), + { buffer, originalname: 'a.xlsx' } as any, + ' 3103 ', + '[{"name":"A","gegenkonto":5}]', + ); + expect(service.export).toHaveBeenCalledWith('t1', { buffer, originalname: 'a.xlsx' }, '3103', [ + { name: 'A', gegenkonto: 5 }, + ]); + await c.listAccounts(req('t1')); + await c.deleteAccount(req('t1'), 'x'); + expect(service.listAccounts).toHaveBeenCalledWith('t1'); + expect(service.deleteAccount).toHaveBeenCalledWith('t1', 'x'); + }); + + it('antwortet ohne Datei mit 400', async () => { + const c = new HandelswareDatevController(makeService() as any); + await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException); + await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException); + await expect(c.importAccountsCsv(req('t1'), undefined)).rejects.toThrow(BadRequestException); + }); + + it('antwortet ohne Mandantenkontext mit 403', async () => { + const c = new HandelswareDatevController(makeService() as any); + await expect(c.listAccounts(req(undefined))).rejects.toThrow(ForbiddenException); + }); +}); + +describe('parseNewAccountsField', () => { + it('akzeptiert eine Liste und leere Werte', () => { + expect(parseNewAccountsField('[{"name":"A","gegenkonto":1,"erloeskonto":2}]')).toEqual([ + { name: 'A', gegenkonto: 1 }, + ]); + expect(parseNewAccountsField(undefined)).toEqual([]); + expect(parseNewAccountsField('[]')).toEqual([]); + }); + + it.each([ + 'kein json', + '{"a":1}', + '[1]', + '[{"name":1,"gegenkonto":1}]', + '[{"name":"A","gegenkonto":"1"}]', + '[{"name":"A","gegenkonto":1.5}]', + '[null]', + ])('lehnt %s ab', (raw) => { + expect(() => parseNewAccountsField(raw)).toThrow(BadRequestException); + }); + + it('lehnt mehr als 10 000 Eintraege ab', () => { + const big = JSON.stringify( + Array.from({ length: 10_001 }, (_, i) => ({ name: `n${i}`, gegenkonto: i })), + ); + expect(() => parseNewAccountsField(big)).toThrow(BadRequestException); + }); +}); + +describe('DTOs', () => { + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + + it('Einstellungen: nur ganze Zahlen 1 bis 999999999', async () => { + const run = (value: unknown) => + pipe.transform(value, { type: 'body', metatype: HandelswareSettingsDto }); + await expect(run({ erloeskonto: 5, startGegenkonto: 6 })).resolves.toBeDefined(); + for (const bad of [ + { erloeskonto: 0, startGegenkonto: 6 }, + { erloeskonto: 5, startGegenkonto: 1000000000 }, + { erloeskonto: 1.5, startGegenkonto: 6 }, + { erloeskonto: '5', startGegenkonto: 6 }, + { erloeskonto: 5 }, + ]) { + await expect(run(bad)).rejects.toThrow(BadRequestException); + } + }); + + it('Konto: Name wird getrimmt, Tabulator im Namen und leerer Name werden abgelehnt', async () => { + const run = (value: unknown) => + pipe.transform(value, { type: 'body', metatype: HandelswareAccountDto }); + const ok: any = await run({ name: ' Kaffee ', gegenkonto: 1, erloeskonto: 2 }); + expect(ok.name).toBe('Kaffee'); + await expect(run({ name: 'a\tb', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow( + BadRequestException, + ); + await expect(run({ name: ' ', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow( + BadRequestException, + ); + await expect(run({ name: 'x'.repeat(121), gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow( + BadRequestException, + ); + }); +}); diff --git a/apps/api/src/handelsware-datev/handelsware-datev.controller.ts b/apps/api/src/handelsware-datev/handelsware-datev.controller.ts new file mode 100644 index 0000000..c0d5d5b --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.controller.ts @@ -0,0 +1,173 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Post, + Put, + Req, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Role } from '@prisma/client'; +import { decodeUploadFilename } from '../accounting/decode-upload-filename'; +import { Roles } from '../auth/decorators/roles.decorator'; +import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; +import { UseModule } from '../module-registry/module.guard'; +import { HandelswareAccountDto } from './dto/handelsware-account.dto'; +import { HandelswareSettingsDto } from './dto/handelsware-settings.dto'; +import { HandelswareDatevService } from './handelsware-datev.service'; + +const MAX_NEW_ACCOUNTS = 10_000; + +/** + * Das Formularfeld `newAccounts` ist ein JSON-Text (Liste der von der Vorschau + * gemeldeten neuen Konten). Defensiv gelesen: gueltiges JSON, ein Feld, hoechstens + * 10 000 Eintraege, jeder mit Text-`name` und ganzzahligem `gegenkonto`. + */ +export function parseNewAccountsField(raw: unknown): { name: string; gegenkonto: number }[] { + const bad = () => + new BadRequestException({ + code: 'newAccountsInvalid', + message: 'Die Angaben zu den neuen Konten sind ungültig.', + }); + if (raw === undefined || raw === null || raw === '') return []; + if (typeof raw !== 'string') throw bad(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw bad(); + } + if (!Array.isArray(parsed) || parsed.length > MAX_NEW_ACCOUNTS) throw bad(); + return parsed.map((entry) => { + if ( + typeof entry !== 'object' || + entry === null || + typeof (entry as { name?: unknown }).name !== 'string' || + !Number.isInteger((entry as { gegenkonto?: unknown }).gegenkonto) + ) { + throw bad(); + } + const { name, gegenkonto } = entry as { name: string; gegenkonto: number }; + return { name, gegenkonto }; + }); +} + +/** + * `@UseModule('handelsware-datev')` auf Klassenebene — Aktivierung UND Freigabe. + * `tenantId` kommt ausschliesslich aus `req.tenantId`. Die Einstellungen aendern + * nur Administratoren (T-FM5-02); die Kontenliste pflegen alle Benutzer mit + * Modulzugriff. + * + * REIHENFOLGE: alle statischen Routen (`accounts`, `accounts/export-csv`, + * `accounts/import-csv`) stehen VOR `accounts/:id` — sonst faengt `:id` sie ab + * (Unit-Tests sehen das nicht, `handelsware-datev.controller.spec.ts` prueft die + * Deklarationsreihenfolge). + */ +@Controller('modules/handelsware-datev') +@UseModule('handelsware-datev') +export class HandelswareDatevController { + constructor(private readonly service: HandelswareDatevService) {} + + private requireTenantId(req: AuthenticatedRequest): string { + const tenantId = req.tenantId; + if (!tenantId) { + throw new ForbiddenException('Kein Mandantenkontext'); + } + return tenantId; + } + + @Get('settings') + async getSettings(@Req() req: AuthenticatedRequest) { + return this.service.getSettings(this.requireTenantId(req)); + } + + @Put('settings') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareSettingsDto) { + return this.service.saveSettings(this.requireTenantId(req), dto); + } + + @Post('preview') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })) + async preview( + @Req() req: AuthenticatedRequest, + @UploadedFile() file: UploadedFileLike | undefined, + ) { + const tenantId = this.requireTenantId(req); + if (!file) { + throw new BadRequestException('Keine Datei hochgeladen'); + } + return this.service.preview(tenantId, { + buffer: file.buffer, + originalname: decodeUploadFilename(file.originalname), + }); + } + + @Post('export') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })) + async export( + @Req() req: AuthenticatedRequest, + @UploadedFile() file: UploadedFileLike | undefined, + @Body('buchungsdatum') buchungsdatum?: string, + @Body('newAccounts') newAccounts?: string, + ) { + const tenantId = this.requireTenantId(req); + if (!file) { + throw new BadRequestException('Keine Datei hochgeladen'); + } + return this.service.export( + tenantId, + { buffer: file.buffer, originalname: decodeUploadFilename(file.originalname) }, + typeof buchungsdatum === 'string' ? buchungsdatum.trim() : '', + parseNewAccountsField(newAccounts), + ); + } + + @Get('accounts') + async listAccounts(@Req() req: AuthenticatedRequest) { + return this.service.listAccounts(this.requireTenantId(req)); + } + + @Post('accounts') + async createAccount(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareAccountDto) { + return this.service.createAccount(this.requireTenantId(req), dto); + } + + @Get('accounts/export-csv') + async exportAccountsCsv(@Req() req: AuthenticatedRequest) { + return this.service.exportAccountsCsv(this.requireTenantId(req)); + } + + @Post('accounts/import-csv') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 1024 * 1024 } })) + async importAccountsCsv( + @Req() req: AuthenticatedRequest, + @UploadedFile() file: UploadedFileLike | undefined, + ) { + const tenantId = this.requireTenantId(req); + if (!file) { + throw new BadRequestException('Keine Datei hochgeladen'); + } + return this.service.importAccountsCsv(tenantId, file.buffer); + } + + @Put('accounts/:id') + async updateAccount( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Body() dto: HandelswareAccountDto, + ) { + return this.service.updateAccount(this.requireTenantId(req), id, dto); + } + + @Delete('accounts/:id') + async deleteAccount(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.service.deleteAccount(this.requireTenantId(req), id); + } +} diff --git a/apps/api/src/handelsware-datev/handelsware-datev.module.ts b/apps/api/src/handelsware-datev/handelsware-datev.module.ts new file mode 100644 index 0000000..fb75ea6 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.module.ts @@ -0,0 +1,31 @@ +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { ModuleRegistryModule } from '../module-registry/module-registry.module'; +import { ModuleRegistryService } from '../module-registry/module-registry.service'; +import { HandelswareDatevController } from './handelsware-datev.controller'; +import { seedHandelswareDatevModule } from './handelsware-datev.seed'; +import { HandelswareDatevService } from './handelsware-datev.service'; + +/** + * Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als + * DATEV-Buchungsdatei exportieren. Traegt sich beim Start in die + * Modulverwaltung ein; aktiviert wird per Marktplatz. + */ +@Module({ + imports: [ModuleRegistryModule], + controllers: [HandelswareDatevController], + providers: [HandelswareDatevService], +}) +export class HandelswareDatevModule implements OnModuleInit { + private readonly logger = new Logger(HandelswareDatevModule.name); + + constructor(private readonly moduleRegistryService: ModuleRegistryService) {} + + async onModuleInit(): Promise { + try { + await seedHandelswareDatevModule(this.moduleRegistryService); + this.logger.log('Handelsware-DATEV module seeded in registry'); + } catch (error) { + this.logger.error('Failed to seed handelsware-datev module', error); + } + } +} diff --git a/apps/api/src/handelsware-datev/handelsware-datev.seed.ts b/apps/api/src/handelsware-datev/handelsware-datev.seed.ts new file mode 100644 index 0000000..a159c4c --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.seed.ts @@ -0,0 +1,22 @@ +import { ModuleRegistryService } from '../module-registry/module-registry.service'; + +/** + * Traegt das Modul "Handelsware" in die Modulverwaltung ein (quick-261002-fm5). + * `isSystem: true` legt den Eintrag an, aktiviert ihn aber NICHT je Mandant — + * der Administrator aktiviert ueber den Marktplatz und erteilt die Freigabe. + */ +export async function seedHandelswareDatevModule( + moduleRegistryService: ModuleRegistryService, +): Promise { + await moduleRegistryService.seedModule({ + slug: 'handelsware-datev', + name: 'Handelsware', + version: '1.0.0', + 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, + }); +} diff --git a/apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts b/apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts new file mode 100644 index 0000000..57ae6a5 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts @@ -0,0 +1,378 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import * as XLSX from 'xlsx'; + +/** + * Zwei Klienten wie in favorites.service.spec.ts: `forTenant` und + * `withTenantTransaction` werden auf den Nachbau umgeleitet. Die Transaktion + * arbeitet auf einer KOPIE des Bestands und uebernimmt sie nur, wenn die + * Funktion ohne Fehler endet — so ist Alles-oder-nichts pruefbar. + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)), + withTenantTransaction: vi.fn((db: any, tenantId: string, fn: (tx: any) => any) => + db.__transaction(tenantId, fn), + ), +})); + +import { HandelswareDatevService } from './handelsware-datev.service'; + +interface Konto { + id: string; + tenantId: string; + name: string; + gegenkonto: number; + erloeskonto: number; +} + +function uniqueError() { + return Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }); +} + +function makeDb(opts: { + config?: { erloeskonto: number | null; startGegenkonto: number | null } | null; + konten?: Konto[]; +}) { + const state = { + config: opts.config === undefined ? { erloeskonto: 4711, startGegenkonto: 2000 } : opts.config, + konten: [...(opts.konten ?? [])], + writes: [] as string[], + seq: 100, + }; + + function client(tenantId: string, s: { konten: Konto[] }, record: (w: string) => void) { + const own = () => s.konten.filter((k) => k.tenantId === tenantId); + return { + handelswareDatevConfig: { + findUnique: vi.fn(async () => state.config), + upsert: vi.fn(async ({ create, update }: any) => { + record('config.upsert'); + state.config = { ...(state.config ?? {}), ...update, ...create } as any; + return state.config; + }), + }, + handelswareKonto: { + findMany: vi.fn(async () => [...own()].sort((a, b) => a.name.localeCompare(b.name))), + findFirst: vi.fn(async ({ where }: any) => own().find((k) => k.id === where.id) ?? null), + create: vi.fn(async ({ data }: any) => { + record('konto.create'); + if (own().some((k) => k.name === data.name)) throw uniqueError(); + const row = { id: `k${++state.seq}`, ...data }; + s.konten.push(row); + return row; + }), + update: vi.fn(async ({ where, data }: any) => { + record('konto.update'); + const row = s.konten.find((k) => k.id === where.id) as Konto; + if (data.name !== row.name && own().some((k) => k.name === data.name)) + throw uniqueError(); + Object.assign(row, data); + return row; + }), + delete: vi.fn(async ({ where }: any) => { + record('konto.delete'); + s.konten.splice( + s.konten.findIndex((k) => k.id === where.id), + 1, + ); + }), + deleteMany: vi.fn(async () => { + record('konto.deleteMany'); + const keep = s.konten.filter((k) => k.tenantId !== tenantId); + s.konten.length = 0; + s.konten.push(...keep); + }), + createMany: vi.fn(async ({ data }: any) => { + record('konto.createMany'); + for (const d of data) { + if (own().some((k) => k.name === d.name)) throw uniqueError(); + s.konten.push({ id: `k${++state.seq}`, ...d }); + } + }), + }, + }; + } + + const db: any = { + __state: state, + __bound: (tenantId: string) => client(tenantId, state, (w) => state.writes.push(w)), + __transaction: async (tenantId: string, fn: (tx: any) => any) => { + const copy = { konten: state.konten.map((k) => ({ ...k })) }; + const txWrites: string[] = []; + const result = await fn(client(tenantId, copy, (w) => txWrites.push(w))); + state.konten = copy.konten; + state.writes.push(...txWrites.map((w) => `tx:${w}`)); + return result; + }, + }; + return db; +} + +function workbook(aoa: unknown[][]): Buffer { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1'); + return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer; +} + +const FILE = { + buffer: workbook([ + ['', '2026'], + ['Kaffee', 12.5], + ['Kakao', -3], + ['Kakao', 1], + ]), + originalname: 'HWA 0326 Test.xlsx', +}; + +const konto = (name: string, gegenkonto: number, erloeskonto = 4000): Konto => ({ + id: `id-${name}`, + tenantId: 't1', + name, + gegenkonto, + erloeskonto, +}); + +describe('HandelswareDatevService — Vorschau', () => { + it('sperrt mit settingsMissing, solange Erloeskonto oder Startwert fehlen', async () => { + for (const config of [ + null, + { erloeskonto: 1, startGegenkonto: null }, + { erloeskonto: null, startGegenkonto: 1 }, + ]) { + const service = new HandelswareDatevService(makeDb({ config })); + const err: any = await service.preview('t1', FILE).catch((e) => e); + expect(err).toBeInstanceOf(BadRequestException); + expect(err.getResponse().code).toBe('settingsMissing'); + } + }); + + it('liefert Zeilen, neue Konten, Datumsvorschlag und Dateinamen — und schreibt nichts', async () => { + const db = makeDb({ konten: [konto('Kaffee', 2010)] }); + const res = await new HandelswareDatevService(db).preview('t1', FILE); + expect(res.headerText).toBe('2026'); + expect(res.suggestedBuchungsdatum).toBe('3103'); + expect(res.exportFilename).toBe('HWA_0326.txt'); + expect(res.rows.map((r) => [r.buchungstext, r.gegenkonto, r.isNew])).toEqual([ + ['Kaffee', 2010, false], + ['Kakao', 2011, true], + ['Kakao', 2011, true], + ]); + expect(res.newAccounts).toEqual([{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }]); + expect(db.__state.writes).toEqual([]); + expect(db.__state.konten).toHaveLength(1); + }); + + it('meldet eine kaputte Datei als 400 invalidFile', async () => { + const service = new HandelswareDatevService(makeDb({})); + const err: any = await service + .preview('t1', { buffer: Buffer.from('xx'), originalname: 'a.xlsx' }) + .catch((e) => e); + expect(err.getResponse().code).toBe('invalidFile'); + }); + + it('gibt Zeilenfehler zurueck statt zu werfen', async () => { + const buffer = workbook([ + ['', 'X'], + ['Kaffee', 'viel'], + ]); + const res = await new HandelswareDatevService(makeDb({})).preview('t1', { + buffer, + originalname: 'a.xlsx', + }); + expect(res.rowErrors).toHaveLength(1); + }); +}); + +describe('HandelswareDatevService — Export', () => { + const submitted = [{ name: 'Kakao', gegenkonto: 2011 }]; + + it('speichert die neuen Konten erst beim Export, in der Transaktion, und liefert die TXT', async () => { + const db = makeDb({ konten: [konto('Kaffee', 2010)] }); + const res = await new HandelswareDatevService(db).export('t1', FILE, '3103', submitted); + expect(res.createdCount).toBe(1); + expect(res.filename).toBe('HWA_0326.txt'); + expect(res.mimeType).toBe('text/plain;charset=utf-8'); + expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe( + '\t2026\t\t\t\t\r\nKaffee\t12.50\tS\t2010\t3103\t4000\r\nKakao\t3.00\tH\t2011\t3103\t4711\r\nKakao\t1.00\tS\t2011\t3103\t4711\r\n', + ); + expect(db.__state.konten.map((k: Konto) => k.name).sort()).toEqual(['Kaffee', 'Kakao']); + expect(db.__state.writes).toEqual(['tx:konto.createMany']); + }); + + it('409 accountsChanged, wenn sich die Liste seit der Vorschau geaendert hat — nichts gespeichert', async () => { + // Inzwischen gibt es schon ein Konto mit Gegenkonto 2011 -> neues Konto waere 2012. + const db = makeDb({ konten: [konto('Kaffee', 2010), konto('Saft', 2011)] }); + const err: any = await new HandelswareDatevService(db) + .export('t1', FILE, '3103', submitted) + .catch((e) => e); + expect(err).toBeInstanceOf(ConflictException); + expect(err.getResponse().code).toBe('accountsChanged'); + expect(err.getResponse().message).toBe( + 'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.', + ); + expect(db.__state.konten).toHaveLength(2); + expect(db.__state.writes).toEqual([]); + }); + + it('409, wenn der Client ein neues Konto verschweigt oder erfindet', async () => { + const db = makeDb({ konten: [konto('Kaffee', 2010)] }); + const service = new HandelswareDatevService(db); + await expect(service.export('t1', FILE, '3103', [])).rejects.toBeInstanceOf(ConflictException); + await expect( + service.export('t1', FILE, '3103', [...submitted, { name: 'Erfunden', gegenkonto: 9 }]), + ).rejects.toBeInstanceOf(ConflictException); + expect(db.__state.konten).toHaveLength(1); + }); + + it('Wettlauf: Eindeutigkeit (P2002) beim Anlegen wird zu 409', async () => { + const db = makeDb({ konten: [konto('Kaffee', 2010)] }); + const original = db.__transaction; + // Ein zweiter Export hat "Kakao" zwischen Berechnung und Speichern angelegt. + db.__transaction = (tenantId: string, fn: (tx: any) => any) => + original(tenantId, (tx: any) => { + tx.handelswareKonto.createMany = async () => { + throw uniqueError(); + }; + return fn(tx); + }); + const err: any = await new HandelswareDatevService(db) + .export('t1', FILE, '3103', submitted) + .catch((e) => e); + expect(err).toBeInstanceOf(ConflictException); + expect(err.getResponse().code).toBe('accountsChanged'); + }); + + it('400 bei ungueltigem Buchungsdatum', async () => { + const err: any = await new HandelswareDatevService(makeDb({})) + .export('t1', FILE, '3102', submitted) + .catch((e) => e); + expect(err.getResponse().code).toBe('buchungsdatumInvalid'); + }); + + it('400 bei Zeilenfehlern', async () => { + const buffer = workbook([ + ['', 'X'], + ['Kaffee', 'viel'], + ]); + const err: any = await new HandelswareDatevService(makeDb({})) + .export('t1', { buffer, originalname: 'a 0326.xlsx' }, '3103', []) + .catch((e) => e); + expect(err).toBeInstanceOf(BadRequestException); + expect(err.getResponse().code).toBe('rowErrors'); + }); + + it('400 settingsMissing beim Export ohne Einstellungen', async () => { + const err: any = await new HandelswareDatevService(makeDb({ config: null })) + .export('t1', FILE, '3103', submitted) + .catch((e) => e); + expect(err.getResponse().code).toBe('settingsMissing'); + }); +}); + +describe('HandelswareDatevService — Kontenliste', () => { + it('legt an, sortiert nach Name und meldet doppelte Namen als 409 nameTaken', async () => { + const db = makeDb({}); + const service = new HandelswareDatevService(db); + await service.createAccount('t1', { name: 'Tee', gegenkonto: 2, erloeskonto: 3 }); + await service.createAccount('t1', { name: 'Kaffee', gegenkonto: 4, erloeskonto: 5 }); + expect((await service.listAccounts('t1')).map((a) => a.name)).toEqual(['Kaffee', 'Tee']); + const err: any = await service + .createAccount('t1', { name: 'Tee', gegenkonto: 9, erloeskonto: 9 }) + .catch((e) => e); + expect(err).toBeInstanceOf(ConflictException); + expect(err.getResponse().code).toBe('nameTaken'); + }); + + it('aendert ein Konto; Namensklau ist 409; unbekannte id ist 404', async () => { + const db = makeDb({ konten: [konto('A', 1), konto('B', 2)] }); + const service = new HandelswareDatevService(db); + const updated = await service.updateAccount('t1', 'id-A', { + name: 'A2', + gegenkonto: 7, + erloeskonto: 8, + }); + expect(updated).toMatchObject({ name: 'A2', gegenkonto: 7 }); + await expect( + service.updateAccount('t1', 'id-A', { name: 'B', gegenkonto: 1, erloeskonto: 1 }), + ).rejects.toBeInstanceOf(ConflictException); + await expect( + service.updateAccount('t1', 'nope', { name: 'X', gegenkonto: 1, erloeskonto: 1 }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('loescht ein Konto; unbekannte id ist 404', async () => { + const db = makeDb({ konten: [konto('A', 1)] }); + const service = new HandelswareDatevService(db); + await expect(service.deleteAccount('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.deleteAccount('t1', 'id-A')).resolves.toEqual({ deleted: true }); + expect(db.__state.konten).toHaveLength(0); + }); + + it('CSV-Import ersetzt die Liste in EINER Transaktion (deleteMany + createMany)', async () => { + const db = makeDb({ konten: [konto('Alt', 1)] }); + const res = await new HandelswareDatevService(db).importAccountsCsv( + 't1', + Buffer.from('Name;Gegenkonto;Konto\nNeu1;10;20\nNeu2;11'), + ); + expect(res).toEqual({ count: 2 }); + expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Neu1', 'Neu2']); + expect(db.__state.konten[1].erloeskonto).toBe(4711); + expect(db.__state.writes).toEqual(['tx:konto.deleteMany', 'tx:konto.createMany']); + }); + + it('CSV-Import mit einer ungueltigen Zeile aendert nichts', async () => { + const db = makeDb({ konten: [konto('Alt', 1)] }); + const err: any = await new HandelswareDatevService(db) + .importAccountsCsv('t1', Buffer.from('Neu1;10;20\nNeu2;abc;20')) + .catch((e) => e); + expect(err).toBeInstanceOf(BadRequestException); + expect(err.getResponse().code).toBe('csvErrors'); + expect(err.getResponse().errors).toHaveLength(1); + expect(db.__state.writes).toEqual([]); + expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']); + }); + + it('CSV-Import: scheitert das Schreiben mittendrin, bleibt die alte Liste', async () => { + const db = makeDb({ konten: [konto('Alt', 1)] }); + const original = db.__transaction; + db.__transaction = (tenantId: string, fn: (tx: any) => any) => + original(tenantId, (tx: any) => { + tx.handelswareKonto.createMany = async () => { + throw new Error('Datenbank weg'); + }; + return fn(tx); + }); + await expect( + new HandelswareDatevService(db).importAccountsCsv('t1', Buffer.from('Neu;1;2')), + ).rejects.toThrow('Datenbank weg'); + expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']); + }); + + it('CSV-Export liefert BOM-CSV als Base64', async () => { + const db = makeDb({ konten: [konto('Käse', 1, 2)] }); + const res = await new HandelswareDatevService(db).exportAccountsCsv('t1'); + expect(res.filename).toBe('Konten.csv'); + expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe('Käse;1;2\r\n'); + }); +}); + +describe('HandelswareDatevService — Einstellungen', () => { + it('configured nur, wenn beide Zahlen gesetzt sind', async () => { + expect(await new HandelswareDatevService(makeDb({ config: null })).getSettings('t1')).toEqual({ + erloeskonto: null, + startGegenkonto: null, + configured: false, + }); + expect((await new HandelswareDatevService(makeDb({})).getSettings('t1')).configured).toBe(true); + }); + + it('speichert per upsert', async () => { + const db = makeDb({ config: null }); + const res = await new HandelswareDatevService(db).saveSettings('t1', { + erloeskonto: 5, + startGegenkonto: 6, + }); + expect(res).toEqual({ erloeskonto: 5, startGegenkonto: 6, configured: true }); + expect(db.__state.writes).toEqual(['config.upsert']); + }); +}); diff --git a/apps/api/src/handelsware-datev/handelsware-datev.service.ts b/apps/api/src/handelsware-datev/handelsware-datev.service.ts new file mode 100644 index 0000000..73800c2 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.service.ts @@ -0,0 +1,340 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; +import type { HandelswareAccountDto } from './dto/handelsware-account.dto'; +import type { HandelswareSettingsDto } from './dto/handelsware-settings.dto'; +import type { + AccountEntry, + FileResponse, + HandelswareSettings, + HandelswareSettingsReady, + NewAccount, + PreviewResult, +} from './handelsware-datev.types'; +import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv'; +import { + assignAccounts, + calculateBuchungsdatum, + generateTxt, + getExportFilename, + isValidBuchungsdatum, +} from './handelsware-transform'; +import { HandelswareFileError, parseHandelswareXlsx } from './handelsware-xlsx'; + +export interface UploadedWorkbook { + buffer: Buffer; + /** Bereits als UTF-8 dekodierter Dateiname. */ + originalname: string; +} + +export interface HandelswareSettingsResponse extends HandelswareSettings { + configured: boolean; +} + +export interface AccountResponse extends AccountEntry { + id: string; +} + +const MAX_ACCOUNTS_IMPORT = 10_000; + +const SETTINGS_MISSING = { + code: 'settingsMissing', + message: 'Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt.', +}; + +const ACCOUNTS_CHANGED = { + code: 'accountsChanged', + message: + 'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.', +}; + +const NAME_TAKEN = { + code: 'nameTaken', + message: 'Ein Konto mit diesem Namen gibt es bereits.', +}; + +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002' + ); +} + +function isReady(settings: HandelswareSettings | null): settings is HandelswareSettingsReady { + return Boolean(settings && settings.erloeskonto !== null && settings.startGegenkonto !== null); +} + +/** Gleichheit der berechneten und der von der Vorschau gemeldeten neuen Konten (Name + Gegenkonto). */ +function sameNewAccounts( + computed: NewAccount[], + submitted: { name: string; gegenkonto: number }[], +): boolean { + if (computed.length !== submitted.length) return false; + const byName = new Map(submitted.map((a) => [a.name, a.gegenkonto])); + if (byName.size !== submitted.length) return false; + return computed.every((a) => byName.get(a.name) === a.gegenkonto); +} + +/** + * Handelsware (quick-261002-fm5): Excel-Umsaetze den Erloeskonten zuordnen, + * Kontenliste je Mandant pflegen, TXT fuer DATEV erzeugen. Alle + * Datenbankzugriffe mandantengebunden (`forTenant` bzw. eine gemeinsame + * `withTenantTransaction`); neue Konten werden NUR beim Export gespeichert, + * die Vorschau schreibt nie. + */ +@Injectable() +export class HandelswareDatevService { + constructor(private readonly prisma: PrismaService) {} + + // --- Einstellungen ------------------------------------------------------- + + async getSettings(tenantId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const row = await tenantPrisma.handelswareDatevConfig.findUnique({ where: { tenantId } }); + const settings: HandelswareSettings = { + erloeskonto: row?.erloeskonto ?? null, + startGegenkonto: row?.startGegenkonto ?? null, + }; + return { ...settings, configured: isReady(settings) }; + } + + async saveSettings( + tenantId: string, + dto: HandelswareSettingsDto, + ): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const data = { erloeskonto: dto.erloeskonto, startGegenkonto: dto.startGegenkonto }; + await tenantPrisma.handelswareDatevConfig.upsert({ + where: { tenantId }, + create: { tenantId, ...data }, + update: data, + }); + return { ...data, configured: true }; + } + + // --- Kontenliste --------------------------------------------------------- + + async listAccounts(tenantId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const rows = await tenantPrisma.handelswareKonto.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + gegenkonto: r.gegenkonto, + erloeskonto: r.erloeskonto, + })); + } + + async createAccount(tenantId: string, dto: HandelswareAccountDto): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + try { + const row = await tenantPrisma.handelswareKonto.create({ + data: { + tenantId, + name: dto.name, + gegenkonto: dto.gegenkonto, + erloeskonto: dto.erloeskonto, + }, + }); + return { + id: row.id, + name: row.name, + gegenkonto: row.gegenkonto, + erloeskonto: row.erloeskonto, + }; + } catch (error) { + if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN); + throw error; + } + } + + async updateAccount( + tenantId: string, + id: string, + dto: HandelswareAccountDto, + ): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } }); + if (!existing) throw new NotFoundException('Konto nicht gefunden'); + try { + const row = await tenantPrisma.handelswareKonto.update({ + where: { id }, + data: { name: dto.name, gegenkonto: dto.gegenkonto, erloeskonto: dto.erloeskonto }, + }); + return { + id: row.id, + name: row.name, + gegenkonto: row.gegenkonto, + erloeskonto: row.erloeskonto, + }; + } catch (error) { + if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN); + throw error; + } + } + + async deleteAccount(tenantId: string, id: string): Promise<{ deleted: true }> { + const tenantPrisma = forTenant(this.prisma, tenantId); + const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } }); + if (!existing) throw new NotFoundException('Konto nicht gefunden'); + await tenantPrisma.handelswareKonto.delete({ where: { id } }); + return { deleted: true }; + } + + async exportAccountsCsv(tenantId: string): Promise { + const accounts = await this.listAccounts(tenantId); + return { + filename: 'Konten.csv', + content: Buffer.from(generateKontenCsv(accounts), 'utf8').toString('base64'), + mimeType: 'text/csv;charset=utf-8', + }; + } + + /** Ersetzt die gesamte Kontenliste durch den CSV-Inhalt — alles oder nichts. */ + async importAccountsCsv(tenantId: string, buffer: Buffer): Promise<{ count: number }> { + const settings = await this.getSettings(tenantId); + const { accounts, errors } = parseKontenCsv(buffer, settings.erloeskonto); + if (errors.length > 0) { + throw new BadRequestException({ + code: 'csvErrors', + message: 'Die CSV-Datei enthält Fehler. Es wurde nichts geändert.', + errors, + }); + } + if (accounts.length > MAX_ACCOUNTS_IMPORT) { + throw new BadRequestException({ + code: 'tooManyRows', + message: `Die Datei enthält mehr als ${MAX_ACCOUNTS_IMPORT} Konten.`, + }); + } + + await withTenantTransaction(this.prisma, tenantId, async (tx) => { + await tx.handelswareKonto.deleteMany({ where: { tenantId } }); + if (accounts.length > 0) { + await tx.handelswareKonto.createMany({ + data: accounts.map((a) => ({ tenantId, ...a })), + }); + } + }); + return { count: accounts.length }; + } + + // --- Import / Export ----------------------------------------------------- + + private parseWorkbook(buffer: Buffer) { + try { + return parseHandelswareXlsx(buffer); + } catch (error) { + if (error instanceof HandelswareFileError) { + throw new BadRequestException({ code: error.code, message: error.message }); + } + throw error; + } + } + + /** Vorschau: liest die Datei, ordnet Konten zu — schreibt NICHTS in die Datenbank. */ + async preview(tenantId: string, file: UploadedWorkbook): Promise { + const settings = await this.getSettings(tenantId); + if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING); + + const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer); + const accounts = await this.listAccounts(tenantId); + const { rows, newAccounts } = assignAccounts(importRows, accounts, settings); + + return { + headerText, + suggestedBuchungsdatum: calculateBuchungsdatum(file.originalname), + exportFilename: getExportFilename(file.originalname), + rows, + newAccounts, + rowErrors, + }; + } + + /** + * Export: berechnet die Zuordnung INNERHALB einer mandantengebundenen + * Transaktion neu und vergleicht mit den neuen Konten, die die Vorschau + * gemeldet hat (409 `accountsChanged`, wenn die Liste sich inzwischen + * geaendert hat). Nur dann werden die neuen Konten gespeichert — in derselben + * Transaktion, in der die Datei erzeugt wird. + */ + async export( + tenantId: string, + file: UploadedWorkbook, + buchungsdatum: string, + submittedNewAccounts: { name: string; gegenkonto: number }[], + ): Promise { + if (!isValidBuchungsdatum(buchungsdatum)) { + throw new BadRequestException({ + code: 'buchungsdatumInvalid', + message: 'Das Buchungsdatum muss als TTMM angegeben werden, zum Beispiel 3103.', + }); + } + + const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer); + if (rowErrors.length > 0) { + throw new BadRequestException({ + code: 'rowErrors', + message: 'Die Datei enthält fehlerhafte Zeilen und kann nicht exportiert werden.', + errors: rowErrors, + }); + } + if (importRows.length === 0) { + throw new BadRequestException({ + code: 'noRows', + message: 'Die Datei enthält keine Datenzeilen.', + }); + } + + try { + return await withTenantTransaction(this.prisma, tenantId, async (tx) => { + const config = await tx.handelswareDatevConfig.findUnique({ where: { tenantId } }); + const settings: HandelswareSettings = { + erloeskonto: config?.erloeskonto ?? null, + startGegenkonto: config?.startGegenkonto ?? null, + }; + if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING); + + const stored: AccountEntry[] = await tx.handelswareKonto.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + }); + const { rows, newAccounts } = assignAccounts(importRows, stored, settings); + + if (!sameNewAccounts(newAccounts, submittedNewAccounts)) { + throw new ConflictException(ACCOUNTS_CHANGED); + } + + if (newAccounts.length > 0) { + await tx.handelswareKonto.createMany({ + data: newAccounts.map((a) => ({ + tenantId, + name: a.name, + gegenkonto: a.gegenkonto, + erloeskonto: a.erloeskonto, + })), + }); + } + + const txt = generateTxt(headerText, rows, buchungsdatum); + return { + filename: getExportFilename(file.originalname), + content: Buffer.from(txt, 'utf8').toString('base64'), + mimeType: 'text/plain;charset=utf-8', + createdCount: newAccounts.length, + }; + }); + } catch (error) { + // Zwei Exporte gleichzeitig: die Eindeutigkeit (Mandant, Name) faengt den Wettlauf. + if (isUniqueViolation(error)) throw new ConflictException(ACCOUNTS_CHANGED); + throw error; + } + } +} diff --git a/apps/api/src/handelsware-datev/handelsware-datev.types.ts b/apps/api/src/handelsware-datev/handelsware-datev.types.ts new file mode 100644 index 0000000..847f6e1 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-datev.types.ts @@ -0,0 +1,83 @@ +/** + * Typen des Moduls Handelsware (quick-261002-fm5): Excel-Umsaetze den + * Erloeskonten zuordnen und als DATEV-Buchungsdatei (TXT) exportieren. + */ + +/** Eine Zeile aus der hochgeladenen Excel-Datei. */ +export interface ImportRow { + /** Zeile in der Excel-Datei (1-basiert) */ + line: number; + buchungstext: string; + umsatz: number; +} + +export type RowErrorCode = 'umsatzInvalid'; + +export interface RowError { + line: number; + code: RowErrorCode; + message: string; +} + +/** Vorschauzeile (ohne Buchungsdatum — das tragen Vorschau und Export einmal fuer alle). */ +export interface PreviewRow { + line: number; + buchungstext: string; + /** Betrag als Text, Punkt, genau 2 Nachkommastellen, ohne Vorzeichen */ + umsatz: string; + sollHaben: 'S' | 'H'; + gegenkonto: number; + erloeskonto: number; + /** true, wenn das Konto fuer dieses Produkt neu vergeben wurde */ + isNew: boolean; +} + +export interface AccountEntry { + name: string; + gegenkonto: number; + erloeskonto: number; +} + +export type NewAccount = AccountEntry; + +/** Einstellungen des Mandanten, wie in der Datenbank (leer = noch nicht hinterlegt). */ +export interface HandelswareSettings { + erloeskonto: number | null; + startGegenkonto: number | null; +} + +/** Vollstaendige Einstellungen — Voraussetzung fuer jede Verarbeitung. */ +export interface HandelswareSettingsReady { + erloeskonto: number; + startGegenkonto: number; +} + +export interface FileResponse { + filename: string; + /** Base64 */ + content: string; + mimeType: string; +} + +export interface PreviewResult { + headerText: string; + suggestedBuchungsdatum: string; + exportFilename: string; + rows: PreviewRow[]; + newAccounts: NewAccount[]; + rowErrors: RowError[]; +} + +export type KontenCsvErrorCode = + | 'nameEmpty' + | 'nameTooLong' + | 'gegenkontoInvalid' + | 'erloeskontoInvalid' + | 'missingErloeskonto' + | 'duplicateName'; + +export interface KontenCsvError { + line: number; + code: KontenCsvErrorCode; + message: string; +} diff --git a/apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts b/apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts new file mode 100644 index 0000000..d9a5cd9 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv'; + +const csv = (text: string, enc: BufferEncoding = 'utf8') => Buffer.from(text, enc); + +describe('parseKontenCsv', () => { + it('liest CRLF und LF gleich', () => { + const a = parseKontenCsv(csv('Kaffee;2010;4000\r\nTee;2011;4001\r\n'), 4711); + const b = parseKontenCsv(csv('Kaffee;2010;4000\nTee;2011;4001'), 4711); + expect(a.accounts).toEqual(b.accounts); + expect(a.accounts).toHaveLength(2); + expect(a.errors).toEqual([]); + }); + + it('dekodiert Windows-1252 und UTF-8 mit BOM', () => { + expect(parseKontenCsv(csv('Käse;2010;4000', 'latin1'), null).accounts[0].name).toBe('Käse'); + const bom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), csv('Käse;2010;4000')]); + expect(parseKontenCsv(bom, null).accounts[0].name).toBe('Käse'); + }); + + it('ueberspringt eine Kopfzeile, wenn die zweite Spalte keine Zahl ist', () => { + const r = parseKontenCsv(csv('Name;Gegenkonto;Konto\nKaffee;2010;4000'), null); + expect(r.accounts).toEqual([{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 }]); + expect(r.errors).toEqual([]); + }); + + it('nimmt fuer eine fehlende dritte Spalte das Standard-Erloeskonto', () => { + const r = parseKontenCsv(csv('Kaffee;2010'), 4711); + expect(r.accounts[0].erloeskonto).toBe(4711); + }); + + it('meldet missingErloeskonto, wenn das Standard-Erloeskonto leer ist', () => { + const r = parseKontenCsv(csv('Kaffee;2010'), null); + expect(r.errors).toEqual([expect.objectContaining({ line: 1, code: 'missingErloeskonto' })]); + }); + + it('meldet ungueltige Zahlen und leere Namen mit Zeilennummer', () => { + const r = parseKontenCsv(csv('ok;1;2\n;5;6\nx;abc;6\ny;5;-1\nz;0;6'), null); + expect(r.errors.map((e) => [e.line, e.code])).toEqual([ + [2, 'nameEmpty'], + [3, 'gegenkontoInvalid'], + [4, 'erloeskontoInvalid'], + [5, 'gegenkontoInvalid'], + ]); + }); + + it('meldet doppelte Namen', () => { + const r = parseKontenCsv(csv('Kaffee;1;2\nKaffee;3;4'), null); + expect(r.errors).toEqual([expect.objectContaining({ line: 2, code: 'duplicateName' })]); + }); + + it('meldet zu lange Namen', () => { + const r = parseKontenCsv(csv(`${'x'.repeat(121)};1;2`), null); + expect(r.errors[0].code).toBe('nameTooLong'); + }); + + it('erlaubt Semikolons im Namen', () => { + const r = parseKontenCsv(csv('Tee; gruen;2010;4000'), null); + expect(r.accounts[0]).toEqual({ name: 'Tee; gruen', gegenkonto: 2010, erloeskonto: 4000 }); + }); +}); + +describe('generateKontenCsv', () => { + it('beginnt mit BOM, nutzt Semikolon und CRLF', () => { + const out = generateKontenCsv([ + { name: 'Käse', gegenkonto: 2010, erloeskonto: 4000 }, + { name: 'Tee', gegenkonto: 2011, erloeskonto: 4001 }, + ]); + expect(out.startsWith('')).toBe(true); + expect(out.slice(1)).toBe('Käse;2010;4000\r\nTee;2011;4001\r\n'); + }); + + it.each([ + '=SUMME(A1)', + '+1', + '-5 % Aktion', + '@cmd', + ])('schuetzt %j mit einem Apostroph', (name) => { + const out = generateKontenCsv([{ name, gegenkonto: 1, erloeskonto: 2 }]); + expect(out.slice(1).startsWith(`'${name};`)).toBe(true); + }); + + it('Export und Import ergeben dieselbe Liste (Rundlauf)', () => { + const accounts = [ + { name: '=1+1', gegenkonto: 2010, erloeskonto: 4000 }, + { name: '-5 % Aktion', gegenkonto: 2011, erloeskonto: 4000 }, + { name: 'Käse', gegenkonto: 2012, erloeskonto: 4001 }, + ]; + const back = parseKontenCsv(Buffer.from(generateKontenCsv(accounts), 'utf8'), null); + expect(back.errors).toEqual([]); + expect(back.accounts).toEqual(accounts); + }); + + it('leere Liste ergibt nur das BOM', () => { + expect(generateKontenCsv([])).toBe(''); + }); +}); diff --git a/apps/api/src/handelsware-datev/handelsware-konten-csv.ts b/apps/api/src/handelsware-datev/handelsware-konten-csv.ts new file mode 100644 index 0000000..428b092 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-konten-csv.ts @@ -0,0 +1,141 @@ +import { decodeCsvText } from '../accounting/decode-csv-text'; +import type { AccountEntry, KontenCsvError } from './handelsware-datev.types'; + +export const MAX_NAME_LENGTH = 120; +const MAX_ACCOUNT_NUMBER = 999_999_999; +/** Zeichen, mit denen Excel einen Zelltext als Formel liest. */ +const FORMULA_TRIGGERS = ['=', '+', '-', '@']; + +function parseAccountNumber(value: string): number | null { + if (!/^\d{1,9}$/.test(value)) return null; + const num = Number.parseInt(value, 10); + return num >= 1 && num <= MAX_ACCOUNT_NUMBER ? num : null; +} + +/** Entfernt den Schutz-Apostroph, den `generateKontenCsv` vor Formelzeichen setzt. */ +function stripFormulaGuard(name: string): string { + if (name.length > 1 && name[0] === "'" && FORMULA_TRIGGERS.includes(name[1])) { + return name.slice(1); + } + return name; +} + +/** + * Liest eine Konten-CSV (Semikolon): Name;Gegenkonto;Konto. UTF-8 oder + * Windows-1252, CRLF oder LF. Eine Kopfzeile (zweite Spalte keine Zahl) wird + * uebersprungen. Fehlt die dritte Spalte, gilt das Standard-Erloeskonto. Der + * Name steht vor den letzten beiden Semikolons, darf also selbst Semikolons + * enthalten. Es wird alles geprueft; bei Fehlern ist `accounts` unbrauchbar. + */ +export function parseKontenCsv( + buffer: Buffer, + defaultErloeskonto: number | null, +): { accounts: AccountEntry[]; errors: KontenCsvError[] } { + const accounts: AccountEntry[] = []; + const errors: KontenCsvError[] = []; + const seen = new Set(); + + const lines = decodeCsvText(buffer).split(/\r?\n/); + let firstContentLine = true; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + if (raw.trim() === '') continue; + const line = i + 1; + const parts = raw.split(';'); + + let name: string; + let gegenText: string; + let kontoText: string; + if (parts.length >= 3) { + kontoText = parts[parts.length - 1].trim(); + gegenText = parts[parts.length - 2].trim(); + name = parts.slice(0, -2).join(';').trim(); + } else { + name = (parts[0] ?? '').trim(); + gegenText = (parts[1] ?? '').trim(); + kontoText = ''; + } + + // Kopfzeile: nur als allererste Inhaltszeile, wenn die zweite Spalte keine Zahl ist. + if (firstContentLine) { + firstContentLine = false; + if (!/^\d+$/.test(gegenText)) continue; + } + + name = stripFormulaGuard(name); + + if (name === '') { + errors.push({ line, code: 'nameEmpty', message: 'Der Name fehlt.' }); + continue; + } + if (name.length > MAX_NAME_LENGTH) { + errors.push({ + line, + code: 'nameTooLong', + message: `Der Name ist länger als ${MAX_NAME_LENGTH} Zeichen.`, + }); + continue; + } + + const gegenkonto = parseAccountNumber(gegenText); + if (gegenkonto === null) { + errors.push({ + line, + code: 'gegenkontoInvalid', + message: 'Das Gegenkonto muss eine ganze Zahl von 1 bis 999999999 sein.', + }); + continue; + } + + let erloeskonto: number | null; + if (kontoText === '') { + if (defaultErloeskonto === null) { + errors.push({ + line, + code: 'missingErloeskonto', + message: 'Das Erlöskonto fehlt und es ist kein Standard-Erlöskonto hinterlegt.', + }); + continue; + } + erloeskonto = defaultErloeskonto; + } else { + erloeskonto = parseAccountNumber(kontoText); + if (erloeskonto === null) { + errors.push({ + line, + code: 'erloeskontoInvalid', + message: 'Das Erlöskonto muss eine ganze Zahl von 1 bis 999999999 sein.', + }); + continue; + } + } + + if (seen.has(name)) { + errors.push({ + line, + code: 'duplicateName', + message: 'Der Name kommt in der Datei mehrfach vor.', + }); + continue; + } + seen.add(name); + accounts.push({ name, gegenkonto, erloeskonto }); + } + + return { accounts, errors }; +} + +/** + * Konten-CSV fuer Excel: UTF-8 mit BOM (damit Umlaute stimmen), Semikolon, + * CRLF. Namen, die mit Formelzeichen beginnen, bekommen einen Apostroph + * davor (Schutz vor Formeleinschleusung, T-FM5-07); `parseKontenCsv` nimmt ihn + * wieder weg. + */ +export function generateKontenCsv(accounts: AccountEntry[]): string { + const lines = accounts.map((a) => { + const name = FORMULA_TRIGGERS.includes(a.name[0] ?? '') ? `'${a.name}` : a.name; + return `${name};${a.gegenkonto};${a.erloeskonto}`; + }); + return `${lines.join('\r\n')}${lines.length > 0 ? '\r\n' : ''}`; +} diff --git a/apps/api/src/handelsware-datev/handelsware-transform.spec.ts b/apps/api/src/handelsware-datev/handelsware-transform.spec.ts new file mode 100644 index 0000000..edca621 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-transform.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import type { ImportRow } from './handelsware-datev.types'; +import { + assignAccounts, + calculateBuchungsdatum, + formatAmount, + generateTxt, + getExportFilename, + isValidBuchungsdatum, +} from './handelsware-transform'; + +// Neutrale Testwerte, keine Zahlen aus einem echten Kontenrahmen. +const SETTINGS = { erloeskonto: 4711, startGegenkonto: 2000 }; + +const row = (buchungstext: string, umsatz: number, line = 2): ImportRow => ({ + line, + buchungstext, + umsatz, +}); + +describe('calculateBuchungsdatum', () => { + it.each([ + ['HWA 0326 Test.xlsx', '3103'], + ['HWA 0226.xlsx', '2802'], + ['x 0228.xlsx', '2902'], + ['HWA 0426.xlsx', '3004'], + ['HWA 0026.xlsx', ''], + ['HWA 1326.xlsx', ''], + ['HWA.xlsx', ''], + ['HWA 12.xlsx', ''], + ])('%s -> %j', (name, expected) => { + expect(calculateBuchungsdatum(name)).toBe(expected); + }); +}); + +describe('isValidBuchungsdatum', () => { + it.each(['3103', '0101', '2902', '3012'])('akzeptiert %s', (v) => { + expect(isValidBuchungsdatum(v)).toBe(true); + }); + it.each([ + '3102', + '0013', + '0000', + '3204', + 'abc', + '310', + '31033', + '3104', + '', + ])('lehnt %j ab', (v) => { + expect(isValidBuchungsdatum(v)).toBe(false); + }); +}); + +describe('formatAmount', () => { + it('Soll fuer positive Werte und Null, Haben fuer negative', () => { + expect(formatAmount(12.5)).toEqual({ formatted: '12.50', sollHaben: 'S' }); + expect(formatAmount(0)).toEqual({ formatted: '0.00', sollHaben: 'S' }); + expect(formatAmount(-3.456)).toEqual({ formatted: '3.46', sollHaben: 'H' }); + }); +}); + +describe('assignAccounts', () => { + const accounts = [ + { name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 }, + { name: 'Tee', gegenkonto: 2005, erloeskonto: 4001 }, + ]; + + it('bekannter Name bekommt sein Gegenkonto und Erloeskonto, isNew false', () => { + const r = assignAccounts([row('Kaffee', 5)], accounts, SETTINGS); + expect(r.rows[0]).toMatchObject({ gegenkonto: 2010, erloeskonto: 4000, isNew: false }); + expect(r.newAccounts).toEqual([]); + }); + + it('unbekannte Namen: hoechstes Gegenkonto + 1, dann + 2, mit Standard-Erloeskonto', () => { + const r = assignAccounts([row('Kakao', 1), row('Saft', 2)], accounts, SETTINGS); + expect(r.newAccounts).toEqual([ + { name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }, + { name: 'Saft', gegenkonto: 2012, erloeskonto: 4711 }, + ]); + expect(r.rows.map((x) => x.isNew)).toEqual([true, true]); + }); + + it('leere Liste: erstes neues Konto ist genau der Startwert, dann + 1', () => { + const r = assignAccounts([row('A', 1), row('B', 1)], [], SETTINGS); + expect(r.newAccounts.map((a) => a.gegenkonto)).toEqual([2000, 2001]); + }); + + it('derselbe unbekannte Name zweimal: ein neues Konto, beide Zeilen als neu', () => { + const r = assignAccounts( + [row('Kakao', 1, 2), row('Saft', 1, 3), row('Kakao', 2, 4)], + accounts, + SETTINGS, + ); + expect(r.newAccounts.map((a) => a.name)).toEqual(['Kakao', 'Saft']); + expect(r.rows[2]).toMatchObject({ gegenkonto: 2011, isNew: true, line: 4 }); + }); + + it('vergleicht Namen genau (Gross-/Kleinschreibung zaehlt)', () => { + const r = assignAccounts([row('kaffee', 1)], accounts, SETTINGS); + expect(r.rows[0].isNew).toBe(true); + }); + + it('formatiert Betrag und Soll/Haben je Zeile', () => { + const r = assignAccounts([row('Kaffee', -2.5)], accounts, SETTINGS); + expect(r.rows[0]).toMatchObject({ umsatz: '2.50', sollHaben: 'H' }); + }); +}); + +describe('generateTxt', () => { + const rows = assignAccounts([row('Müller Käse', 12.5), row('Tee', -3)], [], SETTINGS).rows; + const txt = generateTxt('2026', rows, '3103'); + + it('Kopfzeile: TAB Kopftext und vier weitere Tabs', () => { + expect(txt.split('\r\n')[0]).toBe('\t2026\t\t\t\t'); + }); + + it('Datenzeilen: Text, Umsatz, S/H, Gegenkonto, TTMM, Erloeskonto', () => { + const lines = txt.split('\r\n'); + expect(lines[1]).toBe('Müller Käse\t12.50\tS\t2000\t3103\t4711'); + expect(lines[2]).toBe('Tee\t3.00\tH\t2001\t3103\t4711'); + }); + + it('endet mit CRLF und enthaelt kein einzelnes LF', () => { + expect(txt.endsWith('\r\n')).toBe(true); + expect(txt.replace(/\r\n/g, '')).not.toContain('\n'); + }); + + it('behaelt Umlaute bei UTF-8 bei', () => { + expect(Buffer.from(txt, 'utf8').toString('utf8')).toContain('Müller Käse'); + }); +}); + +describe('getExportFilename', () => { + it.each([ + ['HWA 0326 Test.xlsx', 'HWA_0326.txt'], + ['HWA0326.xlsx', 'HWA_0326.txt'], + ['Liste.xlsx', 'Handelsware_Export.txt'], + ])('%s -> %s', (name, expected) => { + expect(getExportFilename(name)).toBe(expected); + }); +}); diff --git a/apps/api/src/handelsware-datev/handelsware-transform.ts b/apps/api/src/handelsware-datev/handelsware-transform.ts new file mode 100644 index 0000000..b24a295 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-transform.ts @@ -0,0 +1,115 @@ +import type { + AccountEntry, + HandelswareSettingsReady, + ImportRow, + NewAccount, + PreviewRow, +} from './handelsware-datev.types'; + +/** + * Buchungsdatum (TTMM) aus dem Dateinamen: die erste vierstellige Ziffernfolge + * ist MMYY, ergibt den letzten Tag dieses Monats. + * "HWA 0326 Test.xlsx" -> "3103". Ohne Treffer oder mit Monat ausserhalb 1-12: "". + */ +export function calculateBuchungsdatum(filename: string): string { + const match = filename.match(/(\d{2})(\d{2})/); + if (!match) return ''; + const month = Number.parseInt(match[1], 10); + if (month < 1 || month > 12) return ''; + const year = 2000 + Number.parseInt(match[2], 10); + const lastDay = new Date(year, month, 0).getDate(); + return `${String(lastDay).padStart(2, '0')}${String(month).padStart(2, '0')}`; +} + +const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/** TTMM: vier Ziffern, Monat 1-12, Tag passend zum Monat (Februar bis 29). */ +export function isValidBuchungsdatum(ttmm: string): boolean { + if (!/^\d{4}$/.test(ttmm)) return false; + const day = Number.parseInt(ttmm.slice(0, 2), 10); + const month = Number.parseInt(ttmm.slice(2, 4), 10); + if (month < 1 || month > 12) return false; + return day >= 1 && day <= DAYS_PER_MONTH[month - 1]; +} + +/** Betrag ohne Vorzeichen, Punkt, genau 2 Nachkommastellen; Soll fuer >= 0, Haben fuer < 0. */ +export function formatAmount(value: number): { formatted: string; sollHaben: 'S' | 'H' } { + const sollHaben = value < 0 ? 'H' : 'S'; + return { formatted: Math.abs(value).toFixed(2), sollHaben }; +} + +/** + * Ordnet jeder Zeile ihr Konto zu. Bekannte Produkte (genauer, gross-/ + * kleinschreibungsabhaengiger Name) bekommen ihr Gegenkonto und Erloeskonto; + * unbekannte bekommen das naechste freie Gegenkonto (hoechstes vorhandenes + 1, + * bei leerer Liste genau der Startwert aus den Einstellungen) und das + * Standard-Erloeskonto. Dasselbe unbekannte Produkt mehrfach in einer Datei + * bekommt EIN neues Konto. `newAccounts` steht in der Reihenfolge des ersten + * Auftretens. + */ +export function assignAccounts( + importRows: ImportRow[], + accounts: AccountEntry[], + settings: HandelswareSettingsReady, +): { rows: PreviewRow[]; newAccounts: NewAccount[] } { + const known = new Map(); + for (const account of accounts) known.set(account.name, account); + + const created = new Map(); + const newAccounts: NewAccount[] = []; + let next = + accounts.length > 0 + ? Math.max(...accounts.map((a) => a.gegenkonto)) + 1 + : settings.startGegenkonto; + + const rows: PreviewRow[] = []; + for (const row of importRows) { + const { formatted, sollHaben } = formatAmount(row.umsatz); + + let account = known.get(row.buchungstext); + let isNew = false; + if (!account) { + isNew = true; + account = created.get(row.buchungstext); + if (!account) { + account = { name: row.buchungstext, gegenkonto: next++, erloeskonto: settings.erloeskonto }; + created.set(row.buchungstext, account); + newAccounts.push(account); + } + } + + rows.push({ + line: row.line, + buchungstext: row.buchungstext, + umsatz: formatted, + sollHaben, + gegenkonto: account.gegenkonto, + erloeskonto: account.erloeskonto, + isNew, + }); + } + + return { rows, newAccounts }; +} + +/** + * TXT-Datei fuer DATEV: Kopfzeile TAB Kopftext + 4 Tabs, dann je Zeile + * Text, Umsatz, S/H, Gegenkonto, Datum (TTMM), Erloeskonto — tabgetrennt, CRLF, + * die Datei endet mit CRLF. UTF-8 (offene Frage: DATEV erwartet oft ANSI). + */ +export function generateTxt(headerText: string, rows: PreviewRow[], buchungsdatum: string): string { + const lines: string[] = [`\t${headerText}\t\t\t\t`]; + for (const row of rows) { + lines.push( + `${row.buchungstext}\t${row.umsatz}\t${row.sollHaben}\t${row.gegenkonto}\t${buchungsdatum}\t${row.erloeskonto}`, + ); + } + return `${lines.join('\r\n')}\r\n`; +} + +/** Dateiname des Exports: "HWA 0326 Test.xlsx" -> "HWA_0326.txt", sonst "Handelsware_Export.txt". */ +export function getExportFilename(importFilename: string): string { + const match = importFilename.match(/(\w+)\s*(\d{4})/); + if (match) return `${match[1]}_${match[2]}.txt`; + return 'Handelsware_Export.txt'; +} diff --git a/apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts b/apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts new file mode 100644 index 0000000..708b7a7 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import { + HandelswareFileError, + MAX_DATA_ROWS, + parseHandelswareXlsx, + parseUmsatz, +} from './handelsware-xlsx'; + +/** Baut eine Arbeitsmappe aus einer Matrix (Zeile 1 = Kopf). */ +function workbook(aoa: unknown[][]): Buffer { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1'); + return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer; +} + +describe('parseUmsatz', () => { + it('uebernimmt Zahlen unveraendert', () => { + expect(parseUmsatz(12.5)).toBe(12.5); + expect(parseUmsatz(-3)).toBe(-3); + }); + + it('liest deutsche Texte', () => { + expect(parseUmsatz('1.234,56')).toBe(1234.56); + expect(parseUmsatz('-12,5')).toBe(-12.5); + expect(parseUmsatz(' 7,00 ')).toBe(7); + }); + + it('liest Text mit Punkt als Dezimalzeichen', () => { + expect(parseUmsatz('12.5')).toBe(12.5); + }); + + it.each(['', 'abc', '1,2,3', '12,5x', '--1', null, undefined, true, NaN])('lehnt %j ab', (v) => { + expect(parseUmsatz(v)).toBeNull(); + }); +}); + +describe('parseHandelswareXlsx', () => { + it('liest Kopftext aus B1 (Text oder Zahl) und die Zeilen ab Zeile 2', () => { + const textHeader = parseHandelswareXlsx( + workbook([ + ['', 'Marz'], + ['Kaffee', 12.5], + ]), + ); + expect(textHeader.headerText).toBe('Marz'); + const numberHeader = parseHandelswareXlsx( + workbook([ + ['', 2025], + ['Kaffee', 1], + ]), + ); + expect(numberHeader.headerText).toBe('2025'); + }); + + it('liest Zahlen und deutsche Texte als Umsatz und merkt sich die Zeilennummer', () => { + const r = parseHandelswareXlsx( + workbook([ + ['', 'X'], + ['Kaffee', 12.5], + ['Tee', '1.234,56'], + ['Kakao', '-12,5'], + ]), + ); + expect(r.rows).toEqual([ + { line: 2, buchungstext: 'Kaffee', umsatz: 12.5 }, + { line: 3, buchungstext: 'Tee', umsatz: 1234.56 }, + { line: 4, buchungstext: 'Kakao', umsatz: -12.5 }, + ]); + expect(r.rowErrors).toEqual([]); + }); + + it('endet an der ersten Zeile, in der A und B leer sind', () => { + const r = parseHandelswareXlsx(workbook([['', 'X'], ['Kaffee', 1], [], ['Tee', 2]])); + expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']); + }); + + it('meldet nicht numerischen oder leeren Umsatz als Zeilenfehler', () => { + const r = parseHandelswareXlsx( + workbook([ + ['', 'X'], + ['Kaffee', 'viel'], + ['Tee', null], + ['Kakao', 3], + ]), + ); + expect(r.rowErrors).toEqual([ + expect.objectContaining({ line: 2, code: 'umsatzInvalid' }), + expect.objectContaining({ line: 3, code: 'umsatzInvalid' }), + ]); + expect(r.rows).toHaveLength(1); + }); + + it('ueberspringt eine Zeile ohne Buchungstext, aber mit Wert (wie die Vorlage)', () => { + const r = parseHandelswareXlsx( + workbook([ + ['', 'X'], + ['', 5], + ['Kaffee', 1], + ]), + ); + expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']); + expect(r.rowErrors).toEqual([]); + }); + + it('ersetzt Tabulatoren und Zeilenumbrueche im Text durch Leerzeichen', () => { + const r = parseHandelswareXlsx( + workbook([ + ['', 'Kopf\tText'], + ['Kaf\tfee\nneu', 1], + ]), + ); + expect(r.headerText).toBe('Kopf Text'); + expect(r.rows[0].buchungstext).toBe('Kaf fee neu'); + }); + + it('wirft invalidFile bei Muelldaten', () => { + expect(() => parseHandelswareXlsx(Buffer.from('das ist keine Excel-Datei;1;2'))).toThrow( + HandelswareFileError, + ); + try { + parseHandelswareXlsx(Buffer.from([1, 2, 3, 4, 5, 6])); + expect.unreachable(); + } catch (e) { + expect((e as HandelswareFileError).code).toBe('invalidFile'); + } + }); + + it('wirft invalidFile bei kaputtem ZIP', () => { + const broken = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('kaputt')]); + expect(() => parseHandelswareXlsx(broken)).toThrow(HandelswareFileError); + }); + + it('wirft tooManyRows ab mehr als 10 000 Datenzeilen, nicht davor', () => { + const header = ['', 'X']; + const make = (n: number) => + workbook([header, ...Array.from({ length: n }, (_, i) => [`P${i}`, 1])]); + expect(parseHandelswareXlsx(make(MAX_DATA_ROWS)).rows).toHaveLength(MAX_DATA_ROWS); + try { + parseHandelswareXlsx(make(MAX_DATA_ROWS + 1)); + expect.unreachable(); + } catch (e) { + expect((e as HandelswareFileError).code).toBe('tooManyRows'); + } + }); +}); diff --git a/apps/api/src/handelsware-datev/handelsware-xlsx.ts b/apps/api/src/handelsware-datev/handelsware-xlsx.ts new file mode 100644 index 0000000..6444a32 --- /dev/null +++ b/apps/api/src/handelsware-datev/handelsware-xlsx.ts @@ -0,0 +1,130 @@ +import * as XLSX from 'xlsx'; +import type { ImportRow, RowError } from './handelsware-datev.types'; + +/** Obergrenze der Datenzeilen je Datei (T-FM5-04). */ +export const MAX_DATA_ROWS = 10_000; + +export type HandelswareFileErrorCode = 'invalidFile' | 'tooManyRows'; + +export class HandelswareFileError extends Error { + constructor( + readonly code: HandelswareFileErrorCode, + message: string, + ) { + super(message); + this.name = 'HandelswareFileError'; + } +} + +/** Tabulator und Zeilenumbrueche wuerden die Spalten der TXT-Datei zerreissen. */ +export function sanitizeText(value: string): string { + return value.replace(/[\t\r\n]+/g, ' ').trim(); +} + +/** + * Wandelt einen Umsatzwert in eine Zahl: Zahl unveraendert, Text im deutschen + * Format ("1.234,56", "-12,5") oder mit Punkt ("12.5"). `null` bei allem, was + * keine Zahl ist. + */ +export function parseUmsatz(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value !== 'string') return null; + let text = value.replace(/\s/g, ''); + if (text === '') return null; + if (text.includes(',')) { + // Deutsches Format: Punkte sind Tausendertrenner, das Komma ist das Dezimalzeichen. + text = text.replace(/\./g, '').replace(',', '.'); + } + if (!/^-?\d+(\.\d+)?$/.test(text)) return null; + const num = Number(text); + return Number.isFinite(num) ? num : null; +} + +/** Signatur einer xlsx-Datei (ZIP) oder einer alten xls-Datei (OLE2). */ +function looksLikeWorkbook(buffer: Buffer): boolean { + if (buffer.length < 4) return false; + const zip = buffer[0] === 0x50 && buffer[1] === 0x4b; + const ole = buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0; + return zip || ole; +} + +function cellText(cell: XLSX.CellObject | undefined): string { + if (!cell || cell.v === undefined || cell.v === null) return ''; + return String(cell.v); +} + +/** + * Liest die Handelsware-Excel-Datei: Zelle B1 = Kopftext, ab Zeile 2 Spalte A = + * Buchungstext und Spalte B = Umsatz, bis A und B beide leer sind. Nur das erste + * Blatt, keine Formeln/HTML/Formatvorlagen (T-FM5-05), hoechstens + * `MAX_DATA_ROWS` Datenzeilen (T-FM5-04). + * + * Abweichung von der Vorlage: ein Umsatz, der keine Zahl ist, wurde dort still + * als 0 gebucht — hier wird er ein Zeilenfehler. + */ +export function parseHandelswareXlsx(buffer: Buffer): { + headerText: string; + rows: ImportRow[]; + rowErrors: RowError[]; +} { + if (!looksLikeWorkbook(buffer)) { + throw new HandelswareFileError('invalidFile', 'Die Datei ist keine gültige Excel-Datei.'); + } + + let sheet: XLSX.WorkSheet | undefined; + try { + const workbook = XLSX.read(buffer, { + type: 'buffer', + cellFormula: false, + cellHTML: false, + cellStyles: false, + // Zeile 1 (Kopf) + MAX_DATA_ROWS Datenzeilen + 1 Zeile, um "zu viele" zu erkennen. + sheetRows: MAX_DATA_ROWS + 2, + }); + sheet = workbook.Sheets[workbook.SheetNames[0]]; + } catch { + throw new HandelswareFileError( + 'invalidFile', + 'Die Datei konnte nicht als Excel-Datei gelesen werden.', + ); + } + if (!sheet) { + throw new HandelswareFileError('invalidFile', 'Die Excel-Datei enthält kein Tabellenblatt.'); + } + + const headerText = sanitizeText(cellText(sheet.B1)); + + const rows: ImportRow[] = []; + const rowErrors: RowError[] = []; + for (let line = 2; ; line++) { + const textA = sanitizeText(cellText(sheet[`A${line}`])); + const rawB = sheet[`B${line}`]?.v; + const emptyB = rawB === undefined || rawB === null || String(rawB).trim() === ''; + if (textA === '' && emptyB) break; + + if (line - 1 > MAX_DATA_ROWS) { + throw new HandelswareFileError( + 'tooManyRows', + `Die Datei enthält mehr als ${MAX_DATA_ROWS} Datenzeilen.`, + ); + } + + // Zeile ohne Buchungstext, aber mit Wert: uebersprungen (Verhalten der Vorlage). + if (textA === '') continue; + + const umsatz = parseUmsatz(rawB); + if (umsatz === null) { + rowErrors.push({ + line, + code: 'umsatzInvalid', + message: 'Der Umsatz ist keine gültige Zahl.', + }); + continue; + } + rows.push({ line, buchungstext: textA, umsatz }); + } + + return { headerText, rows, rowErrors }; +} diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 27fa83f..5da374a 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -179,7 +179,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. | | reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | -| **Summe** | **61** | **242** | **7** | **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | +| **Summe** | **61** | **250** | **7** | **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -390,6 +391,11 @@ quick-261002-fm5 (Aufgabe 1): +1 `muss-mandantengebunden` (`kantine-datev.servic 90 Paare, davon 50 `muss-mandantengebunden`, 22 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261002-fm5 (Aufgabe 2): +2 `muss-mandantengebunden` (`handelsware-datev.service.ts`/`handelswareDatevConfig` +und `/handelswareKonto`, beide `gebunden`): 92 Paare, davon 52 `muss-mandantengebunden`, +22 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit +`grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + ## Der Hintergrunddienst als Falle — sechs Fälle Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — @@ -829,6 +835,8 @@ werden. | apps/api/src/reminders/reminder-mail.scheduler.ts | reminder | beides | system-gebunden | quick-260929-if2 (Aufgabe 3): der E-Mail-Planer der Erinnerungen ist ein globales 30-Sekunden-Intervall über ALLE Mandanten (bewusst übergreifend, siehe Dateikopf). Die Kandidatenabfrage (`findMany`, nur skalarer Select, `take 200`) liest über `forSystem()` (`system_read_policy ... FOR SELECT`, Migration 20260929140000, nur lesend); Anspruch (`updateMany`), Laden (`findFirst`) und Freigabe (`updateMany`) laufen je Kandidatenzeile über `forTenant(prisma, c.tenantId)`. Der Anspruch ist atomar (`emailSentAt: null` und unveränderte `dueAt` in der Bedingung), damit mehrere Instanzen nie doppelt senden. | | apps/api/src/reminders/reminder-mail.scheduler.ts | user | beides | gebunden | quick-260929-if2 (Aufgabe 3): die Empfängeradresse wird je Kandidatenzeile gelesen (`user.findFirst` mit `where: { id, tenantId }`), gebunden an den Mandanten dieser Zeile — bewusst NICHT als Relation im Systemklienten der Kandidatenabfrage (sonst würde `User` zum Systemlese-Modell). | | apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — die drei Nummern der Kantinenabrechnung (Beraternummer, Mandantennummer, Lohnart), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002120000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der diese Einstellungen über alle Mandanten liest. Zwei mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `getSettings` (`findUnique`), `saveSettings` (`upsert`). Die hochgeladene Kantinen-CSV (Namen, Personalnummern) berührt die Datenbank nie. | +| apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Einstellungen der Handelsware (Standard-Erlöskonto, Startwert Gegenkonto), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`getSettings` `findUnique`, `saveSettings` `upsert`) und einer über den Transaktionsparameter von `withTenantTransaction` (`export` liest die Einstellungen in derselben Transaktion wie die Kontenliste, `tx.handelswareDatevConfig.findUnique`). | +| apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareKonto | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Kontenliste der Handelsware (Produktname → Gegenkonto, Erlöskonto), mehrere Zeilen je Mandant, Name je Mandant eindeutig. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000, Form aus `ProxmoxServer`), keine `system_read_policy`. Sechs Rohtreffer über `tenantPrisma` (`listAccounts` `findMany`, `createAccount` `create`, `updateAccount` `findFirst` UND `update`, `deleteAccount` `findFirst` UND `delete`) und vier über den Transaktionsparameter von `withTenantTransaction` (`importAccountsCsv` `deleteMany` UND `createMany` als EINE Transaktion; `export` `findMany` UND `createMany` — berechnet die Zuordnung neu und speichert neue Konten in derselben Transaktion, in der die Datei entsteht). `updateAccount`/`deleteAccount` prüfen die Kennung zusätzlich mit `where: { id, tenantId }` und antworten mit 404 (zweites Netz, solange der RLS-Schalter aus ist). | ## Was diese Etappe NICHT entscheidet