Files
F-r-ldraledigplannerare/backend
jacobh2 97a7200c1a 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
2026-09-20 09:11:53 +02:00
..
2026-06-14 09:58:30 +02:00
2026-06-01 16:48:21 +02:00
2026-06-14 09:58:30 +02:00

Backend — Föräldraledigplannerare

FastAPI + Python 3.14, packages managed with uv. Layered architecture (entities → repositories → services → routers) with dependency injection. Postgres is a partial stub, not a working swap today: only repositories_impl/postgres/vacation_days_repository.py exists; every other app/dependencies.py repository factory raises ValueError for any database_type other than "sqlite". SQLite is the only usable backend until the remaining Postgres repositories land.

Setup

uv sync                 # install deps (incl. dev group)
uv run uvicorn app.main:app --reload   # run dev server on :8000
uv run pytest           # run tests with coverage
uv run ruff check .     # lint

uv run pytest alone does not enforce a coverage floor (pyproject.toml's addopts only requests a report). CI additionally passes --cov-fail-under=80 (see the root CLAUDE.md) — a locally green run can still fail in CI if coverage dropped.

Configuration

Env vars (prefix APP_, nested via __; see .env.example). Settings lives in app/config.py — that file's module docstring mentions a HMAC_PEPPER setting; no such setting exists, so do not look for it here or set it expecting an effect.

  • APP_ENV — development (default) | test | production. In production, Settings refuses to start unless APP_JWT_SECRET is a random value of at least 32 characters (the shipped dev default is rejected). Production also disables /docs, /redoc, and /openapi.json.
  • APP_DATABASE_TYPE — sqlite (default) | postgres (not usable yet — see above).
  • APP_SQLITE_PATH — default ./foraldraledig.db.
  • APP_CORS_ORIGINS — JSON list; default ["http://localhost:5173"].
  • APP_JWT_SECRET — required to be a strong, random secret in production.
  • APP_JWT_ALGORITHM — default HS256.
  • APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES — default 15.
  • APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS — default 7.
  • APP_LOG_LEVEL — default INFO.
  • APP_SCRAPER_ENABLED — defaults to true. Legal/ToS review of FK scraping + BankID automation completed 2026-06; the scraper is cleared for use. Set to false in any environment without Playwright's Chromium installed (uv run playwright install chromium).
  • APP_SCRAPER_HEADLESS — default true.
  • APP_SCRAPER_SESSION_TTL_SECONDS — default 180; auto-cancels a stalled scraper session.
  • APP_SCRAPER_DEBUG_ENABLED — default false. When enabled, writes personal data to disk (an HTML dump + full-page screenshot of the logged-in FK page — name, balances, payout amounts). Requires APP_SCRAPER_DEBUG_DIR to also be set. Local debugging only; never enable in a shared or production environment.
  • APP_SCRAPER_DEBUG_DIR — where debug artifacts are written when the above is enabled.
  • APP_SCRAPER_DEBUG_DUMP_ON_SUCCESS — default false. Also writes the same debug artifacts (HTML + full-page screenshot) after a successful extraction, not just on error — a capture aid for confirming FK's real DOM (e.g. the family panel structure app/scrapers/fk_scraper.py currently assumes). Same personal-data warning as APP_SCRAPER_DEBUG_ENABLED applies; only takes effect when both APP_SCRAPER_DEBUG_ENABLED and APP_SCRAPER_DEBUG_DIR are also set.
  • APP_SCRAPER_RESULT_RETENTION_SECONDS — default 300; how long a finished scraper session's result stays in memory before being purged.
  • APP_RULES__* — the Swedish parental-leave rule constants (LeaveRules, nested under rules), overridable per field, e.g. APP_RULES__MAX_SAVABLE_DAYS_AT_AGE_4=96. Defaults: 390 sjukpenning days, 90 lägstanivå days, 90 reserved days/parent, 60 max dubbeldagar over the first 15 months, 96 max savable at age 4 (48 per parent when the child has two or more parents — see resolve_age4_savable_cap), age-12 hard expiry, 6 preschool planning days/year, 25 statutory vacation days/year, 20 minimum vacation days/year that must be taken. These are common-case figures only — children born before 2014, multiples, and single custody differ and are not modeled.

Layout

app/
  config.py            # pydantic-settings (app config + frozen LeaveRules)
  dependencies.py      # DI factories (pick repo impl by settings.database_type)
  entities/            # plain dataclasses (domain)
  models/              # pydantic API schemas
  repositories/        # abstract interfaces
  repositories_impl/   # concrete impls per backend (sqlite/, postgres/)
  services/            # business logic
  routers/             # FastAPI routes
  db/connection.py     # connection + (dev) schema bootstrap
  utils/               # errors, logger

Demo data (manual/browser testing)

scripts/seed_demo.py seeds a realistic, ready-to-explore workspace through the normal service/repository layers (no raw SQL) — a couple workspace with a partner, a child born ~6 months ago, vacation rows for only some years (so the is_assumed badge is visible), day overrides, and a spread of leave wishes (fixed, flexible, and one deliberately underfunded to show a shortfall + remedies + surplus advice). Login credentials are fixed and printed at the end.

uv run python scripts/seed_demo.py            # create once; re-running is a no-op
uv run python scripts/seed_demo.py --reset     # wipe the demo workspace and rebuild

Then open http://localhost:5173 and log in with the printed demo@example.com credentials. Safe to re-run: without --reset it detects the existing demo user and changes nothing; --reset only ever deletes the one demo workspace.

Reference vertical slice

VacationDays is implemented end-to-end (entity → schema → repo interface → SQLite impl → service → router → tests). Copy this slice when adding a new entity.

The inline schema in db/connection.py exists only so dev/tests are self-contained. Production schema is owned by Alembic migrations (a senior task).