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:
2026-08-06 09:03:42 +02:00
co-authored by Claude Opus 4.8
parent 3bbe262039
commit f1c37a7d25
9 changed files with 657 additions and 0 deletions
+188
View File
@@ -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 } });
}
@@ -0,0 +1,109 @@
// ==================== GUTSCHRIFT-NUMMERNKREIS ====================
// Verwaltet die (einzeilige) Nummernkreis-Konfiguration und vergibt fortlaufende
// Gutschrift-Nummern transaktional (Row-Lock via SELECT ... FOR UPDATE), damit
// keine Doppelvergaben/Lücken bei gleichzeitigem Anlegen entstehen.
import prisma from '../lib/prisma.js';
import { Prisma } from '@prisma/client';
export interface NumberRangeConfig {
prefix: string;
includeYear: boolean;
separator: string;
padding: number;
nextNumber: number;
resetYearly: boolean;
currentYear: number | null;
}
// Liefert die Konfiguration; legt bei Bedarf eine Default-Zeile an.
export async function getOrCreateRange() {
const existing = await prisma.creditNoteNumberRange.findFirst();
if (existing) return existing;
return prisma.creditNoteNumberRange.create({ data: {} });
}
// Speichert die vom Admin editierbaren Felder. `nextNumber` ist bewusst
// mit dabei (manuelles Setzen des nächsten Zählers erlaubt), aber validiert.
export async function updateRange(input: Partial<NumberRangeConfig>) {
const range = await getOrCreateRange();
const data: Prisma.CreditNoteNumberRangeUpdateInput = {};
if (typeof input.prefix === 'string') data.prefix = input.prefix.slice(0, 40);
if (typeof input.includeYear === 'boolean') data.includeYear = input.includeYear;
if (typeof input.separator === 'string') data.separator = input.separator.slice(0, 5);
if (typeof input.padding === 'number' && Number.isInteger(input.padding)) {
data.padding = Math.min(Math.max(input.padding, 1), 10);
}
if (typeof input.resetYearly === 'boolean') data.resetYearly = input.resetYearly;
if (typeof input.nextNumber === 'number' && Number.isInteger(input.nextNumber) && input.nextNumber >= 1) {
data.nextNumber = input.nextNumber;
}
return prisma.creditNoteNumberRange.update({ where: { id: range.id }, data });
}
// Baut die Nummer aus Konfig + Zähler + (optional) Jahr.
function formatNumber(cfg: {
prefix: string;
includeYear: boolean;
separator: string;
padding: number;
}, value: number, year: number): string {
const padded = String(value).padStart(cfg.padding, '0');
const yearPart = cfg.includeYear ? `${year}${cfg.separator}` : '';
return `${cfg.prefix}${yearPart}${padded}`;
}
// Nur-Vorschau der NÄCHSTEN Nummer, ohne den Zähler zu verbrauchen.
export async function previewNextNumber(): Promise<string> {
const range = await getOrCreateRange();
const year = new Date().getFullYear();
const value = range.resetYearly && range.currentYear !== year ? 1 : range.nextNumber;
return formatNumber(range, value, year);
}
// Vergibt die nächste Nummer und erhöht den Zähler transaktional mit
// Row-Lock, damit parallele Anlagen sich nicht dieselbe Nummer greifen.
export async function assignNextNumber(): Promise<string> {
// Sicherstellen, dass eine Zeile existiert (außerhalb der Lock-Transaktion).
await getOrCreateRange();
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{
id: number;
prefix: string;
includeYear: boolean | number;
separator: string;
padding: number;
nextNumber: number;
resetYearly: boolean | number;
currentYear: number | null;
}>>(Prisma.sql`SELECT * FROM CreditNoteNumberRange ORDER BY id ASC LIMIT 1 FOR UPDATE`);
const row = rows[0];
const year = new Date().getFullYear();
const resetYearly = !!row.resetYearly;
// Jahreswechsel: Zähler zurücksetzen, wenn so konfiguriert.
const value = resetYearly && row.currentYear !== year ? 1 : row.nextNumber;
const number = formatNumber(
{
prefix: row.prefix,
includeYear: !!row.includeYear,
separator: row.separator,
padding: row.padding,
},
value,
year,
);
await tx.creditNoteNumberRange.update({
where: { id: row.id },
data: { nextNumber: value + 1, currentYear: year },
});
return number;
});
}