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:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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<CreditNoteCustomerType> {
|
||||
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 } });
|
||||
}
|
||||
@@ -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<NumberRangeConfig>) {
|
||||
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<string> {
|
||||
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<string> {
|
||||
// Sicherstellen, dass eine Zeile existiert (außerhalb der Lock-Transaktion).
|
||||
await getOrCreateRange();
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<Array<{
|
||||
id: number;
|
||||
prefix: string;
|
||||
includeYear: boolean | number;
|
||||
separator: string;
|
||||
padding: number;
|
||||
nextNumber: number;
|
||||
resetYearly: boolean | number;
|
||||
currentYear: number | null;
|
||||
}>>(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;
|
||||
});
|
||||
}
|
||||
@@ -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=<id>&customerId=<cid>` mit
|
||||
|
||||
Reference in New Issue
Block a user