package services import ( "fmt" "math" "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" ) // FeeCalculator computes patent litigation costs based on GKG/RVG fee schedules. type FeeCalculator struct{} func NewFeeCalculator() *FeeCalculator { return &FeeCalculator{} } // resolveSchedule returns the actual schedule data, resolving aliases. func resolveSchedule(version string) (*feeScheduleData, error) { sched, ok := feeSchedules[version] if !ok { return nil, fmt.Errorf("unknown fee schedule: %s", version) } if sched.AliasOf != "" { target, ok := feeSchedules[sched.AliasOf] if !ok { return nil, fmt.Errorf("alias target not found: %s", sched.AliasOf) } return target, nil } return sched, nil } // CalculateBaseFee computes the base 1.0x fee using the step-based accumulator. // feeType must be "GKG" or "RVG". // // Algorithm: The first bracket defines the minimum fee (one step). Each subsequent // bracket accumulates ceil(portion / stepSize) * increment for the portion of the // Streitwert that falls within that bracket's range. func (fc *FeeCalculator) CalculateBaseFee(streitwert float64, version string, feeType string) (float64, error) { sched, err := resolveSchedule(version) if err != nil { return 0, err } if streitwert <= 0 { return 0, nil } brackets := sched.Brackets fee := 0.0 prevUpper := 0.0 for i, b := range brackets { increment := b.GKGIncrement if feeType == "RVG" { increment = b.RVGIncrement } if i == 0 { // First bracket: minimum fee = one increment fee = increment prevUpper = b.UpperBound continue } if streitwert <= prevUpper { break } portion := math.Min(streitwert, b.UpperBound) - prevUpper if portion <= 0 { break } steps := math.Ceil(portion / b.StepSize) fee += steps * increment prevUpper = b.UpperBound } return fee, nil } // CalculateCourtFees computes court fees for a given instance type. // Returns the base fee, multiplier, computed court fee, and the fee source. func (fc *FeeCalculator) CalculateCourtFees(streitwert float64, instanceType string, version string) (baseFee, multiplier, courtFee float64, source string, err error) { cfg, ok := instanceConfigs[instanceType] if !ok { return 0, 0, 0, "", fmt.Errorf("unknown instance type: %s", instanceType) } source = cfg.CourtSource if cfg.CourtSource == "fixed" { return 0, 0, cfg.FixedCourtFee, source, nil } // Both GKG and PatKostG use the same step-based GKG bracket lookup baseFee, err = fc.CalculateBaseFee(streitwert, version, "GKG") if err != nil { return 0, 0, 0, "", err } multiplier = cfg.CourtFactor courtFee = baseFee * multiplier return baseFee, multiplier, courtFee, source, nil } // CalculateAttorneyFees computes the fees for one attorney type at one instance. // Returns nil if numAttorneys <= 0. func (fc *FeeCalculator) CalculateAttorneyFees( streitwert float64, version string, vgFactor, tgFactor float64, numAttorneys, numClients int, oralHearing bool, vatRate float64, ) (*models.AttorneyBreakdown, error) { if numAttorneys <= 0 { return nil, nil } baseFee, err := fc.CalculateBaseFee(streitwert, version, "RVG") if err != nil { return nil, err } vgFee := vgFactor * baseFee // Increase fee (Nr. 1008 VV RVG) for multiple clients increaseFee := 0.0 if numClients > 1 { factor := float64(numClients-1) * erhoehungsfaktor if factor > erhoehungsfaktorMax { factor = erhoehungsfaktorMax } increaseFee = factor * baseFee } tgFee := 0.0 if oralHearing { tgFee = tgFactor * baseFee } subtotalNet := vgFee + increaseFee + tgFee + auslagenpauschale vat := subtotalNet * vatRate subtotalGross := subtotalNet + vat totalGross := subtotalGross * float64(numAttorneys) return &models.AttorneyBreakdown{ BaseFee: baseFee, VGFactor: vgFactor, VGFee: vgFee, IncreaseFee: increaseFee, TGFactor: tgFactor, TGFee: tgFee, Pauschale: auslagenpauschale, SubtotalNet: subtotalNet, VAT: vat, SubtotalGross: subtotalGross, Count: numAttorneys, TotalGross: totalGross, }, nil } // CalculateInstanceTotal computes the full cost for one court instance. // Bug 3 fix: expert fees are included in the court subtotal (not silently dropped). func (fc *FeeCalculator) CalculateInstanceTotal(streitwert float64, inst models.InstanceInput, vatRate float64) (*models.InstanceResult, error) { cfg, ok := instanceConfigs[string(inst.Type)] if !ok { return nil, fmt.Errorf("unknown instance type: %s", inst.Type) } version := string(inst.FeeVersion) if version == "" { version = "Aktuell" } baseFee, multiplier, courtFee, source, err := fc.CalculateCourtFees(streitwert, string(inst.Type), version) if err != nil { return nil, fmt.Errorf("court fees: %w", err) } // Bug 3 fix: include expert fees in court subtotal courtSubtotal := courtFee + inst.ExpertFees // Attorney (Rechtsanwalt) fees raBreakdown, err := fc.CalculateAttorneyFees( streitwert, version, cfg.RAVGFactor, cfg.RATGFactor, inst.NumAttorneys, inst.NumClients, inst.OralHearing, vatRate, ) if err != nil { return nil, fmt.Errorf("attorney fees: %w", err) } // Patent attorney (Patentanwalt) fees var paBreakdown *models.AttorneyBreakdown if cfg.HasPA && inst.NumPatentAttorneys > 0 { paBreakdown, err = fc.CalculateAttorneyFees( streitwert, version, cfg.PAVGFactor, cfg.PATGFactor, inst.NumPatentAttorneys, inst.NumClients, inst.OralHearing, vatRate, ) if err != nil { return nil, fmt.Errorf("patent attorney fees: %w", err) } } attorneyTotal := 0.0 if raBreakdown != nil { attorneyTotal = raBreakdown.TotalGross } paTotal := 0.0 if paBreakdown != nil { paTotal = paBreakdown.TotalGross } return &models.InstanceResult{ Type: inst.Type, Label: cfg.Label, CourtFeeBase: baseFee, CourtFeeMultiplier: multiplier, CourtFeeSource: source, CourtFee: courtFee, ExpertFees: inst.ExpertFees, CourtSubtotal: courtSubtotal, AttorneyBreakdown: raBreakdown, PatentAttorneyBreakdown: paBreakdown, AttorneySubtotal: attorneyTotal, PatentAttorneySubtotal: paTotal, InstanceTotal: courtSubtotal + attorneyTotal + paTotal, }, nil } // CalculateSecurityForCosts computes the Prozesskostensicherheit. // // Bug 1 fix: uses (1 + VAT) for the total, not (1 - VAT) as in the Excel. // Bug 2 fix: uses GKG base fee for the court fee component, not RVG. func (fc *FeeCalculator) CalculateSecurityForCosts(streitwert float64, version string, numClients int, vatRate float64) (*models.SecurityForCosts, error) { rvgBase, err := fc.CalculateBaseFee(streitwert, version, "RVG") if err != nil { return nil, err } // Bug 2 fix: use GKG base for court fees, not RVG gkgBase, err := fc.CalculateBaseFee(streitwert, version, "GKG") if err != nil { return nil, err } // Increase fee (Nr. 1008 VV RVG) for multiple clients increaseFee := 0.0 if numClients > 1 { factor := float64(numClients-1) * erhoehungsfaktor if factor > erhoehungsfaktorMax { factor = erhoehungsfaktorMax } increaseFee = factor * rvgBase } // 1. Instanz: 2.5x RA + increase + 2.5x PA + increase + EUR 5,000 inst1 := 2.5*rvgBase + increaseFee + 2.5*rvgBase + increaseFee + 5000 // 2. Instanz: 2.8x RA + increase + 2.8x PA + increase + 4.0x GKG court + EUR 5,000 inst2 := 2.8*rvgBase + increaseFee + 2.8*rvgBase + increaseFee + 4.0*gkgBase + 5000 // NZB: 2.3x RA + increase + 2.3x PA + increase nzb := 2.3*rvgBase + increaseFee + 2.3*rvgBase + increaseFee subtotalNet := inst1 + inst2 + nzb // Bug 1 fix: add VAT, don't subtract vat := subtotalNet * vatRate totalGross := subtotalNet + vat return &models.SecurityForCosts{ Instance1: inst1, Instance2: inst2, NZB: nzb, SubtotalNet: subtotalNet, VAT: vat, TotalGross: totalGross, }, nil } // CalculateFullLitigation computes costs for all enabled instances in a proceeding path. func (fc *FeeCalculator) CalculateFullLitigation(req models.FeeCalculateRequest) (*models.FeeCalculateResponse, error) { if req.Streitwert <= 0 { return nil, fmt.Errorf("streitwert must be positive") } resp := &models.FeeCalculateResponse{} grandTotal := 0.0 for _, inst := range req.Instances { if !inst.Enabled { continue } result, err := fc.CalculateInstanceTotal(req.Streitwert, inst, req.VATRate) if err != nil { return nil, fmt.Errorf("instance %s: %w", inst.Type, err) } resp.Instances = append(resp.Instances, *result) grandTotal += result.InstanceTotal } // Build totals based on proceeding path switch req.ProceedingPath { case models.PathInfringement: resp.Totals = fc.infringementTotals(resp.Instances) case models.PathNullity: resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten Nichtigkeitsverfahren", Total: grandTotal}} case models.PathCancellation: resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten Löschungsverfahren", Total: grandTotal}} default: resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten", Total: grandTotal}} } // Security for costs (only for infringement proceedings) if req.IncludeSecurityCosts && req.ProceedingPath == models.PathInfringement { version := "Aktuell" numClients := 1 for _, inst := range req.Instances { if inst.Enabled { version = string(inst.FeeVersion) if version == "" { version = "Aktuell" } numClients = inst.NumClients break } } sec, err := fc.CalculateSecurityForCosts(req.Streitwert, version, numClients, req.VATRate) if err != nil { return nil, fmt.Errorf("security for costs: %w", err) } resp.SecurityForCosts = sec } return resp, nil } // infringementTotals builds summary totals for infringement proceedings. func (fc *FeeCalculator) infringementTotals(instances []models.InstanceResult) []models.FeeTotal { byType := make(map[models.InstanceType]float64) for _, inst := range instances { byType[inst.Type] = inst.InstanceTotal } var totals []models.FeeTotal // "Gesamtkosten bei Nichtzulassung" = LG + OLG + BGH NZB nzb := byType[models.InstanceLG] + byType[models.InstanceOLG] + byType[models.InstanceBGHNZB] if nzb > 0 { totals = append(totals, models.FeeTotal{Label: "Gesamtkosten bei Nichtzulassung", Total: nzb}) } // "Gesamtkosten bei Revision" = LG + OLG + BGH Rev rev := byType[models.InstanceLG] + byType[models.InstanceOLG] + byType[models.InstanceBGHRev] if rev > 0 { totals = append(totals, models.FeeTotal{Label: "Gesamtkosten bei Revision", Total: rev}) } if len(totals) == 0 { total := 0.0 for _, v := range byType { total += v } totals = append(totals, models.FeeTotal{Label: "Gesamtkosten Verletzungsverfahren", Total: total}) } return totals } // GetSchedules returns information about all available fee schedules. func (fc *FeeCalculator) GetSchedules() []models.FeeScheduleInfo { order := []string{"2005", "2013", "2021", "2025", "Aktuell"} result := make([]models.FeeScheduleInfo, 0, len(order)) for _, key := range order { sched := feeSchedules[key] info := models.FeeScheduleInfo{ Key: key, Label: sched.Label, ValidFrom: sched.ValidFrom, } if sched.AliasOf != "" { info.IsAlias = true info.AliasOf = sched.AliasOf } result = append(result, info) } return result }