Gutschriften Phase 1: Backend (Modell, Nummernkreis, CRUD, USt-Rechnung)
Neues Feature Gutschriftsverwaltung (Subventionen am Vertrag): - Modelle CreditNote + CreditNoteNumberRange + Enums (GELD/SACHWERT, PRIVAT/FIRMA, NETTO/BRUTTO) + Migration (IF NOT EXISTS, auf Dev angewandt). - USt pro Gutschrift waehlbar (vatRelevant + Basis Netto/Brutto + Satz); Netto/USt/Brutto werden berechnet und getrennt gespeichert (ZUGFeRD-tauglich). Kundentyp Privat/Firma aus Kunde vorbelegt. - Nummernkreis in Settings verwaltbar; Nummernvergabe transaktional mit SELECT ... FOR UPDATE (keine Doppelvergabe). Bsp GS-2026-0001. - Service/Controller/Routes: GET/POST /contracts/:id/credit-notes, GET .../defaults, GET/PUT/DELETE /credit-notes/:id, GET/PUT /credit-notes/number-range. Portal-Token geblockt (interner Bereich), CREATE/UPDATE/DELETE auditiert. Verifiziert: USt-Rechnung (200 netto->238, 200 brutto->168,07+31,93) und fortlaufende Nummernvergabe. Phase 2 (Vertrag-UI + Beleg-Upload + Nummernkreis-UI) und Phase 3 (PDF + ZUGFeRD) folgen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// ==================== 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 { 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 };
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
const amount = Number(input.amount);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
throw new ApiError(400, 'Ungültiger Betrag');
|
||||
}
|
||||
|
||||
const vatRelevant = !!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 });
|
||||
|
||||
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,
|
||||
notes: input.notes?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
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 den Default-Kundentyp aus dem Vertrag (Firma vs. Privat).
|
||||
export async function getDefaultCustomerType(contractId: number): Promise<CreditNoteCustomerType> {
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
select: { customer: { select: { type: true } } },
|
||||
});
|
||||
return contract?.customer?.type === 'BUSINESS' ? 'FIRMA' : 'PRIVAT';
|
||||
}
|
||||
|
||||
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);
|
||||
const number = 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);
|
||||
// Nummer bleibt unverändert (einmal vergeben = fix).
|
||||
return prisma.creditNote.update({ where: { id }, data: normalized });
|
||||
}
|
||||
|
||||
export async function deleteCreditNote(id: number) {
|
||||
const existing = await prisma.creditNote.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new ApiError(404, 'Gutschrift nicht gefunden');
|
||||
}
|
||||
return prisma.creditNote.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// Setzt/aktualisiert den Pfad des hochgeladenen Überweisungsbelegs.
|
||||
export async function setReceiptPath(id: number, receiptPath: string | null) {
|
||||
return prisma.creditNote.update({ where: { id }, data: { receiptPath } });
|
||||
}
|
||||
Reference in New Issue
Block a user