Rework balances around per-parent days left and deduce the partner from FK

Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent 375bc64f2f
commit e4719cb9ae
71 changed files with 5705 additions and 1859 deletions
@@ -1,41 +1,41 @@
"""Tests for `ManualFetchService` (backend/app/services/fetch_data_service.py).
This is the manual-entry path a parent's Försäkringskassan balances enter the
app through — now the primary onboarding action. `git grep` found zero
existing coverage for this service or its router before this file.
app through — now the primary onboarding action. The service is a thin
wrapper over the shared `app.services.leave_snapshot.build_snapshot`
derivation (also used by the FK scraper), plus the "default `total_days` off
the workspace's parent count" behaviour that is unique to manual entry (a
scraped snapshot always carries FK's own total).
`test_fk_scraper.py::test_reserved_days_derived` already proves the
`reserved_days_left <= reserved_days_total` domain rule holds for the
scraper-side derivation; this file proves the equivalent manual-entry
boundary — the `ManualFetchService.save_manual_entry` validation itself —
which was previously untested.
`test_leave_snapshot.py` covers `build_snapshot`'s own derivation/validation
exhaustively; this file only proves the wrapper forwards correctly and gets
the parent-count default right, plus basic persistence round-trips.
"""
from datetime import datetime
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.entities.personal_data import PersonalData
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.services.fetch_data_service import ManualFetchService
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction
from app.utils.errors import ValidationError
from tests.conftest import SeededIds
def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict:
base = dict(
base: dict[str, object] = dict(
personal_data_uuid=seeded_ids.personal_data_uuid,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
total_days=480,
days_used=100,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=150,
sjukpenning_left=310,
lagsta_niva_left=70,
dubbeldagar_left=60,
sjukpenning_total=390,
sjukpenning_used=80,
lagsta_niva_total=90,
lagsta_niva_used=20,
reserved_days_left=90,
)
base.update(overrides)
return base
@@ -44,39 +44,50 @@ def _valid_kwargs(seeded_ids: SeededIds, **overrides: object) -> dict:
@pytest.fixture
def manual_fetch_service(
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
) -> ManualFetchService:
return ManualFetchService(parental_leave_repo)
return ManualFetchService(parental_leave_repo, personal_data_repo)
class TestSaveManualEntryValidation:
async def test_days_used_exceeding_total_days_is_rejected(
async def test_sjukpenning_left_exceeding_total_is_rejected(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=101)
with pytest.raises(ValidationError, match="days_used cannot exceed total_days"):
kwargs = _valid_kwargs(
seeded_ids,
total_days=240,
sjukpenning_left=196,
lagsta_niva_left=0,
reserved_days_left=0,
)
with pytest.raises(ValidationError, match="sjukpenning_left"):
await manual_fetch_service.save_manual_entry(**kwargs)
async def test_days_used_equal_to_total_days_is_accepted(
async def test_sjukpenning_left_equal_to_total_is_accepted(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
"""The boundary itself (0 days left) is valid, not an off-by-one reject."""
kwargs = _valid_kwargs(seeded_ids, total_days=100, days_used=100)
"""The boundary itself (that tier fully unused) is valid, not an off-by-one reject."""
kwargs = _valid_kwargs(
seeded_ids,
total_days=240,
sjukpenning_left=195,
lagsta_niva_left=45,
reserved_days_left=90,
)
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.days_left == 0
assert fetch.days_left == 240
async def test_reserved_days_left_exceeding_total_is_rejected(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=91)
with pytest.raises(
ValidationError, match="reserved_days_left cannot exceed reserved_days_total"
):
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=91)
with pytest.raises(ValidationError, match="reserved_days_left"):
await manual_fetch_service.save_manual_entry(**kwargs)
async def test_reserved_days_left_equal_to_total_is_accepted(
@@ -84,7 +95,7 @@ class TestSaveManualEntryValidation:
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, reserved_days_total=90, reserved_days_left=90)
kwargs = _valid_kwargs(seeded_ids, reserved_days_left=90)
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.reserved_days_left == 90
@@ -96,11 +107,8 @@ class TestSaveManualEntryValidation:
kwargs = _valid_kwargs(
seeded_ids,
total_days=480,
days_used=100,
sjukpenning_total=390,
sjukpenning_used=80,
lagsta_niva_total=90,
lagsta_niva_used=20,
sjukpenning_left=310,
lagsta_niva_left=70,
)
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
@@ -112,6 +120,68 @@ class TestSaveManualEntryValidation:
assert fetch.created_at is not None
assert fetch.updated_at is not None
async def test_deduced_source_round_trips(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
kwargs = _valid_kwargs(seeded_ids, source="deduced")
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.source == "deduced"
class TestSaveManualEntryTotalDaysDefault:
"""`total_days` defaults to `rules.total_days_per_child // parent_count`
when omitted — the whole point of Task 5/§"default pool follows parent
count" for manual entry.
"""
async def test_omitted_total_days_defaults_to_full_pool_for_a_sole_parent(
self,
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
"""`seeded_ids`'s workspace has exactly one member."""
kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=390, lagsta_niva_left=90)
del kwargs["total_days"]
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.total_days == 480
async def test_omitted_total_days_defaults_to_half_pool_with_a_partner(
self,
manual_fetch_service: ManualFetchService,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name="Partner",
created_at=datetime.now(),
)
)
kwargs = _valid_kwargs(seeded_ids, sjukpenning_left=195, lagsta_niva_left=45)
del kwargs["total_days"]
fetch = await manual_fetch_service.save_manual_entry(**kwargs)
assert fetch.total_days == 240
async def test_omitted_total_days_without_a_personal_data_repo_raises(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
seeded_ids: SeededIds,
) -> None:
"""A `ManualFetchService` built without a `personal_data_repo` (e.g. a
direct-construction call site that always passes `total_days`
explicitly) can't resolve the default and must fail loudly rather
than silently guessing.
"""
service = ManualFetchService(parental_leave_repo)
kwargs = _valid_kwargs(seeded_ids)
del kwargs["total_days"]
with pytest.raises(ValueError, match="total_days"):
await service.save_manual_entry(**kwargs)
class TestSaveManualEntryPersistence:
async def test_snapshot_persists_and_is_readable_via_get_latest(
@@ -143,16 +213,22 @@ class TestSaveManualEntryPersistence:
manual_fetch_service: ManualFetchService,
seeded_ids: SeededIds,
) -> None:
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=50))
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=60))
await manual_fetch_service.save_manual_entry(**_valid_kwargs(seeded_ids, days_used=70))
await manual_fetch_service.save_manual_entry(
**_valid_kwargs(seeded_ids, dubbeldagar_left=50)
)
await manual_fetch_service.save_manual_entry(
**_valid_kwargs(seeded_ids, dubbeldagar_left=40)
)
await manual_fetch_service.save_manual_entry(
**_valid_kwargs(seeded_ids, dubbeldagar_left=30)
)
history = await manual_fetch_service.list_history(
seeded_ids.personal_data_uuid, seeded_ids.child_id
)
assert len(history) == 3
assert [h.days_used for h in history] == [70, 60, 50]
assert [h.dubbeldagar_left for h in history] == [30, 40, 50]
async def test_list_history_with_no_snapshots_returns_empty_list(
self,
@@ -161,3 +237,133 @@ class TestSaveManualEntryPersistence:
) -> None:
history = await manual_fetch_service.list_history(uuid4(), uuid4())
assert history == []
class TestPersistPartnerDeduction:
"""`persist_partner_deduction` — called by the FK scraper router's `_persist`
after `_deduce_partner` produces a `PartnerDeduction` for the family panel.
"""
async def test_creates_a_deduced_snapshot_for_the_sole_other_member(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
partner = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name="Partner",
created_at=datetime.now(),
)
)
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
)
snapshot = await parental_leave_repo.get_latest_by_parent(partner.uuid, seeded_ids.child_id)
assert snapshot is not None
assert snapshot.source == "deduced"
assert snapshot.days_left == 150
assert snapshot.tier_data.sjukpenning_left == 130
assert snapshot.tier_data.lagsta_niva_left == 20
async def test_sole_member_workspace_persists_nothing(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
"""`seeded_ids`'s workspace has exactly one member (the caller) — there
is no "other member" to attribute a deduced snapshot to."""
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
)
history = await parental_leave_repo.list_history(
seeded_ids.personal_data_uuid, seeded_ids.child_id
)
assert history == []
async def test_more_than_one_other_member_persists_nothing(
self,
parental_leave_repo: SQLiteParentalLeaveRepository,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
"""Ambiguous "which member is the partner" — never guess."""
for name in ("Partner A", "Partner B"):
await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name=name,
created_at=datetime.now(),
)
)
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
)
history = await parental_leave_repo.list_history(
seeded_ids.personal_data_uuid, seeded_ids.child_id
)
assert history == []
async def test_repo_failure_is_swallowed_not_raised(
self,
personal_data_repo: SQLitePersonalDataRepository,
seeded_ids: SeededIds,
) -> None:
"""The caller's own snapshot is already saved by the time this runs —
a DB error persisting the partner's snapshot must never fail the
scraper session."""
await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=seeded_ids.workspace_id,
name="Partner",
created_at=datetime.now(),
)
)
failing_repo = AsyncMock()
failing_repo.create.side_effect = RuntimeError("DB unavailable")
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=failing_repo,
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
caller_personal_data_uuid=seeded_ids.personal_data_uuid,
total_days=240,
sjukpenning_left=130,
lagsta_niva_left=20,
dubbeldagar_left=20,
) # must not raise