diff --git a/backend/prisma/migrations/20260806100000_credit_notes/migration.sql b/backend/prisma/migrations/20260806100000_credit_notes/migration.sql new file mode 100644 index 00000000..f512ba8b --- /dev/null +++ b/backend/prisma/migrations/20260806100000_credit_notes/migration.sql @@ -0,0 +1,49 @@ +-- Gutschriften (Subventionen) zu einem Vertrag: Geldbetrag (Überweisung) +-- oder Sachwert. USt pro Gutschrift wählbar (vatRelevant + Basis Netto/Brutto). +-- ZUGFeRD-taugliches Datenmodell (Netto/USt/Brutto getrennt). +-- ON DELETE CASCADE: wird der Vertrag gelöscht, verschwinden die Gutschriften. + +CREATE TABLE IF NOT EXISTS `CreditNote` ( + `id` INT NOT NULL AUTO_INCREMENT, + `contractId` INT NOT NULL, + `number` VARCHAR(191) NOT NULL, + `type` ENUM('GELD', 'SACHWERT') NOT NULL, + `sachwertDescription` VARCHAR(191) NULL, + `customerType` ENUM('PRIVAT', 'FIRMA') NOT NULL, + `vatRelevant` BOOLEAN NOT NULL DEFAULT false, + `amountBasis` ENUM('NETTO', 'BRUTTO') NOT NULL DEFAULT 'BRUTTO', + `vatRate` DOUBLE NOT NULL DEFAULT 19, + `amountNet` DOUBLE NOT NULL DEFAULT 0, + `amountVat` DOUBLE NOT NULL DEFAULT 0, + `amountGross` DOUBLE NOT NULL DEFAULT 0, + `currency` VARCHAR(191) NOT NULL DEFAULT 'EUR', + `creditDate` DATETIME(3) NOT NULL, + `place` VARCHAR(191) NULL, + `signedAt` DATETIME(3) NULL, + `goodsReceived` BOOLEAN NOT NULL DEFAULT false, + `receiptPath` VARCHAR(191) NULL, + `pdfPath` VARCHAR(191) NULL, + `notes` TEXT NULL, + `createdBy` VARCHAR(191) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `CreditNote_number_key` (`number`), + INDEX `CreditNote_contractId_idx` (`contractId`), + CONSTRAINT `CreditNote_contractId_fkey` FOREIGN KEY (`contractId`) + REFERENCES `Contract` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Nummernkreis (Einzel-Zeile, in den Einstellungen verwaltbar). +CREATE TABLE IF NOT EXISTS `CreditNoteNumberRange` ( + `id` INT NOT NULL AUTO_INCREMENT, + `prefix` VARCHAR(191) NOT NULL DEFAULT 'GS-', + `includeYear` BOOLEAN NOT NULL DEFAULT true, + `separator` VARCHAR(191) NOT NULL DEFAULT '-', + `padding` INT NOT NULL DEFAULT 4, + `nextNumber` INT NOT NULL DEFAULT 1, + `resetYearly` BOOLEAN NOT NULL DEFAULT true, + `currentYear` INT NULL, + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 007244dd..ce388e37 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -811,6 +811,7 @@ model Contract { historyEntries ContractHistoryEntry[] documents ContractDocument[] invoices Invoice[] + creditNotes CreditNote[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -946,6 +947,89 @@ model Invoice { @@index([contractId]) } +// ==================== GUTSCHRIFTEN (CREDIT NOTES / SUBVENTIONEN) ==================== + +// Art der Gutschrift: Geldbetrag (per Überweisung, in EUR) oder Sachwert +// (Smartphone, Elektrogerät …, näher im Textfeld beschrieben). +enum CreditNoteType { + GELD + SACHWERT +} + +// Kundentyp zum Zeitpunkt der Gutschrift – bestimmt die USt-Sicht +// (Privat rechnet brutto, Firma zieht Vorsteuer). Wird aus dem Kunden +// vorbelegt, ist aber pro Gutschrift überschreibbar. +enum CreditNoteCustomerType { + PRIVAT + FIRMA +} + +// Basis, auf die sich der eingegebene Betrag bezieht (nur relevant, wenn +// vatRelevant = true). Bei NETTO wird die USt aufgeschlagen, bei BRUTTO +// herausgerechnet. +enum CreditNoteAmountBasis { + NETTO + BRUTTO +} + +model CreditNote { + id Int @id @default(autoincrement()) + contractId Int + contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade) + + number String @unique // aus Nummernkreis, z.B. GS-2026-0001 + + type CreditNoteType + // Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55"). + sachwertDescription String? + + customerType CreditNoteCustomerType + // USt-relevant ja/nein (Steuerberater-Auslegung: Vermittlungsleistung vs. + // reiner Abschlussbonus/Entgeltminderung). Bei false: net = brutto, USt = 0. + vatRelevant Boolean @default(false) + amountBasis CreditNoteAmountBasis @default(BRUTTO) + vatRate Float @default(19) // Prozent + amountNet Float @default(0) + amountVat Float @default(0) + amountGross Float @default(0) + currency String @default("EUR") + + // Datum der Gutschrift (frei änderbar). + creditDate DateTime + // Ort der Unterschrift + Unterschrift-/Bestätigungszeitpunkt. + place String? + signedAt DateTime? + // Sachwert: Bestätigung "Ware erhalten". + goodsReceived Boolean @default(false) + + // Überweisungsbeleg (nur GELD) – hochgeladene Datei. + receiptPath String? + // Generiertes Gutschrift-Dokument (PDF bzw. ZUGFeRD-Hybrid) – Phase 3. + pdfPath String? + + notes String? + createdBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([contractId]) +} + +// Nummernkreis für Gutschriften (Einzel-Zeile, in den Einstellungen +// verwaltbar). Nummer wird transaktional vergeben, damit keine Lücken/ +// Doppelvergaben entstehen. +model CreditNoteNumberRange { + id Int @id @default(autoincrement()) + prefix String @default("GS-") + includeYear Boolean @default(true) + separator String @default("-") + padding Int @default(4) + nextNumber Int @default(1) + resetYearly Boolean @default(true) + currentYear Int? + updatedAt DateTime @updatedAt +} + // ==================== INTERNET CONTRACT DETAILS ==================== model InternetContractDetails { diff --git a/backend/src/controllers/creditNote.controller.ts b/backend/src/controllers/creditNote.controller.ts new file mode 100644 index 00000000..a3bfad68 --- /dev/null +++ b/backend/src/controllers/creditNote.controller.ts @@ -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 { + 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 [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 { + 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 { + 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: `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 { + 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 { + 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'); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 97f2b906..a86e727b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,6 +38,7 @@ import documentRoutes from './routes/document.routes.js'; import meterRoutes from './routes/meter.routes.js'; import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js'; import contractRoutes from './routes/contract.routes.js'; +import creditNoteRoutes from './routes/creditNote.routes.js'; import platformRoutes from './routes/platform.routes.js'; import cancellationPeriodRoutes from './routes/cancellation-period.routes.js'; import contractDurationRoutes from './routes/contract-duration.routes.js'; @@ -355,6 +356,7 @@ app.use('/api/documents', documentRoutes); app.use('/api/meters', meterRoutes); app.use('/api/stressfrei-emails', stressfreiEmailRoutes); app.use('/api/contracts', contractRoutes); +app.use('/api/credit-notes', creditNoteRoutes); app.use('/api/platforms', platformRoutes); app.use('/api/cancellation-periods', cancellationPeriodRoutes); app.use('/api/contract-durations', contractDurationRoutes); diff --git a/backend/src/routes/contract.routes.ts b/backend/src/routes/contract.routes.ts index 32cc29f3..e88a255a 100644 --- a/backend/src/routes/contract.routes.ts +++ b/backend/src/routes/contract.routes.ts @@ -4,6 +4,7 @@ import path from 'path'; import fs from 'fs'; import * as contractController from '../controllers/contract.controller.js'; import * as invoiceController from '../controllers/invoice.controller.js'; +import * as creditNoteController from '../controllers/creditNote.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js'; @@ -57,6 +58,12 @@ router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), router.get('/:id/invoices', authenticate, requirePermission('contracts:read'), invoiceController.getInvoicesByContract); router.post('/:id/invoices', authenticate, requirePermission('contracts:update'), invoiceController.addInvoiceByContract); +// Gutschriften (Subventionen) am Vertrag – Mitarbeiter/Admin (Controller +// blockt Portal-Token zusätzlich explizit). +router.get('/:contractId/credit-notes', authenticate, requirePermission('contracts:read'), creditNoteController.listByContract); +router.get('/:contractId/credit-notes/defaults', authenticate, requirePermission('contracts:read'), creditNoteController.formDefaults); +router.post('/:contractId/credit-notes', authenticate, requirePermission('contracts:update'), creditNoteController.create); + // Vertragsdokumente router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments); router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), validateUploadedFile, contractController.uploadContractDocument); diff --git a/backend/src/routes/creditNote.routes.ts b/backend/src/routes/creditNote.routes.ts new file mode 100644 index 00000000..9fee4b18 --- /dev/null +++ b/backend/src/routes/creditNote.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import * as creditNoteController from '../controllers/creditNote.controller.js'; +import { authenticate, requirePermission } from '../middleware/auth.js'; + +const router = Router(); + +// Nummernkreis-Verwaltung (Einstellungen). VOR /:id, damit "number-range" +// nicht als ID interpretiert wird. +router.get('/number-range', authenticate, requirePermission('settings:read'), creditNoteController.getNumberRange); +router.put('/number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateNumberRange); + +// Einzelne Gutschrift +router.get('/:id', authenticate, requirePermission('contracts:read'), creditNoteController.getOne); +router.put('/:id', authenticate, requirePermission('contracts:update'), creditNoteController.update); +router.delete('/:id', authenticate, requirePermission('contracts:delete'), creditNoteController.remove); + +export default router; diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts new file mode 100644 index 00000000..6bc0c144 --- /dev/null +++ b/backend/src/services/creditNote.service.ts @@ -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 { + 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 } }); +} diff --git a/backend/src/services/creditNoteNumberRange.service.ts b/backend/src/services/creditNoteNumberRange.service.ts new file mode 100644 index 00000000..48d251ef --- /dev/null +++ b/backend/src/services/creditNoteNumberRange.service.ts @@ -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) { + 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 { + 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 { + // Sicherstellen, dass eine Zeile existiert (außerhalb der Lock-Transaktion). + await getOrCreateRange(); + + return prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw>(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; + }); +} diff --git a/docs/todo.md b/docs/todo.md index 2ad6be79..a08e255e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06) + - Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld** + (Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld). + Betrag, Datum (änderbar), Ort/Unterschrift, bei Sachwert „Ware erhalten", + bei Geld Überweisungsbeleg (Upload, Phase 2). + - **USt pro Gutschrift wählbar** (`vatRelevant` + Basis NETTO/BRUTTO + + `vatRate`) → Netto/USt/Brutto werden berechnet & getrennt gespeichert + (ZUGFeRD-tauglich). `PRIVAT`/`FIRMA` aus Kunde vorbelegt. Verifiziert: + nicht-USt 200→200/0/200; NETTO 200→200/38/238; BRUTTO 200→168,07/31,93/200. + - **Nummernkreis** (`CreditNoteNumberRange`, Einzel-Zeile, in Settings + verwaltbar): Präfix/Jahr/Separator/Padding/Startzähler/Jahres-Reset. + Nummernvergabe transaktional mit `SELECT … FOR UPDATE` (keine Doppel- + vergabe). Beispiel `GS-2026-0001`. + - Neu: Model `CreditNote` + `CreditNoteNumberRange` + Enums + Migration + (IF NOT EXISTS, auf Dev angewandt). 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 explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE + auditiert. + - **Offen:** Phase 2 (Vertrag-UI + Beleg-Upload + Nummernkreis-UI), + Phase 3 (PDF + ZUGFeRD/PDF-A3, muss gegen ZUGFeRD-Validator geprüft + werden). USt-Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater + klären – Modell deckt beide über `vatRelevant` ab. + - [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03) - „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das Neu-Formular unter `/contracts/new?copyFrom=&customerId=` mit