diff --git a/internal/db/migrations/128_deadline_rules_unit_check.down.sql b/internal/db/migrations/128_deadline_rules_unit_check.down.sql new file mode 100644 index 0000000..8dbd6cf --- /dev/null +++ b/internal/db/migrations/128_deadline_rules_unit_check.down.sql @@ -0,0 +1,11 @@ +-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit / +-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted +-- arbitrary text, so dropping the CHECKs restores that shape exactly. +-- No data revert necessary — the constraint addition was purely +-- additive and validated against live data before adding. + +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check; + +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check; diff --git a/internal/db/migrations/128_deadline_rules_unit_check.up.sql b/internal/db/migrations/128_deadline_rules_unit_check.up.sql new file mode 100644 index 0000000..3aefbb0 --- /dev/null +++ b/internal/db/migrations/128_deadline_rules_unit_check.up.sql @@ -0,0 +1,36 @@ +-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with +-- 'working_days' added to the allowed set. +-- +-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1 +-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP +-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd +-- combine-max pattern). The schema currently accepts free-text on +-- duration_unit (no CHECK), which is why 'working_days' rows already exist +-- in the DB but were silently dropped by the calculator. Adding the CHECK +-- pins the contract and prevents typos. +-- +-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt +-- path stays in lockstep with the primary path. +-- +-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was +-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules` +-- on 2026-05-25 (returned only days/weeks/months) plus the two live +-- alt-unit rows already at 'working_days' — both shapes pass. +-- +-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on +-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE). + +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check; + +ALTER TABLE paliad.deadline_rules + ADD CONSTRAINT deadline_rules_duration_unit_check + CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days')); + +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check; + +ALTER TABLE paliad.deadline_rules + ADD CONSTRAINT deadline_rules_alt_duration_unit_check + CHECK (alt_duration_unit IS NULL + OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days')); diff --git a/internal/services/deadline_calculator.go b/internal/services/deadline_calculator.go index 416a0d0..68051b2 100644 --- a/internal/services/deadline_calculator.go +++ b/internal/services/deadline_calculator.go @@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator { } // CalculateEndDate applies a single rule's duration + timing to the event date, -// then bumps forward off non-working days for the given (country, regime). -// Returns (adjusted, original, didAdjust). +// then bumps off non-working days for the given (country, regime). For +// rules with both a primary and an alt duration (alt_duration_value/_unit) +// and a combine_op of 'max' or 'min', both legs are computed independently +// and combined per the operator — this implements RoP R.198 / R.213 +// ("31 days OR 20 working days, whichever is longer") and the equivalent +// shape under EPC. Returns (adjusted, original, didAdjust). +// +// Snap direction follows timing: 'after' snaps forward to the next +// working day (RoP R.300.b — period extends to the next working day), +// 'before' snaps *backward* to the preceding working day so the +// statutory cut-off is not pushed past its hard limit. +// +// duration_unit='working_days' walks day-by-day via the holiday service +// (skipping weekends + court holidays), so its result is always already a +// working day — no post-arithmetic snap needed for that leg. +// +// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md +// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds). func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) { - endDate := eventDate - timing := "after" if rule.Timing != nil { timing = *rule.Timing } + adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime) + + // combine_op + alt_duration_*: compute the alt leg independently, + // then pick the later (max) or earlier (min) of the two adjusted + // end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar + // days vs. 20 working days, whichever is longer). + if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil { + altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime) + switch *rule.CombineOp { + case "max": + if altAdj.After(adjusted) { + adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj + } + case "min": + if altAdj.Before(adjusted) { + adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj + } + } + } + + return adjusted, raw, wasAdjusted +} + +// computeLeg evaluates a single (value, unit) duration against the event +// date in the given timing direction and snap-adjusts the result. Returns +// the snap-adjusted end-date, the pre-snap end-date, and whether a snap +// occurred. working_days arithmetic never needs a snap (the walker lands +// on a working day by construction). +func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) { sign := 1 if timing == "before" { sign = -1 } - - switch rule.DurationUnit { - case "days": - endDate = endDate.AddDate(0, 0, sign*rule.DurationValue) - case "weeks": - endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7) - case "months": - endDate = endDate.AddDate(0, sign*rule.DurationValue, 0) + raw = c.addDuration(eventDate, value, unit, sign, country, regime) + if unit == "working_days" { + return raw, raw, false } + if timing == "before" { + return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime) + } + return c.holidays.AdjustForNonWorkingDays(raw, country, regime) +} - original := endDate - adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime) - return adjusted, original, wasAdjusted +// addDuration adds `sign * value` of the given unit to eventDate. For +// 'working_days' it walks day-by-day skipping weekends and court +// holidays via the holiday service. +func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time { + switch unit { + case "days": + return eventDate.AddDate(0, 0, sign*value) + case "weeks": + return eventDate.AddDate(0, 0, sign*value*7) + case "months": + return eventDate.AddDate(0, sign*value, 0) + case "working_days": + return c.addWorkingDays(eventDate, sign*value, country, regime) + } + return eventDate +} + +// addWorkingDays walks `n` business days from `date` (negative `n` walks +// backward). The event day itself is never counted; we step first, then +// skip past non-working days, repeated n times. Result is always a +// working day for the given (country, regime). Matches UPC RoP R.300.b's +// "the day on which the event happens shall not be counted" convention +// applied to the business-day axis. +// +// Bound: each business-day step is bounded by a 60-day inner cap so a +// misconfigured holiday table can never spin forever. The longest +// real-world non-working run between adjacent business days is the +// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned. +func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time { + if n == 0 { + return date + } + step := 1 + count := n + if n < 0 { + step = -1 + count = -n + } + cur := date + for i := 0; i < count; i++ { + cur = cur.AddDate(0, 0, step) + for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ { + cur = cur.AddDate(0, 0, step) + } + } + return cur } // CalculateFromRules calculates deadlines for a slice of rules using the diff --git a/internal/services/deadline_calculator_test.go b/internal/services/deadline_calculator_test.go index a7edf9c..be98f38 100644 --- a/internal/services/deadline_calculator_test.go +++ b/internal/services/deadline_calculator_test.go @@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) { } } -func TestCalculateEndDate_BeforeTiming(t *testing.T) { +// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5 +// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4 +// "no later than X before the oral hearing"), a computed cut-off that +// lands on a weekend / holiday must snap *backward* to the preceding +// working day. Forward snap would push the cut-off past the statutory +// limit and miss the deadline. See +// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5. +func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) { holidays := NewHolidayService(nil) calc := NewDeadlineCalculator(holidays) @@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) { DurationUnit: "months", Timing: ptr("before"), } - // "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday). - // Adjust: Sunday → Monday 2026-03-16. + // "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday). + // Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later + // in 2026, so no extra holiday in this window). in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC) + adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC") + wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) + wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC) + if !original.Equal(wantOrig) { + t.Errorf("original: got %s, want %s", original, wantOrig) + } + if !adjusted.Equal(wantAdj) { + t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj) + } + if !wasAdjusted { + t.Error("expected wasAdjusted=true (Sun → preceding Fri)") + } +} + +// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag. +// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon). +// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through +// Sat → Karfreitag → Thu 2026-04-02. +func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "1-month before, Ostern cluster", + DurationValue: 1, + DurationUnit: "months", + Timing: ptr("before"), + } + in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC) + adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } + if !wasAdjusted { + t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)") + } +} + +// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend. +// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2), +// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19. +func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "5 working days", + DurationValue: 5, + DurationUnit: "working_days", + Timing: ptr("after"), + } + in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC) + adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } + // working_days arithmetic lands on a working day by construction, so the + // "snap" reports no adjustment and original == adjusted. + if !original.Equal(want) { + t.Errorf("original: got %s, want %s", original, want) + } + if wasAdjusted { + t.Error("working_days result should not report a snap adjustment") + } +} + +// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday; +// 20 working days lands on the Friday four weeks later. Anchor Fri +// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in +// window. This exercises the R.198 / R.213 "20 working days" leg. +func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "20 working days", + DurationValue: 20, + DurationUnit: "working_days", + Timing: ptr("after"), + } + in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC) adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") - want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor +// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend, +// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09 +// (3). Result = Thu 2026-04-09. +func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "3 working days over Ostern", + DurationValue: 3, + DurationUnit: "working_days", + Timing: ptr("after"), + } + in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon +// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2), +// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4), +// Tue 06 (5). Result = Tue 2026-01-06. +func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "5 working days over year-end", + DurationValue: 5, + DurationUnit: "working_days", + Timing: ptr("after"), + } + in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor +// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3), +// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result = +// Fri 2026-04-10. +func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "5 working days before", + DurationValue: 5, + DurationUnit: "working_days", + Timing: ptr("before"), + } + in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but +// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue +// 13 (2), Wed 14 (3). Result = Wed 2026-01-14. +func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "3 working days from Saturday", + DurationValue: 3, + DurationUnit: "working_days", + Timing: ptr("after"), + } + in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted +// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20 +// working days, whichever is longer". Anchor Mon 2026-01-12. +// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 = +// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year +// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d. +// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk +// gives Mon 2026-02-09 (20 business days later, no DE holiday). +// +// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins. +func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "31d OR 20wd, max", + DurationValue: 31, + DurationUnit: "days", + Timing: ptr("after"), + AltDurationValue: ptr(20), + AltDurationUnit: ptr("working_days"), + CombineOp: ptr("max"), + } + in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the +// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri +// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap); +// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor +// where the working-days side overshoots. Anchor over a long-weekend +// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon +// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig +// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal +// + snap ≈ 20wd + cluster. +// +// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun +// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic +// "alt wins", we use a configurable anchor and check the relative order +// instead. +func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + // Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday; + // holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec + // 24 is a working day here). +14 calendar days = Thu 2027-01-07. + // +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ... + // arrives much later. Use 14 days vs 20 working_days to make alt + // reliably win on this stretch. + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "14d OR 20wd, max", + DurationValue: 14, + DurationUnit: "days", + Timing: ptr("after"), + AltDurationValue: ptr(20), + AltDurationUnit: ptr("working_days"), + CombineOp: ptr("max"), + } + in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + // Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working + // day → no snap. Alt 20 working_days walks past Christmas + Neujahr: + // Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as + // non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2), + // Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon + // 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08 + // (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14 + // (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20 + // (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result = + // Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25. + want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date. +// Same shape as the max test but inverted. Same Dec 24 2026 anchor, +// 14d vs 20wd: min = Jan 7 2027 (the primary leg). +func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "14d OR 20wd, min", + DurationValue: 14, + DurationUnit: "days", + Timing: ptr("after"), + AltDurationValue: ptr(20), + AltDurationUnit: ptr("working_days"), + CombineOp: ptr("min"), + } + in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC) + if !adjusted.Equal(want) { + t.Errorf("adjusted: got %s, want %s", adjusted, want) + } +} + +// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to +// the primary-only result (defensive: drift in seed data shouldn't crash +// the calculator). Same as the basic days test but with combine_op set +// and alt fields nil. +func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) { + holidays := NewHolidayService(nil) + calc := NewDeadlineCalculator(holidays) + + rule := models.DeadlineRule{ + ID: uuid.New(), + Name: "Primary only, stray combine_op", + DurationValue: 10, + DurationUnit: "days", + Timing: ptr("after"), + CombineOp: ptr("max"), + } + in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) + adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC") + want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC) if !adjusted.Equal(want) { t.Errorf("adjusted: got %s, want %s", adjusted, want) } @@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) { // PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat // 2026-08-29") locks the live behaviour. } - diff --git a/internal/services/holidays.go b/internal/services/holidays.go index 61cc8e9..c74895d 100644 --- a/internal/services/holidays.go +++ b/internal/services/holidays.go @@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string) return h != nil && h.IsClosure } +// AdjustForNonWorkingDaysBackward is the symmetric counterpart of +// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it +// lands on a working day for the given (country, regime). Used for +// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before +// the oral hearing") — when the computed cut-off lands on a weekend or +// public holiday, the lawyer must finish *earlier*, not later. Forward +// snap would push the cut-off past the statutory limit and cause the +// step to be filed too late. Bound by the same 60-iter cap as the +// forward variant. +func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) { + original = date + adjusted = date + for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ { + adjusted = adjusted.AddDate(0, 0, -1) + wasAdjusted = true + } + return adjusted, original, wasAdjusted +} + // AdjustForNonWorkingDays moves the date forward to the next working day for // the given (country, regime). Returns adjusted date, the original // (unmodified) date, and whether any adjustment was made.