d5f33e8c7f
The dashboard stated the loss and stopped there. With Jacob at 48/48 savable
and 24 days over the cap, and Amelie at 3/48 with 45 days of headroom, those
24 days are not lost at all — transferring them before the 4th birthday saves
every one. The app never said so.
`_rule_age4_pooling_loss` already computed exactly that advice, bounded by the
FK-reported `transferable_days_left`, in finished Swedish and English. It never
ran: `age4_pooling_tip_min_child_age_years` held it back until the child turned
2. The calendar IS the plan, so gate on the plan instead — the noise case is a
newborn with nothing planned, not a young child whose parents have planned in
detail. `age4_pooling_tip_min_planned_days` (20) replaces the age gate.
Both variants drop to `info`. A projection four years out should not read as a
verdict on the user's planning; the hero carries the alarm, this card carries
the advice. The copy now leads with "plans change, this is only a projection"
and closes by pointing at keeping balances up to date, since parents will come
back and record what they actually took.
- Suppress `preserve_for_age_4` whenever `age4_pooling_loss` fires for the same
child. The pooling tip states the same loss plus the remedy and the correct
per-parent cap, so emitting both gave two contradictory "you're losing N
days" figures.
- `preserve_for_age_4` now sends `parent_cap` and its copy interpolates it. It
claimed "only 96 days can be saved" — false for a two-parent family, where
the cap is 48 each, as the Age4Bars card two rows below already showed.
- The hero grows a "How to save them" line derived from the tip itself, so the
two can never disagree on the number. Dismissing the tip retires both.
- "Försäkringskassan" in that line links to FK's BankID login carrying a `goto`
to Min föräldrapenning — the e-service showing "Dagar ni kan föra över" —
so the transfer is one click away. `translate()` returns a plain string and
word order differs between locales, so the anchor's position travels inside
the template as `{fk}` and the rendered string is split around it.
Verified in the browser: hero, link target, tip severity and the absence of
`preserve_for_age_4`, in both languages and at 390px. Note the demo seed cannot
reach this shape — it gives the surplus parent no FK snapshot at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
184 lines
8.3 KiB
Python
184 lines
8.3 KiB
Python
"""Application configuration (pydantic-settings).
|
|
|
|
`Settings` loads from environment variables (prefix ``APP_``) and an optional
|
|
``.env`` file, falling back to the defaults below for local dev. Keep secrets
|
|
(JWT_SECRET, HMAC_PEPPER, scraper credentials, ...) in the environment / a
|
|
secret store, never in source.
|
|
|
|
The Swedish parental-leave rule values live in the nested, frozen ``LeaveRules``
|
|
model. They are domain defaults rather than routine deployment config, but are
|
|
modelled as settings so they're typed, validated, and overridable should a rule
|
|
year change (e.g. ``APP_RULES__MAX_SAVABLE_DAYS_AT_AGE_4=96``). NOTE: these are
|
|
the common-case figures — children born before 2014, multiples, and single
|
|
custody differ and should become explicit config when tackled.
|
|
"""
|
|
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
_DEV_JWT_SECRET = "dev-secret-change-in-production-min-32-chars"
|
|
|
|
|
|
class LeaveRules(BaseModel):
|
|
"""Frozen Swedish föräldrapenning rule defaults (single source of truth)."""
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
sjukpenning_days: int = 390 # income-based tier
|
|
lagsta_niva_days: int = 90 # flat low-rate tier
|
|
|
|
reserved_days_per_parent: int = 90 # non-transferable ("reserverade dagar")
|
|
|
|
max_dubbeldagar: int = 60 # both parents, same child-day
|
|
dubbeldagar_months_limit: int = 15 # only within the child's first 15 months
|
|
|
|
max_savable_days_at_age_4: int = 96 # age-4 ceiling — per CHILD (pooled across parents)
|
|
hard_expiry_age_years: int = 12
|
|
|
|
# A couple's age-4 pooling-loss tip (see
|
|
# `app.utils.tip_predicates.age4_pooling_loss`) only fires once the
|
|
# calendar holds at least this many planned parental-benefit days for the
|
|
# child. The calendar IS the plan: below this the projection is mostly
|
|
# untouched entitlement rather than a real plan, so the "loss" is an
|
|
# artefact of not having planned yet, not something to act on. Child age is
|
|
# deliberately NOT the gate — a family that plans in detail early should get
|
|
# the advice early. A tunable threshold, not a legal rule.
|
|
age4_pooling_tip_min_planned_days: int = 20
|
|
|
|
sgi_protected_first_months: int = 12
|
|
sgi_min_days_per_week_after_age_1: int = 5
|
|
|
|
# Default reserve for preschool planning-day ("studiedagar") closures — the
|
|
# per-child value is user-editable; this is just the seeded default.
|
|
preschool_planning_days_per_year: int = 6
|
|
|
|
# Swedish statutory semester entitlement (semesterlagen). Used by
|
|
# `AllocationInputLoader` to default a vacation-year the user has not
|
|
# entered yet — "not entered" must never be read as "zero days".
|
|
statutory_vacation_days_per_year: int = 25
|
|
|
|
# Semesterlagen requires at least this many vacation days to be *taken*
|
|
# each year; only days above it may be saved (up to 5 years). An employer
|
|
# may set a higher floor, so this is only the seeded default — the
|
|
# per-parent value on PersonalData.min_vacation_days_per_year is what the
|
|
# allocator actually enforces (PLANNING_ENGINE_SPEC.md §9.2).
|
|
min_vacation_days_per_year: int = 20
|
|
|
|
# Semesterlagen: a vacation day saved above the floor (see
|
|
# `min_vacation_days_per_year`) may be carried forward for this many
|
|
# years before it expires. Used by `AnalyticsService`'s per-year vacation
|
|
# carry-forward bank (§ per-parent-per-year balance series).
|
|
vacation_carry_forward_years: int = 5
|
|
|
|
@property
|
|
def total_days_per_child(self) -> int:
|
|
return self.sjukpenning_days + self.lagsta_niva_days
|
|
|
|
@property
|
|
def days_per_parent(self) -> int:
|
|
"""Default joint-custody split: half of each tier, summed.
|
|
|
|
Derived rather than stored so it can never desync from
|
|
`sjukpenning_days`/`lagsta_niva_days` if a rule year changes either.
|
|
"""
|
|
return self.sjukpenning_days // 2 + self.lagsta_niva_days // 2
|
|
|
|
@property
|
|
def transferable_days_per_parent(self) -> int:
|
|
return self.days_per_parent - self.reserved_days_per_parent
|
|
|
|
@property
|
|
def max_savable_days_per_parent_at_age_4(self) -> int:
|
|
"""Age-4 ceiling — per PARENT, non-poolable. `max_savable_days_at_age_4`
|
|
(96) is a per-child pool; as soon as a child has two parents, each
|
|
parent's own savable balance is capped at half of that (48), not 96
|
|
each (which would let a two-parent family save 192 combined). This
|
|
applies purely based on how many parents the child has.
|
|
`app.utils.tip_predicates.resolve_age4_savable_cap` is the single
|
|
place that decides which of the two applies.
|
|
"""
|
|
return self.max_savable_days_at_age_4 // 2
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="APP_",
|
|
env_nested_delimiter="__",
|
|
env_file=".env",
|
|
extra="ignore",
|
|
)
|
|
|
|
# Deployment environment. Gates production-only hardening (see the
|
|
# model_validator below and main.py's docs_url handling). Never set to
|
|
# "production" in tests/dev — the validator below will refuse to start.
|
|
app_env: Literal["development", "test", "production"] = "development"
|
|
|
|
# 'sqlite' for local dev, 'postgres' for production (swap repository impls).
|
|
database_type: str = "sqlite"
|
|
sqlite_path: str = "./foraldraledig.db"
|
|
|
|
# Lock CORS down to the frontend origin(s).
|
|
cors_origins: list[str] = ["http://localhost:5173"]
|
|
|
|
# Auth — change jwt_secret in production (min 32 chars).
|
|
jwt_secret: str = _DEV_JWT_SECRET
|
|
jwt_algorithm: str = "HS256"
|
|
jwt_access_token_expire_minutes: int = 15
|
|
jwt_refresh_token_expire_days: int = 7
|
|
|
|
log_level: str = "INFO"
|
|
|
|
# Per-IP request rate limiting (slowapi). ON by default; the limits
|
|
# themselves are declared per-endpoint in the routers.
|
|
#
|
|
# Turn it OFF only where the "one IP == one client" assumption that
|
|
# `get_remote_address` relies on is false. The E2E suite is exactly that
|
|
# case: every browser page load in the whole run comes from 127.0.0.1,
|
|
# so the limiter ends up measuring the test runner rather than a user —
|
|
# a 20-test run makes ~50 `POST /api/auth/refresh` calls (one per page
|
|
# load) against a 30/minute budget and starts handing out 429s
|
|
# mid-suite. See the matching comment in `frontend/playwright.config.ts`.
|
|
rate_limit_enabled: bool = True
|
|
|
|
# FK scraper — legal/ToS review completed (2026-06); cleared for use.
|
|
# Default is True. Set APP_SCRAPER_ENABLED=false to disable (e.g. an
|
|
# environment without Playwright chromium installed).
|
|
# Playwright chromium binaries must be installed: `playwright install chromium`.
|
|
scraper_enabled: bool = True
|
|
scraper_headless: bool = True
|
|
scraper_session_ttl_seconds: int = 180 # auto-cancel awaiting_scan/authenticating sessions
|
|
# Debug artifacts (HTML + full-page screenshot of the logged-in FK page)
|
|
# are OFF by default — they contain personal data (name, balances,
|
|
# payout amounts). Both fields below must be set to enable them; this is
|
|
# a deliberate opt-in for local debugging only, never for production.
|
|
scraper_debug_dir: str | None = None
|
|
scraper_debug_enabled: bool = False
|
|
# Also dump 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 assumed by
|
|
# `app.scrapers.fk_scraper._SEL.FAMILY*`). Same personal-data warning as
|
|
# above applies; only takes effect when both `scraper_debug_enabled` and
|
|
# `scraper_debug_dir` are also set.
|
|
scraper_debug_dump_on_success: bool = False
|
|
# How long a terminal (done/error/cancelled) session's QR/result stays in
|
|
# RAM before `_ttl_watchdog`/`poll` purge it from `_sessions`.
|
|
scraper_result_retention_seconds: int = 300
|
|
|
|
rules: LeaveRules = LeaveRules()
|
|
|
|
@model_validator(mode="after")
|
|
def _require_strong_jwt_secret_in_production(self) -> "Settings":
|
|
if self.app_env == "production" and (
|
|
self.jwt_secret == _DEV_JWT_SECRET or len(self.jwt_secret) < 32
|
|
):
|
|
raise ValueError(
|
|
"APP_JWT_SECRET must be set to a random value of at least 32 characters "
|
|
"when APP_ENV=production"
|
|
)
|
|
return self
|
|
|
|
|
|
settings = Settings()
|