Files
HomeAssistantVS/custom_components/taskmate/coord_rewards.py
T

593 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reward operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
from datetime import date
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class RewardsMixin:
"""Mixin providing reward CRUD, claiming, and pool allocation logic."""
def get_reward(self, reward_id: str) -> Reward | None:
"""Get a reward by ID."""
return self.storage.get_reward(reward_id)
def is_pool_mode_claim(self, claim: RewardClaim) -> bool:
"""True if the claim is covered by pool allocations (points already deducted).
Pool-mode claims must NOT be counted against a child's spendable balance,
because their cost was already removed from child.points at allocation time.
"""
reward = self.get_reward(claim.reward_id)
if not reward:
return False
if getattr(reward, "is_jackpot", False):
return self.storage.get_total_allocated_for_reward(claim.reward_id) >= reward.cost
alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
return bool(alloc and alloc.allocated_points >= reward.cost)
async def async_add_reward(
self,
name: str,
cost: int = 50,
description: str = "",
icon: str = "mdi:gift",
assigned_to: list[str] | None = None,
is_jackpot: bool = False,
pool_enabled: bool = False,
quantity: int | None = None,
expires_at: str | None = None,
) -> Reward:
"""Add a new reward."""
reward = Reward(
name=name,
cost=cost,
description=description,
icon=icon,
assigned_to=assigned_to or [],
is_jackpot=is_jackpot,
pool_enabled=pool_enabled, # Reward.__post_init__ forces this on for jackpots (#552)
quantity=quantity,
expires_at=expires_at,
)
self.storage.add_reward(reward)
await self.storage.async_save()
await self.async_refresh()
return reward
async def async_update_reward(self, reward: Reward) -> None:
"""Update a reward.
If the cost is reduced below any existing pool allocation, the excess is
refunded to the contributing children's wallets so over-allocated pools
can't appear as e.g. 11/10. If the edit makes the reward unavailable
(quantity set to 0, or expires_at moved into the past) any pool
allocations on that reward are refunded in full.
"""
old = self.get_reward(reward.id)
# Jackpots are always pool-mode (#552); keep stored data consistent.
if reward.is_jackpot:
reward.pool_enabled = True
self.storage.update_reward(reward)
if old and reward.cost < old.cost:
self._refund_pool_excess(reward, "Pool refund (reward cost reduced)")
became_unavailable = (
self._reward_is_unavailable(reward)
and old is not None
and not self._reward_is_unavailable(old)
)
if became_unavailable:
reason = (
"Pool refund (reward expired)"
if self._reward_is_expired(reward)
else "Pool refund (reward sold out)"
)
self._refund_all_pool_allocations(reward, reason)
await self.storage.async_save()
await self.async_refresh()
async def async_remove_reward(self, reward_id: str) -> None:
"""Remove a reward and clean up any pending claims and pool allocations referencing it."""
self.storage.remove_reward_claims_for_reward(reward_id)
# Refund any earmarked pool points back to their contributors before the
# allocations are dropped — otherwise the points deducted at allocation
# time would be silently lost (#564). Mirrors the expiry/sold-out paths.
reward = self.get_reward(reward_id)
if reward:
self._refund_all_pool_allocations(reward, "Pool refund (reward deleted)")
self.storage.remove_pool_allocations_for_reward(reward_id)
self.storage.remove_reward(reward_id)
await self.storage.async_save()
await self.async_refresh()
@staticmethod
def _reward_is_sold_out(reward: Reward) -> bool:
"""True if the reward has a stock count and it's been exhausted."""
return reward.quantity is not None and reward.quantity <= 0
@staticmethod
def _reward_is_expired(reward: Reward) -> bool:
"""True if the reward has an expiry date and it's on/before today."""
if not reward.expires_at:
return False
try:
deadline = date.fromisoformat(reward.expires_at)
except (TypeError, ValueError):
return False
return deadline <= dt_util.now().date()
@classmethod
def _reward_is_unavailable(cls, reward: Reward) -> bool:
"""True if the reward cannot currently be claimed or allocated to."""
return cls._reward_is_sold_out(reward) or cls._reward_is_expired(reward)
def _refund_all_pool_allocations(self, reward: Reward, reason: str) -> None:
"""Refund every pool allocation on `reward` back to its contributor.
Used when a reward becomes unavailable (sold out or expired) while
children still have points earmarked for it. Reuses the existing
per-allocation refund helper so the PointsTransaction audit trail
stays consistent with cost-reduction refunds.
"""
allocations = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
]
for alloc in allocations:
self._apply_pool_refund(alloc, alloc.allocated_points, reward, reason)
def _refund_pool_excess(self, reward: Reward, reason: str) -> None:
"""Trim any pool allocations on `reward` that exceed its cost.
Non-jackpot: each allocation is capped at the reward's cost individually.
Jackpot: allocations are trimmed starting from the newest contributor
until the combined total matches the cost.
"""
allocations = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
]
if not allocations:
return
if reward.is_jackpot:
overshoot = sum(a.allocated_points for a in allocations) - reward.cost
if overshoot <= 0:
return
for alloc in sorted(allocations, key=lambda a: a.id, reverse=True):
if overshoot <= 0:
break
refund = min(alloc.allocated_points, overshoot)
self._apply_pool_refund(alloc, refund, reward, reason)
overshoot -= refund
else:
for alloc in allocations:
if alloc.allocated_points > reward.cost:
self._apply_pool_refund(
alloc, alloc.allocated_points - reward.cost, reward, reason
)
def _apply_pool_refund(
self, allocation: PoolAllocation, refund: int, reward: Reward, reason: str
) -> None:
"""Refund `refund` points from `allocation` back to the child's wallet.
Updates or removes the allocation record and writes an audit transaction.
"""
if refund <= 0:
return
child = self.get_child(allocation.child_id)
if not child:
return
child.points += refund
self.storage.update_child(child)
remaining = allocation.allocated_points - refund
if remaining <= 0:
self.storage.remove_pool_allocation(allocation.child_id, allocation.reward_id)
else:
self.storage.upsert_pool_allocation(PoolAllocation(
child_id=allocation.child_id,
reward_id=allocation.reward_id,
allocated_points=remaining,
id=allocation.id,
))
self.storage.add_points_transaction(PointsTransaction(
child_id=allocation.child_id,
points=refund,
reason=f"{reason}: {reward.name}",
created_at=dt_util.now(),
))
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
"""Child claims a reward — creates a pending claim awaiting parent approval.
Two modes are supported:
* Wallet mode (default): requires child.points (minus committed) to cover cost
* Pool mode: if pool allocations exist for this (child, reward) and they fill the
reward's cost, the claim is a "redeem" — no wallet check needed. For jackpot
rewards the pool total across all contributing children must reach the cost.
"""
reward = self.get_reward(reward_id)
if not reward:
raise ValueError(f"Reward {reward_id} not found")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
# Cost is always static
effective_cost = reward.cost
# Detect pool mode: a filled pool allocation for this (child, reward) is sufficient,
# or for jackpots the summed pool across all children reaches cost.
pool_filled = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(reward_id)
if pool_total >= effective_cost:
pool_filled = True
else:
allocation = self.storage.get_pool_allocation(child_id, reward_id)
if allocation and allocation.allocated_points >= effective_cost:
pool_filled = True
if not pool_filled:
# Wallet mode: verify child has enough uncommitted points.
# Pool-mode pending claims already had their cost deducted at allocation time,
# so they are skipped here to avoid double-counting against the wallet.
pending_claims = self.storage.get_pending_reward_claims()
committed = 0
for c in pending_claims:
if c.child_id == child_id and not self.is_pool_mode_claim(c):
pending_reward = self.get_reward(c.reward_id)
if pending_reward:
committed += pending_reward.cost
available_points = child.points - committed
if available_points < effective_cost:
raise ValueError(
f"Not enough points. Need {effective_cost}, have {available_points} available"
)
claim = RewardClaim(
reward_id=reward_id,
child_id=child_id,
claimed_at=dt_util.now(),
)
self.storage.add_reward_claim(claim)
self.hass.bus.async_fire(
"taskmate_reward_claimed",
{
"child_id": child.id,
"reward_id": reward.id,
"claim_id": claim.id,
"cost": reward.cost,
"timestamp": dt_util.now().isoformat(),
},
)
await self.storage.async_save()
await self.async_refresh()
await self._async_notify_pending_reward_claim(
child.name, reward.name, reward.cost, claim_id=claim.id,
)
return claim
def _spend_period_start(self) -> date:
today = dt_util.now().date()
period = self.storage.get_setting("spend_cap_period", "weekly")
if period == "monthly":
return today.replace(day=1)
from datetime import timedelta
return today - timedelta(days=today.weekday()) # Monday of this week
def _spent_in_period(self, child_id: str) -> int:
"""Total reward cost a child has had approved in the current cap period."""
start = self._spend_period_start()
reward_cost = {r.id: r.cost for r in self.storage.get_rewards()}
total = 0
for claim in self.storage.get_reward_claims():
if claim.child_id != child_id or not claim.approved:
continue
when = claim.approved_at or claim.claimed_at
if when and dt_util.as_local(when).date() >= start:
total += reward_cost.get(claim.reward_id, 0)
return total
def _enforce_spend_cap(self, child_id: str, cost: int) -> None:
"""Raise if approving a `cost` spend would exceed the per-period cap."""
enabled = self.storage.get_setting("spend_cap_enabled", False)
if not (enabled is True or str(enabled).lower() == "true"):
return
try:
cap = int(float(self.storage.get_setting("spend_cap_amount", "0")))
except (ValueError, TypeError):
cap = 0
if cap <= 0:
return
if self._spent_in_period(child_id) + cost > cap:
raise ValueError(
f"Spending cap reached: {cap} per period already used"
)
async def async_approve_reward(self, claim_id: str) -> None:
"""Approve a reward claim and deduct points from the child.
If a pool allocation exists for this (child, reward) pair with enough points,
the deduction consumes the pool allocation first (pool mode). Otherwise the
wallet-mode path deducts directly from child.points.
"""
claims = self.storage.get_reward_claims()
for claim in claims:
if claim.id == claim_id:
if claim.approved:
_LOGGER.warning("Reward claim %s already approved, ignoring", claim_id)
return
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
if not reward or not child:
raise ValueError(f"Reward or child not found for claim {claim_id}")
# Cost is always static
effective_cost = reward.cost
# Spending cap: block approval if it would exceed the per-period budget.
self._enforce_spend_cap(claim.child_id, effective_cost)
# Detect pool mode: either a direct allocation, or a filled jackpot pool.
pool_alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id)
is_pool_mode = False
if reward.is_jackpot:
pool_total = self.storage.get_total_allocated_for_reward(claim.reward_id)
if pool_total >= effective_cost:
is_pool_mode = True
elif pool_alloc and pool_alloc.allocated_points >= effective_cost:
is_pool_mode = True
if is_pool_mode:
# Pool mode: points were already deducted from child.points at allocation
# time — approving the redeem just clears the allocation record(s).
# Refund any over-allocation first (e.g. left over from a prior cost reduction)
# so the child doesn't lose points beyond the reward's actual cost.
self._refund_pool_excess(reward, "Pool refund on redeem")
if reward.is_jackpot:
jackpot_allocs = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == claim.reward_id and a.allocated_points > 0
]
for alloc in jackpot_allocs:
self.storage.remove_pool_allocation(alloc.child_id, alloc.reward_id)
else:
self.storage.remove_pool_allocation(claim.child_id, claim.reward_id)
else:
# Wallet mode: deduct directly from child.points
if child.points < effective_cost:
raise ValueError(
f"Not enough points to approve. Need {effective_cost}, have {child.points}"
)
child.points -= effective_cost
self.storage.update_child(child)
if reward.quantity is not None:
reward.quantity = max(0, reward.quantity - 1)
self.storage.update_reward(reward)
if reward.quantity == 0:
# Last unit claimed — refund any points other children
# still have earmarked for this reward's pool.
self._refund_all_pool_allocations(
reward, "Pool refund (reward sold out)"
)
claim.approved = True
claim.approved_at = dt_util.now()
self.storage.update_reward_claim(claim)
await self.storage.async_save()
await self.async_refresh()
# Dismiss the mobile approval push now this claim is reviewed.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_reward_claim", claim_id
)
# Timed unlock (#678): allowlisted entity on, auto-off later.
await self.async_start_unlock(reward, child)
self.hass.bus.async_fire("taskmate_reward_approved", {
"child_id": child.id, "child_name": child.name,
"reward_id": reward.id, "reward_name": reward.name,
"cost": effective_cost,
"timestamp": dt_util.now().isoformat(),
})
if getattr(self, "badges", None):
await self.badges.evaluate_for_child(claim.child_id, "reward_redeemed")
return
_LOGGER.warning("Reward claim %s not found for approval", claim_id)
async def async_reject_reward(self, claim_id: str) -> None:
"""Reject a reward claim — no refund needed as points were never deducted."""
claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None)
self.storage.remove_reward_claim(claim_id)
await self.storage.async_save()
await self.async_refresh()
if claim:
reward = self.get_reward(claim.reward_id)
child = self.get_child(claim.child_id)
self.hass.bus.async_fire("taskmate_reward_rejected", {
"child_id": claim.child_id,
"child_name": getattr(child, "name", ""),
"reward_id": claim.reward_id,
"reward_name": getattr(reward, "name", ""),
"timestamp": dt_util.now().isoformat(),
})
# Dismiss the mobile approval push for this reviewed claim.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_reward_claim", claim_id
)
async def async_allocate_points_to_pool(
self, child_id: str, reward_id: str, points: int
) -> PoolAllocation:
"""Move `points` from a child's spendable balance into a reward pool.
Deducts immediately from child.points so the visible balance reflects the
commitment. The matching PoolAllocation record tracks the earmarked total
for each (child, reward) pair. Requested points are capped silently at the
pool's remaining capacity and the child's spendable balance.
Allocations are locked — there is no matching "withdraw" operation.
"""
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
reward = self.get_reward(reward_id)
if not reward:
raise ValueError(f"Reward {reward_id} not found")
if self._reward_is_sold_out(reward):
raise ValueError(f"Reward '{reward.name}' is sold out")
if self._reward_is_expired(reward):
raise ValueError(f"Reward '{reward.name}' has expired")
if points < 1:
raise ValueError("Points to allocate must be at least 1")
# Spendable balance = child.points points committed to other pending claims.
# (Already-allocated points are no longer part of child.points, so we do NOT
# subtract total_allocated here — they've been deducted at allocation time.)
# Pool-mode pending claims are also skipped — their cost was already removed
# from child.points at allocation time, so counting it again would block the
# child from allocating to any other pool reward while one awaits approval.
pending_claims = self.storage.get_pending_reward_claims()
committed = 0
for c in pending_claims:
if c.child_id == child_id and not self.is_pool_mode_claim(c):
pending_reward = self.get_reward(c.reward_id)
if pending_reward:
committed += pending_reward.cost
spendable = child.points - committed
if spendable < 1:
raise ValueError(f"No spendable points available for {child.name}")
# Compute remaining pool capacity
existing = self.storage.get_pool_allocation(child_id, reward_id)
current_child_allocation = existing.allocated_points if existing else 0
if reward.is_jackpot:
room_left = reward.cost - self.storage.get_total_allocated_for_reward(reward_id)
else:
room_left = reward.cost - current_child_allocation
if room_left <= 0:
raise ValueError(f"Pool for reward '{reward.name}' is already full")
capped_points = min(points, spendable, room_left)
# Deduct from the visible balance; the allocation record holds the earmarked points.
child.points -= capped_points
self.storage.update_child(child)
allocation = PoolAllocation(
child_id=child_id,
reward_id=reward_id,
allocated_points=current_child_allocation + capped_points,
id=existing.id if existing else PoolAllocation(child_id, reward_id).id,
)
self.storage.upsert_pool_allocation(allocation)
# Audit trail: negative transaction showing the deduction
transaction = PointsTransaction(
child_id=child_id,
points=-capped_points,
reason=f"Allocated to pool: {reward.name}",
created_at=dt_util.now(),
)
self.storage.add_points_transaction(transaction)
await self.storage.async_save()
await self.async_refresh()
return allocation
async def _async_restock_rewards(self) -> None:
"""Refill `quantity` to restock_amount on the period boundary.
daily → every day; weekly → Mondays; monthly → the 1st. A
``restock_last`` stamp guards against restocking twice in a day.
"""
from homeassistant.util import dt as dt_util
today = dt_util.now().date()
today_iso = today.isoformat()
changed = False
for reward in self.storage.get_rewards():
if not getattr(reward, "restock_enabled", False):
continue
if int(getattr(reward, "restock_amount", 0) or 0) <= 0:
continue
if getattr(reward, "restock_last", "") == today_iso:
continue
period = getattr(reward, "restock_period", "weekly")
due = (
period == "daily"
or (period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
if not due:
continue
reward.quantity = int(reward.restock_amount)
reward.restock_last = today_iso
self.storage.update_reward(reward)
changed = True
_LOGGER.info("Restocked reward '%s' to %d", reward.name, reward.quantity)
if changed:
await self.storage.async_save()
await self.async_refresh()
async def _async_expire_rewards(self) -> None:
"""Refund pool allocations on any reward whose expires_at is past.
The reward row itself is kept in storage so the sensor can surface the
"Expired" state and existing claim history stays intact.
"""
changed = False
for reward in self.storage.get_rewards():
if not self._reward_is_expired(reward):
continue
allocations_before = [
a for a in self.storage.get_pool_allocations()
if a.reward_id == reward.id and a.allocated_points > 0
]
if not allocations_before:
continue
self._refund_all_pool_allocations(reward, "Pool refund (reward expired)")
changed = True
_LOGGER.info(
"Reward '%s' expired on %s — refunded %d pool allocation(s)",
reward.name, reward.expires_at, len(allocations_before),
)
if changed:
await self.storage.async_save()
await self.async_refresh()