// ==================== 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 { assignNextNumber as assignNextDeliveryNoteNumber } from './deliveryNoteNumberRange.service.js'; import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; import { Prisma, 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, kein ZUGFeRD; eigene Lieferscheinnummer statt Gutschriftsnummer). export function isNonMonetary(n: { type: CreditNoteType; amountGross: number }): boolean { return n.type === 'SACHWERT' && n.amountGross === 0; } // Die je nach aktuellem Typ „gültige" Belegnummer: Lieferscheinnummer bei // betragslosem Sachwert, sonst Gutschriftsnummer. export function effectiveNumber(cn: { type: CreditNoteType; amountGross: number; number: string | null; deliveryNoteNumber: string | null; }): string | null { return isNonMonetary(cn) ? cn.deliveryNoteNumber : cn.number; } 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' }, }); } // Gesamtübersicht aller Belege (Gutschriften + Lieferscheine). `customerIds` // scoped die Liste (Portal: eigene + vertretene Kunden); ohne = alle (Staff). export async function getAllCreditNotes(opts: { customerIds?: number[]; page?: number; limit?: number; search?: string; }) { const { customerIds, page = 1, limit = 50, search } = opts; const skip = (Math.max(page, 1) - 1) * limit; const where: Prisma.CreditNoteWhereInput = {}; if (customerIds) { where.contract = { customerId: { in: customerIds } }; } if (search && search.trim()) { const s = search.trim(); where.OR = [ { number: { contains: s } }, { deliveryNoteNumber: { contains: s } }, { sachwertDescription: { contains: s } }, { contract: { contractNumber: { contains: s } } }, { contract: { customer: { customerNumber: { contains: s } } } }, { contract: { customer: { lastName: { contains: s } } } }, { contract: { customer: { companyName: { contains: s } } } }, ]; } const [items, total] = await Promise.all([ prisma.creditNote.findMany({ where, include: { contract: { select: { id: true, contractNumber: true, type: true, customer: { select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true }, }, }, }, }, orderBy: { createdAt: 'desc' }, skip, take: limit, }), prisma.creditNote.count({ where }), ]); return { items, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }; } 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, addressId: true, billingAddressId: 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, // Für die Empfängeradresse auf dem Beleg: Rechnungsadresse hat Vorrang, // sonst Lieferadresse. Ohne beide kann kein Beleg erstellt werden. hasRecipientAddress: !!(contract?.billingAddressId || contract?.addressId), }; } export async function createCreditNote( contractId: number, input: CreateCreditNoteInput, createdBy?: string, ) { const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { id: true, addressId: true, billingAddressId: true }, }); if (!contract) { throw new ApiError(404, 'Vertrag nicht gefunden'); } // Empfängeradresse für den Beleg: Rechnungsadresse bevorzugt, sonst // Lieferadresse. Ohne beide kann kein Beleg erzeugt werden. if (!contract.billingAddressId && !contract.addressId) { throw new ApiError( 400, 'Keine Rechnungs- oder Lieferadresse am Vertrag hinterlegt. Bitte zuerst eine Adresse zuordnen.', ); } const normalized = validateAndNormalize(input); if (normalized.payoutBankCardId) { await assertBankCardBelongsToContract(contractId, normalized.payoutBankCardId); } // Betragsloser Sachwert = Lieferschein → eigene Lieferscheinnummer aus dem // separaten Kreis; der Gutschrift-Zähler bleibt unangetastet. Sonst echte // Gutschrift → Gutschriftsnummer. const nonMonetary = isNonMonetary(normalized); const number = nonMonetary ? null : await assignNextNumber(); const deliveryNoteNumber = nonMonetary ? await assignNextDeliveryNoteNumber() : null; return prisma.creditNote.create({ data: { contractId, number, deliveryNoteNumber, ...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); } // Nummern lazy pro Serie vergeben und NIE wieder freigeben (keine Lücken, // GoBD): je nach aktuellem Typ bekommt der Beleg bei Bedarf die fehlende // Nummer der passenden Serie; eine bereits vergebene Nummer der anderen // Serie bleibt am Beleg reserviert (nicht angezeigt), damit ein späteres // Zurückwechseln keine neue Nummer verbraucht. const nonMonetary = isNonMonetary(normalized); let number = existing.number; let deliveryNoteNumber = existing.deliveryNoteNumber; if (nonMonetary) { if (deliveryNoteNumber === null) deliveryNoteNumber = await assignNextDeliveryNoteNumber(); } 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, deliveryNoteNumber, 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 } }); }