Files
opencrm/backend/src/services/creditNote.service.ts
T
duffyduckandClaude Opus 4.8 15ac003dad Gutschrift: betragsloser Sachwert bekommt keine Gutschriftsnummer
Ein betragsloser Sachwert ist eher ein Lieferschein als eine Gutschrift
-> er soll KEINE Gutschriftsnummer aus dem Nummernkreis verbrauchen.

- Schema: CreditNote.number nullbar (Migration MODIFY ... NULL, UNIQUE
  bleibt - MySQL erlaubt mehrere NULLs).
- createCreditNote: betragsloser Sachwert -> number=null, assignNextNumber
  wird NICHT aufgerufen (Zaehler unangetastet).
- updateCreditNote: Uebergaenge - wird betragslos -> Nummer entfernen;
  bekommt nachtraeglich Betrag & hatte keine -> jetzt Nummer vergeben.
- PDF/Liste/Modal/Audit: Fallback 'Sachwert-Uebergabe'/'Beleg #id' wenn
  keine Nummer; PDF-Titel 'Sachwert-Uebergabe', kein ZUGFeRD (schon vorher).

Verifiziert: Sachwert 0 -> number null + Zaehler bleibt; Geld -> Nummer
+ Zaehler +1; Sachwert nachtraeglich mit Betrag -> Nummer vergeben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 17:40:15 +02:00

287 lines
10 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 { 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 } });
}