"""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 per-parent age-4 pooling-loss tip (see # `app.utils.tip_predicates.age4_pooling_loss`) only starts warning once # the child has turned this many years old — the first couple of years # are too early to usefully act on, so don't nag before then. age4_pooling_tip_min_child_age_years: int = 2 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 @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" # 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()