Files
opencrm/backend/src/services/creditNote.service.ts
T
duffyduckandClaude Opus 4.8 48e65be91c Hauptmenue: Gutschriften/Lieferscheine-Gesamtuebersicht (portal-scoped)
Neuer Menuepunkt 'Gutschriften' -> Seite /credit-notes mit Tabelle
aller Belege (Beleg-Nr, Art, Kunde, Vertrag, Betrag, Datum, PDF),
Suche + Pagination.

Neuer Endpoint GET /credit-notes (NICHT staff-only wie die uebrigen
Credit-Note-Endpoints): Staff sieht alle Belege aller Kunden, Portal-
Kunden nur eigene + vertretene (Vollmacht via hasAuthorization).
customerIds kommt aus dem JWT, nicht aus Query/Body -> nicht
manipulierbar. Fuer Portal wird receiptPath aus der Response entfernt
(Belege bleiben staff-only). Route requirePermission contracts:read.

Verifiziert: Staff -> alle Belege; Portal-scoped -> nur eigene, korrekt
zugeordnet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:23:53 +02:00

375 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==================== 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 } });
}