From 20d42c5270908fe0179821620f72ca0288e8b192 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 24 May 2026 14:27:54 +0200 Subject: [PATCH] Energie-Bonus aufgeteilt in Sofort + Neukunden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnergyContractDetails.bonus war ein einzelnes Feld. Strom-/Gas- Verträge haben aber typischerweise zwei Boni (Sofort beim Wechsel + Neukunden-Bonus nach 12 Monaten), die getrennt verbucht werden müssen. Migration 20260524100000_split_energy_bonus: - ADD COLUMN IF NOT EXISTS instantBonus, newCustomerBonus - bestehende `bonus`-Werte → instantBonus (Annahme: Sofort) - DROP COLUMN IF EXISTS bonus UI: - ContractForm zeigt zwei Input-Felder - Detail-Ansicht zeigt beide einzeln + Gesamtbonus - Kostenvorschau listet beide einzeln, dann Gesamt, dann effektive Jahreskosten Cost-Calc: calculateCosts() bekommt beide Boni; CostCalculation liefert instantBonus, newCustomerBonus, totalBonus. PDF-Template: drei neue Variablen energyDetails.instantBonus, .newCustomerBonus, .totalBonus. Live-verifiziert auf dev: PUT mit beiden Werten → DB persistiert, GET liefert zurueck. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 30 +++++++++++ backend/prisma/schema.prisma | 9 +++- .../src/controllers/contract.controller.ts | 3 +- backend/src/services/contract.service.ts | 9 ++-- backend/src/services/pdfTemplate.service.ts | 10 +++- docs/todo.md | 27 ++++++++++ .../src/pages/contracts/ContractDetail.tsx | 54 ++++++++++++++----- frontend/src/pages/contracts/ContractForm.tsx | 9 ++-- frontend/src/types/index.ts | 3 +- frontend/src/utils/energyCalculations.ts | 18 ++++--- 10 files changed, 143 insertions(+), 29 deletions(-) create mode 100644 backend/prisma/migrations/20260524100000_split_energy_bonus/migration.sql diff --git a/backend/prisma/migrations/20260524100000_split_energy_bonus/migration.sql b/backend/prisma/migrations/20260524100000_split_energy_bonus/migration.sql new file mode 100644 index 00000000..50c9acf7 --- /dev/null +++ b/backend/prisma/migrations/20260524100000_split_energy_bonus/migration.sql @@ -0,0 +1,30 @@ +-- Energie-Bonus in Sofort-Bonus + Neukunden-Bonus aufteilen. +-- Bestehende Werte werden nach `instantBonus` migriert (Annahme: bei +-- Bestandsverträgen war "Bonus" üblicherweise der Sofort-Bonus). +-- Wer das anders hatte, kann die Werte über die UI nachträglich verschieben. +-- +-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon +-- `prisma db push` gefahren hat. + +ALTER TABLE `EnergyContractDetails` + ADD COLUMN IF NOT EXISTS `instantBonus` DOUBLE NULL, + ADD COLUMN IF NOT EXISTS `newCustomerBonus` DOUBLE NULL; + +-- Daten kopieren, sofern die alte Spalte noch existiert und das Ziel leer ist +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'EnergyContractDetails' + AND COLUMN_NAME = 'bonus' +); +SET @sql := IF( + @col_exists > 0, + 'UPDATE `EnergyContractDetails` SET `instantBonus` = `bonus` WHERE `bonus` IS NOT NULL AND `instantBonus` IS NULL', + 'SELECT "bonus-Spalte existiert nicht mehr, nichts zu migrieren"' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Alte Spalte droppen, falls vorhanden +ALTER TABLE `EnergyContractDetails` DROP COLUMN IF EXISTS `bonus`; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cfc7310e..4620a6c3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -805,7 +805,14 @@ model EnergyContractDetails { basePrice Float? // €/Monat unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler) - bonus Float? + // Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus + // (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am + // Vertragsende / nach 12 Monaten). Beide werden im Detail als + // Gesamtbonus aufsummiert und in der Kostenvorschau einzeln + // dargestellt. Migration kopiert das alte `bonus` nach + // `instantBonus` (Annahme: meistgenutzte Variante). + instantBonus Float? // Sofort-Bonus + newCustomerBonus Float? // Neukunden-Bonus previousProviderName String? previousCustomerNumber String? invoices Invoice[] // Rechnungen diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index 7c3682ef..ea2a0151 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -164,7 +164,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise = { meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch', - basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus', + basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', + instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus', }; // Hauptfelder vergleichen diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index b0182b47..7391b956 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -218,7 +218,8 @@ interface ContractCreateData { annualConsumption?: number; basePrice?: number; unitPrice?: number; - bonus?: number; + instantBonus?: number; + newCustomerBonus?: number; previousProviderName?: string; previousCustomerNumber?: string; }; @@ -710,7 +711,8 @@ export async function createFollowUpContract(previousContractId: number) { previousContract.energyDetails.annualConsumption ?? undefined, basePrice: previousContract.energyDetails.basePrice ?? undefined, unitPrice: previousContract.energyDetails.unitPrice ?? undefined, - bonus: previousContract.energyDetails.bonus ?? undefined, + instantBonus: previousContract.energyDetails.instantBonus ?? undefined, + newCustomerBonus: previousContract.energyDetails.newCustomerBonus ?? undefined, previousProviderName: previousContract.providerName ?? undefined, previousCustomerNumber: previousContract.customerNumberAtProvider ?? undefined, @@ -898,7 +900,8 @@ export async function createRenewalContract(previousContractId: number) { basePrice: ed.basePrice, unitPrice: ed.unitPrice, unitPriceNt: ed.unitPriceNt, - bonus: ed.bonus, + instantBonus: ed.instantBonus, + newCustomerBonus: ed.newCustomerBonus, previousProviderName: ed.previousProviderName, previousCustomerNumber: ed.previousCustomerNumber, }, diff --git a/backend/src/services/pdfTemplate.service.ts b/backend/src/services/pdfTemplate.service.ts index 0035addd..a28ca376 100644 --- a/backend/src/services/pdfTemplate.service.ts +++ b/backend/src/services/pdfTemplate.service.ts @@ -87,7 +87,9 @@ export const CRM_FIELDS = [ { path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' }, { path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' }, { path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' }, - { path: 'energyDetails.bonus', label: 'Bonus (€)', group: 'Energie' }, + { path: 'energyDetails.instantBonus', label: 'Sofort-Bonus (€)', group: 'Energie' }, + { path: 'energyDetails.newCustomerBonus', label: 'Neukunden-Bonus (€)', group: 'Energie' }, + { path: 'energyDetails.totalBonus', label: 'Gesamtbonus (€)', group: 'Energie' }, // Internet/DSL/Glasfaser/Kabel { path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' }, { path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' }, @@ -469,7 +471,11 @@ export async function generateFilledPdf( 'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '', 'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '', 'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '', - 'energyDetails.bonus': contract.energyDetails?.bonus?.toString() || '', + 'energyDetails.instantBonus': contract.energyDetails?.instantBonus?.toString() || '', + 'energyDetails.newCustomerBonus': contract.energyDetails?.newCustomerBonus?.toString() || '', + 'energyDetails.totalBonus': ( + ((contract.energyDetails?.instantBonus ?? 0) + (contract.energyDetails?.newCustomerBonus ?? 0)) || '' + ).toString(), // Internet 'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '', 'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '', diff --git a/docs/todo.md b/docs/todo.md index 96037928..f2cc2350 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -120,6 +120,33 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf. +- [x] **🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)** + - Bisher gab es ein einzelnes `bonus`-Feld auf `EnergyContractDetails`. + Jetzt zwei Felder `instantBonus` (Sofort) und `newCustomerBonus` + (Neukunden), die zusammen den Gesamtbonus ergeben. + - **Migration** `20260524100000_split_energy_bonus`: + `ADD COLUMN IF NOT EXISTS instantBonus`, `... newCustomerBonus`, + `UPDATE` kopiert bestehendes `bonus` → `instantBonus` + (Annahme: Bestandsverträge hatten primär Sofort-Bonus), dann + `DROP COLUMN IF EXISTS bonus`. Idempotent. + - **Form** (`ContractForm.tsx`): zwei Input-Felder „Sofort-Bonus (€)" + + „Neukunden-Bonus (€)" statt einem. + - **Detail-Ansicht**: zeigt beide Felder einzeln und einen + aggregierten „Gesamtbonus" (fett) darunter. + - **Kostenvorschau**: listet Sofort + Neukunden einzeln auf + (jeweils grün), dann „Gesamtbonus" (mittel grün, fett), danach + Effektive Jahreskosten. + - **Cost-Calc** (`energyCalculations.ts`): `calculateCosts()` nimmt + jetzt beide Bonus-Werte; `CostCalculation` liefert `instantBonus`, + `newCustomerBonus` + `totalBonus`. + - **PDF-Template-Variablen**: drei neue Placeholder + `energyDetails.instantBonus`, `.newCustomerBonus`, `.totalBonus` + (alter `.bonus`-Placeholder entfernt). + - **Audit-Log** unterscheidet jetzt beide Felder. + - **Live-verifiziert** auf dev: PUT mit beiden Feldern → DB hat + `instantBonus=75, newCustomerBonus=125`, GET liefert beide + Werte zurück. + - [x] **🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)** - **30.13 MIME-Extension-XSS** (MEDIUM): `GET /api/files/download` lieferte hochgeladene Dateien via `res.sendFile` aus. Da multer diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index e7cc76ac..5a45f2c5 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -609,7 +609,8 @@ function EnergyConsumptionCalculation({ basePrice, unitPrice, unitPriceNt, - bonus, + instantBonus, + newCustomerBonus, hasMeter, contractMeters, }: { @@ -619,7 +620,8 @@ function EnergyConsumptionCalculation({ endDate?: string; basePrice?: number; unitPrice?: number; - bonus?: number; + instantBonus?: number; + newCustomerBonus?: number; unitPriceNt?: number; hasMeter?: boolean; contractMeters?: ContractMeter[]; @@ -656,7 +658,7 @@ function EnergyConsumptionCalculation({ const htKwh = consumption?.consumptionHt ?? consumption?.consumptionKwh ?? 0; const ntKwh = consumption?.consumptionNt; const costs = consumption && consumption.consumptionKwh > 0 - ? calculateCosts(htKwh, basePrice, unitPrice, bonus, ntKwh, unitPriceNt) + ? calculateCosts(htKwh, basePrice, unitPrice, instantBonus, ntKwh, unitPriceNt, newCustomerBonus) : null; const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected'); @@ -780,12 +782,24 @@ function EnergyConsumptionCalculation({ {formatNumber(costs.annualTotalCost)} € - {/* Bonus */} - {costs.bonus != null && costs.bonus > 0 && ( + {/* Bonus – Sofort + Neukunden einzeln, dann Gesamt */} + {costs.totalBonus != null && costs.totalBonus > 0 && ( <> -
- Bonus - - {formatNumber(costs.bonus)} € + {costs.instantBonus != null && costs.instantBonus > 0 && ( +
+ Sofort-Bonus + - {formatNumber(costs.instantBonus)} € +
+ )} + {costs.newCustomerBonus != null && costs.newCustomerBonus > 0 && ( +
+ Neukunden-Bonus + - {formatNumber(costs.newCustomerBonus)} € +
+ )} +
+ Gesamtbonus + - {formatNumber(costs.totalBonus)} €
@@ -2565,10 +2579,25 @@ export default function ContractDetail() {
)} - {c.energyDetails.bonus && ( + {/* Bonus: Sofort + Neukunden einzeln + Gesamtbonus */} + {c.energyDetails.instantBonus != null && c.energyDetails.instantBonus > 0 && (
-
Bonus
-
{c.energyDetails.bonus.toLocaleString('de-DE')} €
+
Sofort-Bonus
+
{c.energyDetails.instantBonus.toLocaleString('de-DE')} €
+
+ )} + {c.energyDetails.newCustomerBonus != null && c.energyDetails.newCustomerBonus > 0 && ( +
+
Neukunden-Bonus
+
{c.energyDetails.newCustomerBonus.toLocaleString('de-DE')} €
+
+ )} + {((c.energyDetails.instantBonus ?? 0) + (c.energyDetails.newCustomerBonus ?? 0)) > 0 && ( +
+
Gesamtbonus
+
+ {((c.energyDetails.instantBonus ?? 0) + (c.energyDetails.newCustomerBonus ?? 0)).toLocaleString('de-DE')} € +
)} {c.energyDetails.previousProviderName && ( @@ -2636,7 +2665,8 @@ export default function ContractDetail() { basePrice={c.energyDetails.basePrice} unitPrice={c.energyDetails.unitPrice} unitPriceNt={c.energyDetails.unitPriceNt} - bonus={c.energyDetails.bonus} + instantBonus={c.energyDetails.instantBonus} + newCustomerBonus={c.energyDetails.newCustomerBonus} hasMeter={!!c.energyDetails.meter} contractMeters={c.energyDetails.contractMeters} /> diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx index 989ab989..e97031c5 100644 --- a/frontend/src/pages/contracts/ContractForm.tsx +++ b/frontend/src/pages/contracts/ContractForm.tsx @@ -282,7 +282,8 @@ export default function ContractForm() { basePrice: c.energyDetails?.basePrice || '', unitPrice: c.energyDetails?.unitPrice || '', unitPriceNt: c.energyDetails?.unitPriceNt || '', - bonus: c.energyDetails?.bonus || '', + instantBonus: c.energyDetails?.instantBonus || '', + newCustomerBonus: c.energyDetails?.newCustomerBonus || '', // Internet details downloadSpeed: c.internetDetails?.downloadSpeed || '', uploadSpeed: c.internetDetails?.uploadSpeed || '', @@ -522,7 +523,8 @@ export default function ContractForm() { basePrice: data.basePrice ? parseFloat(data.basePrice) : null, unitPrice: data.unitPrice ? parseFloat(data.unitPrice) : null, unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null, - bonus: data.bonus ? parseFloat(data.bonus) : null, + instantBonus: data.instantBonus ? parseFloat(data.instantBonus) : null, + newCustomerBonus: data.newCustomerBonus ? parseFloat(data.newCustomerBonus) : null, }; } @@ -1003,7 +1005,8 @@ export default function ContractForm() { {...register('unitPriceNt')} /> )} - + +
{/* Hinweis für Zählerstände und Rechnungen */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f6b80681..d2a50ac4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -469,7 +469,8 @@ export interface EnergyContractDetails { basePrice?: number; // €/Monat unitPrice?: number; // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler) - bonus?: number; + instantBonus?: number; // Sofort-Bonus + newCustomerBonus?: number; // Neukunden-Bonus previousProviderName?: string; previousCustomerNumber?: string; invoices?: Invoice[]; // Rechnungen diff --git a/frontend/src/utils/energyCalculations.ts b/frontend/src/utils/energyCalculations.ts index fa3bc7ca..e8034fd0 100644 --- a/frontend/src/utils/energyCalculations.ts +++ b/frontend/src/utils/energyCalculations.ts @@ -23,8 +23,10 @@ export interface CostCalculation { annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt annualTotalCost: number; // Summe monthlyPayment: number; // annualTotalCost / 12 - bonus?: number; - effectiveAnnualCost: number; // annualTotalCost - bonus + instantBonus?: number; // Sofort-Bonus + newCustomerBonus?: number; // Neukunden-Bonus + totalBonus?: number; // Summe = instantBonus + newCustomerBonus + effectiveAnnualCost: number; // annualTotalCost - totalBonus } /** @@ -207,9 +209,10 @@ export function calculateCosts( consumptionKwh: number, basePrice?: number, unitPrice?: number, - bonus?: number, + instantBonus?: number, consumptionNtKwh?: number, - unitPriceNt?: number + unitPriceNt?: number, + newCustomerBonus?: number, ): CostCalculation | null { // Mindestens ein Preis muss vorhanden sein if (basePrice == null && unitPrice == null) { @@ -221,7 +224,8 @@ export function calculateCosts( const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0); const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0); const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt; - const effectiveAnnualCost = annualTotalCost - (bonus ?? 0); + const totalBonus = (instantBonus ?? 0) + (newCustomerBonus ?? 0); + const effectiveAnnualCost = annualTotalCost - totalBonus; const monthlyPayment = effectiveAnnualCost / 12; return { @@ -230,7 +234,9 @@ export function calculateCosts( annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined, annualTotalCost, monthlyPayment, - bonus: bonus ?? undefined, + instantBonus: instantBonus && instantBonus > 0 ? instantBonus : undefined, + newCustomerBonus: newCustomerBonus && newCustomerBonus > 0 ? newCustomerBonus : undefined, + totalBonus: totalBonus > 0 ? totalBonus : undefined, effectiveAnnualCost, }; }