"""Reward operations mixin for TaskMateCoordinator.""" from __future__ import annotations import logging from datetime import date, datetime from typing import TYPE_CHECKING from homeassistant.util import dt as dt_util from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim from .timewindow import has_window, is_within_window if TYPE_CHECKING: pass _LOGGER = logging.getLogger(__name__) # Upper bound on a single child's pending reward claims. Each claim writes a # permanent record and pushes a notification to every routed parent, so an # unbounded queue is a way to bury the approvals list and spam phones. Mirrors # the pending-swap-request cap. _MAX_PENDING_CLAIMS_PER_CHILD = 20 def reward_is_time_locked(reward: Reward, now: datetime | None = None) -> bool: """True if a time-locked reward is currently outside its allowed window (#857). The lock has two independent parts, both optional: a set of weekdays and a time-of-day window. Whatever is configured must pass. An empty day list means every day; an unusable time window (blank, malformed, or equal bounds) means any time. The weekday is read from the local day `now` falls on, so an overnight window on a day-restricted reward only covers the part of it before midnight. """ if not getattr(reward, "time_lock_enabled", False): return False moment = now or dt_util.now() days = getattr(reward, "available_days", None) or [] if days and moment.weekday() not in days: return True start = getattr(reward, "available_from", "") or "" end = getattr(reward, "available_until", "") or "" return has_window(start, end) and not is_within_window(start, end, moment) 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 funded from the pool rather than a wallet. Pool-funded claims must NOT be counted against a child's spendable balance, because their cost was already removed from child.points at allocation time. A jackpot is always pool-funded (#552), even if the pool has since dipped below the cost — it is still not the claimer's wallet that is on the hook for it. """ reward = self.get_reward(claim.reward_id) if not reward: return False if getattr(reward, "is_jackpot", False): return True alloc = self.storage.get_pool_allocation(claim.child_id, claim.reward_id) return bool(alloc and alloc.allocated_points >= reward.cost) def _require_funded_jackpot(self, reward: Reward, cost: int) -> None: """Raise unless the shared pool covers a jackpot's cost. Jackpots are funded from one shared pool and never from a wallet (#552). The pool can fall short of a pending claim — the cost is raised, a contributor is unassigned, a child is deleted — and without this guard the fallback path charged the full jackpot to whichever child happened to be holding the claim. """ pool_total = self.storage.get_total_allocated_for_reward(reward.id) if pool_total < cost: raise ValueError( f"'{reward.name}' is a shared jackpot and its pool is not full. Need {cost}, have {pool_total} saved up" ) 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. An edit can also take the savings jar itself away: switching a reward between jackpot and ordinary, turning pool mode off, or dropping a child from ``assigned_to``. Allocations are locked with no withdraw operation, so anything left behind by those edits is a child's points stranded for good — they are refunded, and any pending claim the edit has invalidated is cancelled with it. """ old = self.get_reward(reward.id) # Jackpots are always pool-mode (#552); keep stored data consistent. if reward.is_jackpot: reward.pool_enabled = True if old and reward.cost != old.cost: self._freeze_unrecorded_prices(old) self.storage.update_reward(reward) cancelled_claim_ids = self._settle_pool_after_edit(old, reward) if old else [] 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() # Dismiss the approval pushes for claims this edit just cancelled. if cancelled_claim_ids and getattr(self, "notifications", None): for claim_id in cancelled_claim_ids: await self.notifications.clear_approval("pending_reward_claim", claim_id) def _settle_pool_after_edit(self, old: Reward, reward: Reward) -> list[str]: """Refund savings and cancel claims an edit has just invalidated. Returns the ids of the cancelled claims so the caller can clear their approval notifications once the change is saved. """ # Funding changed: the shared jar became per-child jars, or the other # way round, or pool mode was switched off altogether. Whatever is in # the pool was saved towards something that no longer exists. funding_changed = reward.is_jackpot != old.is_jackpot or (old.pool_enabled and not reward.pool_enabled) if funding_changed: self._refund_all_pool_allocations(reward, "Pool refund (reward funding changed)") else: # Narrowed assignment: a child who can no longer be given this # reward can no longer redeem what they saved towards it either. for alloc in list(self.storage.get_pool_allocations()): if alloc.reward_id == reward.id and not self._reward_is_for_child(reward, alloc.child_id): self._apply_pool_refund( alloc, alloc.allocated_points, reward, "Pool refund (reward assignment changed)" ) cancelled: list[str] = [] for claim in self.storage.get_reward_claims(): if claim.reward_id != reward.id or claim.approved: continue # A pool-funded claim whose pool has just been refunded would fall # through to the claimer's wallet on approval, and an unassigned # child's claim should not be approvable at all. if funding_changed or not self._reward_is_for_child(reward, claim.child_id): self.storage.remove_reward_claim(claim.id) cancelled.append(claim.id) if cancelled: _LOGGER.info( "Cancelled %d pending claim(s) for '%s' — the edit changed how it is funded or who it is for", len(cancelled), reward.name, ) return cancelled def _freeze_unrecorded_prices(self, old: Reward) -> None: """Stamp the current price onto approved claims that never recorded one. Claims approved before the price was stored alongside them have no record of what was paid, so history reads the reward's live cost. This is the last moment that cost is still the one those purchases were approved at, so it is written down before the edit lands. """ for claim in self.storage.get_reward_claims(): if claim.reward_id == old.id and claim.approved and claim.approved_cost is None: claim.approved_cost = old.cost self.storage.update_reward_claim(claim) 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() # Module-level so sensor.py can reuse the same rule when building state. _reward_is_time_locked = staticmethod(reward_is_time_locked) @staticmethod def _reward_is_for_child(reward: Reward, child_id: str) -> bool: """True if ``child_id`` is allowed this reward. An empty ``assigned_to`` means "everyone", matching how the cards build their reward list. The cards filter on this, so the coordinator has to as well — otherwise a direct service call can claim or save towards a reward meant for a sibling. """ assigned = reward.assigned_to if isinstance(reward.assigned_to, list) else [] return not assigned or child_id in assigned @classmethod def _reward_is_unavailable(cls, reward: Reward) -> bool: """True if the reward is permanently out of reach — sold out or expired. Deliberately excludes the time lock: this predicate drives pool refunds, and a time lock is temporary, so folding it in would refund every saver's points the moment a window closed. """ 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(), ) ) def reward_claim_funding(self, reward: Reward, child_id: str) -> int: """What is available to pay for this reward, from whichever purse applies. A jackpot is paid from the shared pool, an ordinary reward from either a filled savings jar or the child's uncommitted balance — so "can they afford it" cannot be answered from the wallet alone. """ if reward.is_jackpot: return self.storage.get_total_allocated_for_reward(reward.id) child = self.get_child(child_id) if not child: return 0 committed = 0 for claim in self.storage.get_pending_reward_claims(): if claim.child_id == child_id and not self.is_pool_mode_claim(claim): pending_reward = self.get_reward(claim.reward_id) if pending_reward: committed += pending_reward.cost wallet = child.points - committed allocation = self.storage.get_pool_allocation(child_id, reward.id) return max(wallet, allocation.allocated_points) if allocation else wallet def validate_reward_claim(self, reward_id: str, child_id: str) -> tuple[Reward, Child]: """Check a claim is allowed, raising ValueError with the reason if not. Every rule that decides whether a child may claim right now lives here, so the claim itself and anything that offers it — the per-reward button entity, for one — cannot drift apart. Mutates nothing. """ 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 not self._reward_is_for_child(reward, child_id): raise ValueError(f"Reward '{reward.name}' is not available to {child.name}") 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 self._reward_is_time_locked(reward): raise ValueError(f"Reward '{reward.name}' is not available right now") # A pool-filled claim skips the wallet check below, and a zero-cost # reward can never fail it, so without these two guards a child can # queue unlimited claims — each one a stored record plus a push to # every parent. pending = self.storage.get_pending_reward_claims() own_pending = [c for c in pending if c.child_id == child_id] # A jackpot is funded from one shared pool, so it is redeemed once per # funding cycle: a queued claim owns that pool whoever made it. Without # this, a second child's claim is approved after the pool is already # spent, falls through to wallet mode, and charges them the full cost # all over again (#873). if reward.is_jackpot: blocking = [c for c in pending if c.reward_id == reward_id] else: blocking = [c for c in own_pending if c.reward_id == reward_id] if blocking: raise ValueError(f"A claim for '{reward.name}' is already waiting for approval") if len(own_pending) >= _MAX_PENDING_CLAIMS_PER_CHILD: raise ValueError("Too many reward claims are already waiting for approval") # 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: # A short pool is the end of it: a jackpot is never redeemed out of # one child's wallet, however many points they happen to have. self._require_funded_jackpot(reward, 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") return reward, child 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, child = self.validate_reward_claim(reward_id, child_id) 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: paid = claim.approved_cost total += paid if paid is not None else 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}") # Stock and expiry are checked when the claim is made, but a # claim can sit in the queue for days and several can stack up # against the same item. Re-check here or approving them in # turn hands out more units than exist (the counter floors at # zero, so it doesn't even show). The time lock is deliberately # not re-checked: approval is the parent's call, not the # child's, so an out-of-hours approval is legitimate. 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 # 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: # The pool can have drained since the claim was made, and # the wallet path below is not an acceptable fallback for a # shared reward — it would charge the whole jackpot to the # one child holding the claim. self._require_funded_jackpot(reward, 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() claim.approved_cost = effective_cost self.storage.update_reward_claim(claim) # One pool, one redemption. Claims stored before a jackpot was # limited to a single pending claim (#873) can still be sitting # in the queue; the pool that would have paid for them has just # been spent, so approving one of those later would fall # through to its claimer's wallet. Retire them with the pool. superseded = [] if reward.is_jackpot: superseded = [c for c in claims if c.id != claim.id and c.reward_id == reward.id and not c.approved] for other in superseded: self.storage.remove_reward_claim(other.id) if superseded: _LOGGER.info( "Retired %d duplicate pending claim(s) for jackpot '%s' on redemption", len(superseded), reward.name, ) 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) for other in superseded: await self.notifications.clear_approval("pending_reward_claim", other.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, "claim_id": claim.id, "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 *pending* reward claim — no refund needed as points were never deducted. That "no refund needed" only holds while the claim is pending. Two parents can review the same claim at once — one in the panel, one from a mobile approval push — and the second one is acting on a list that no longer matches storage. Deleting an approved claim there would erase the purchase from history while its points, its stock and any timed unlock stayed spent, so the stale review is refused instead. """ claim = next((c for c in self.storage.get_reward_claims() if c.id == claim_id), None) if claim is not None and claim.approved: reward = self.get_reward(claim.reward_id) name = reward.name if reward else "This reward" raise ValueError(f"'{name}' has already been approved and can no longer be rejected") 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", ""), "claim_id": claim.id, "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 not self._reward_is_for_child(reward, child_id): raise ValueError(f"Reward '{reward.name}' is not available to {child.name}") 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()