Files
opencrm/backend/src/services/contract.service.ts
T
duffyduck 77602bb4ac contracts: VVL (Vertragsverlängerung) als Split-Button neben Folgevertrag
VVL = Vertragsverlängerung beim selben Anbieter (vs. Folgevertrag = i.d.R.
Anbieterwechsel).

Im Gegensatz zu createFollowUpContract wird ALLES kopiert:
- Provider, Tarif, Portal-Username/Passwort (verschlüsselt)
- Preise (basePrice/unitPrice/bonus etc.)
- Notes, Commission, Internet-Zugangsdaten, SIP-Daten, SIM-PINs
- ContractDocuments (1:1, gleiche Datei-Referenz)
- Detail-Tabellen (Energy/Internet/Mobile/TV/CarInsurance) komplett

Berechnet:
- newStartDate = oldStartDate + Vertragslaufzeit (Monate aus
  ContractDuration.code/description geparsed: "24M" / "24 Monate" / "2J")
- newEndDate = newStartDate + Laufzeit
- status = DRAFT (User bestätigt manuell)

NICHT kopiert:
- documentType "Auftragsformular" (das wird neu unterschrieben)
- cancellation*-Felder (alter Cancel-Flow nicht relevant)

Frontend:
- Split-Button: Hauptaktion "Folgevertrag anlegen" + ChevronDown-Pfeil
- Dropdown: "VVL anlegen" mit Bestätigungs-Modal
- Modal zeigt Vorhersage des neuen Startdatums (alter Start +
  Vertragslaufzeit als Hinweis)

History-Einträge wie bei Folgevertrag, mit eigenem VVL-Wording.
Doppel-Schutz: maximal 1 Folge-/VVL-Vertrag pro Vorgänger.

Live-verifiziert:
- Contract #17 (FIBER, 2026-05-01, 24M) → VVL mit Start 2028-05-01 ✓
- Provider/Tarif/Preise/Credentials 1:1 übernommen
- 2 Dokumente kopiert (außer Auftragsformular)
- History-Einträge in beiden Verträgen vorhanden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:12:39 +02:00

