From 97a7200c1a763cb9661db07a49ec5c302d15c714 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 20 Sep 2026 09:11:53 +0200 Subject: [PATCH] Parse transferable days from FK instead of deriving them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scraper never read "överlåtbara dagar" from Försäkringskassan. It only visited ansok-och-planera-foraldrapenning#/valjdagar and derived the reserved/transferable split in build_snapshot, assuming transferable days are spent before reserved ones. On real data that gave 49 for the user and 0 for the partner where FK states 129 and 71. The assumption cannot be repaired: reserved-first, transferable-first, proportional and cap-based models were all checked against real figures and none reproduces FK's accounting (reserved-first fits the partner but is 10 days off for the user). FK publishes the real numbers per parent per tier on the min-foraldrapenning page, which the scraper had never visited. - Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction, summing sjukpenningnivå + lägstanivå per parent. The totals are not literals on the page. Parse only direct-child

elements: the first group embeds a tooltip with decoy "45 dagar"/"90 dagar" text. - build_snapshot accepts the reported value and derives reserved days from it; the old arithmetic remains only as a fallback. An implausible FK value degrades to that fallback rather than failing the fetch. - Record provenance in transferable_days_reported (migration 006) and badge derived values as estimates in the dashboard breakdown. Drop the footnote's claim that at most 150 days are transferable — real data contradicts it as a per-family figure. - Select the child's section by name when FK lists several; never guess. Verified against a live BankID scrape: scraped 240/101/139 with 129 transferable, deduced partner 240/169/71 with 71, both flagged as reported. Incidental fixes found along the way: - The fetch router never forwarded the partner's transferable figure to persist_partner_deduction, so the partner's stored snapshot would have kept the flawed split even once the parse landed. - 'deduced' snapshots were labelled "Manually entered" by a two-way ternary over a three-value union. Replaced with a compiler-checked exhaustive map. - Alembic resolved its database from alembic.ini alone, ignoring APP_SQLITE_PATH, so migrations silently targeted the developer's real database even when pointed elsewhere. env.py now resolves it, preferring -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value. - backend/captures/ was not ignored: the root pattern was anchored as /captures/ despite a comment saying scraper dumps must never be tracked. - CLAUDE.md quoted test counts and coverage percentages that had gone stale, which made a correct verification report look suspect. Gate thresholds kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT --- .gitignore | 2 +- CLAUDE.md | 6 +- backend/alembic.ini | 3 + backend/app/db/connection.py | 1 + backend/app/entities/parental_leave.py | 5 + backend/app/models/parental_leave.py | 2 + .../sqlite/parental_leave_repository.py | 10 +- backend/app/routers/fetch_data.py | 4 + backend/app/scrapers/fk_scraper.py | 277 ++++++++- backend/app/services/fetch_data_service.py | 7 +- backend/app/services/leave_snapshot.py | 60 +- backend/migrations/env.py | 37 +- backend/migrations/url_resolution.py | 67 ++ .../006_transferable_days_reported.py | 44 ++ .../fk_min_foraldrapenning_synthetic.html | 89 +++ backend/tests/unit/test_fetch_data_router.py | 3 + backend/tests/unit/test_fk_scraper.py | 577 ++++++++++++++++++ .../unit/test_migrations_url_resolution.py | 85 +++ .../unit/test_services/test_leave_snapshot.py | 95 +++ .../dashboard/ParentalLeaveBreakdown.test.tsx | 28 +- .../dashboard/ParentalLeaveBreakdown.tsx | 31 +- frontend/src/i18n/LanguageContext.test.tsx | 6 +- frontend/src/i18n/en/dashboard.ts | 4 +- frontend/src/i18n/sv/dashboard.ts | 4 +- frontend/src/services/api.test.ts | 3 + frontend/src/services/api.ts | 2 + frontend/src/styles/components.css | 4 + frontend/src/types/entities.ts | 10 +- frontend/tests/fixtures/mockData.ts | 1 + 29 files changed, 1422 insertions(+), 45 deletions(-) create mode 100644 backend/migrations/url_resolution.py create mode 100644 backend/migrations/versions/006_transferable_days_reported.py create mode 100644 backend/tests/fixtures/fk_min_foraldrapenning_synthetic.html create mode 100644 backend/tests/unit/test_migrations_url_resolution.py diff --git a/.gitignore b/.gitignore index 9a29f2a..dc93b00 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ backend/debug # Scraper page captures and browser-verification screenshots contain real # personal data from Försäkringskassan — never track them. Tests use the # synthetic fixture in backend/tests/fixtures/ instead. -/captures/ +captures/ /test.html # C extensions diff --git a/CLAUDE.md b/CLAUDE.md index 3f84ef1..1e6f52f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ uv run ruff check . # lint uv run ruff format --check . # format check uv run pytest # tests + coverage (config in pyproject.toml) ``` -Gate: `--cov-fail-under=80` (total now ~84%), climbing toward **90**. Never lower it; raise only as coverage grows. +Gate: `--cov-fail-under=80`, climbing toward **90**. Never lower it; raise only as coverage grows. **Frontend** (from `frontend/`): ```bash @@ -123,7 +123,7 @@ npm run lint # ESLint, zero warnings npm run coverage # Vitest with coverage ``` Gates (baseline measured after including `src/pages/**`): lines 96 | functions 91 | branches 92 | statements 96. -A regression ratchet, not a target. Current: 97.26% lines/statements, 93.05% functions, 93.51% branches — above thresholds. Never lower gates to pass. +A regression ratchet, not a target. Verify current coverage is above these thresholds; never lower gates to pass. ### WHO RUNS VERIFICATION (cost rule) @@ -136,7 +136,7 @@ These checks are mechanical: fixed commands, judgement limited to reading pass/f Applies to full-suite runs: `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .`, `npm run typecheck`, `npm run lint`, `npm run coverage`, `npx vitest run`, `uv run alembic upgrade head`. Does **not** apply to quick mid-task diagnostics (reading one file, a `git grep`, one focused test) — delegating those costs more than it saves. The rule targets long whole-suite passes. -**Brief the Haiku verifier with:** exact commands, directory to run from, expected baseline (e.g. "currently 361 passed / 0 failed — must not regress"), and an instruction to paste **raw output verbatim**, failures included, not a summary. "All green" without output is useless — several agents have reported gates passing that were failing. +**Brief the Haiku verifier with:** exact commands, directory to run from, and an instruction to report exact pass/fail counts and coverage percentages from their run, then compare them against the current branch state to verify no regression; require **raw output verbatim**, failures included, not a summary. "All green" without output is useless — several agents have reported gates passing that were failing. ### MODEL TIERS diff --git a/backend/alembic.ini b/backend/alembic.ini index 4ac5e7c..24512a0 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -2,6 +2,9 @@ script_location = migrations prepend_sys_path = . version_path_separator = os +# Last-resort fallback only. migrations/env.py's _resolve_url() overrides this +# from the app's own APP_SQLITE_PATH setting (or an `-x sqlalchemy.url=...` +# argument) whenever either is available — this value is used only if neither is. sqlalchemy.url = sqlite:///./foraldraledig.db [loggers] diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index 1fb2c4b..c187503 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -130,6 +130,7 @@ CREATE TABLE IF NOT EXISTS parental_leave_fetch ( tier_data TEXT NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, + transferable_days_reported INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE diff --git a/backend/app/entities/parental_leave.py b/backend/app/entities/parental_leave.py index c5e81cf..3d1df61 100644 --- a/backend/app/entities/parental_leave.py +++ b/backend/app/entities/parental_leave.py @@ -42,3 +42,8 @@ class ParentalLeaveFetch: tier_data: TierData created_at: datetime | None = field(default=None) updated_at: datetime | None = field(default=None) + # True when `transferable_days_left` is FK's own reported figure (parsed + # from the "Dagar ni kan föra över" panel); False when it was derived by + # `app.services.leave_snapshot.build_snapshot`'s transferable-first + # fallback, including when only `reserved_days_left` was supplied. + transferable_days_reported: bool = field(default=False) diff --git a/backend/app/models/parental_leave.py b/backend/app/models/parental_leave.py index 3b9a2ad..63145d6 100644 --- a/backend/app/models/parental_leave.py +++ b/backend/app/models/parental_leave.py @@ -64,6 +64,7 @@ class ParentalLeaveFetchResponse(BaseModel): reserved_days_total: int reserved_days_left: int transferable_days_left: int + transferable_days_reported: bool dubbeldagar_left: int tier_data: TierDataResponse created_at: datetime | None @@ -84,6 +85,7 @@ class ParentalLeaveFetchResponse(BaseModel): reserved_days_total=fetch.reserved_days_total, reserved_days_left=fetch.reserved_days_left, transferable_days_left=fetch.transferable_days_left, + transferable_days_reported=fetch.transferable_days_reported, dubbeldagar_left=fetch.dubbeldagar_left, tier_data=TierDataResponse.from_entity(fetch.tier_data), created_at=fetch.created_at, diff --git a/backend/app/repositories_impl/sqlite/parental_leave_repository.py b/backend/app/repositories_impl/sqlite/parental_leave_repository.py index 33ee631..0419fde 100644 --- a/backend/app/repositories_impl/sqlite/parental_leave_repository.py +++ b/backend/app/repositories_impl/sqlite/parental_leave_repository.py @@ -45,6 +45,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository): tier_data=tier_data, created_at=datetime.fromisoformat(row["created_at"]), updated_at=datetime.fromisoformat(row["updated_at"]), + transferable_days_reported=bool(row["transferable_days_reported"]), ) @staticmethod @@ -69,8 +70,9 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository): INSERT INTO parental_leave_fetch (id, personal_data_uuid, workspace_id, child_id, source, fetched_at, total_days, days_used, days_left, reserved_days_total, reserved_days_left, - transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at, + transferable_days_reported) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(item.id), @@ -89,6 +91,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository): self._tier_data_to_json(item.tier_data), item.created_at.isoformat(), item.updated_at.isoformat(), + int(item.transferable_days_reported), ), ) await commit(self._conn) @@ -137,7 +140,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository): UPDATE parental_leave_fetch SET days_used = ?, days_left = ?, reserved_days_left = ?, transferable_days_left = ?, dubbeldagar_left = ?, - tier_data = ?, updated_at = ? + tier_data = ?, updated_at = ?, transferable_days_reported = ? WHERE id = ? """, ( @@ -148,6 +151,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository): item.dubbeldagar_left, self._tier_data_to_json(item.tier_data), item.updated_at.isoformat(), + int(item.transferable_days_reported), str(id), ), ) diff --git a/backend/app/routers/fetch_data.py b/backend/app/routers/fetch_data.py index 9703525..6986c3e 100644 --- a/backend/app/routers/fetch_data.py +++ b/backend/app/routers/fetch_data.py @@ -165,6 +165,7 @@ async def start_fetch( if payload.child_id is not None: child = await scope.check_child(payload.child_id) child_id = child.id + child_name = child.name else: children = await child_repo.list_by_workspace(workspace_id) if not children: @@ -173,6 +174,7 @@ async def start_fetch( "No child in workspace — add one before fetching.", ) child_id = children[0].id + child_name = children[0].name # Capture the repos for the background task. The connection lives for the # app's lifetime (app.state.db_connection), so using it from the @@ -197,6 +199,7 @@ async def start_fetch( sjukpenning_left=partner.sjukpenning_left, lagsta_niva_left=partner.lagsta_niva_left, dubbeldagar_left=partner.dubbeldagar_left, + transferable_days_left=partner.transferable_days_left, ) except Exception as exc: logger.warning("partner-deduction persist failed, session unaffected: %s", exc) @@ -207,6 +210,7 @@ async def start_fetch( child_id=child_id, owner_user_id=current_user.id, persist_callback=_persist, + child_name=child_name, ) return _StartResponse( session_id=session_id, diff --git a/backend/app/scrapers/fk_scraper.py b/backend/app/scrapers/fk_scraper.py index 421a270..7b399ce 100644 --- a/backend/app/scrapers/fk_scraper.py +++ b/backend/app/scrapers/fk_scraper.py @@ -33,7 +33,7 @@ import logging import re import uuid from collections.abc import Awaitable, Callable -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from datetime import datetime from pathlib import Path from typing import Literal @@ -43,6 +43,7 @@ from app.config import settings from app.entities.parental_leave import ParentalLeaveFetch from app.services.fetch_data_service import FetchDataService from app.services.leave_snapshot import build_snapshot +from app.utils.errors import ValidationError logger = logging.getLogger(__name__) @@ -61,6 +62,14 @@ POST_LOGIN_URL_FRAGMENT = "ansok-och-planera-foraldrapenning#/" # The data page we navigate to after clicking Ansök DATA_PAGE_URL_FRAGMENT = "ansok-och-planera-foraldrapenning#/valjdagar" +# The "Min föräldrapenning" e-service page. Reports the transferable-days +# split ("Dagar ni kan föra över") directly, per-tier, for both parents — +# figures that cannot be reconstructed from #/valjdagar alone (see +# `_extract_transferable_days`'s docstring). +MIN_FP_URL = ( + "https://www.forsakringskassan.se/privatperson/mina-sidor/alla-e-tjanster/min-foraldrapenning" +) + class _SEL: # Login page @@ -100,6 +109,15 @@ class _SEL: FAMILY_DISCRETE = FAMILY + " .visa-dagar__discrete" FAMILY_NIVA_STATISTIK = FAMILY + " .visa-dagar__desktop .niva-statistik__dagar span" + # Min föräldrapenning page — "Dagar ni kan föra över" panel (per child). + # See `MIN_FP_URL`'s comment and `_extract_transferable_days`. + MIN_FP_BARN_SEKTION = ".min-fp .barn-sektion" + BARN_RUBRIK = ".barn-rubrik strong" + PANEL_FORA_OVER_DAGAR = ".panel-fora-over-dagar" + FORA_OVER_GROUP = ".fora-over-group" + MINFP_LABEL = "h4.minfp-label" + EXPANDABLE_BUTTON = ".expandable-paragraph__button" + # --------------------------------------------------------------------------- # Family-panel summary and partner deduction @@ -125,6 +143,33 @@ class PartnerDeduction: sjukpenning_left: int lagsta_niva_left: int dubbeldagar_left: int + transferable_days_left: int | None = None + + +@dataclass(frozen=True) +class TransferableDays: + """FK's own reported "Dagar ni kan föra över" split, per tier and parent. + + Read from the `min-foraldrapenning` e-service page (see `MIN_FP_URL`), + not derived — this is the source of truth `_apply_transferable` feeds + back into `build_snapshot` as `transferable_days_left`. Partner fields + are `None` for a solo parent (no "Annan förälder" group on the page). + """ + + mine_sjukpenning: int + mine_lagsta_niva: int + partner_sjukpenning: int | None = None + partner_lagsta_niva: int | None = None + + @property + def mine_total(self) -> int: + return self.mine_sjukpenning + self.mine_lagsta_niva + + @property + def partner_total(self) -> int | None: + if self.partner_sjukpenning is None or self.partner_lagsta_niva is None: + return None + return self.partner_sjukpenning + self.partner_lagsta_niva # --------------------------------------------------------------------------- @@ -146,6 +191,13 @@ class _SessionState: # treat a mismatched owner exactly like an unknown session_id — never a # 403, so a foreign session_id can't be distinguished from a made-up one. owner_user_id: UUID + # The child's display name, when the caller had it loaded already (see + # `start_session`). Used only to pick the right `.barn-sektion` on the + # min-foraldrapenning page when a workspace has more than one child — + # see `_extract_transferable_days`. `None` disables that feature for + # multi-child workspaces (never guesses a section) but never affects + # the caller's own #/valjdagar extraction. + child_name: str | None = None status: SessionStatus = "launching" qr_data: bytes | None = None # raw PNG bytes; never persisted to disk/DB result: ParentalLeaveFetch | None = None @@ -210,6 +262,7 @@ class FKScraperService(FetchDataService): persist_callback: ( Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None ) = None, + child_name: str | None = None, ) -> str: """Launch a new headless browser session. Returns the session_id. @@ -218,6 +271,11 @@ class FKScraperService(FetchDataService): `owner_user_id` binds the session to the caller that started it — see `_SessionState.owner_user_id`. + `child_name`, if the caller already has the `Child` loaded, is used + only to pick the right section on the min-foraldrapenning page when + a workspace has more than one child — see `_extract_transferable_days`. + The scraper never looks it up itself (no repository access from here). + At most one live (non-terminal) session per `owner_user_id` — starting a new one cancels any session already in flight for the same caller (see F5: unbounded growth of `_sessions` per owner). @@ -237,6 +295,7 @@ class FKScraperService(FetchDataService): personal_data_uuid=personal_data_uuid, child_id=child_id, owner_user_id=owner_user_id, + child_name=child_name, status="launching", persist_callback=persist_callback, ) @@ -572,6 +631,24 @@ async def _do_extract(page, state: _SessionState) -> None: state.family = None state.partner_deduction = None + # "Dagar ni kan föra över" panel on the min-foraldrapenning page -> FK's + # own reported transferable-days split, replacing the transferable-first + # assumption in `data`/`state.partner_deduction`. Best-effort only, and + # must run after the family-panel block above: the family panel only + # exists on #/valjdagar, and this navigates away from it. + try: + transferable = await _extract_transferable_days(page, state, state.child_name) + if transferable is not None: + data = _apply_transferable(data, transferable) + if state.partner_deduction is not None and transferable.partner_total is not None: + state.partner_deduction = replace( + state.partner_deduction, transferable_days_left=transferable.partner_total + ) + except Exception as exc: + logger.info( + "[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__ + ) + if state.persist_callback is not None: try: await state.persist_callback(data, state.partner_deduction) @@ -795,3 +872,201 @@ def _deduce_partner(mine: ParentalLeaveFetch, family: FamilySummary) -> PartnerD lagsta_niva_left=lagsta_left, dubbeldagar_left=mine.dubbeldagar_left, ) + + +# --------------------------------------------------------------------------- +# Transferable days ("Dagar ni kan föra över") — FK's own reported split +# --------------------------------------------------------------------------- + +_TRANSFERABLE_RE = re.compile(r"(\d+)\s+dagar?\s+på\s+(sjukpenningnivå|lägstanivå)") + + +async def _extract_transferable_days( + page, state: _SessionState, child_name: str | None +) -> TransferableDays | None: + """Read the "Dagar ni kan föra över" panel on the min-foraldrapenning + e-service page and return FK's own reported transferable-days split. + + This figure cannot be reconstructed from #/valjdagar (see + `app.services.leave_snapshot.build_snapshot`'s module docstring) — FK's + own accounting there is not reproducible by any plausible model, so it + must be read off this second page directly. + + Fully defensive by design, mirroring `_extract_family_summary`: any + missing element, unmatched child, or unexpected text is "no transferable + data available" — logged at INFO and returned as None — never raised. + The caller's own `data` (already validated) must be unaffected either way. + """ + try: + logger.debug("[%s] navigating to min-foraldrapenning page", state.session_id) + await page.goto(MIN_FP_URL, wait_until="domcontentloaded") + + logger.debug("[%s] waiting for transfer panel", state.session_id) + await page.wait_for_selector(_SEL.PANEL_FORA_OVER_DAGAR, timeout=30_000) + + # The panel mounts before the async fetch lands — wait for real + # per-tier figures rather than trusting first paint (mirrors the + # #/valjdagar hydration guard in `_do_extract`). + logger.debug("[%s] waiting for transfer panel data to hydrate", state.session_id) + await page.wait_for_function( + """(sel) => { + const els = document.querySelectorAll(sel); + for (const el of els) { + if (/\\d+\\s+dagar?\\s+på/.test(el.textContent)) return true; + } + return false; + }""", + arg=_SEL.FORA_OVER_GROUP + " p", + timeout=30_000, + ) + logger.debug("[%s] transfer panel data hydrated", state.session_id) + + sections = page.locator(_SEL.MIN_FP_BARN_SEKTION) + section_count = await sections.count() + if section_count == 0: + logger.info( + "[%s] no barn-sektion found on min-foraldrapenning page; skipping " + "transferable-days parse", + state.session_id, + ) + return None + + if section_count == 1: + chosen = sections.first + else: + if not child_name or not child_name.strip(): + logger.info( + "[%s] multiple children on min-foraldrapenning page and no " + "child_name to match; skipping transferable-days parse", + state.session_id, + ) + return None + target = child_name.strip().split()[0].casefold() + chosen = None + for i in range(section_count): + section = sections.nth(i) + rubrik_loc = section.locator(_SEL.BARN_RUBRIK) + if await rubrik_loc.count() == 0: + continue + rubrik_text = (await rubrik_loc.first.inner_text(timeout=2_000)).strip().casefold() + if rubrik_text == target: + chosen = section + break + if chosen is None: + logger.info( + "[%s] no barn-sektion matched child_name; skipping transferable-days parse", + state.session_id, + ) + return None + + panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR) + if await panel.count() == 0: + logger.info( + "[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse", + state.session_id, + ) + return None + + # Content is present in the DOM even when the accordion is collapsed, + # so text reads work as-is. Still click through as insurance against + # FK switching to lazy rendering — a same-page toggle, never a dialog. + button = panel.locator(_SEL.EXPANDABLE_BUTTON) + if await button.count() > 0: + aria_expanded = await button.first.get_attribute("aria-expanded") + if aria_expanded == "false": + logger.debug("[%s] expanding collapsed transfer panel", state.session_id) + await button.first.click(timeout=5_000) + + groups = panel.locator(_SEL.FORA_OVER_GROUP) + group_count = await groups.count() + + mine_sjuk: int | None = None + mine_lagsta: int | None = None + partner_sjuk: int | None = None + partner_lagsta: int | None = None + + for i in range(group_count): + group = groups.nth(i) + label_loc = group.locator(_SEL.MINFP_LABEL) + if await label_loc.count() == 0: + continue + label_text = (await label_loc.first.inner_text(timeout=2_000)).strip() + + # Direct children only — the tooltip inside "Du kan föra över" + # holds decoy "45 dagar"/"90 dagar" text that a descendant + # selector would also pick up. + p_texts = await group.locator(":scope > p").all_inner_texts() + sjuk: int | None = None + lagsta: int | None = None + for t in p_texts: + m = _TRANSFERABLE_RE.search(t) + if not m: + continue + value = int(m.group(1)) + if m.group(2) == "sjukpenningnivå": + sjuk = value + else: + lagsta = value + + if label_text.startswith("Du kan"): + mine_sjuk, mine_lagsta = sjuk, lagsta + elif label_text.startswith("Annan förälder"): + partner_sjuk, partner_lagsta = sjuk, lagsta + + if mine_sjuk is None or mine_lagsta is None: + logger.info( + "[%s] 'Du kan föra över' group missing or incomplete; skipping " + "transferable-days parse", + state.session_id, + ) + return None + + return TransferableDays( + mine_sjukpenning=mine_sjuk, + mine_lagsta_niva=mine_lagsta, + partner_sjukpenning=partner_sjuk, + partner_lagsta_niva=partner_lagsta, + ) + except Exception as exc: + logger.info( + "[%s] transferable-days panel parsing failed (%s); skipping", + state.session_id, + type(exc).__name__, + ) + return None + + +def _apply_transferable( + data: ParentalLeaveFetch, transferable: TransferableDays +) -> ParentalLeaveFetch: + """Re-run `build_snapshot` with FK's own reported transferable-days + figure in place of the transferable-first assumption used when `data` + was first built. + + An FK-reported figure that fails `build_snapshot`'s validation (should + never happen, but FK's markup is not a contract) degrades to the + original `data` unchanged rather than failing the whole fetch. + """ + try: + return build_snapshot( + personal_data_uuid=data.personal_data_uuid, + workspace_id=data.workspace_id, + child_id=data.child_id, + source=data.source, + total_days=data.total_days, + sjukpenning_left=data.tier_data.sjukpenning_left, + lagsta_niva_left=data.tier_data.lagsta_niva_left, + dubbeldagar_left=data.dubbeldagar_left, + transferable_days_left=transferable.mine_total, + fetched_at=data.fetched_at, + ) + except ValidationError as exc: + logger.warning( + "FK-reported transferable_days_left=%d implausible against derived " + "days_left=%d (child_id=%s); keeping derived reserved/transferable split: %s", + transferable.mine_total, + data.days_left, + data.child_id, + exc, + ) + return data diff --git a/backend/app/services/fetch_data_service.py b/backend/app/services/fetch_data_service.py index ddd0cd2..982ae84 100644 --- a/backend/app/services/fetch_data_service.py +++ b/backend/app/services/fetch_data_service.py @@ -118,6 +118,7 @@ async def persist_partner_deduction( sjukpenning_left: int, lagsta_niva_left: int, dubbeldagar_left: int, + transferable_days_left: int | None = None, ) -> None: """Persist a 'deduced' snapshot for the caller's partner, if there is one. @@ -126,7 +127,10 @@ async def persist_partner_deduction( `app.scrapers.fk_scraper._deduce_partner`). Takes plain fields rather than the scraper's `PartnerDeduction` dataclass to avoid a router/scraper import cycle (`fk_scraper` already imports from this module) — the - router unpacks it before calling in. + router unpacks it before calling in. `transferable_days_left`, when + known (FK's own reported figure for the partner), is forwarded to + `build_snapshot` so the partner's reserved/transferable split doesn't + fall back to the transferable-first assumption either. A no-op (logged, never raised) unless the workspace has exactly one other non-deleted member: with zero, there's no one to persist for; with @@ -155,6 +159,7 @@ async def persist_partner_deduction( sjukpenning_left=sjukpenning_left, lagsta_niva_left=lagsta_niva_left, dubbeldagar_left=dubbeldagar_left, + transferable_days_left=transferable_days_left, ) await parental_leave_repo.create(fetch) except Exception as exc: diff --git a/backend/app/services/leave_snapshot.py b/backend/app/services/leave_snapshot.py index d97a008..3db8dd4 100644 --- a/backend/app/services/leave_snapshot.py +++ b/backend/app/services/leave_snapshot.py @@ -9,6 +9,13 @@ itself — see PLANNING_ENGINE_SPEC.md and the "Bug A" note in `app.utils.tip_predicates.default_tier_pool_share` for what happens when two call sites disagree. +FK's `min-foraldrapenning` e-service page does report the +reserved/transferable split directly, in its "Dagar ni kan föra över" panel +— when a caller has that figure (a scraper parse, or a detailed manual +entry) it should be passed as `transferable_days_left`. The +"transferable-first" arithmetic below is only a fallback for when that +figure isn't available. + Domain: a child has `rules.total_days_per_child` days total (390 sjukpenningnivå + 90 lägstanivå). Split across two parents each gets half (195 + 45 = 240); `rules.reserved_days_per_parent` (90) of each parent's @@ -40,16 +47,29 @@ def build_snapshot( lagsta_niva_left: int, dubbeldagar_left: int, reserved_days_left: int | None = None, + transferable_days_left: int | None = None, fetched_at: datetime | None = None, ) -> ParentalLeaveFetch: """Derive and validate a full `ParentalLeaveFetch` snapshot. - `reserved_days_left`, when omitted, is derived assuming transferable - (non-reserved) days are always consumed before reserved ones — the same - assumption the FK scraper's page reading has always made, since FK's UI - doesn't expose the reserved/transferable split directly. When the caller - already knows the reserved split (e.g. a future, more detailed manual - form), passing it directly skips that assumption. + `reserved_days_left` and `transferable_days_left` are mutually + exclusive — passing both raises `ValidationError`, since each one alone + fully determines the other (`transferable_days_left = days_left - + reserved_days_left`). + + `transferable_days_left` is FK's own reported figure, read off the + "Dagar ni kan föra över" panel on the `min-foraldrapenning` e-service + page. When it is supplied, `reserved_days_left` is computed from it + (`days_left - transferable_days_left`) and checked by the same + validators below as any other value. + + When neither is supplied, `reserved_days_left` falls back to an + assumption — transferable (non-reserved) days are always consumed + before reserved ones — used only because FK's own figure wasn't + available (manual entry with no split known, or a failed parse of that + panel). When the caller already knows the reserved split directly + (e.g. a future, more detailed manual form), passing `reserved_days_left` + skips that assumption. """ if source not in _VALID_SOURCES: raise ValidationError( @@ -57,6 +77,17 @@ def build_snapshot( message_key="parentalLeave.invalidSource", data={"source": source}, ) + if reserved_days_left is not None and transferable_days_left is not None: + raise ValidationError( + "reserved_days_left and transferable_days_left are mutually exclusive " + "— each one alone determines the other", + message_key="parentalLeave.conflictingTransferableOverride", + data={ + "reserved_days_left": reserved_days_left, + "transferable_days_left": transferable_days_left, + }, + ) + transferable_days_reported = transferable_days_left is not None rules = settings.rules @@ -75,18 +106,22 @@ def build_snapshot( lagsta_niva_used = lagsta_niva_total - lagsta_niva_left # -- Reserved / transferable split -- - # Reserved days cannot be transferred to the other parent; the - # long-standing assumption (matches FK's own "spend transferable first") - # is that transferable days are used up before any reserved day is. + # Reserved days cannot be transferred to the other parent. FK reports + # the transferable figure directly (see docstring); when it isn't + # available, fall back to the long-standing assumption (matches FK's + # own "spend transferable first") that transferable days are used up + # before any reserved day is. reserved_days_total = rules.reserved_days_per_parent - if reserved_days_left is None: + if transferable_days_left is not None: + reserved_days_left = days_left - transferable_days_left + elif reserved_days_left is not None: + transferable_days_left = days_left - reserved_days_left + else: transferable_total = total_days - reserved_days_total transferable_used = min(days_used, transferable_total) reserved_used = days_used - transferable_used reserved_days_left = reserved_days_total - reserved_used transferable_days_left = transferable_total - transferable_used - else: - transferable_days_left = days_left - reserved_days_left # -- Validate before returning -- if days_left < 0: @@ -180,6 +215,7 @@ def build_snapshot( reserved_days_total=reserved_days_total, reserved_days_left=reserved_days_left, transferable_days_left=transferable_days_left, + transferable_days_reported=transferable_days_reported, dubbeldagar_left=dubbeldagar_left, tier_data=tier_data, created_at=now, diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 9e2372c..64dbf05 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -7,20 +7,49 @@ layer stays independent of any Python model objects. from __future__ import annotations import asyncio +import logging from logging.config import fileConfig from typing import Any from alembic import context +from migrations.url_resolution import coerce_sqlite_async, resolve_database_url + # Load the alembic.ini logging config without disabling existing loggers — # when running inside the FastAPI app the app's own loggers must not be reset. if context.config.config_file_name is not None: fileConfig(context.config.config_file_name, disable_existing_loggers=False) +logger = logging.getLogger("alembic.env") + + +def _resolve_url() -> str: + """Resolve the database URL for this migration run. + + This is the SINGLE place both the offline and online paths get their URL + from — `alembic.ini`'s `sqlalchemy.url` is deliberately never read + directly anywhere else, so Alembic can never silently target a different + database than the one the running app actually uses. + + The actual resolution order/logic lives in `migrations.url_resolution` + (kept separate so it is unit-testable without an active Alembic + context); this just wires Alembic's `-x` args and ini value into it. + """ + x_args = context.get_x_argument(as_dictionary=True) + ini_url = context.config.get_main_option("sqlalchemy.url") + url = resolve_database_url(x_args, ini_url) + if not url: + raise RuntimeError( + "Could not resolve a database URL: no -x sqlalchemy.url, no " + "APP_SQLITE_PATH-derived setting, and no alembic.ini sqlalchemy.url." + ) + return url + def run_migrations_offline() -> None: """Emit SQL to stdout (used by `alembic upgrade --sql`).""" - url = context.config.get_main_option("sqlalchemy.url") + url = _resolve_url() + logger.info("Resolved database URL (offline mode): %s", url) context.configure( url=url, target_metadata=None, @@ -41,10 +70,8 @@ async def run_migrations_online() -> None: """Run migrations against a live DB connection.""" from sqlalchemy.ext.asyncio import create_async_engine - url = context.config.get_main_option("sqlalchemy.url") - # Coerce sqlite:// → sqlite+aiosqlite:// for async support. - if url and url.startswith("sqlite:///"): - url = url.replace("sqlite:///", "sqlite+aiosqlite:///", 1) + url = coerce_sqlite_async(_resolve_url()) + logger.info("Resolved database URL (online mode): %s", url) connectable = create_async_engine(url) async with connectable.connect() as connection: diff --git a/backend/migrations/url_resolution.py b/backend/migrations/url_resolution.py new file mode 100644 index 0000000..efc4fbf --- /dev/null +++ b/backend/migrations/url_resolution.py @@ -0,0 +1,67 @@ +"""Pure database-URL resolution logic for Alembic migrations. + +Kept separate from `env.py` so it is importable and unit-testable on its own: +`env.py` has import-time side effects tied to an active Alembic +`MigrationContext` (it calls `alembic.context.config...` at module scope) and +cannot be imported outside of an actual `alembic` invocation. This module has +no such dependency — it only takes plain values in and returns plain values +out. + +Resolution order implemented by `resolve_database_url` (first match wins): +1. An explicit `-x sqlalchemy.url=...` argument — a documented way to point a + one-off Alembic run at a scratch database without touching the environment. +2. `APP_SQLITE_PATH` read directly from `os.environ` — but only when it is + genuinely present and non-empty. This deliberately does NOT go through + `app.config.Settings`: `Settings.sqlite_path` has a default + (`"./foraldraledig.db"`), so a `Settings()` instance can never tell "the + env var was set to the default's value" apart from "the env var was never + set at all" — the default always wins, which made step 3 below dead code + and meant a `sqlalchemy.url` set programmatically on the Alembic `Config` + (e.g. `cfg.set_main_option(...)` in a test) was silently ignored in favour + of whatever `./foraldraledig.db` resolved to, including the developer's + real local database. Reading `os.environ` directly is the only way to + distinguish "explicitly set" from "defaulted". + Only the real `APP_SQLITE_PATH` environment variable counts here — a value + from an `.env` file is deliberately NOT treated as "explicitly set" (this + resolver never loads `.env`). `.env` is a developer convenience with + quietly-applied defaults, exactly the kind of implicit source that caused + this bug; requiring a real env var keeps "what database does this + migration run touch" answerable by looking at the process environment + alone, and it means an `.env` file can never silently retarget migrations + for anyone running Alembic (locally or in CI) who does not also have that + exact environment variable exported. +3. The `sqlalchemy.url` value from `alembic.ini` — or from + `Config.set_main_option("sqlalchemy.url", ...)`, which is indistinguishable + from `alembic.ini` by the time it reaches `context.config.get_main_option` + — as the fallback when neither of the above applies. + +This is the single place that decides which database Alembic touches — never +read `alembic.ini`'s `sqlalchemy.url` directly anywhere else, so migrations +can never silently target a different database than the one the running app +actually uses. +""" + +from __future__ import annotations + +import os + + +def resolve_database_url(x_args: dict[str, str], ini_url: str | None) -> str | None: + """Resolve the database URL from -x args, a real APP_SQLITE_PATH env var, + then the ini fallback.""" + explicit_url = x_args.get("sqlalchemy.url") + if explicit_url: + return explicit_url + + sqlite_path = os.environ.get("APP_SQLITE_PATH") + if sqlite_path: + return f"sqlite:///{sqlite_path}" + + return ini_url + + +def coerce_sqlite_async(url: str) -> str: + """sqlite:/// → sqlite+aiosqlite:/// so the async engine can use it.""" + if url.startswith("sqlite:///"): + return url.replace("sqlite:///", "sqlite+aiosqlite:///", 1) + return url diff --git a/backend/migrations/versions/006_transferable_days_reported.py b/backend/migrations/versions/006_transferable_days_reported.py new file mode 100644 index 0000000..62575bf --- /dev/null +++ b/backend/migrations/versions/006_transferable_days_reported.py @@ -0,0 +1,44 @@ +"""Add parental_leave_fetch.transferable_days_reported. + +Tracks provenance of `transferable_days_left`: True when it is FK's own +reported figure (parsed from the "Dagar ni kan föra över" panel on the +`min-foraldrapenning` e-service page, or a detailed manual entry that +supplied it directly); False when `app.services.leave_snapshot.build_snapshot` +derived it via the transferable-first fallback (manual entry with no split +known, or a failed parse of that panel). + +Existing rows default to false, which is accurate — every snapshot persisted +before this migration was derived via the fallback, never FK-reported. + +A plain `ADD COLUMN` suffices here (like 003's `take_pl_on_red_days`, unlike +002's CHECK-constraint change which needed a full table rebuild) since +SQLite supports adding a NOT NULL column with a constant DEFAULT directly. + +Must stay byte-for-byte equivalent to the `parental_leave_fetch` table in +`app/db/connection.py`'s `_FULL_SCHEMA` — +`tests/integration/test_schema_parity.py` asserts exactly that. + +Revision ID: 006 +Revises: 005 +Create Date: 2026-09-20 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "006" +down_revision: str | None = "005" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE parental_leave_fetch " + "ADD COLUMN transferable_days_reported INTEGER NOT NULL DEFAULT 0" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE parental_leave_fetch DROP COLUMN transferable_days_reported") diff --git a/backend/tests/fixtures/fk_min_foraldrapenning_synthetic.html b/backend/tests/fixtures/fk_min_foraldrapenning_synthetic.html new file mode 100644 index 0000000..a33f72a --- /dev/null +++ b/backend/tests/fixtures/fk_min_foraldrapenning_synthetic.html @@ -0,0 +1,89 @@ + + + + + + Min föräldrapenning - Försäkringskassan (synthetic fixture) + + +

