Files
opencrm/backend/src/controllers/creditNote.controller.ts
T
duffyduckandClaude Opus 4.8 642fb05c76 Gutschriften Phase 3b/1: Gutschrift-PDF (pdfkit)
PDF-Erzeugung fuer Gutschriften mit Firmenstammdaten als Absender,
Empfaenger (Kunde+Adresse), Netto/USt/Brutto (bzw. ohne USt-Ausweis),
Sachwert/'Ware erhalten' bzw. Ueberweisungs-Bankverbindung,
Unterschriftsblock, Firmen-Fusszeile.

Endpoint POST /credit-notes/:id/pdf; 'PDF'-Button in CreditNotesSection
(erzeugen + im Tab oeffnen bzw. vorhandenes ansehen). PDF-Pfad wird bei
inhaltlicher Aenderung geleert -> Neu-Erzeugung. Download ueber
fileDownload (credit-notes subDir, Vertrags-Ownership).

Lokal verifiziert (valides %PDF, korrekte Betraege).

ZUGFeRD-XML-Embedding (PDF/A-3, EN 16931) folgt als Teil 2 mit
Validator-Pruefung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:32:34 +02:00

196 lines
7.2 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.
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';
import { generateCreditNotePdf } from '../services/creditNotePdf.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 [defaults, nextNumber] = await Promise.all([
creditNoteService.getCreditNoteDefaults(contractId),
numberRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { ...defaults, 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');
}
}
// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2).
export async function generatePdf(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const { pdfPath } = await generateCreditNotePdf(id);
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Gutschrift-PDF erzeugt',
});
res.json({ success: true, data: { pdfPath } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Erzeugen des PDF');
}
}
// ---- 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');
}
}