1192 lines
40 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 { ContractType, ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js';
export interface ContractFilters {
customerId?: number;
customerIds?: number[]; // Für Kundenportal: eigene ID + vertretene Kunden
type?: ContractType;
status?: ContractStatus;
search?: string;
page?: number;
limit?: number;
}
export async function getAllContracts(filters: ContractFilters) {
const { customerId, customerIds, type, status, search, page = 1, limit = 20 } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
// Entweder einzelne customerId ODER Liste von customerIds (für Kundenportal)
if (customerIds && customerIds.length > 0) {
where.customerId = { in: customerIds };
} else if (customerId) {
where.customerId = customerId;
}
if (type) where.type = type;
// Status-Filter: Deaktivierte Verträge standardmäßig ausblenden
if (status) {
where.status = status;
} else {
// Wenn kein Status-Filter gesetzt, alle außer DEACTIVATED anzeigen
where.status = { not: ContractStatus.DEACTIVATED };
}
if (search) {
where.OR = [
// Basis-Vertragsfelder
{ contractNumber: { contains: search } },
{ providerName: { contains: search } },
{ tariffName: { contains: search } },
{ customerNumberAtProvider: { contains: search } },
{ provider: { name: { contains: search } } },
{ tariff: { name: { contains: search } } },
// Kundenname
{ customer: { firstName: { contains: search } } },
{ customer: { lastName: { contains: search } } },
{ customer: { companyName: { contains: search } } },
{ customer: { customerNumber: { contains: search } } },
// Internet-Vertragsdetails
{ internetDetails: { routerSerialNumber: { contains: search } } },
{ internetDetails: { homeId: { contains: search } } },
{ internetDetails: { activationCode: { contains: search } } },
{ internetDetails: { phoneNumbers: { some: { phoneNumber: { contains: search } } } } },
// Mobilfunk-Vertragsdetails
{ mobileDetails: { phoneNumber: { contains: search } } },
{ mobileDetails: { simCardNumber: { contains: search } } },
{ mobileDetails: { deviceImei: { contains: search } } },
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
// Energie-Vertragsdetails (Zählernummer)
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
// TV-Vertragsdetails
{ tvDetails: { smartcardNumber: { contains: search } } },
// KFZ-Versicherung
{ carInsuranceDetails: { licensePlate: { contains: search } } },
{ carInsuranceDetails: { vin: { contains: search } } },
{ carInsuranceDetails: { policyNumber: { contains: search } } },
];
}
const [contracts, total] = await Promise.all([
prisma.contract.findMany({
where,
skip,
take,
orderBy: [{ createdAt: 'desc' }],
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
},
},
address: true,
billingAddress: true,
salesPlatform: true,
cancellationPeriod: true,
contractDuration: true,
provider: true,
tariff: true,
contractCategory: true,
mobileDetails: {
select: {
phoneNumber: true,
simCards: { select: { phoneNumber: true, isMain: true } },
},
},
carInsuranceDetails: { select: { licensePlate: true } },
},
}),
prisma.contract.count({ where }),
]);
return {
contracts,
pagination: buildPaginationResponse(page, limit, total),
};
}
export async function getContractById(id: number, decryptPassword = false) {
const contract = await prisma.contract.findUnique({
where: { id },
include: {
customer: true,
address: true,
billingAddress: true,
bankCard: true,
identityDocument: true,
salesPlatform: true,
cancellationPeriod: true,
contractDuration: true,
provider: true,
tariff: true,
contractCategory: true,
previousProvider: true,
previousContract: {
include: {
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
},
energyDetails: { include: { meter: { include: { readings: true } }, contractMeters: { include: { meter: { include: { readings: true } } }, orderBy: { position: 'asc' as const } }, invoices: true } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
stressfreiEmail: true,
invoices: { orderBy: { invoiceDate: 'desc' as const } },
documents: { orderBy: { createdAt: 'desc' as const } },
followUpContract: {
select: { id: true, contractNumber: true, status: true },
},
},
});
if (!contract) return null;
// Decrypt password if requested and exists
if (decryptPassword && contract.portalPasswordEncrypted) {
try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
contract.portalPasswordEncrypted
);
} catch {
// Password decryption failed, leave as is
}
}
return contract;
}
interface ContractCreateData {
customerId: number;
type: ContractType;
contractCategoryId?: number;
status?: ContractStatus;
addressId?: number;
billingAddressId?: number;
bankCardId?: number;
identityDocumentId?: number;
salesPlatformId?: number;
previousContractId?: number;
providerId?: number;
tariffId?: number;
providerName?: string;
tariffName?: string;
customerNumberAtProvider?: string;
priceFirst12Months?: string;
priceFrom13Months?: string;
priceAfter24Months?: string;
startDate?: Date;
endDate?: Date;
cancellationPeriodId?: number;
contractDurationId?: number;
commission?: number;
portalUsername?: string;
portalPassword?: string;
stressfreiEmailId?: number;
notes?: string;
// Kündigungsdaten
cancellationConfirmationDate?: Date;
cancellationConfirmationOptionsDate?: Date;
wasSpecialCancellation?: boolean;
// Type-specific details
energyDetails?: {
meterId?: number;
annualConsumption?: number;
basePrice?: number;
unitPrice?: number;
bonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
};
internetDetails?: {
downloadSpeed?: number;
uploadSpeed?: number;
routerModel?: string;
routerSerialNumber?: string;
installationDate?: Date;
// Internet-Zugangsdaten
internetUsername?: string;
internetPassword?: string;
// Objekt & Lage
propertyType?: string;
propertyLocation?: string;
connectionLocation?: string;
// Glasfaser-spezifisch
homeId?: string;
// Vodafone DSL/Kabel spezifisch
activationCode?: string;
phoneNumbers?: {
id?: number;
phoneNumber: string;
isMain?: boolean;
sipUsername?: string;
sipPassword?: string;
sipServer?: string;
}[];
};
mobileDetails?: {
requiresMultisim?: boolean;
dataVolume?: number;
includedMinutes?: number;
includedSMS?: number;
deviceModel?: string;
deviceImei?: string;
// Legacy-Felder
phoneNumber?: string;
simCardNumber?: string;
// SIM-Karten Liste
simCards?: {
id?: number;
phoneNumber?: string;
simCardNumber?: string;
pin?: string;
puk?: string;
isMultisim?: boolean;
isMain?: boolean;
}[];
};
tvDetails?: {
receiverModel?: string;
smartcardNumber?: string;
package?: string;
};
carInsuranceDetails?: {
licensePlate?: string;
hsn?: string;
tsn?: string;
vin?: string;
vehicleType?: string;
firstRegistration?: Date;
noClaimsClass?: string;
insuranceType?: 'LIABILITY' | 'PARTIAL' | 'FULL';
deductiblePartial?: number;
deductibleFull?: number;
policyNumber?: string;
previousInsurer?: string;
};
}
export async function createContract(data: ContractCreateData) {
const {
energyDetails,
internetDetails,
mobileDetails,
tvDetails,
carInsuranceDetails,
portalPassword,
...contractData
} = data;
// Encrypt password if provided
const portalPasswordEncrypted = portalPassword
? encrypt(portalPassword)
: undefined;
const contract = await prisma.contract.create({
data: {
...contractData,
contractNumber: generateContractNumber(data.type),
portalPasswordEncrypted,
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
? { energyDetails: { create: energyDetails } }
: {}),
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
? {
internetDetails: {
create: {
downloadSpeed: internetDetails.downloadSpeed,
uploadSpeed: internetDetails.uploadSpeed,
routerModel: internetDetails.routerModel,
routerSerialNumber: internetDetails.routerSerialNumber,
installationDate: internetDetails.installationDate,
internetUsername: internetDetails.internetUsername,
internetPasswordEncrypted: internetDetails.internetPassword
? encrypt(internetDetails.internetPassword)
: undefined,
propertyType: internetDetails.propertyType,
propertyLocation: internetDetails.propertyLocation,
connectionLocation: internetDetails.connectionLocation,
homeId: internetDetails.homeId,
activationCode: internetDetails.activationCode,
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
? {
create: internetDetails.phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
? encrypt(pn.sipPassword)
: undefined,
sipServer: pn.sipServer,
})),
}
: undefined,
},
},
}
: {}),
...(mobileDetails && data.type === 'MOBILE'
? {
mobileDetails: {
create: {
requiresMultisim: mobileDetails.requiresMultisim,
dataVolume: mobileDetails.dataVolume,
includedMinutes: mobileDetails.includedMinutes,
includedSMS: mobileDetails.includedSMS,
deviceModel: mobileDetails.deviceModel,
deviceImei: mobileDetails.deviceImei,
phoneNumber: mobileDetails.phoneNumber,
simCardNumber: mobileDetails.simCardNumber,
simCards: mobileDetails.simCards
? {
create: mobileDetails.simCards.map((sc) => ({
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
pin: sc.pin ? encrypt(sc.pin) : undefined,
puk: sc.puk ? encrypt(sc.puk) : undefined,
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
})),
}
: undefined,
},
},
}
: {}),
...(tvDetails && data.type === 'TV'
? { tvDetails: { create: tvDetails } }
: {}),
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
? { carInsuranceDetails: { create: carInsuranceDetails } }
: {}),
},
include: {
customer: true,
address: true,
billingAddress: true,
salesPlatform: true,
energyDetails: true,
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
});
return contract;
}
export async function updateContract(
id: number,
data: Partial<ContractCreateData>
) {
const {
energyDetails,
internetDetails,
mobileDetails,
tvDetails,
carInsuranceDetails,
portalPassword,
...contractData
} = data;
// Encrypt password if provided
const portalPasswordEncrypted = portalPassword
? encrypt(portalPassword)
: undefined;
// Update main contract
await prisma.contract.update({
where: { id },
data: {
...contractData,
...(portalPasswordEncrypted ? { portalPasswordEncrypted } : {}),
},
});
// Update type-specific details
if (energyDetails) {
const existingEcd = await prisma.energyContractDetails.findUnique({
where: { contractId: id },
select: { id: true, meterId: true },
});
await prisma.energyContractDetails.upsert({
where: { contractId: id },
update: energyDetails,
create: { contractId: id, ...energyDetails },
});
// ContractMeter synchronisieren wenn sich der Zähler ändert
if (energyDetails.meterId !== undefined && existingEcd) {
const oldMeterId = existingEcd.meterId;
const newMeterId = energyDetails.meterId;
if (oldMeterId !== newMeterId) {
// Alle alten ContractMeter-Einträge entfernen
await prisma.contractMeter.deleteMany({
where: { energyContractDetailsId: existingEcd.id },
});
// Neuen ContractMeter-Eintrag erstellen (wenn ein Zähler gesetzt)
if (newMeterId) {
const contract = await prisma.contract.findUnique({
where: { id },
select: { startDate: true },
});
await prisma.contractMeter.create({
data: {
energyContractDetailsId: existingEcd.id,
meterId: newMeterId,
position: 0,
installedAt: contract?.startDate,
},
});
}
}
}
}
if (internetDetails) {
const { phoneNumbers, internetPassword, ...internetData } = internetDetails;
const existing = await prisma.internetContractDetails.findUnique({
where: { contractId: id },
include: { phoneNumbers: true },
});
// Prepare internet data with encryption
const preparedInternetData = {
downloadSpeed: internetData.downloadSpeed,
uploadSpeed: internetData.uploadSpeed,
routerModel: internetData.routerModel,
routerSerialNumber: internetData.routerSerialNumber,
installationDate: internetData.installationDate,
internetUsername: internetData.internetUsername,
// Only update password if new value provided, otherwise keep existing
...(internetPassword
? { internetPasswordEncrypted: encrypt(internetPassword) }
: {}),
propertyType: internetData.propertyType,
propertyLocation: internetData.propertyLocation,
connectionLocation: internetData.connectionLocation,
homeId: internetData.homeId,
activationCode: internetData.activationCode,
};
if (existing) {
await prisma.internetContractDetails.update({
where: { contractId: id },
data: preparedInternetData,
});
if (phoneNumbers) {
// Get existing phone numbers for preserving encrypted passwords
const existingPhoneNumbers = existing.phoneNumbers || [];
// Delete all existing phone numbers
await prisma.phoneNumber.deleteMany({
where: { internetContractDetailsId: existing.id },
});
// Create new phone numbers with encryption
await prisma.phoneNumber.createMany({
data: phoneNumbers.map((pn) => {
// Find existing entry to preserve sipPassword if not changed
const existingPn = pn.id
? existingPhoneNumbers.find((e) => e.id === pn.id)
: undefined;
return {
internetContractDetailsId: existing.id,
phoneNumber: pn.phoneNumber,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
// Preserve existing sipPassword if no new value provided
sipPasswordEncrypted: pn.sipPassword
? encrypt(pn.sipPassword)
: existingPn?.sipPasswordEncrypted ?? undefined,
sipServer: pn.sipServer,
};
}),
});
}
} else {
await prisma.internetContractDetails.create({
data: {
contractId: id,
...preparedInternetData,
...(internetPassword
? { internetPasswordEncrypted: encrypt(internetPassword) }
: {}),
phoneNumbers: phoneNumbers
? {
create: phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
? encrypt(pn.sipPassword)
: undefined,
sipServer: pn.sipServer,
})),
}
: undefined,
},
});
}
}
if (mobileDetails) {
const { simCards, ...mobileData } = mobileDetails;
const existing = await prisma.mobileContractDetails.findUnique({
where: { contractId: id },
});
if (existing) {
await prisma.mobileContractDetails.update({
where: { contractId: id },
data: mobileData,
});
if (simCards) {
// Get existing sim cards to preserve PIN/PUK if not provided
const existingSimCards = await prisma.simCard.findMany({
where: { mobileDetailsId: existing.id },
});
const existingSimCardMap = new Map(existingSimCards.map(sc => [sc.id, sc]));
// Delete existing sim cards
await prisma.simCard.deleteMany({
where: { mobileDetailsId: existing.id },
});
// Create new sim cards, preserving PIN/PUK if not provided
await prisma.simCard.createMany({
data: simCards.map((sc) => {
const existingSc = sc.id ? existingSimCardMap.get(sc.id) : undefined;
return {
mobileDetailsId: existing.id,
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
// Preserve existing PIN/PUK if no new value provided
pin: sc.pin ? encrypt(sc.pin) : (existingSc?.pin ?? undefined),
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
};
}),
});
}
} else {
await prisma.mobileContractDetails.create({
data: {
contractId: id,
...mobileData,
simCards: simCards
? {
create: simCards.map((sc) => ({
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
pin: sc.pin ? encrypt(sc.pin) : undefined,
puk: sc.puk ? encrypt(sc.puk) : undefined,
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
})),
}
: undefined,
},
});
}
}
if (tvDetails) {
await prisma.tvContractDetails.upsert({
where: { contractId: id },
update: tvDetails,
create: { contractId: id, ...tvDetails },
});
}
if (carInsuranceDetails) {
await prisma.carInsuranceDetails.upsert({
where: { contractId: id },
update: carInsuranceDetails,
create: { contractId: id, ...carInsuranceDetails },
});
}
return getContractById(id);
}
export async function deleteContract(id: number) {
// Vertragskette erhalten beim Löschen:
// Wenn A → B → C und B gelöscht wird, soll C direkt auf A zeigen (A → C)
// 1. Zu löschenden Vertrag holen um dessen Vorgänger zu kennen
const contractToDelete = await prisma.contract.findUnique({
where: { id },
select: { previousContractId: true },
});
// 2. Folgevertrag(e) mit dem Vorgänger des gelöschten Vertrags verbinden
// So bleibt die Kette erhalten: A → B → C wird zu A → C
await prisma.contract.updateMany({
where: { previousContractId: id },
data: { previousContractId: contractToDelete?.previousContractId ?? null },
});
return prisma.contract.delete({ where: { id } });
}
export async function createFollowUpContract(previousContractId: number) {
const previousContract = await getContractById(previousContractId);
if (!previousContract) {
throw new Error('Vorgängervertrag nicht gefunden');
}
// Prüfen ob bereits ein Folgevertrag existiert
const existingFollowUp = await prisma.contract.findFirst({
where: { previousContractId },
select: { id: true, contractNumber: true },
});
if (existingFollowUp) {
throw new Error(`Es existiert bereits ein Folgevertrag: ${existingFollowUp.contractNumber}`);
}
// Copy data but exclude provider credentials and some fields
const newContractData: ContractCreateData = {
customerId: previousContract.customerId,
type: previousContract.type,
status: 'DRAFT',
addressId: previousContract.addressId ?? undefined,
bankCardId: previousContract.bankCardId ?? undefined,
identityDocumentId: previousContract.identityDocumentId ?? undefined,
salesPlatformId: previousContract.salesPlatformId ?? undefined,
previousContractId: previousContract.id,
// Explicitly NOT copying: providerName, tariffName, portalUsername, portalPassword, price fields
cancellationPeriodId: previousContract.cancellationPeriodId ?? undefined,
contractDurationId: previousContract.contractDurationId ?? undefined,
// notes nicht mehr automatisch setzen - wird jetzt über Historie-Eintrag dokumentiert
};
// Copy type-specific details (without credentials)
if (previousContract.energyDetails) {
newContractData.energyDetails = {
meterId: previousContract.energyDetails.meterId ?? undefined,
annualConsumption:
previousContract.energyDetails.annualConsumption ?? undefined,
basePrice: previousContract.energyDetails.basePrice ?? undefined,
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
bonus: previousContract.energyDetails.bonus ?? undefined,
previousProviderName: previousContract.providerName ?? undefined,
previousCustomerNumber:
previousContract.customerNumberAtProvider ?? undefined,
};
}
if (previousContract.internetDetails) {
newContractData.internetDetails = {
downloadSpeed:
previousContract.internetDetails.downloadSpeed ?? undefined,
uploadSpeed: previousContract.internetDetails.uploadSpeed ?? undefined,
routerModel: previousContract.internetDetails.routerModel ?? undefined,
routerSerialNumber:
previousContract.internetDetails.routerSerialNumber ?? undefined,
phoneNumbers: previousContract.internetDetails.phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
isMain: pn.isMain,
})),
};
}
if (previousContract.mobileDetails) {
newContractData.mobileDetails = {
requiresMultisim: previousContract.mobileDetails.requiresMultisim ?? undefined,
dataVolume: previousContract.mobileDetails.dataVolume ?? undefined,
includedMinutes:
previousContract.mobileDetails.includedMinutes ?? undefined,
includedSMS: previousContract.mobileDetails.includedSMS ?? undefined,
deviceModel: previousContract.mobileDetails.deviceModel ?? undefined,
deviceImei: previousContract.mobileDetails.deviceImei ?? undefined,
phoneNumber: previousContract.mobileDetails.phoneNumber ?? undefined,
simCardNumber: previousContract.mobileDetails.simCardNumber ?? undefined,
// Copy simCards without PIN/PUK (security)
simCards: previousContract.mobileDetails.simCards?.map((sc) => ({
phoneNumber: sc.phoneNumber ?? undefined,
simCardNumber: sc.simCardNumber ?? undefined,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
})),
};
}
if (previousContract.tvDetails) {
newContractData.tvDetails = {
receiverModel: previousContract.tvDetails.receiverModel ?? undefined,
smartcardNumber:
previousContract.tvDetails.smartcardNumber ?? undefined,
package: previousContract.tvDetails.package ?? undefined,
};
}
if (previousContract.carInsuranceDetails) {
newContractData.carInsuranceDetails = {
licensePlate:
previousContract.carInsuranceDetails.licensePlate ?? undefined,
hsn: previousContract.carInsuranceDetails.hsn ?? undefined,
tsn: previousContract.carInsuranceDetails.tsn ?? undefined,
vin: previousContract.carInsuranceDetails.vin ?? undefined,
vehicleType: previousContract.carInsuranceDetails.vehicleType ?? undefined,
firstRegistration:
previousContract.carInsuranceDetails.firstRegistration ?? undefined,
noClaimsClass:
previousContract.carInsuranceDetails.noClaimsClass ?? undefined,
insuranceType: previousContract.carInsuranceDetails.insuranceType,
deductiblePartial:
previousContract.carInsuranceDetails.deductiblePartial ?? undefined,
deductibleFull:
previousContract.carInsuranceDetails.deductibleFull ?? undefined,
previousInsurer: previousContract.providerName ?? undefined,
};
}
return createContract(newContractData);
}
/**
* Hilfsfunktion: extrahiert die Anzahl Monate aus einer ContractDuration.
* Code-Beispiele: "12M", "24M", "1J", "2J". Falls nichts erkannt wird, fällt
* sie auf 12 Monate als sicheren Default zurück.
*/
function durationToMonths(code: string | null | undefined, description: string | null | undefined): number {
const c = (code || '').trim();
const d = (description || '').trim();
let m = c.match(/^(\d+)\s*M$/i);
if (m) return parseInt(m[1], 10);
m = c.match(/^(\d+)\s*J$/i);
if (m) return parseInt(m[1], 10) * 12;
m = d.match(/(\d+)\s*Monat/i);
if (m) return parseInt(m[1], 10);
m = d.match(/(\d+)\s*Jahr/i);
if (m) return parseInt(m[1], 10) * 12;
return 12;
}
/**
* VVL = Vertragsverlängerung beim selben Anbieter.
*
* Im Gegensatz zu createFollowUpContract werden ALLE Daten 1:1 kopiert:
* Provider, Tarif, Portal-Credentials, Preise, Notes, ContractDocuments.
*
* Berechnet wird das neue Startdatum: altes startDate + Vertragslaufzeit.
* Stimmt das gefundene Datum nicht mit dem späteren Auftrag überein, kann
* der User es im Vertrag manuell anpassen.
*
* NICHT mitkopiert wird:
* - das Auftragsdokument (documentType "Auftragsformular") das ist
* schließlich die NEU zu unterschreibende VVL.
* - Kündigungsschreiben/-bestätigung (das war der ALTE Cancel-Flow,
* bei einer VVL nicht relevant)
*/
export async function createRenewalContract(previousContractId: number) {
const previousContract = await getContractById(previousContractId, true);
if (!previousContract) {
throw new Error('Vorgängervertrag nicht gefunden');
}
// Bereits ein Folge-/VVL-Vertrag vorhanden?
const existing = await prisma.contract.findFirst({
where: { previousContractId },
select: { id: true, contractNumber: true },
});
if (existing) {
throw new Error(`Es existiert bereits ein Folgevertrag: ${existing.contractNumber}`);
}
// Neues Startdatum = altes Start + Laufzeit
let newStartDate: Date | null = null;
let newEndDate: Date | null = null;
if (previousContract.startDate && previousContract.contractDuration) {
const months = durationToMonths(
previousContract.contractDuration.code,
previousContract.contractDuration.description,
);
newStartDate = new Date(previousContract.startDate);
newStartDate.setMonth(newStartDate.getMonth() + months);
newEndDate = new Date(newStartDate);
newEndDate.setMonth(newEndDate.getMonth() + months);
}
// Vertrags-Daten 1:1 kopieren (außer id/contractNumber/Datums-/Cancellation-Felder)
const contractNumber = generateContractNumber(previousContract.type);
const newContract = await prisma.contract.create({
data: {
contractNumber,
customerId: previousContract.customerId,
type: previousContract.type,
status: 'DRAFT',
contractCategoryId: previousContract.contractCategoryId,
addressId: previousContract.addressId,
billingAddressId: previousContract.billingAddressId,
bankCardId: previousContract.bankCardId,
identityDocumentId: previousContract.identityDocumentId,
salesPlatformId: previousContract.salesPlatformId,
cancellationPeriodId: previousContract.cancellationPeriodId,
contractDurationId: previousContract.contractDurationId,
previousContractId: previousContract.id,
previousProviderId: previousContract.previousProviderId,
providerId: previousContract.providerId,
tariffId: previousContract.tariffId,
providerName: previousContract.providerName,
tariffName: previousContract.tariffName,
customerNumberAtProvider: previousContract.customerNumberAtProvider,
portalUsername: previousContract.portalUsername,
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
commission: previousContract.commission,
notes: previousContract.notes,
startDate: newStartDate,
endDate: newEndDate,
// Cancellation-Felder bewusst leer lassen die VVL hat den alten
// Cancel-Flow nicht geerbt.
},
});
// Detail-Tabellen 1:1 kopieren (id rausnehmen, contractId neu)
if (previousContract.energyDetails) {
const ed = previousContract.energyDetails;
const newEnergy = await prisma.energyContractDetails.create({
data: {
contractId: newContract.id,
meterId: ed.meterId,
maloId: ed.maloId,
annualConsumption: ed.annualConsumption,
annualConsumptionKwh: ed.annualConsumptionKwh,
basePrice: ed.basePrice,
unitPrice: ed.unitPrice,
unitPriceNt: ed.unitPriceNt,
bonus: ed.bonus,
previousProviderName: ed.previousProviderName,
previousCustomerNumber: ed.previousCustomerNumber,
},
});
// ContractMeter-Verknüpfungen mitkopieren
for (const cm of ed.contractMeters || []) {
await prisma.contractMeter.create({
data: {
energyContractDetailsId: newEnergy.id,
meterId: cm.meterId,
position: cm.position,
installedAt: cm.installedAt,
removedAt: cm.removedAt,
finalReading: cm.finalReading,
},
});
}
}
if (previousContract.internetDetails) {
const id = previousContract.internetDetails;
const newInet = await prisma.internetContractDetails.create({
data: {
contractId: newContract.id,
downloadSpeed: id.downloadSpeed,
uploadSpeed: id.uploadSpeed,
routerModel: id.routerModel,
routerSerialNumber: id.routerSerialNumber,
installationDate: id.installationDate,
internetUsername: id.internetUsername,
internetPasswordEncrypted: id.internetPasswordEncrypted,
propertyType: id.propertyType,
propertyLocation: id.propertyLocation,
connectionLocation: id.connectionLocation,
homeId: id.homeId,
activationCode: id.activationCode,
},
});
for (const pn of id.phoneNumbers || []) {
await prisma.phoneNumber.create({
data: {
internetContractDetailsId: newInet.id,
phoneNumber: pn.phoneNumber,
isMain: pn.isMain,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPasswordEncrypted,
sipServer: pn.sipServer,
},
});
}
}
if (previousContract.mobileDetails) {
const md = previousContract.mobileDetails;
const newMob = await prisma.mobileContractDetails.create({
data: {
contractId: newContract.id,
requiresMultisim: md.requiresMultisim,
dataVolume: md.dataVolume,
includedMinutes: md.includedMinutes,
includedSMS: md.includedSMS,
deviceModel: md.deviceModel,
deviceImei: md.deviceImei,
phoneNumber: md.phoneNumber,
simCardNumber: md.simCardNumber,
},
});
for (const sc of md.simCards || []) {
await prisma.simCard.create({
data: {
mobileDetailsId: newMob.id,
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
pin: sc.pin,
puk: sc.puk,
},
});
}
}
if (previousContract.tvDetails) {
await prisma.tvContractDetails.create({
data: {
contractId: newContract.id,
receiverModel: previousContract.tvDetails.receiverModel,
smartcardNumber: previousContract.tvDetails.smartcardNumber,
package: previousContract.tvDetails.package,
},
});
}
if (previousContract.carInsuranceDetails) {
const ci = previousContract.carInsuranceDetails;
await prisma.carInsuranceDetails.create({
data: {
contractId: newContract.id,
licensePlate: ci.licensePlate,
hsn: ci.hsn,
tsn: ci.tsn,
vin: ci.vin,
vehicleType: ci.vehicleType,
firstRegistration: ci.firstRegistration,
noClaimsClass: ci.noClaimsClass,
insuranceType: ci.insuranceType,
deductiblePartial: ci.deductiblePartial,
deductibleFull: ci.deductibleFull,
previousInsurer: ci.previousInsurer,
},
});
}
// ContractDocuments mitkopieren AUSSER "Auftragsformular" (das ist die
// neue Unterschrift, die der User selbst hochlädt). Files werden NICHT
// physisch dupliziert; beide Verträge zeigen auf dieselbe Datei.
const docs = await prisma.contractDocument.findMany({
where: { contractId: previousContract.id },
});
for (const d of docs) {
if (d.documentType.toLowerCase().includes('auftragsformular')) continue;
await prisma.contractDocument.create({
data: {
contractId: newContract.id,
documentType: d.documentType,
documentPath: d.documentPath,
originalName: d.originalName,
notes: d.notes,
uploadedBy: d.uploadedBy,
},
});
}
return prisma.contract.findUnique({ where: { id: newContract.id } });
}
// Decrypt password for viewing
export async function getContractPassword(id: number): Promise<string | null> {
const contract = await prisma.contract.findUnique({
where: { id },
select: { portalPasswordEncrypted: true },
});
if (!contract?.portalPasswordEncrypted) return null;
try {
return decrypt(contract.portalPasswordEncrypted);
} catch {
return null;
}
}
// Decrypt SimCard PIN/PUK
export async function getSimCardCredentials(simCardId: number): Promise<{ pin: string | null; puk: string | null }> {
const simCard = await prisma.simCard.findUnique({
where: { id: simCardId },
select: { pin: true, puk: true },
});
if (!simCard) return { pin: null, puk: null };
try {
return {
pin: simCard.pin ? decrypt(simCard.pin) : null,
puk: simCard.puk ? decrypt(simCard.puk) : null,
};
} catch {
return { pin: null, puk: null };
}
}
// Decrypt Internet password
export async function getInternetCredentials(contractId: number): Promise<{ password: string | null }> {
const internetDetails = await prisma.internetContractDetails.findUnique({
where: { contractId },
select: { internetPasswordEncrypted: true },
});
if (!internetDetails?.internetPasswordEncrypted) return { password: null };
try {
return {
password: decrypt(internetDetails.internetPasswordEncrypted),
};
} catch {
return { password: null };
}
}
// Decrypt SIP password for a phone number
export async function getSipCredentials(phoneNumberId: number): Promise<{ password: string | null }> {
const phoneNumber = await prisma.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: { sipPasswordEncrypted: true },
});
if (!phoneNumber?.sipPasswordEncrypted) return { password: null };
try {
return {
password: decrypt(phoneNumber.sipPasswordEncrypted),
};
} catch {
return { password: null };
}
}
// ==================== VERTRAGSBAUM FÜR KUNDENANSICHT ====================
export interface ContractTreeNode {
contract: {
id: number;
contractNumber: string;
type: ContractType;
status: ContractStatus;
startDate: Date | null;
endDate: Date | null;
providerName: string | null;
tariffName: string | null;
previousContractId: number | null;
provider?: { id: number; name: string } | null;
tariff?: { id: number; name: string } | null;
contractCategory?: { id: number; name: string } | null;
customer?: { id: number; firstName: string; lastName: string; companyName: string | null; customerNumber: string } | null;
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
mobileDetails?: {
phoneNumber: string | null;
simCards: { phoneNumber: string | null; isMain: boolean }[];
} | null;
carInsuranceDetails?: { licensePlate: string | null } | null;
};
predecessors: ContractTreeNode[];
hasHistory: boolean;
}
/**
* Verträge eines Kunden als Baumstruktur abrufen.
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
* Vorgänger werden rekursiv eingebettet.
*/
export async function getContractTreeForCustomer(customerId: number): Promise<ContractTreeNode[]> {
// Alle Verträge des Kunden laden (außer DEACTIVATED)
const allContracts = await prisma.contract.findMany({
where: {
customerId,
status: { not: ContractStatus.DEACTIVATED },
},
select: {
id: true,
contractNumber: true,
type: true,
status: true,
startDate: true,
endDate: true,
providerName: true,
tariffName: true,
previousContractId: true,
provider: { select: { id: true, name: true } },
tariff: { select: { id: true, name: true } },
contractCategory: { select: { id: true, name: true } },
customer: { select: { id: true, firstName: true, lastName: true, companyName: true, customerNumber: true } },
address: { select: { street: true, houseNumber: true, postalCode: true, city: true } },
mobileDetails: {
select: {
phoneNumber: true,
simCards: { select: { phoneNumber: true, isMain: true } },
},
},
carInsuranceDetails: { select: { licensePlate: true } },
},
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
});
// Map für schnellen Zugriff: contractId -> contract
const contractMap = new Map(allContracts.map(c => [c.id, c]));
// Set der IDs die als Vorgänger referenziert werden
const predecessorIds = new Set(
allContracts
.filter(c => c.previousContractId !== null)
.map(c => c.previousContractId!)
);
// Wurzelverträge = Verträge die keinen Nachfolger haben
// (werden von keinem anderen Vertrag als previousContractId referenziert)
const rootContracts = allContracts.filter(c => !predecessorIds.has(c.id));
// Rekursive Funktion um Vorgängerkette aufzubauen
function buildPredecessorChain(contractId: number | null): ContractTreeNode[] {
if (contractId === null) return [];
const contract = contractMap.get(contractId);
if (!contract) return [];
const predecessors = buildPredecessorChain(contract.previousContractId);
return [{
contract,
predecessors,
hasHistory: predecessors.length > 0,
}];
}
// Baumstruktur für jeden Wurzelvertrag aufbauen
const tree: ContractTreeNode[] = rootContracts.map(contract => {
const predecessors = buildPredecessorChain(contract.previousContractId);
return {
contract,
predecessors,
hasHistory: predecessors.length > 0,
};
});
return tree;
}