Warn when saving unevenly costs the family days
Only ninety-six days survive a child's fourth birthday, and with two parents that is forty-eight each rather than a shared pool. A parent sitting on sixty days while the other has ten therefore forfeits twelve that the family could have kept, and nothing said so. The advice leads with taking the days as leave before the birthday, which turns them into actual time at home, and offers transferring only as the fallback that merely preserves them. The transferable figure is bounded by what Försäkringskassan actually reports as transferable, since the reserved days cannot move, and the copy says plainly that a transfer happens there and not in this app. Without the partner's figures the tip hedges rather than inventing a number.
This commit is contained in:
@@ -41,6 +41,12 @@ class LeaveRules(BaseModel):
|
||||
max_savable_days_per_parent_at_age_4: int = 48
|
||||
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
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ supporting value. Keeping them pure makes them trivial to unit-test.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -69,6 +71,35 @@ def days_left_at_age_4_projection(
|
||||
return projected_left, expiring
|
||||
|
||||
|
||||
def age4_pooling_loss(
|
||||
parent_projected_lefts: list[int],
|
||||
per_parent_cap: int,
|
||||
per_child_cap: int,
|
||||
) -> int:
|
||||
"""Days a couple loses because the age-4 cap applies per parent, not pooled.
|
||||
|
||||
Each parent's own projected balance at the child's 4th birthday is capped
|
||||
individually at `per_parent_cap` (see `resolve_age4_savable_cap`) before
|
||||
summing, even though the family's shared ceiling is `per_child_cap`. When
|
||||
one parent is over their own cap while another still has headroom under
|
||||
it, the family ends up keeping less than `per_child_cap` would otherwise
|
||||
allow — e.g. 60 and 10 days left (cap 48, ceiling 96): the family could
|
||||
keep 70 pooled but only keeps 48 + 10 = 58, losing 12.
|
||||
|
||||
A parent with no known projection should simply be left out of
|
||||
`parent_projected_lefts` — omitting them is the conservative treatment on
|
||||
both sides of the comparison (as if their balance were 0), so a caller
|
||||
missing one parent's data still gets a safe (never-overstated) answer.
|
||||
|
||||
Returns the shortfall, 0 when nothing is lost.
|
||||
"""
|
||||
if not parent_projected_lefts:
|
||||
return 0
|
||||
reachable_pooled = min(sum(parent_projected_lefts), per_child_cap)
|
||||
reachable_per_parent = sum(min(left, per_parent_cap) for left in parent_projected_lefts)
|
||||
return max(reachable_pooled - reachable_per_parent, 0)
|
||||
|
||||
|
||||
def is_child_under_age(birth_date: date, months: int) -> bool:
|
||||
"""True if the child's age in months is less than `months`."""
|
||||
return date.today() < birth_date + relativedelta(months=months)
|
||||
@@ -170,3 +201,21 @@ def has_vacation_near_expiry(
|
||||
expiry logic when it has the full vacation history per year.
|
||||
"""
|
||||
return vacation_saved_in > 0 and vacation_total > threshold_remaining_days
|
||||
|
||||
|
||||
def hash_tip_data(data: dict[str, object]) -> str:
|
||||
"""Stable fingerprint of a tip's `data` bag.
|
||||
|
||||
Used to decide whether a previously-dismissed tip's underlying situation
|
||||
has changed materially: if a rule fires again under the same `Tip.id` but
|
||||
with different `data` (a bigger `expiring_days` count, a moved
|
||||
`fourth_birthday`, a new `gap_days`, ...), the old dismissal describes a
|
||||
fact that's no longer true and must not silence the new one. See
|
||||
`app.entities.tip_dismissal.TipDismissal` and
|
||||
`app.services.tips_service.TipsService._drop_stale_dismissed`.
|
||||
|
||||
`data` only ever holds JSON scalars (see `app.models.tips.TipDataValue`),
|
||||
so `sort_keys=True` alone gives a stable, order-independent digest.
|
||||
"""
|
||||
canonical = json.dumps(data, sort_keys=True, default=str)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
Reference in New Issue
Block a user