// ==================== GUTSCHRIFTEN (CREDIT NOTES) ==================== // CRUD für Vertrags-Gutschriften (Subventionen: Geld/Sachwert) inkl. // USt-Berechnung (pro Gutschrift wählbar: vatRelevant + Basis Netto/Brutto). import prisma from '../lib/prisma.js'; import { ApiError } from '../utils/apiError.js'; import { assignNextNumber } from './creditNoteNumberRange.service.js'; import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client'; 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 }; } // Betragsloser Sachwert = reine Übergabe/Lieferschein (keine Rechnung, // keine USt, keine Gutschriftsnummer, kein ZUGFeRD). function isNonMonetary(n: { type: CreditNoteType; amountGross: number }): boolean { return n.type === 'SACHWERT' && n.amountGross === 0; } 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; // Leeres/fehlendes Betragsfeld = 0. Sachwerte dürfen betragslos sein // (reine Übergabe des Gegenstands als Subvention → keine Rechnung, s.u.); // Geld-Gutschriften brauchen dagegen einen echten Betrag. const amount = input.amount === undefined || input.amount === null || (input.amount as unknown) === '' ? 0 : Number(input.amount); if (!Number.isFinite(amount) || amount < 0) { throw new ApiError(400, 'Ungültiger Betrag'); } if (type === 'GELD' && amount <= 0) { throw new ApiError(400, 'Bei Geld-Gutschriften bitte einen Betrag größer 0 angeben.'); } // Ohne Betrag gibt es keine USt (nicht-monetärer Sachwert). const vatRelevant = amount > 0 && !!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); } // Betragsloser Sachwert = Lieferschein → KEINE Gutschriftsnummer vergeben // (Nummernkreis-Zähler bleibt unangetastet). Sonst nächste Nummer ziehen. const number = isNonMonetary(normalized) ? null : 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 an den (neuen) monetären Status anpassen: // - wird betragsloser Sachwert (Lieferschein) → Nummer entfernen, // - wird eine echte Gutschrift und hatte noch keine Nummer → jetzt vergeben, // - sonst bestehende Nummer beibehalten (einmal vergeben = fix). let number = existing.number; if (isNonMonetary(normalized)) { number = null; } else if (number === null) { number = await assignNextNumber(); } // Ein evtl. schon erzeugtes PDF ist nach inhaltlicher Änderung veraltet → // Pfad leeren. Reihenfolge (R140): erst DB-Update, DANN die alte Datei // löschen – schlägt das Update fehl, bleibt die Datei. const updated = await prisma.creditNote.update({ where: { id }, data: { ...normalized, number, pdfPath: null } }); deleteUploadByRelativePath(existing.pdfPath); return updated; } export async function deleteCreditNote(id: number) { const existing = await prisma.creditNote.findUnique({ where: { id } }); if (!existing) { throw new ApiError(404, 'Gutschrift nicht gefunden'); } // Reihenfolge (R140): erst den DB-Datensatz löschen, DANN die Dateien – // schlägt das DB-Delete fehl, bleiben PDF + Beleg erhalten (kein // ins-Leere-zeigender Eintrag). const deleted = await prisma.creditNote.delete({ where: { id } }); deleteUploadByRelativePath(existing.pdfPath); deleteUploadByRelativePath(existing.receiptPath); return deleted; } // Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs. export async function setReceiptPath(id: number, receiptPath: string | null) { return prisma.creditNote.update({ where: { id }, data: { receiptPath } }); }