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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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'); } }