Files
paliad/internal/calc/fees_test.go
m 0be2dfb5a0 fix(t-paliad-111): bug bundle (correctness) — UPC GESAMTKOSTEN, court-set dates, REGEL save flow
Three correctness bugs from the t-paliad-101 QA sweep, fixed together since
they all change displayed/saved numbers users rely on.

B1 — Kostenrechner UPC GESAMTKOSTEN double-count
  ComputeUPCInstance was setting InstanceTotal = effectiveCourtFee +
  recoverableCeiling. The R.152 recoverable-cost cap is the OPPOSING
  side's worst-case loss-of-suit liability, not the user's own cost —
  folding it into GESAMTKOSTEN inflated the UPC total under a label
  that means "your outlay," and the DE LG/OLG/BGH branches don't add
  any opponent estimate. Drop it from InstanceTotal; the ceiling
  still surfaces as its own RecoverableCeiling line item.

  Live pre-fix on paliad.de (Streitwert 100k, UPC 1. Instanz only):
    instanceTotal = 52600 = 14600 court fee + 38000 R.152 ceiling
  Post-fix:
    instanceTotal = 14600 (court fee only); RecoverableCeiling stays 38000

B3 — Court-determined Termine emit trigger date as a real-looking date
  Zwischenverfahren / Mündliche Verhandlung / Entscheidung all live in
  paliad.deadline_rules with duration_value=0 and parent_id=NULL, so
  Calculate() classified them as IsRootEvent and emitted the trigger
  date as their own DueDate. Worse, RoP.151 "Antrag auf Kostenentscheidung"
  parents off inf.decision and chained 1 month off the placeholder ->
  bogus deadline that the UI rendered as real.

  Fix: classify a zero-duration rule as IsCourtSet (not IsRootEvent)
  when primary_party = 'court' or event_type ∈ {hearing, decision,
  order}. Track court-set rule IDs and propagate IsCourtSet downstream
  to any rule whose parent is court-set, so RoP.151 also surfaces as
  court-set rather than a fabricated date. Save-modal already greys
  out IsCourtSet rows so the "Gerichtsbestimmte Termine ohne Datum
  werden übersprungen" footnote becomes truthful again.

  Live pre-fix on paliad.de (UPC_INF, trigger 2026-04-29):
    Zwischenverfahren / Oral / Entscheidung -> dueDate 2026-04-29
    Antrag auf Kostenentscheidung -> 2026-05-29 (bogus, +1mo from trigger)

B6 — Fristenrechner save flow stored rule code in TITLE
  Frontend was concatenating "RoP.023 — Klageerwiderung" into the
  title because deadlines had nowhere else to put the citation, and
  the /deadlines REGEL column ended up showing "—". Add migration 032
  with a paliad.deadlines.rule_code text column, plumb it through
  CreateDeadlineInput / insertTx, drop the now-redundant r.code AS
  rule_code JOIN alias on the list query (the deadline owns its
  citation), and render f.rule_code on the project-detail deadlines
  table + /deadlines events list + deadline-detail page.

Build, vet, and tests all clean. New unit test
TestIsCourtDeterminedRule pins the B3 discriminator across the
event_type / primary_party combinations seen in migrations 012 + 031.

Repro creds: tester@hlc.de
2026-05-04 14:42:29 +02:00

268 lines
6.8 KiB
Go
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.

