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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user