+
+
+

Testbarn

+
+ +
+
+

+ +

+
+
+ +
+
+

Du kan föra över

+ +
+
+
+

Vem kan du föra över dagar till?

+
+
+ Förälder +
Du kan föra över alla dina dagar till barnets andra förälder.
+

Annan person

+
Du kan föra över dagar till en annan person som är folkbokförd med barnet.
+

Varje vårdnadshavare kan föra över 45 dagar till andra personer. + Om du har ensam vårdnad kan du föra över 90 dagar till andra personer.

+
+
+
+
+

84 dagar på sjukpenningnivå

+

45 dagar på lägstanivå

+
+ +
+

Annan förälder kan föra över

+

26 dagar på sjukpenningnivå

+

45 dagar på lägstanivå

+
+ + +
+

Reserverade dagar

+

Vissa dagar är reserverade och går inte att föra över till någon annan.

+
+ +
+
+
+
+
+
+ + diff --git a/backend/tests/unit/test_fetch_data_router.py b/backend/tests/unit/test_fetch_data_router.py index 6e3f084..b54ed7d 100644 --- a/backend/tests/unit/test_fetch_data_router.py +++ b/backend/tests/unit/test_fetch_data_router.py @@ -320,11 +320,13 @@ class TestScraperEndpointsEnabled: child_id: UUID, persist_callback: Any, owner_user_id: UUID, + child_name: str | None = None, ) -> str: captured["workspace_id"] = workspace_id captured["personal_data_uuid"] = personal_data_uuid captured["child_id"] = child_id captured["owner_user_id"] = owner_user_id + captured["child_name"] = child_name return "fake-session-id" monkeypatch.setattr(fetch_data_router._scraper_service, "start_session", fake_start_session) @@ -410,6 +412,7 @@ class TestPersistCallbackPartnerDeduction: child_id: UUID, persist_callback: Any, owner_user_id: UUID, + child_name: str | None = None, ) -> str: captured["persist_callback"] = persist_callback return "fake-session-id" diff --git a/backend/tests/unit/test_fk_scraper.py b/backend/tests/unit/test_fk_scraper.py index 7e8b797..82d864a 100644 --- a/backend/tests/unit/test_fk_scraper.py +++ b/backend/tests/unit/test_fk_scraper.py @@ -27,9 +27,13 @@ from app.scrapers.fk_scraper import ( FamilySummary, FKScraperService, PartnerDeduction, + TransferableDays, + _apply_transferable, _deduce_partner, _do_extract, _extract_family_summary, + _extract_transferable_days, + _SEL, _SessionState, _set_status, _sessions, @@ -642,6 +646,578 @@ class TestDeducePartner: assert deduction.lagsta_niva_left >= 0 +# --------------------------------------------------------------------------- +# Transferable days ("Dagar ni kan föra över") -> min-foraldrapenning page +# --------------------------------------------------------------------------- + +# Small balanced-
HTML toolkit for the mock page below. Regex alone +# (as `_make_mock_page` above uses) can't safely slice `.barn-sektion` or +# `.fora-over-group`, since they contain further nested
s (the tooltip, +# in particular) — a naive ".*?
" would stop at the first nested close. + + +def _balanced_div_slice(html: str, open_tag_start: int) -> str: + """From the index of a '/
pairs.""" + depth = 0 + for m in re.finditer(r"]*>|", html[open_tag_start:], re.IGNORECASE): + if m.group(0).lower().startswith(" in fixture") + + +def _find_divs_by_class(html: str, class_name: str) -> list[str]: + """Balanced outer-HTML slices for every
+ in document order (non-overlapping — resumes the search after each + match's own closing tag, so nested divs of the same class, unused here, + aren't double-counted).""" + pattern = re.compile(rf']*\bclass="[^"]*\b{re.escape(class_name)}\b[^"]*"[^>]*>') + results: list[str] = [] + pos = 0 + while True: + m = pattern.search(html, pos) + if not m: + break + sl = _balanced_div_slice(html, m.start()) + results.append(sl) + pos = m.start() + len(sl) + return results + + +def _find_barn_rubrik_text(section_html: str) -> str | None: + idx = section_html.find("barn-rubrik") + if idx == -1: + return None + m = re.search(r"(.*?)", section_html[idx:], re.DOTALL) + return m.group(1).strip() if m else None + + +def _direct_child_p_texts(div_slice: str) -> list[str]: + """Text of

elements that are direct children of div_slice's own root + element — i.e. not nested inside any further

— mirroring + Playwright's `:scope > p`.""" + open_m = re.match(r"]*>", div_slice, re.IGNORECASE) + inner = div_slice[open_m.end() : -len("
")] + texts: list[str] = [] + depth = 0 + for m in re.finditer(r"<(/?)(div|p)\b[^>]*>", inner, re.IGNORECASE): + closing = m.group(1) == "/" + tag = m.group(2).lower() + if tag == "div": + depth += -1 if closing else 1 + continue + if tag == "p" and not closing and depth == 0: + end_m = re.search(r"", inner[m.end() :], re.IGNORECASE) + if end_m: + raw = inner[m.end() : m.end() + end_m.start()] + texts.append(re.sub(r"<[^>]+>", "", raw).strip()) + return texts + + +def _make_min_fp_mock_page(html: str): + """Build a minimal async mock Page for the min-foraldrapenning "Dagar ni + kan föra över" panel selectors. Balanced-
slicing + regex over the + fixture HTML, not a real browser — see `_find_divs_by_class` above. + """ + min_fp_slices = _find_divs_by_class(html, "min-fp") + min_fp_html = min_fp_slices[0] if min_fp_slices else "" + barn_sektioner = _find_divs_by_class(min_fp_html, "barn-sektion") if min_fp_html else [] + + def _make_group_locator(group_html: str) -> MagicMock: + loc = MagicMock() + + def sub_locator(sel: str) -> MagicMock: + if sel == _SEL.MINFP_LABEL: + m = re.search( + r']*class="[^"]*minfp-label[^"]*"[^>]*>(.*?)', + group_html, + re.DOTALL, + ) + sub = MagicMock() + sub.count = AsyncMock(return_value=1 if m else 0) + if m: + text = re.sub(r"<[^>]+>", "", m.group(1)).strip() + sub.first.inner_text = AsyncMock(return_value=text) + return sub + if sel == ":scope > p": + sub = MagicMock() + sub.all_inner_texts = AsyncMock(return_value=_direct_child_p_texts(group_html)) + return sub + raise AssertionError(f"unexpected selector on group locator: {sel!r}") + + loc.locator.side_effect = sub_locator + return loc + + def _make_panel_locator(panel_html: str) -> MagicMock: + loc = MagicMock() + loc.count = AsyncMock(return_value=1) + + def sub_locator(sel: str) -> MagicMock: + if sel == _SEL.EXPANDABLE_BUTTON: + m = re.search( + r']*class="[^"]*expandable-paragraph__button[^"]*"[^>]*>', + panel_html, + ) + sub = MagicMock() + sub.count = AsyncMock(return_value=1 if m else 0) + if m: + aria_m = re.search(r'aria-expanded="(true|false)"', m.group(0)) + sub.first.get_attribute = AsyncMock( + return_value=aria_m.group(1) if aria_m else None + ) + sub.first.click = AsyncMock() + return sub + if sel == _SEL.FORA_OVER_GROUP: + groups = _find_divs_by_class(panel_html, "fora-over-group") + sub = MagicMock() + sub.count = AsyncMock(return_value=len(groups)) + sub.nth.side_effect = lambda i: _make_group_locator(groups[i]) + return sub + raise AssertionError(f"unexpected selector on panel locator: {sel!r}") + + loc.locator.side_effect = sub_locator + return loc + + def _make_section_locator(section_html: str) -> MagicMock: + loc = MagicMock() + + def sub_locator(sel: str) -> MagicMock: + if sel == _SEL.BARN_RUBRIK: + text = _find_barn_rubrik_text(section_html) + sub = MagicMock() + sub.count = AsyncMock(return_value=1 if text is not None else 0) + sub.first.inner_text = AsyncMock(return_value=text or "") + return sub + if sel == _SEL.PANEL_FORA_OVER_DAGAR: + panels = _find_divs_by_class(section_html, "panel-fora-over-dagar") + sub = MagicMock() + sub.count = AsyncMock(return_value=len(panels)) + if panels: + panel_loc = _make_panel_locator(panels[0]) + sub.locator.side_effect = panel_loc.locator.side_effect + return sub + raise AssertionError(f"unexpected selector on section locator: {sel!r}") + + loc.locator.side_effect = sub_locator + return loc + + def _make_sections_root_locator() -> MagicMock: + loc = MagicMock() + loc.count = AsyncMock(return_value=len(barn_sektioner)) + if barn_sektioner: + loc.first = _make_section_locator(barn_sektioner[0]) + loc.nth.side_effect = lambda i: _make_section_locator(barn_sektioner[i]) + return loc + + page = MagicMock() + page.goto = AsyncMock() + page.wait_for_selector = AsyncMock() + page.wait_for_function = AsyncMock() + + def locator_factory(sel: str) -> MagicMock: + if sel == _SEL.MIN_FP_BARN_SEKTION: + return _make_sections_root_locator() + raise AssertionError(f"unexpected top-level selector: {sel!r}") + + page.locator.side_effect = locator_factory + return page + + +_MULTI_CHILD_MIN_FP_HTML = """ +
+
+

Testbarn

+
+
+

Du kan föra över

+

84 dagar på sjukpenningnivå

+

45 dagar på lägstanivå

+
+
+

Annan förälder kan föra över

+

26 dagar på sjukpenningnivå

+

45 dagar på lägstanivå

+
+
+
+
+

Andrabarn

+
+
+

Du kan föra över

+

10 dagar på sjukpenningnivå

+

5 dagar på lägstanivå

+
+
+
+
+""" + +_SOLO_PARENT_MIN_FP_HTML = """ +
+
+

Testbarn

+
+
+

Du kan föra över

+

84 dagar på sjukpenningnivå

+

45 dagar på lägstanivå

+
+
+
+
+""" + +_NO_TRANSFER_PANEL_MIN_FP_HTML = """ +
+
+

Testbarn

+
+
+
+""" + +_INCOMPLETE_MINE_MIN_FP_HTML = """ +
+
+

Testbarn

+
+
+

Du kan föra över

+

84 dagar på sjukpenningnivå

+
+
+
+
+""" + + +def _min_fp_state() -> _SessionState: + return _SessionState( + session_id="min-fp-test", + workspace_id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + child_id=uuid.uuid4(), + owner_user_id=uuid.uuid4(), + ) + + +class TestExtractTransferableDays: + """`_extract_transferable_days` reads FK's own "Dagar ni kan föra över" + figures off the min-foraldrapenning page — the fix for the bug where + `transferable_days_left` could never be reconstructed from #/valjdagar + alone (see facts.md: FK reports 129/71, the old derivation gave 49/0).""" + + async def test_both_groups_parsed_golden_regression(self) -> None: + """Pinning regression: mine 84+45=129, partner 26+45=71 — the exact + `facts.md` figures FK itself reports, which the old arithmetic + derivation could never reproduce.""" + html = _load_fixture("fk_min_foraldrapenning_synthetic.html") + page = _make_min_fp_mock_page(html) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is not None + assert result.mine_total == 129 + assert result.partner_total == 71 + + async def test_tooltip_decoys_ignored(self) -> None: + """The tooltip's decoy "45 dagar"/"90 dagar" text must not leak into + `mine_sjukpenning`/`mine_lagsta_niva` via a non-scoped read.""" + html = _load_fixture("fk_min_foraldrapenning_synthetic.html") + page = _make_min_fp_mock_page(html) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is not None + assert result.mine_sjukpenning == 84 + assert result.mine_lagsta_niva == 45 + + async def test_collapsed_accordion_still_yields_correct_numbers(self) -> None: + html = _load_fixture("fk_min_foraldrapenning_synthetic.html").replace( + 'aria-expanded="true"', 'aria-expanded="false"' + ) + page = _make_min_fp_mock_page(html) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is not None + assert result.mine_total == 129 + assert result.partner_total == 71 + + async def test_panel_absent_returns_none(self) -> None: + page = _make_min_fp_mock_page(_NO_TRANSFER_PANEL_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is None + + async def test_single_section_used_without_name_match(self) -> None: + """A single `.barn-sektion` is used as-is — no `child_name` needed.""" + page = _make_min_fp_mock_page(_SOLO_PARENT_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), None) + + assert result is not None + assert result.mine_total == 129 + + async def test_multi_child_correct_section_chosen_by_name(self) -> None: + page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), "Andrabarn") + + assert result is not None + assert result.mine_sjukpenning == 10 + assert result.mine_lagsta_niva == 5 + + async def test_multi_child_unmatched_name_returns_none(self) -> None: + page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), "Okänt Barn") + + assert result is None + + async def test_multi_child_no_name_returns_none(self) -> None: + """Never guess a section when there's more than one and no name to match.""" + page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), None) + + assert result is None + + async def test_solo_parent_partner_fields_none(self) -> None: + page = _make_min_fp_mock_page(_SOLO_PARENT_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is not None + assert result.partner_sjukpenning is None + assert result.partner_lagsta_niva is None + assert result.partner_total is None + + async def test_incomplete_mine_group_returns_none(self) -> None: + page = _make_min_fp_mock_page(_INCOMPLETE_MINE_MIN_FP_HTML) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is None + + async def test_exception_during_parse_returns_none(self) -> None: + """Any unexpected failure (e.g. a locator raising) degrades to None + rather than propagating — mirrors `_extract_family_summary`.""" + page = _make_min_fp_mock_page(_load_fixture("fk_min_foraldrapenning_synthetic.html")) + page.wait_for_selector = AsyncMock(side_effect=RuntimeError("boom")) + + result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn") + + assert result is None + + +class TestApplyTransferable: + """`_apply_transferable` re-derives `data` with FK's own reported split.""" + + def _mine(self, **overrides: object) -> ParentalLeaveFetch: + now = datetime.now() + base = dict( + id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + workspace_id=uuid.uuid4(), + child_id=uuid.uuid4(), + source="scraped", + fetched_at=now, + total_days=240, + days_used=140, + days_left=100, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=10, + dubbeldagar_left=20, + tier_data=TierData( + sjukpenning_total=195, + sjukpenning_used=135, + sjukpenning_left=60, + lagsta_niva_total=45, + lagsta_niva_used=5, + lagsta_niva_left=40, + ), + created_at=now, + updated_at=now, + ) + base.update(overrides) + return ParentalLeaveFetch(**base) + + def test_valid_transferable_updates_reserved_split(self) -> None: + mine = self._mine() + transferable = TransferableDays(mine_sjukpenning=50, mine_lagsta_niva=10) + + result = _apply_transferable(mine, transferable) + + assert result.transferable_days_left == 60 + assert result.reserved_days_left == 40 # days_left(100) - transferable(60) + assert result.transferable_days_reported is True + + def test_implausible_transferable_returns_original_unchanged(self) -> None: + """transferable_days_left > days_left is impossible — build_snapshot + raises ValidationError (reserved_days_left would go negative), and + the caller's original estimate must survive unchanged, never raise.""" + mine = self._mine() + transferable = TransferableDays(mine_sjukpenning=500, mine_lagsta_niva=500) + + result = _apply_transferable(mine, transferable) + + assert result is mine + + +class TestDoExtractTransferableIntegration: + """`_do_extract`'s new transferable-days block, end to end. + + `_mine`'s figures mirror `facts.md` exactly: days_left=94+45=139, and the + old transferable-first derivation (no FK figure available) computes + transferable_days_left=49 — the bug this feature fixes (FK itself + reports 129). The family fixture below likewise mirrors facts.md's + "210 av 480 dagar kvar totalt" / "Annan förälder: 71 dagar kvar". + """ + + _FAMILY_HTML = """ +
+
+ 210 + av 480 dagar kvar efter ansökan +
+ (120 dagar) + (90 dagar) +
+
+
+ """ + + def setup_method(self) -> None: + _sessions.clear() + + def teardown_method(self) -> None: + _sessions.clear() + + def _mine(self, **overrides: object) -> ParentalLeaveFetch: + now = datetime.now() + base = dict( + id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + workspace_id=uuid.uuid4(), + child_id=uuid.uuid4(), + source="scraped", + fetched_at=now, + total_days=240, + days_used=101, + days_left=139, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=49, + dubbeldagar_left=39, + tier_data=TierData( + sjukpenning_total=195, + sjukpenning_used=101, + sjukpenning_left=94, + lagsta_niva_total=45, + lagsta_niva_used=0, + lagsta_niva_left=45, + ), + created_at=now, + updated_at=now, + ) + base.update(overrides) + return ParentalLeaveFetch(**base) + + async def _run( + self, + mine: ParentalLeaveFetch, + min_fp_html: str, + family_html: str = "", + child_name: str | None = "Testbarn", + ) -> _SessionState: + sid = str(uuid.uuid4()) + state = _SessionState( + session_id=sid, + workspace_id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + child_id=uuid.uuid4(), + owner_user_id=uuid.uuid4(), + child_name=child_name, + ) + _sessions[sid] = state + + min_fp_page = _make_min_fp_mock_page(min_fp_html) + family_page = _make_mock_page(family_html) + + page = MagicMock() + page.wait_for_selector = AsyncMock() + page.wait_for_function = AsyncMock() + page.goto = min_fp_page.goto + + def locator_factory(sel: str) -> MagicMock: + if sel == _SEL.MIN_FP_BARN_SEKTION: + return min_fp_page.locator(sel) + if sel.startswith(_SEL.FAMILY): + return family_page.locator(sel) + loc = MagicMock() + loc.first.click = AsyncMock() + loc.count = AsyncMock(return_value=0) + return loc + + page.locator.side_effect = locator_factory + + with patch( + "app.scrapers.fk_scraper._extract_parental_leave", new_callable=AsyncMock + ) as mock_extract: + mock_extract.return_value = mine + await _do_extract(page, state) + + return state + + async def test_transferable_days_applied_to_result(self) -> None: + """Golden regression via `_do_extract`: the persisted result carries + FK's own 129, not the old broken derivation's 49.""" + mine = self._mine() + state = await self._run(mine, _load_fixture("fk_min_foraldrapenning_synthetic.html")) + + assert state.status == "done" + assert state.result is not None + assert state.result.transferable_days_left == 129 + assert state.result.reserved_days_left == 10 # 139 - 129 + + async def test_partner_deduction_picks_up_transferable_days(self) -> None: + mine = self._mine() + state = await self._run( + mine, + _load_fixture("fk_min_foraldrapenning_synthetic.html"), + family_html=self._FAMILY_HTML, + ) + + assert state.partner_deduction is not None + assert state.partner_deduction.days_left == 71 + assert state.partner_deduction.transferable_days_left == 71 + + async def test_exception_in_block_leaves_data_untouched(self) -> None: + """Mirrors `test_absent_family_panel_does_not_affect_main_extraction`: + a failure in the new block must never affect the caller's own, + already-validated `data`.""" + mine = self._mine() + with patch( + "app.scrapers.fk_scraper._extract_transferable_days", + new_callable=AsyncMock, + side_effect=RuntimeError("boom"), + ): + state = await self._run(mine, _load_fixture("fk_min_foraldrapenning_synthetic.html")) + + assert state.status == "done" + assert state.result is mine + assert state.result.transferable_days_left == 49 # unchanged from the original estimate + + # --------------------------------------------------------------------------- # Session state machine # --------------------------------------------------------------------------- @@ -728,6 +1304,7 @@ class TestSessionStateMachine: "sjukpenning_left": 130, "lagsta_niva_left": 20, "dubbeldagar_left": 20, + "transferable_days_left": None, } async def test_poll_done_partner_deduction_is_none_when_absent(self) -> None: diff --git a/backend/tests/unit/test_migrations_url_resolution.py b/backend/tests/unit/test_migrations_url_resolution.py new file mode 100644 index 0000000..59e1b21 --- /dev/null +++ b/backend/tests/unit/test_migrations_url_resolution.py @@ -0,0 +1,85 @@ +"""Tests for `migrations.url_resolution` — the Alembic database-URL resolver. + +Regression coverage for the bug where `resolve_database_url` read +`app.config.Settings().sqlite_path` (which always has a default value) ahead +of the Alembic `ini_url`, so `ini_url` — including a `sqlalchemy.url` set +programmatically via `Config.set_main_option(...)`, as integration tests do — +was unreachable dead code and migrations always ran against +`./foraldraledig.db` regardless of what the caller asked for. See +`migrations/env.py`'s `_resolve_url` for how this plugs into Alembic. +""" + +from migrations.url_resolution import coerce_sqlite_async, resolve_database_url + + +class TestResolveDatabaseUrl: + def test_app_sqlite_path_set_wins_over_ini_url(self, monkeypatch) -> None: + monkeypatch.setenv("APP_SQLITE_PATH", "/tmp/scratch/other.db") + + url = resolve_database_url(x_args={}, ini_url="sqlite:///./foraldraledig.db") + + assert url == "sqlite:////tmp/scratch/other.db" + + def test_app_sqlite_path_unset_falls_back_to_ini_url(self, monkeypatch) -> None: + monkeypatch.delenv("APP_SQLITE_PATH", raising=False) + + url = resolve_database_url(x_args={}, ini_url="sqlite:///./foraldraledig.db") + + assert url == "sqlite:///./foraldraledig.db" + + def test_app_sqlite_path_empty_string_treated_as_unset(self, monkeypatch) -> None: + monkeypatch.setenv("APP_SQLITE_PATH", "") + + url = resolve_database_url(x_args={}, ini_url="sqlite:///./ini-fallback.db") + + assert url == "sqlite:///./ini-fallback.db" + + def test_x_argument_wins_over_env_and_ini(self, monkeypatch) -> None: + monkeypatch.setenv("APP_SQLITE_PATH", "/tmp/scratch/other.db") + + url = resolve_database_url( + x_args={"sqlalchemy.url": "sqlite:///./explicit-scratch.db"}, + ini_url="sqlite:///./foraldraledig.db", + ) + + assert url == "sqlite:///./explicit-scratch.db" + + def test_ini_url_used_when_env_var_unset(self, monkeypatch) -> None: + monkeypatch.delenv("APP_SQLITE_PATH", raising=False) + + url = resolve_database_url(x_args={}, ini_url="sqlite:///./ini-fallback.db") + + assert url == "sqlite:///./ini-fallback.db" + + def test_regression_programmatic_ini_url_wins_over_app_default(self, monkeypatch) -> None: + """The exact real-world scenario that regressed: a caller sets + `sqlalchemy.url` programmatically on the Alembic `Config` (as + `tests/integration/test_migration_004_backfill.py` does) and no + `APP_SQLITE_PATH` is set. This must resolve to that ini value, NOT to + `app.config.Settings`'s default of `./foraldraledig.db`. + """ + monkeypatch.delenv("APP_SQLITE_PATH", raising=False) + + url = resolve_database_url(x_args={}, ini_url="sqlite:////tmp/whatever.db") + + assert url == "sqlite:////tmp/whatever.db" + assert url != "sqlite:///./foraldraledig.db" + + +class TestCoerceSqliteAsync: + def test_sqlite_url_coerced_to_aiosqlite(self) -> None: + assert ( + coerce_sqlite_async("sqlite:///./foraldraledig.db") + == "sqlite+aiosqlite:///./foraldraledig.db" + ) + + def test_resolved_env_url_is_coerced(self, monkeypatch) -> None: + monkeypatch.setenv("APP_SQLITE_PATH", "/tmp/scratch/other.db") + + url = coerce_sqlite_async(resolve_database_url(x_args={}, ini_url=None)) + + assert url == "sqlite+aiosqlite:////tmp/scratch/other.db" + + def test_non_sqlite_url_left_untouched(self) -> None: + postgres_url = "postgresql+asyncpg://user:pass@host/db" + assert coerce_sqlite_async(postgres_url) == postgres_url diff --git a/backend/tests/unit/test_services/test_leave_snapshot.py b/backend/tests/unit/test_services/test_leave_snapshot.py index d593278..b359db2 100644 --- a/backend/tests/unit/test_services/test_leave_snapshot.py +++ b/backend/tests/unit/test_services/test_leave_snapshot.py @@ -120,6 +120,101 @@ class TestReservedTransferableSplit: assert fetch.transferable_days_left == 0 +class TestTransferableDaysReported: + """`transferable_days_left` as FK's own reported figure (vs. the + transferable-first fallback) — ground-truth numbers from real FK data, + see the task that added this: user 129 reported -> reserved 10; partner + 71 reported -> reserved 0. + """ + + def test_fk_reported_transferable_user_figures(self) -> None: + fetch = build_snapshot( + **_ids(), + source="scraped", + total_days=240, + sjukpenning_left=94, + lagsta_niva_left=45, + dubbeldagar_left=0, + transferable_days_left=129, + ) + assert fetch.days_left == 139 + assert fetch.reserved_days_left == 10 + assert fetch.transferable_days_left == 129 + assert fetch.transferable_days_reported is True + + def test_fk_reported_transferable_partner_figures(self) -> None: + fetch = build_snapshot( + **_ids(), + source="scraped", + total_days=240, + sjukpenning_left=26, + lagsta_niva_left=45, + dubbeldagar_left=0, + transferable_days_left=71, + ) + assert fetch.days_left == 71 + assert fetch.reserved_days_left == 0 + assert fetch.transferable_days_left == 71 + assert fetch.transferable_days_reported is True + + def test_both_reserved_and_transferable_overrides_raises(self) -> None: + with pytest.raises(ValidationError, match="mutually exclusive") as exc_info: + build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=94, + lagsta_niva_left=45, + dubbeldagar_left=0, + reserved_days_left=10, + transferable_days_left=129, + ) + assert exc_info.value.message_key == "parentalLeave.conflictingTransferableOverride" + + def test_implausible_reported_transferable_raises(self) -> None: + """days_left is 139; a reported transferable of 200 would make + reserved_days_left negative (139 - 200 = -61) — the existing + negative-reserved-days validator must catch it.""" + with pytest.raises(ValidationError, match="negative") as exc_info: + build_snapshot( + **_ids(), + source="scraped", + total_days=240, + sjukpenning_left=94, + lagsta_niva_left=45, + dubbeldagar_left=0, + transferable_days_left=200, + ) + assert exc_info.value.message_key == "parentalLeave.negativeReservedDaysLeft" + + def test_derived_fallback_sets_transferable_days_reported_false(self) -> None: + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=60, + lagsta_niva_left=40, + dubbeldagar_left=60, + ) + assert fetch.reserved_days_left == 90 + assert fetch.transferable_days_left == 10 + assert fetch.transferable_days_reported is False + + def test_explicit_reserved_days_left_also_not_reported(self) -> None: + """A caller-supplied `reserved_days_left` is a user estimate too — + only `transferable_days_left` counts as FK-reported.""" + fetch = build_snapshot( + **_ids(), + source="manual", + total_days=240, + sjukpenning_left=150, + lagsta_niva_left=20, + dubbeldagar_left=60, + reserved_days_left=60, + ) + assert fetch.transferable_days_reported is False + + class TestNegativeAndOverTotalGuards: def test_negative_sjukpenning_left_raises(self) -> None: with pytest.raises(ValidationError, match="negative"): diff --git a/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx b/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx index 9340496..9610392 100644 --- a/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx +++ b/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx @@ -53,8 +53,25 @@ describe('ParentalLeaveBreakdown', () => { render() const notes = screen.getAllByTestId('reserved-days-note') expect(notes).toHaveLength(1) - expect(notes[0]).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade`) - expect(notes[0]).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`) + expect(notes[0]).toHaveTextContent( + `${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern.`, + ) + }) + + it('renders the transferable-days value plain when it was reported by FK', () => { + render() + const cell = screen.getByTestId('breakdown-transferable-pd-a') + expect(cell).toHaveTextContent('50') + expect(cell.querySelector('.badge')).not.toBeInTheDocument() + }) + + it('marks the transferable-days value as an estimate when it was derived, not reported by FK', () => { + const derived = { ...alex, data: { ...alexData, transferableDaysReported: false } } + render() + const cell = screen.getByTestId('breakdown-transferable-pd-a') + expect(cell).toHaveTextContent('Antaget') + expect(cell).toHaveTextContent('50') + expect(cell.querySelector('.badge')).toBeInTheDocument() }) it('shows the scraped source and date per parent', () => { @@ -67,6 +84,13 @@ describe('ParentalLeaveBreakdown', () => { expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Manuellt angiven') }) + it('shows the deduced source label', () => { + render() + const provenance = screen.getByTestId('breakdown-provenance-pd-a') + expect(provenance).toHaveTextContent('Härlett från Försäkringskassan') + expect(provenance).not.toHaveTextContent('Manuellt angiven') + }) + it('formats the provenance date using the en-US locale when the language is English', () => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) vi.stubGlobal('localStorage', { diff --git a/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx b/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx index 5d7f948..1b9af46 100644 --- a/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx +++ b/frontend/src/components/dashboard/ParentalLeaveBreakdown.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react' -import type { ParentalLeaveFetch } from '@/types' +import type { ParentalLeaveFetch, ParentalLeaveSource } from '@/types' import { LEAVE_RULES } from '@/utils/constants' -import { useTranslation } from '@/i18n' +import { useTranslation, type TranslationKey } from '@/i18n' /** One parent's balance snapshot — `data` is `null` when nothing has been * fetched or entered for them yet. */ @@ -15,6 +15,13 @@ export interface ParentalLeaveBreakdownProps { parents: ParentalLeaveBreakdownParent[] } +/** Map parental leave source values to their i18n translation keys. */ +const SOURCE_LABEL_KEYS: Record = { + scraped: 'parentalLeaveBreakdown.sourceScraped', + manual: 'parentalLeaveBreakdown.sourceManual', + deduced: 'parentalLeaveBreakdown.sourceDeduced', +} + /** * The raw balance snapshot per parent (one column each): what is left of * each tier, transferable and double days, and where the figures came from. @@ -74,7 +81,18 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps) {t('parentalLeaveBreakdown.transferableDays')} - {parents.map((parent) => cell(parent, 'breakdown-transferable', (d) => d.transferableDaysLeft))} + {parents.map((parent) => + cell(parent, 'breakdown-transferable', (d) => ( + <> + {!d.transferableDaysReported && ( + + {t('parentalLeaveBreakdown.estimatedBadge')} + + )} + {d.transferableDaysLeft} + + )), + )} {t('parentalLeaveBreakdown.dubbeldagarLeft')} @@ -88,11 +106,7 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)
  • {parent.name}{' '} {parent.data - ? `${ - parent.data.source === 'scraped' - ? t('parentalLeaveBreakdown.sourceScraped') - : t('parentalLeaveBreakdown.sourceManual') - } ${formatDate(parent.data.fetchedAt)}` + ? `${t(SOURCE_LABEL_KEYS[parent.data.source])} ${formatDate(parent.data.fetchedAt)}` : t('parentalLeaveBreakdown.noBalance')}
  • ))} @@ -101,7 +115,6 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)

    {t('parentalLeaveBreakdown.reservedNote', { reserved: LEAVE_RULES.reservedDaysPerParent, - transferable: LEAVE_RULES.transferableDaysPerParent, })}

    diff --git a/frontend/src/i18n/LanguageContext.test.tsx b/frontend/src/i18n/LanguageContext.test.tsx index 80a2386..1e59c34 100644 --- a/frontend/src/i18n/LanguageContext.test.tsx +++ b/frontend/src/i18n/LanguageContext.test.tsx @@ -106,11 +106,7 @@ describe('LanguageContext', () => { }) it('interpolates multiple params', () => { - expect( - translate('en', 'parentalLeaveBreakdown.reservedNote', { reserved: 90, transferable: 45 }), - ).toBe( - '90 of each parent\'s days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).', - ) + expect(translate('en', 'balance.statusLine', { left: 90, total: 240 })).toBe('90 of 240 days left') }) it('leaves an unmatched placeholder untouched', () => { diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts index d9321fa..820a3e3 100644 --- a/frontend/src/i18n/en/dashboard.ts +++ b/frontend/src/i18n/en/dashboard.ts @@ -25,11 +25,13 @@ export const dashboard = { 'overviewMetrics.defaultTitle': 'Overview', 'parentalLeaveBreakdown.dubbeldagarLeft': 'Double days left', 'parentalLeaveBreakdown.empty': 'No balances fetched yet.', + 'parentalLeaveBreakdown.estimatedBadge': 'Assumed', 'parentalLeaveBreakdown.lagstaNiva': 'Minimum rate', 'parentalLeaveBreakdown.leftOfTotal': '{left} of {total}', 'parentalLeaveBreakdown.noBalance': 'No balance yet', - 'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).', + 'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent.', 'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå (income-based)', + 'parentalLeaveBreakdown.sourceDeduced': 'Derived from Försäkringskassan', 'parentalLeaveBreakdown.sourceManual': 'Manually entered', 'parentalLeaveBreakdown.sourceScraped': 'Fetched from Försäkringskassan', 'parentalLeaveBreakdown.transferableDays': 'Transferable days', diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts index 7369dd1..4598aaf 100644 --- a/frontend/src/i18n/sv/dashboard.ts +++ b/frontend/src/i18n/sv/dashboard.ts @@ -25,11 +25,13 @@ export const dashboard = { 'overviewMetrics.defaultTitle': 'Översikt', 'parentalLeaveBreakdown.dubbeldagarLeft': 'Dubbeldagar kvar', 'parentalLeaveBreakdown.empty': 'Inga saldon hämtade än.', + 'parentalLeaveBreakdown.estimatedBadge': 'Antaget', 'parentalLeaveBreakdown.lagstaNiva': 'Lägstanivå', 'parentalLeaveBreakdown.leftOfTotal': '{left} av {total}', 'parentalLeaveBreakdown.noBalance': 'Inget saldo än', - 'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).', + 'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern.', 'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå', + 'parentalLeaveBreakdown.sourceDeduced': 'Härlett från Försäkringskassan', 'parentalLeaveBreakdown.sourceManual': 'Manuellt angiven', 'parentalLeaveBreakdown.sourceScraped': 'Hämtad från Försäkringskassan', 'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar', diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 72f2591..3f27b67 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -117,6 +117,7 @@ describe('API response mappers (snake_case → camelCase)', () => { reserved_days_total: 90, reserved_days_left: 80, transferable_days_left: 100, + transferable_days_reported: false, dubbeldagar_left: 60, tier_data: { sjukpenning_total: 390, @@ -149,6 +150,7 @@ describe('API response mappers (snake_case → camelCase)', () => { expect(result.reservedDaysTotal).toBe(90) expect(result.reservedDaysLeft).toBe(80) expect(result.transferableDaysLeft).toBe(100) + expect(result.transferableDaysReported).toBe(false) expect(result.dubbeldagarLeft).toBe(60) expect(result.createdAt).toBe('2026-06-14T17:09:47') expect(result.updatedAt).toBe('2026-06-14T17:09:47') @@ -1068,6 +1070,7 @@ describe('endpoint functions (representative coverage)', () => { reserved_days_total: 90, reserved_days_left: 90, transferable_days_left: 100, + transferable_days_reported: false, dubbeldagar_left: 60, tier_data: { sjukpenning_total: 390, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8e4531f..0c05a63 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -83,6 +83,7 @@ export interface RawParentalLeaveFetch { reserved_days_total: number reserved_days_left: number transferable_days_left: number + transferable_days_reported: boolean dubbeldagar_left: number tier_data: RawTierData created_at: string @@ -103,6 +104,7 @@ function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch { reservedDaysTotal: raw.reserved_days_total, reservedDaysLeft: raw.reserved_days_left, transferableDaysLeft: raw.transferable_days_left, + transferableDaysReported: raw.transferable_days_reported, dubbeldagarLeft: raw.dubbeldagar_left, tierData: toTierData(raw.tier_data), createdAt: raw.created_at, diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 2def8d3..29b15e4 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -603,6 +603,10 @@ color: var(--c-text); } +.parental-leave-breakdown__badge { + margin-right: var(--sp-1); +} + .parental-leave-breakdown__reserved-note { margin: 0; max-width: 64ch; diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index 64a9144..cf72d8a 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -127,7 +127,7 @@ export interface ParentalLeaveFetch { personalDataUuid: string workspaceId: string childId: string - source: 'scraped' | 'manual' | 'deduced' + source: ParentalLeaveSource fetchedAt: string totalDays: number daysUsed: number @@ -135,6 +135,12 @@ export interface ParentalLeaveFetch { reservedDaysTotal: number reservedDaysLeft: number transferableDaysLeft: number + // True when `transferableDaysLeft` came straight from Försäkringskassan's + // own figure (a scraped snapshot); false when it's derived from the + // statutory reserved-days assumption (a manually-entered balance) — see + // `LEAVE_RULES.transferableDaysPerParent`. The UI must not present the + // derived figure as fact. + transferableDaysReported: boolean dubbeldagarLeft: number tierData: TierData createdAt: string @@ -201,6 +207,8 @@ export interface PlannedDayExclusion { // avoid colliding with the `CalendarDay` component. export type CalendarDayType = 'red_day' | 'klamdag' | 'weekend' | 'working_day' +export type ParentalLeaveSource = 'scraped' | 'manual' | 'deduced' + export interface CalendarDayEntry { date: string // ISO-8601 date /** Exact holiday name (may be the combined "X; Söndag" form); null unless dayType === 'red_day'. */ diff --git a/frontend/tests/fixtures/mockData.ts b/frontend/tests/fixtures/mockData.ts index fd14178..fab74cd 100644 --- a/frontend/tests/fixtures/mockData.ts +++ b/frontend/tests/fixtures/mockData.ts @@ -117,6 +117,7 @@ export function makeParentalLeaveFetch(overrides: Partial = reservedDaysTotal: 90, reservedDaysLeft: 70, transferableDaysLeft: 50, + transferableDaysReported: true, dubbeldagarLeft: 55, tierData: makeTierData(), createdAt: '2026-05-01T10:00:00',