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,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']);
});
});