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 @@
+
+
+
+
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