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