Files
opencrm/backend/src/controllers/creditNote.controller.ts
T
duffyduckandClaude Opus 4.8 8d93c1767b R145-Hygiene: limit-Floor + getContracts fail-closed (Konsistenz)
Pentester R145 (nice-to-have, keine Findings):
1) listAll: limit bekommt einen Floor (Math.max(1, ...)) - limit=-5
   ergab vorher take:-5 an Prisma. page ebenso auf >=1 geklemmt.
2) getContracts nutzte dasselbe 'if (isCustomerPortal && customerId)'-
   Muster und war NICHT fail-closed. Jetzt konsistent zu listAll:
   - Controller: Portal-Token immer gescoped (ohne customerId -> []).
   - Service getAllContracts: 'if (customerIds)' statt '.length > 0',
     damit ein leeres Array strikt auf IN () filtert (0 Treffer) statt
     durchzufallen. Einziger Caller ist der Contract-Controller ->
     keine Regression fuer den Normalfall.

Verifiziert: Staff -> alle; Portal customerIds=[] -> 0 Vertraege/Belege.

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

274 lines
11 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 { effectiveNumber } from '../services/creditNote.service.js';
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js';
import * as authorizationService from '../services/authorization.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);
}
// ---- Gesamtübersicht (Hauptmenü) ----
// Anders als die übrigen Credit-Note-Endpunkte NICHT staff-only: Portal-Kunden
// dürfen ihre eigenen (+ vertretene) Belege sehen. Scoping über customerIds
// aus dem JWT eine im Body/Query mitgeschickte customerId hat keinen Effekt.
export async function listAll(req: AuthRequest, res: Response): Promise<void> {
try {
// Portal-User: nur eigene + vertretene Kunden MIT Vollmacht.
// Fail-closed (Pentest R144): Ein Portal-Token wird IMMER gescoped. Fehlt
// wider Erwarten die customerId (null/0), ergibt das eine LEERE Menge
// nicht den Staff-Zweig (der alle Belege zeigen würde).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal) {
customerIds = req.user.customerId ? [req.user.customerId] : [];
if (req.user.customerId) {
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
for (const repCustId of representedIds) {
if (await authorizationService.hasAuthorization(repCustId, req.user.customerId)) {
customerIds.push(repCustId);
}
}
}
}
const page = Math.max(parseInt((req.query.page as string) || '1') || 1, 1);
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '50') || 50, 1), 200);
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await creditNoteService.getAllCreditNotes({ customerIds, page, limit, search });
// Portal-Kunden dürfen keine Überweisungsbelege laden → receiptPath aus der
// Response entfernen (Beleg-Download bleibt ohnehin staff-only).
const isPortal = !!req.user?.isCustomerPortal;
const items = isPortal
? result.items.map((cn) => ({ ...cn, receiptPath: null }))
: result.items;
res.json({ success: true, data: { items, pagination: result.pagination } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Belegübersicht');
}
}
// ---- 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: `Beleg ${effectiveNumber(created) ?? `#${created.id}`} 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: `Beleg ${effectiveNumber(updated) ?? `#${updated.id}`} 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: `Beleg ${effectiveNumber(deleted) ?? `#${deleted.id}`} 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');
}
}
// ---- Lieferschein-Nummernkreis (betragslose Sachwerte) ----
export async function getDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const [range, nextNumber] = await Promise.all([
deliveryRangeService.getOrCreateRange(),
deliveryRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden des Lieferschein-Nummernkreises');
}
}
export async function updateDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const range = await deliveryRangeService.updateRange(req.body ?? {});
const preview = await deliveryRangeService.previewNextNumber();
await logChange({
req,
action: 'UPDATE',
resourceType: 'DeliveryNoteNumberRange',
resourceId: range.id.toString(),
label: `Lieferschein-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 Lieferschein-Nummernkreises');
}
}