Let vacation years go back to the child's birth year

Settings only offered vacation-day rows from the current year forward,
while analytics ran its balance series from the child's birth year. Every
year in the gap had no vacation_days row, so the series assumed a full
statutory 25 earned with no use and banked earned - floor of it forward —
phantom saved days the user had no row to correct.

Settings now offers editable rows back to the birth year, and a past year
with no row assumes only the floor was earned, so it banks nothing. A past
year the user did enter is untouched by that rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:49:56 +02:00
parent 06592df87c
commit 1a02bdcdbf
12 changed files with 248 additions and 33 deletions
@@ -843,11 +843,18 @@ class TestVacationYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._vacation_year_series` — the
FIFO carry-forward bank simulation documented on `ParentYearBalance`.
Every case here passes `current_year=years[0]` so its whole series is
"current year or later" — these tests exercise the FIFO mechanics
themselves, not the past-vs-current-year `earned` rule, which
`TestPastYearEarnedRule` below covers on its own.
"""
def test_no_rows_assumes_statutory_default_and_floor(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
first = result[0]
assert first.earned == 25 # rules.statutory_vacation_days_per_year default
@@ -866,7 +873,9 @@ class TestVacationYearBalanceSeries:
def test_carry_chain_accumulates_across_years(self) -> None:
years = [2025, 2026, 2027]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
# Unused years keep saving 5/year (25 earned - 20 floor) and the
# prior years' saved days carry forward untouched.
@@ -881,7 +890,9 @@ class TestVacationYearBalanceSeries:
# Exactly carry_years + 1 years: only 2020's own batch can possibly
# have aged out by the last year in this series.
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
# 2020's 5 saved days must not still be sitting in `carried_in`
@@ -905,7 +916,9 @@ class TestVacationYearBalanceSeries:
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 3))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
for year in years[carry_years:]:
@@ -931,7 +944,9 @@ class TestVacationYearBalanceSeries:
saved_days_in=12,
),
}
result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, rows, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
assert by_year[2025].carried_in == 0 # nothing entered/simulated yet
@@ -954,7 +969,9 @@ class TestVacationYearBalanceSeries:
saved_days_in=0,
),
}
result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, rows, {}, floor=20, current_year=years[0]
)
by_year = {r.year: r for r in result}
# 2025 saved 5 (25 - floor 20) with no usage; a zero saved_days_in on
@@ -980,7 +997,9 @@ class TestVacationYearBalanceSeries:
),
}
use_by_year = {2025: 15}
result = AnalyticsService._vacation_year_series(years, rows, use_by_year, floor=20)
result = AnalyticsService._vacation_year_series(
years, rows, use_by_year, floor=20, current_year=years[0]
)
balance = result[0]
assert balance.left == 15
@@ -1000,7 +1019,9 @@ class TestVacationYearBalanceSeries:
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
result = AnalyticsService._vacation_year_series(
years, {}, {}, floor=20, current_year=years[0]
)
last = result[-1]
assert last.expiring_age == 5
@@ -1009,6 +1030,66 @@ class TestVacationYearBalanceSeries:
assert last.expiring == last.expiring_age + last.expiring_over_cap
class TestPastYearEarnedRule:
"""`_vacation_year_series`'s `current_year` parameter — the fix for
Settings now offering editable rows back to the child's birth year
(`computeVacationYearRange`). Before this, a year before `current_year`
with no `vacation_days` row was indistinguishable from a future year:
it assumed the full statutory 25 and banked `25 - floor` forward even
though the row was never rendered for the user to correct — phantom
"saved" vacation. See `ParentYearBalance`'s docstring.
"""
def test_phantom_past_years_with_no_row_carry_nothing_forward(self) -> None:
years = [2020, 2021, 2022]
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20, current_year=2023)
by_year = {r.year: r for r in result}
for year in years:
assert by_year[year].earned == 20 # floor, not the statutory 25
assert by_year[year].is_assumed is True
assert by_year[year].carried_in == 0
assert by_year[year].carried_out == 0
assert [r.carried_in for r in result] == [0, 0, 0]
def test_entered_past_row_still_carries_its_surplus_forward(self) -> None:
"""A past year the user genuinely entered data for is untouched by
the phantom-year rule above: its `total_days` still earns normally,
and any real surplus it banks still carries into the next (also
past) year — only a phantom (no-row) year banks nothing, never an
entered one.
"""
from app.entities.vacation_days import VacationDays
years = [2020, 2021]
rows = {
2020: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2020,
total_days=30,
saved_days_in=0,
),
}
result = AnalyticsService._vacation_year_series(
years, rows, {}, floor=20, current_year=2023
)
by_year = {r.year: r for r in result}
assert by_year[2020].earned == 30 # the entered row, not the floor
assert by_year[2020].is_assumed is False
assert by_year[2020].carried_out == 10 # 30 - floor(20), genuinely saved
# 2021 has no row and is itself a past year, but 2020's REAL surplus
# must still show up here, untouched.
assert by_year[2021].earned == 20 # floor — 2021 itself has no row
assert by_year[2021].carried_in == 10
assert by_year[2021].carried_out == 10
class TestParentalLeaveYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._pl_year_series`."""