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
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,