Parse transferable days from FK instead of deriving them

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 <p> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
2026-09-20 09:11:53 +02:00
parent 3eb8511eb6
commit 97a7200c1a
29 changed files with 1422 additions and 45 deletions
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+3
View File
@@ -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]
+1
View File
@@ -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
+5
View File
@@ -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)
+2
View File
@@ -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,
@@ -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),
),
)
+4
View File
@@ -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,
+276 -1
View File
@@ -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
+6 -1
View File
@@ -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:
+48 -12
View File
@@ -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,
+32 -5
View File
@@ -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:
+67
View File
@@ -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
@@ -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")
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<!--
Synthetic stand-in for a captured FK "min-foraldrapenning" e-service page
(https://www.forsakringskassan.se/privatperson/mina-sidor/alla-e-tjanster/min-foraldrapenning).
This is NOT a real Försäkringskassan page dump — it is a hand-built,
minimal HTML fragment that reproduces only the DOM structure and CSS
selectors that `app/scrapers/fk_scraper.py::_extract_transferable_days`
reads (see `_SEL.MIN_FP_*` / `_SEL.BARN_RUBRIK` / `_SEL.PANEL_FORA_OVER_DAGAR`
/ `_SEL.FORA_OVER_GROUP` / `_SEL.MINFP_LABEL` / `_SEL.EXPANDABLE_BUTTON` in
that module). All names, day counts, and amounts below are fictitious (see
F1 of the scraper/config/ops security audit — real captures never belong
in the repo; `captures/` is gitignored).
The tooltip block under "Du kan föra över" deliberately repeats the
fixed "45 dagar"/"90 dagar" boilerplate text FK shows on the real page
(how many days each guardian may transfer to another *person*, not the
other parent) so tests genuinely exercise `:scope > p` scoping rather
than passing by luck on a fixture with no decoy text.
-->
<html lang="sv">
<head>
<meta charset="UTF-8">
<title>Min föräldrapenning - Försäkringskassan (synthetic fixture)</title>
</head>
<body>
<div class="min-fp">
<div class="barn-sektion">
<div class="barn-header">
<h2 class="barn-rubrik"><strong>Testbarn</strong></h2>
</div>
<div class="panel-fora-over-dagar">
<div class="expandable-paragraph expandable-paragraph--open">
<h3 class="expandable-paragraph__heading heading--h4">
<button type="button" class="expandable-paragraph__button" aria-expanded="true"
aria-controls="fkui-vue-element-0002">
<span class="expandable-paragraph__icon"></span>
Dagar ni kan föra över
</button>
</h3>
<div id="fkui-vue-element-0002" class="expandable-paragraph__container"
style="overflow: hidden;">
<div class="expandable-paragraph__content">
<div class="fora-over-group">
<div class="tooltip__container">
<h4 class="minfp-label">Du kan föra över</h4>
<button class="tooltip__button" type="button" aria-label="Mer information">?</button>
</div>
<div class="tooltip">
<div class="tooltip__bubble">
<h4 class="tooltip__header">Vem kan du föra över dagar till?</h4>
<div class="tooltip__body">
<div>
<strong>Förälder</strong>
<div>Du kan föra över alla dina dagar till barnets andra förälder.</div>
<p><strong>Annan person</strong></p>
<div>Du kan föra över dagar till en annan person som är folkbokförd med barnet.</div>
<p>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.</p>
</div>
</div>
</div>
</div>
<p>84 dagar på sjukpenningnivå</p>
<p>45 dagar på lägstanivå</p>
</div>
<div class="fora-over-group">
<h4 class="minfp-label">Annan förälder kan föra över</h4>
<p>26 dagar på sjukpenningnivå</p>
<p>45 dagar på lägstanivå</p>
</div>
<!---->
<div class="reserverade-dagar">
<h4 class="heading--h6">Reserverade dagar</h4>
<p>Vissa dagar är reserverade och går inte att föra över till någon annan.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -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"
+577
View File
@@ -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-<div> 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 <div>s (the tooltip,
# in particular) — a naive ".*?</div>" would stop at the first nested close.
def _balanced_div_slice(html: str, open_tag_start: int) -> str:
"""From the index of a '<div' opening tag, return that div's full outer
HTML (opening tag through its matching closing tag), counting nested
<div>/</div> pairs."""
depth = 0
for m in re.finditer(r"<div\b[^>]*>|</div\s*>", html[open_tag_start:], re.IGNORECASE):
if m.group(0).lower().startswith("</div"):
depth -= 1
if depth == 0:
return html[open_tag_start : open_tag_start + m.end()]
else:
depth += 1
raise ValueError("unbalanced <div> in fixture")
def _find_divs_by_class(html: str, class_name: str) -> list[str]:
"""Balanced outer-HTML slices for every <div class="...class_name...">
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'<div\b[^>]*\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"<strong>(.*?)</strong>", 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 <p> elements that are direct children of div_slice's own root
element — i.e. not nested inside any further <div> — mirroring
Playwright's `:scope > p`."""
open_m = re.match(r"<div\b[^>]*>", div_slice, re.IGNORECASE)
inner = div_slice[open_m.end() : -len("</div>")]
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"</p\s*>", 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-<div> 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'<h4\b[^>]*class="[^"]*minfp-label[^"]*"[^>]*>(.*?)</h4>',
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'<button\b[^>]*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 = """
<div class="min-fp">
<div class="barn-sektion">
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
<div class="panel-fora-over-dagar">
<div class="fora-over-group">
<h4 class="minfp-label">Du kan föra över</h4>
<p>84 dagar på sjukpenningnivå</p>
<p>45 dagar på lägstanivå</p>
</div>
<div class="fora-over-group">
<h4 class="minfp-label">Annan förälder kan föra över</h4>
<p>26 dagar på sjukpenningnivå</p>
<p>45 dagar på lägstanivå</p>
</div>
</div>
</div>
<div class="barn-sektion">
<div class="barn-header"><h2 class="barn-rubrik"><strong>Andrabarn</strong></h2></div>
<div class="panel-fora-over-dagar">
<div class="fora-over-group">
<h4 class="minfp-label">Du kan föra över</h4>
<p>10 dagar på sjukpenningnivå</p>
<p>5 dagar på lägstanivå</p>
</div>
</div>
</div>
</div>
"""
_SOLO_PARENT_MIN_FP_HTML = """
<div class="min-fp">
<div class="barn-sektion">
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
<div class="panel-fora-over-dagar">
<div class="fora-over-group">
<h4 class="minfp-label">Du kan föra över</h4>
<p>84 dagar på sjukpenningnivå</p>
<p>45 dagar på lägstanivå</p>
</div>
</div>
</div>
</div>
"""
_NO_TRANSFER_PANEL_MIN_FP_HTML = """
<div class="min-fp">
<div class="barn-sektion">
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
<div class="some-other-panel"></div>
</div>
</div>
"""
_INCOMPLETE_MINE_MIN_FP_HTML = """
<div class="min-fp">
<div class="barn-sektion">
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
<div class="panel-fora-over-dagar">
<div class="fora-over-group">
<h4 class="minfp-label">Du kan föra över</h4>
<p>84 dagar på sjukpenningnivå</p>
</div>
</div>
</div>
</div>
"""
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 = """
<div data-test="forDigOchAnnanForalder">
<div class="visa-dagar" data-test="body">
<span class="visa-dagar__dagar-kvar">210</span>
<span class="visa-dagar__discrete">av 480 dagar kvar efter ansökan</span>
<div class="visa-dagar__desktop">
<span class="niva-statistik__dagar"><span>(120 dagar)</span></span>
<span class="niva-statistik__dagar"><span>(90 dagar)</span></span>
</div>
</div>
</div>
"""
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:
@@ -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
@@ -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"):
@@ -53,8 +53,25 @@ describe('ParentalLeaveBreakdown', () => {
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
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(<ParentalLeaveBreakdown parents={[alex]} />)
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(<ParentalLeaveBreakdown parents={[derived]} />)
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(<ParentalLeaveBreakdown parents={[{ ...alex, data: { ...alexData, source: 'deduced' } }]} />)
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', {
@@ -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<ParentalLeaveSource, TranslationKey> = {
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)
</tr>
<tr data-testid="breakdown-transferable">
<th scope="row">{t('parentalLeaveBreakdown.transferableDays')}</th>
{parents.map((parent) => cell(parent, 'breakdown-transferable', (d) => d.transferableDaysLeft))}
{parents.map((parent) =>
cell(parent, 'breakdown-transferable', (d) => (
<>
{!d.transferableDaysReported && (
<span className="badge badge--muted parental-leave-breakdown__badge">
{t('parentalLeaveBreakdown.estimatedBadge')}
</span>
)}
{d.transferableDaysLeft}
</>
)),
)}
</tr>
<tr data-testid="breakdown-dubbeldagar">
<th scope="row">{t('parentalLeaveBreakdown.dubbeldagarLeft')}</th>
@@ -88,11 +106,7 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)
<li key={parent.personalDataUuid} data-testid={`breakdown-provenance-${parent.personalDataUuid}`}>
<span className="parental-leave-breakdown__provenance-name">{parent.name}</span>{' '}
{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')}
</li>
))}
@@ -101,7 +115,6 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)
<p className="parental-leave-breakdown__reserved-note" data-testid="reserved-days-note">
{t('parentalLeaveBreakdown.reservedNote', {
reserved: LEAVE_RULES.reservedDaysPerParent,
transferable: LEAVE_RULES.transferableDaysPerParent,
})}
</p>
</div>
+1 -5
View File
@@ -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', () => {
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3
View File
@@ -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,
+2
View File
@@ -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,
+4
View File
@@ -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;
+9 -1
View File
@@ -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'. */
+1
View File
@@ -117,6 +117,7 @@ export function makeParentalLeaveFetch(overrides: Partial<ParentalLeaveFetch> =
reservedDaysTotal: 90,
reservedDaysLeft: 70,
transferableDaysLeft: 50,
transferableDaysReported: true,
dubbeldagarLeft: 55,
tierData: makeTierData(),
createdAt: '2026-05-01T10:00:00',