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>
375 lines
14 KiB
TypeScript
375 lines
14 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 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 } });
|
||
}
|