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
@@ -0,0 +1,177 @@
import { Response } from 'express';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { ApiError } from '../utils/apiError.js';
import * as creditNoteService from '../services/creditNote.service.js';
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
// Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne
// Subventions-Belege). Portal-Kunden dürfen sie weder sehen noch ändern
// Defense-in-Depth: nicht nur die UI blendet aus, der Endpoint blockt
// Portal-Token explizit.
function blockPortal(req: AuthRequest, res: Response): boolean {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
return true;
}
return false;
}
function idParam(req: AuthRequest, res: Response, name: string): number | null {
const raw = req.params[name];
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed < 1) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
return parsed;
}
function handleError(res: Response, error: unknown, fallback: string) {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : fallback,
} as ApiResponse);
}
// ---- Gutschriften pro Vertrag ----
export async function listByContract(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const contractId = idParam(req, res, 'contractId');
if (contractId === null) return;
const data = await creditNoteService.getCreditNotesByContract(contractId);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Gutschriften');
}
}
// Vorbelegung fürs Formular: Default-Kundentyp (Firma/Privat aus Kunde) +
// Vorschau der nächsten Gutschrift-Nummer.
export async function formDefaults(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const contractId = idParam(req, res, 'contractId');
if (contractId === null) return;
const [customerType, nextNumber] = await Promise.all([
creditNoteService.getDefaultCustomerType(contractId),
numberRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { customerType, nextNumber } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Vorbelegung');
}
}
export async function create(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const contractId = idParam(req, res, 'contractId');
if (contractId === null) return;
const created = await creditNoteService.createCreditNote(contractId, req.body ?? {}, req.user?.email);
await logChange({
req,
action: 'CREATE',
resourceType: 'CreditNote',
resourceId: created.id.toString(),
label: `Gutschrift ${created.number} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`,
});
res.status(201).json({ success: true, data: created } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Anlegen der Gutschrift');
}
}
export async function getOne(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const data = await creditNoteService.getCreditNoteById(id);
if (!data) {
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' } as ApiResponse);
return;
}
res.json({ success: true, data } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Gutschrift');
}
}
export async function update(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const updated = await creditNoteService.updateCreditNote(id, req.body ?? {});
await logChange({
req,
action: 'UPDATE',
resourceType: 'CreditNote',
resourceId: updated.id.toString(),
label: `Gutschrift ${updated.number} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`,
});
res.json({ success: true, data: updated } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Ändern der Gutschrift');
}
}
export async function remove(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const deleted = await creditNoteService.deleteCreditNote(id);
await logChange({
req,
action: 'DELETE',
resourceType: 'CreditNote',
resourceId: id.toString(),
label: `Gutschrift ${deleted.number} gelöscht`,
});
res.json({ success: true } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Löschen der Gutschrift');
}
}
// ---- Nummernkreis (Einstellungen) ----
export async function getNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const [range, nextNumber] = await Promise.all([
numberRangeService.getOrCreateRange(),
numberRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden des Nummernkreises');
}
}
export async function updateNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const range = await numberRangeService.updateRange(req.body ?? {});
const preview = await numberRangeService.previewNextNumber();
await logChange({
req,
action: 'UPDATE',
resourceType: 'CreditNoteNumberRange',
resourceId: range.id.toString(),
label: `Gutschrift-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
});
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Speichern des Nummernkreises');
}
}