package calc
import (
"math"
"testing"
)
func TestComputeBaseFee_GKG_2025(t *testing.T) {
tests := []struct {
streitwert float64
isRVG bool
want float64
}{
// Minimum fee: first bracket, one increment
{100, false, 40},
{300, false, 40},
// First bracket boundary: includes additional step for 300-500 range
{500, false, 80},
{501, false, 101}, // enters second bracket: 80 + 21
{1000, false, 101}, // still one step in second bracket
{2000, false, 143}, // 80 + 3*21 = 143
// RVG base for 1M EUR
{1000000, true, 5553.5},
// GKG base for 1M EUR
{1000000, false, 6278},
}
for _, tt := range tests {
got, err := ComputeBaseFee(tt.streitwert, tt.isRVG, "2025")
if err != nil {
t.Fatalf("ComputeBaseFee(%v, %v, 2025): %v", tt.streitwert, tt.isRVG, err)
}
if math.Abs(got-tt.want) > 0.01 {
t.Errorf("ComputeBaseFee(%v, isRVG=%v, 2025) = %v, want %v", tt.streitwert, tt.isRVG, got, tt.want)
}
}
}
func TestComputeBaseFee_Aktuell_Alias(t *testing.T) {
v2025, err := ComputeBaseFee(1000000, false, "2025")
if err != nil {
t.Fatal(err)
}
vAktuell, err := ComputeBaseFee(1000000, false, "Aktuell")
if err != nil {
t.Fatal(err)
}
if v2025 != vAktuell {
t.Errorf("Aktuell alias: got %v, want %v (same as 2025)", vAktuell, v2025)
}
}
func TestComputeBaseFee_UnknownVersion(t *testing.T) {
_, err := ComputeBaseFee(1000, false, "1999")
if err == nil {
t.Error("expected error for unknown version, got nil")
}
}
func TestComputeDEInstance_LG(t *testing.T) {
input := InstanceInput{
Enabled: true,
FeeVersion: "2025",
NumAttorneys: 1,
NumPatentAttorneys: 1,
NumClients: 1,
OralHearing: true,
}
meta := DEInfringementInstances[0] // LG
result, err := ComputeDEInstance(1000000, input, meta, 0.19)
if err != nil {
t.Fatal(err)
}
if !result.Enabled {
t.Fatal("expected enabled=true")
}
if result.CourtFeeBasis != "GKG" {
t.Errorf("expected GKG, got %s", result.CourtFeeBasis)
}
// Court fee: 3.0 × 6278 = 18834
if math.Abs(result.CourtFee-18834) > 0.01 {
t.Errorf("CourtFee = %v, want 18834", result.CourtFee)
}
if result.Attorney == nil {
t.Fatal("expected attorney breakdown")
}
if result.PatentAttorney == nil {
t.Fatal("expected patent attorney breakdown")
}
if result.InstanceTotal <= 0 {
t.Error("expected positive instance total")
}
}
func TestComputeDEInstance_Disabled(t *testing.T) {
input := InstanceInput{Enabled: false}
meta := DEInfringementInstances[0]
result, err := ComputeDEInstance(1000000, input, meta, 0.19)
if err != nil {
t.Fatal(err)
}
if result.Enabled {
t.Error("expected enabled=false")
}
if result.InstanceTotal != 0 {
t.Errorf("expected 0 total for disabled, got %v", result.InstanceTotal)
}
}
func TestComputeUPCInstance_Pre2026(t *testing.T) {
input := InstanceInput{
Enabled: true,
FeeVersion: "pre2026",
IsSME: false,
}
result, err := ComputeUPCInstance(1000000, input, "UPC_FIRST")
if err != nil {
t.Fatal(err)
}
// Fixed: 11000, value-based for 1M: 4000
if result.FixedFee != 11000 {
t.Errorf("FixedFee = %v, want 11000", result.FixedFee)
}
if result.ValueBasedFee != 4000 {
t.Errorf("ValueBasedFee = %v, want 4000", result.ValueBasedFee)
}
if result.CourtFeesTotal != 15000 {
t.Errorf("CourtFeesTotal = %v, want 15000", result.CourtFeesTotal)
}
// Recoverable for 1M: 112000
if result.RecoverableCeiling != 112000 {
t.Errorf("RecoverableCeiling = %v, want 112000", result.RecoverableCeiling)
}
}
func TestComputeUPCInstance_SME(t *testing.T) {
input := InstanceInput{
Enabled: true,
FeeVersion: "pre2026",
IsSME: true,
}
result, err := ComputeUPCInstance(1000000, input, "UPC_FIRST")
if err != nil {
t.Fatal(err)
}
// SME: 15000 × (1 - 0.4) = 9000
if result.CourtFeesSME != 9000 {
t.Errorf("CourtFeesSME = %v, want 9000", result.CourtFeesSME)
}
// InstanceTotal is the user's own outlay — court fee only, never the
// opposing side's R.152 recoverable cap (which stays on RecoverableCeiling).
expectedTotal := 9000.0
if math.Abs(result.InstanceTotal-expectedTotal) > 0.01 {
t.Errorf("InstanceTotal = %v, want %v", result.InstanceTotal, expectedTotal)
}
if result.RecoverableCeiling != 112000 {
t.Errorf("RecoverableCeiling = %v, want 112000 (separate line item)", result.RecoverableCeiling)
}
}
func TestComputeEPAInstance(t *testing.T) {
input := InstanceInput{Enabled: true}
result, err := ComputeEPAInstance(input, "EPA_OPPOSITION")
if err != nil {
t.Fatal(err)
}
if result.Fee != 880 {
t.Errorf("EPA Opposition Fee = %v, want 880", result.Fee)
}
}
func TestComputeEPAInstance_SME(t *testing.T) {
input := InstanceInput{Enabled: true, IsSME: true}
result, err := ComputeEPAInstance(input, "EPA_APPEAL")
if err != nil {
t.Fatal(err)
}
if result.Fee != 1880 {
t.Errorf("EPA Appeal SME Fee = %v, want 1880", result.Fee)
}
}
func TestCalculate_FullRequest(t *testing.T) {
req := CostRequest{
Streitwert: 1000000,
VATRate: 0.19,
Instances: map[string]InstanceInput{
"LG": {
Enabled: true,
FeeVersion: "Aktuell",
NumAttorneys: 1,
NumPatentAttorneys: 1,
NumClients: 1,
OralHearing: true,
},
"UPC_FIRST": {
Enabled: true,
FeeVersion: "2026",
},
"EPA_OPPOSITION": {
Enabled: true,
},
},
}
resp, err := Calculate(req)
if err != nil {
t.Fatal(err)
}
// Should have 6 DE results, 2 UPC results, 2 EPA results
if len(resp.DEResults) != 6 {
t.Errorf("expected 6 DE results, got %d", len(resp.DEResults))
}
if len(resp.UPCResults) != 2 {
t.Errorf("expected 2 UPC results, got %d", len(resp.UPCResults))
}
if len(resp.EPAResults) != 2 {
t.Errorf("expected 2 EPA results, got %d", len(resp.EPAResults))
}
// LG should be enabled
if !resp.DEResults[0].Enabled {
t.Error("LG should be enabled")
}
// OLG should be disabled
if resp.DEResults[1].Enabled {
t.Error("OLG should be disabled")
}
// Grand total should be positive
if resp.Totals.GrandTotal <= 0 {
t.Error("expected positive grand total")
}
// EPA fees should include opposition
if resp.Totals.EPAFees != 880 {
t.Errorf("EPA fees = %v, want 880", resp.Totals.EPAFees)
}
}
func TestAttorneyFees_MultipleClients(t *testing.T) {
input := InstanceInput{
Enabled: true,
FeeVersion: "2025",
NumAttorneys: 1,
NumClients: 3,
OralHearing: true,
}
meta := DEInfringementInstances[0] // LG
result, err := ComputeDEInstance(1000000, input, meta, 0.19)
if err != nil {
t.Fatal(err)
}
// Erhöhung: min((3-1)*0.3, 2.0) × baseFee = 0.6 × 5553.5 = 3332.1
if result.Attorney == nil {
t.Fatal("expected attorney breakdown")
}
if math.Abs(result.Attorney.Erhoehungsgebuehr-3332.1) > 0.01 {
t.Errorf("Erhoehungsgebuehr = %v, want 3332.1", result.Attorney.Erhoehungsgebuehr)
}
}