59 files
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .models import PointsTransaction, PoolAllocation, Reward, RewardClaim
|
||||
from .models import Child, PointsTransaction, PoolAllocation, Reward, RewardClaim
|
||||
from .timewindow import has_window, is_within_window
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -52,19 +52,37 @@ class RewardsMixin:
|
||||
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).
|
||||
"""True if the claim is funded from the pool rather than a wallet.
|
||||
|
||||
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.
|
||||
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 self.storage.get_total_allocated_for_reward(claim.reward_id) >= reward.cost
|
||||
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,
|
||||
@@ -102,12 +120,22 @@ class RewardsMixin:
|
||||
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 = (
|
||||
@@ -121,6 +149,63 @@ class RewardsMixin:
|
||||
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)
|
||||
@@ -253,14 +338,34 @@ class RewardsMixin:
|
||||
)
|
||||
)
|
||||
|
||||
async def async_claim_reward(self, reward_id: str, child_id: str) -> RewardClaim:
|
||||
"""Child claims a reward — creates a pending claim awaiting parent approval.
|
||||
def reward_claim_funding(self, reward: Reward, child_id: str) -> int:
|
||||
"""What is available to pay for this reward, from whichever purse applies.
|
||||
|
||||
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.
|
||||
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:
|
||||
@@ -306,9 +411,10 @@ class RewardsMixin:
|
||||
# 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
|
||||
# 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:
|
||||
@@ -330,6 +436,19 @@ class RewardsMixin:
|
||||
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,
|
||||
@@ -378,7 +497,8 @@ class RewardsMixin:
|
||||
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)
|
||||
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:
|
||||
@@ -436,9 +556,12 @@ class RewardsMixin:
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -475,13 +598,34 @@ class RewardsMixin:
|
||||
|
||||
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)
|
||||
@@ -506,8 +650,20 @@ class RewardsMixin:
|
||||
_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."""
|
||||
"""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()
|
||||
|
||||
Reference in New Issue
Block a user