Pentester-Hinweis: generierte Gutschrift-PDFs blieben nach dem Loeschen der Gutschrift als verwaiste Files im Upload-Ordner liegen (harmlos, da ohne DB-Referenz nicht mehr abrufbar - aber unsauber). deleteCreditNote entfernt jetzt PDF (pdfPath) + Ueberweisungsbeleg (receiptPath) von der Platte. updateCreditNote loescht das alte PDF beim Leeren von pdfPath. Kein verwaister Ordner-Muell mehr. Verifiziert: PDF nach Erzeugung vorhanden, nach Loeschen der Gutschrift weg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
9.4 KiB
TypeScript
270 lines
9.4 KiB
TypeScript
// ==================== GUTSCHRIFTEN (CREDIT NOTES) ====================
|
|
// CRUD für Vertrags-Gutschriften (Subventionen: Geld/Sachwert) inkl.
|
|
// USt-Berechnung (pro Gutschrift wählbar: vatRelevant + Basis Netto/Brutto).
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import prisma from '../lib/prisma.js';
|
|
import { ApiError } from '../utils/apiError.js';
|
|
import { assignNextNumber } from './creditNoteNumberRange.service.js';
|
|
import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client';
|
|
|
|
// Löscht eine hochgeladene/erzeugte Datei von der Platte (best effort).
|
|
function deleteFileIfExists(filePath: string | null) {
|
|
if (!filePath) return;
|
|
const absolute = path.join(process.cwd(), filePath);
|
|
if (fs.existsSync(absolute)) {
|
|
try {
|
|
fs.unlinkSync(absolute);
|
|
} catch (error) {
|
|
console.error('Fehler beim Löschen der Gutschrift-Datei:', absolute, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
|
|
|
|
export interface AmountResult {
|
|
amountNet: number;
|
|
amountVat: number;
|
|
amountGross: number;
|
|
}
|
|
|
|
/**
|
|
* Rechnet aus dem eingegebenen Betrag Netto/USt/Brutto aus.
|
|
* - vatRelevant = false → keine USt: net = brutto = Betrag, USt = 0.
|
|
* - vatRelevant = true, Basis NETTO → USt aufschlagen.
|
|
* - vatRelevant = true, Basis BRUTTO → USt herausrechnen.
|
|
*/
|
|
export function computeAmounts(params: {
|
|
amount: number;
|
|
vatRelevant: boolean;
|
|
amountBasis: CreditNoteAmountBasis;
|
|
vatRate: number;
|
|
}): AmountResult {
|
|
const amount = round2(params.amount);
|
|
if (!params.vatRelevant || params.vatRate <= 0) {
|
|
return { amountNet: amount, amountVat: 0, amountGross: amount };
|
|
}
|
|
const rate = params.vatRate / 100;
|
|
if (params.amountBasis === 'NETTO') {
|
|
const net = amount;
|
|
const vat = round2(net * rate);
|
|
return { amountNet: net, amountVat: vat, amountGross: round2(net + vat) };
|
|
}
|
|
// BRUTTO
|
|
const gross = amount;
|
|
const net = round2(gross / (1 + rate));
|
|
return { amountNet: net, amountVat: round2(gross - net), amountGross: gross };
|
|
}
|
|
|
|
const ALLOWED_TYPES = new Set(['GELD', 'SACHWERT']);
|
|
const ALLOWED_CUSTOMER_TYPES = new Set(['PRIVAT', 'FIRMA']);
|
|
const ALLOWED_BASIS = new Set(['NETTO', 'BRUTTO']);
|
|
|
|
export interface CreateCreditNoteInput {
|
|
type: string;
|
|
sachwertDescription?: string | null;
|
|
customerType?: string;
|
|
vatRelevant?: boolean;
|
|
amountBasis?: string;
|
|
vatRate?: number;
|
|
amount: number; // eingegebener Betrag (Basis siehe amountBasis)
|
|
currency?: string;
|
|
creditDate: string;
|
|
place?: string | null;
|
|
signedAt?: string | null;
|
|
goodsReceived?: boolean;
|
|
payoutBankCardId?: number | null; // nur GELD: Auszahlungskonto des Kunden
|
|
notes?: string | null;
|
|
}
|
|
|
|
function validateAndNormalize(input: CreateCreditNoteInput) {
|
|
if (!ALLOWED_TYPES.has(input.type)) {
|
|
throw new ApiError(400, 'Ungültige Gutschrift-Art');
|
|
}
|
|
const type = input.type as CreditNoteType;
|
|
|
|
if (type === 'SACHWERT' && (!input.sachwertDescription || !input.sachwertDescription.trim())) {
|
|
throw new ApiError(400, 'Bei Sachwerten bitte beschreiben, was gewährt wird.');
|
|
}
|
|
|
|
const customerType = (input.customerType && ALLOWED_CUSTOMER_TYPES.has(input.customerType)
|
|
? input.customerType
|
|
: 'PRIVAT') as CreditNoteCustomerType;
|
|
|
|
const amountBasis = (input.amountBasis && ALLOWED_BASIS.has(input.amountBasis)
|
|
? input.amountBasis
|
|
: 'BRUTTO') as CreditNoteAmountBasis;
|
|
|
|
const amount = Number(input.amount);
|
|
if (!Number.isFinite(amount) || amount < 0) {
|
|
throw new ApiError(400, 'Ungültiger Betrag');
|
|
}
|
|
|
|
const vatRelevant = !!input.vatRelevant;
|
|
const vatRate = Number.isFinite(Number(input.vatRate)) ? Number(input.vatRate) : 19;
|
|
if (vatRate < 0 || vatRate > 100) {
|
|
throw new ApiError(400, 'Ungültiger USt-Satz');
|
|
}
|
|
|
|
const creditDate = new Date(input.creditDate);
|
|
if (isNaN(creditDate.getTime())) {
|
|
throw new ApiError(400, 'Ungültiges Datum');
|
|
}
|
|
|
|
const signedAt = input.signedAt ? new Date(input.signedAt) : null;
|
|
if (signedAt && isNaN(signedAt.getTime())) {
|
|
throw new ApiError(400, 'Ungültiges Unterschriftsdatum');
|
|
}
|
|
|
|
const amounts = computeAmounts({ amount, vatRelevant, amountBasis, vatRate });
|
|
|
|
// Auszahlungskonto nur bei GELD relevant; bei Sachwert immer leeren.
|
|
let payoutBankCardId: number | null = null;
|
|
if (type === 'GELD' && input.payoutBankCardId != null && input.payoutBankCardId !== ('' as unknown)) {
|
|
const parsed = Number(input.payoutBankCardId);
|
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
throw new ApiError(400, 'Ungültiges Auszahlungskonto');
|
|
}
|
|
payoutBankCardId = parsed;
|
|
}
|
|
|
|
return {
|
|
type,
|
|
sachwertDescription: type === 'SACHWERT' ? input.sachwertDescription!.trim() : null,
|
|
customerType,
|
|
vatRelevant,
|
|
amountBasis,
|
|
vatRate,
|
|
...amounts,
|
|
currency: (input.currency || 'EUR').slice(0, 3).toUpperCase(),
|
|
creditDate,
|
|
place: input.place?.trim() || null,
|
|
signedAt,
|
|
goodsReceived: !!input.goodsReceived,
|
|
payoutBankCardId,
|
|
notes: input.notes?.trim() || null,
|
|
};
|
|
}
|
|
|
|
// Stellt sicher, dass die gewählte Bankkarte dem Kunden des Vertrags gehört
|
|
// (kein Fremdkonto unterschieben).
|
|
async function assertBankCardBelongsToContract(contractId: number, bankCardId: number) {
|
|
const [contract, card] = await Promise.all([
|
|
prisma.contract.findUnique({ where: { id: contractId }, select: { customerId: true } }),
|
|
prisma.bankCard.findUnique({ where: { id: bankCardId }, select: { customerId: true } }),
|
|
]);
|
|
if (!card || !contract || card.customerId !== contract.customerId) {
|
|
throw new ApiError(400, 'Das gewählte Auszahlungskonto gehört nicht zum Kunden dieses Vertrags.');
|
|
}
|
|
}
|
|
|
|
export async function getCreditNotesByContract(contractId: number) {
|
|
return prisma.creditNote.findMany({
|
|
where: { contractId },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
export async function getCreditNoteById(id: number) {
|
|
return prisma.creditNote.findUnique({ where: { id } });
|
|
}
|
|
|
|
// Ermittelt die Formular-Vorbelegung aus dem Kunden:
|
|
// - customerType: Firma vs. Privat (aus Kunde.type)
|
|
// - vatRelevant : Default nur bei Firmenkunde OHNE USt-Befreiung
|
|
// (Kleinunternehmer §19 → wie Privat, keine USt-Vorbelegung).
|
|
// WICHTIG: Das ist nur die Vorbelegung. Jede angelegte Gutschrift speichert
|
|
// ihren eigenen Snapshot; ein späterer Statuswechsel des Kunden ändert
|
|
// bestehende Gutschriften nicht.
|
|
export async function getCreditNoteDefaults(contractId: number) {
|
|
const contract = await prisma.contract.findUnique({
|
|
where: { id: contractId },
|
|
select: {
|
|
bankCardId: true,
|
|
customer: {
|
|
select: {
|
|
type: true,
|
|
vatExempt: true,
|
|
bankCards: {
|
|
where: { isActive: true },
|
|
select: { id: true, iban: true, accountHolder: true, bankName: true, description: true },
|
|
orderBy: { createdAt: 'asc' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const isBusiness = contract?.customer?.type === 'BUSINESS';
|
|
const vatExempt = !!contract?.customer?.vatExempt;
|
|
return {
|
|
customerType: (isBusiness ? 'FIRMA' : 'PRIVAT') as CreditNoteCustomerType,
|
|
vatRelevant: isBusiness && !vatExempt,
|
|
// Bankkarten des Kunden für das Auszahlungskonto-Dropdown; die
|
|
// Vertrags-Abbuchkarte als Default-Vorschlag markiert.
|
|
bankCards: contract?.customer?.bankCards ?? [],
|
|
contractBankCardId: contract?.bankCardId ?? null,
|
|
};
|
|
}
|
|
|
|
export async function createCreditNote(
|
|
contractId: number,
|
|
input: CreateCreditNoteInput,
|
|
createdBy?: string,
|
|
) {
|
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { id: true } });
|
|
if (!contract) {
|
|
throw new ApiError(404, 'Vertrag nicht gefunden');
|
|
}
|
|
|
|
const normalized = validateAndNormalize(input);
|
|
if (normalized.payoutBankCardId) {
|
|
await assertBankCardBelongsToContract(contractId, normalized.payoutBankCardId);
|
|
}
|
|
const number = await assignNextNumber();
|
|
|
|
return prisma.creditNote.create({
|
|
data: {
|
|
contractId,
|
|
number,
|
|
...normalized,
|
|
createdBy,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updateCreditNote(id: number, input: CreateCreditNoteInput) {
|
|
const existing = await prisma.creditNote.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
throw new ApiError(404, 'Gutschrift nicht gefunden');
|
|
}
|
|
const normalized = validateAndNormalize(input);
|
|
if (normalized.payoutBankCardId) {
|
|
await assertBankCardBelongsToContract(existing.contractId, normalized.payoutBankCardId);
|
|
}
|
|
// Nummer bleibt unverändert (einmal vergeben = fix). Ein evtl. schon
|
|
// erzeugtes PDF ist nach inhaltlicher Änderung veraltet → Pfad leeren
|
|
// (der User erzeugt es bei Bedarf neu) und die alte Datei entfernen,
|
|
// damit sie nicht verwaist liegen bleibt.
|
|
if (existing.pdfPath) deleteFileIfExists(existing.pdfPath);
|
|
return prisma.creditNote.update({ where: { id }, data: { ...normalized, pdfPath: null } });
|
|
}
|
|
|
|
export async function deleteCreditNote(id: number) {
|
|
const existing = await prisma.creditNote.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
throw new ApiError(404, 'Gutschrift nicht gefunden');
|
|
}
|
|
// Verwaiste Dateien vermeiden: generiertes PDF + Überweisungsbeleg von der
|
|
// Platte entfernen (Pentest R138-Hinweis).
|
|
deleteFileIfExists(existing.pdfPath);
|
|
deleteFileIfExists(existing.receiptPath);
|
|
return prisma.creditNote.delete({ where: { id } });
|
|
}
|
|
|
|
// Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs.
|
|
export async function setReceiptPath(id: number, receiptPath: string | null) {
|
|
return prisma.creditNote.update({ where: { id }, data: { receiptPath } });
|
|
}
|