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) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:38:22 +02:00
parent 1f85277a3b
commit 44c1d4351f
21 changed files with 2186 additions and 1 deletions
@@ -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());
+33
View File
@@ -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
@@ -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');
});
});
@@ -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;
}
+2
View File
@@ -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,
],
@@ -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;
}
@@ -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;
}
@@ -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,
);
});
});
@@ -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);
}
}
@@ -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<void> {
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);
}
}
}
@@ -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<void> {
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,
});
}
@@ -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']);
});
});
@@ -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<HandelswareSettingsResponse> {
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<HandelswareSettingsResponse> {
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<AccountResponse[]> {
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<AccountResponse> {
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<AccountResponse> {
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<FileResponse> {
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<PreviewResult> {
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<FileResponse & { createdCount: number }> {
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;
}
}
}
@@ -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;
}
@@ -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('');
});
});
@@ -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<string>();
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' : ''}`;
}
@@ -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);
});
});
@@ -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<string, AccountEntry>();
for (const account of accounts) known.set(account.name, account);
const created = new Map<string, NewAccount>();
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';
}
@@ -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');
}
});
});
@@ -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 };
}