99from datetime import timedelta , datetime , time
1010from typing import Any
1111from zoneinfo import ZoneInfo
12- from homeassistant .components .sensor import SensorEntity , SensorDeviceClass
12+ from homeassistant .components .sensor import SensorEntity , SensorDeviceClass , SensorStateClass
1313from homeassistant .config_entries import ConfigEntry
1414from homeassistant .const import EntityCategory , STATE_UNAVAILABLE
1515from homeassistant .core import HomeAssistant
1616from homeassistant .helpers .entity_platform import AddEntitiesCallback
17+ from homeassistant .helpers .restore_state import RestoreEntity
1718from .const import DOMAIN , ICON_UPDATE , ICON_CREDIT , ICON_NO_LIMIT , ICON_FREE_EXPRESS , ICON_DELIVERY , ICON_BAGS , \
1819 ICON_CART , ICON_ACCOUNT , ICON_EMAIL , ICON_PHONE , ICON_PREMIUM_DAYS , ICON_LAST_ORDER , ICON_NEXT_ORDER_SINCE , \
1920 ICON_NEXT_ORDER_TILL , ICON_INFO , ICON_DELIVERY_TIME , ICON_MONTHLY_SPENT
2021from .entity import BaseEntity
2122from .hub import RohlikAccount
22- from .utils import extract_delivery_datetime , calculate_current_month_orders_total
23+ from .utils import extract_delivery_datetime , get_earliest_order , parse_delivery_datetime_string
2324
2425SCAN_INTERVAL = timedelta (seconds = 600 )
2526
@@ -424,26 +425,163 @@ async def async_will_remove_from_hass(self) -> None:
424425 self ._rohlik_account .remove_callback (self .async_write_ha_state )
425426
426427
427- class MonthlySpent (BaseEntity , SensorEntity ):
428- """Sensor for amount spent in current month."""
428+ class MonthlySpent (BaseEntity , SensorEntity , RestoreEntity ):
429+ """Sensor for amount spent in current month with HA-side accumulation.
430+
431+ Only tracks orders that are delivered and closed (have final price).
432+ Orders from the delivered_orders endpoint should all be finalized.
433+ Uses Home Assistant's restore state to persist monthly totals across restarts.
434+ """
429435
430436 _attr_translation_key = "monthly_spent"
431437 _attr_should_poll = False
438+ _attr_state_class = SensorStateClass .TOTAL
439+
440+ def __init__ (self , rohlik_account : RohlikAccount ) -> None :
441+ super ().__init__ (rohlik_account )
442+ self ._monthly_total : float = 0.0
443+ self ._processed_orders : set [str ] = set () # Store order IDs
444+ self ._current_month : str = datetime .now (ZoneInfo ("Europe/Prague" )).strftime ("%Y-%m" )
445+ self ._last_reset : datetime | None = None
446+
447+ def _is_order_final (self , order : dict ) -> bool :
448+ """
449+ Verify order has a final price.
450+
451+ Since orders come from the 'delivered_orders' endpoint, they should be finalized.
452+ We verify by checking that priceComposition exists and has a valid amount.
453+ """
454+ # Check if priceComposition exists
455+ price_comp = order .get ('priceComposition' )
456+ if not price_comp :
457+ return False
458+
459+ # Check if total exists
460+ total = price_comp .get ('total' )
461+ if not total :
462+ return False
463+
464+ # Check if amount exists and is a valid number
465+ amount = total .get ('amount' )
466+ if amount is None :
467+ return False
468+
469+ # Verify it's a valid number
470+ try :
471+ float (amount )
472+ return True
473+ except (ValueError , TypeError ):
474+ return False
475+
476+ async def async_added_to_hass (self ) -> None :
477+ """Restore state when added to HA."""
478+ await super ().async_added_to_hass ()
479+
480+ if (last_state := await self .async_get_last_state ()) is not None :
481+ self ._monthly_total = last_state .attributes .get ("monthly_total" , 0.0 )
482+ self ._processed_orders = set (last_state .attributes .get ("processed_orders" , []))
483+ self ._current_month = last_state .attributes .get ("current_month" , datetime .now (ZoneInfo ("Europe/Prague" )).strftime ("%Y-%m" ))
484+ if last_reset_str := last_state .attributes .get ("last_reset" ):
485+ self ._last_reset = datetime .fromisoformat (last_reset_str )
486+
487+ self ._check_and_reset_month ()
488+ self ._process_new_orders ()
489+
490+ self ._rohlik_account .register_callback (self ._handle_coordinator_update )
491+
492+ def _check_and_reset_month (self ) -> None :
493+ """Reset total if month changed."""
494+ current_month = datetime .now (ZoneInfo ("Europe/Prague" )).strftime ("%Y-%m" )
495+ if current_month != self ._current_month :
496+ _LOGGER .info (f"Month changed from { self ._current_month } to { current_month } , resetting monthly total" )
497+ self ._monthly_total = 0.0
498+ self ._processed_orders = set ()
499+ self ._current_month = current_month
500+ self ._last_reset = datetime .now (ZoneInfo ("Europe/Prague" ))
501+
502+ def _process_new_orders (self ) -> None :
503+ """Process new orders and add to total.
504+
505+ Only processes orders that are delivered and closed (have final price).
506+ Uses order ID for unique identification.
507+ """
508+ orders = self ._rohlik_account .data .get ('delivered_orders' , [])
509+ if not orders :
510+ return
511+
512+ current_month_pattern = datetime .now (ZoneInfo ("Europe/Prague" )).strftime ("%Y-%m-" )
513+ new_orders_count = 0
514+
515+ for order in orders :
516+ try :
517+ order_time = order .get ('orderTime' , '' )
518+
519+ # Only process orders from current month
520+ if current_month_pattern not in order_time :
521+ continue
522+
523+ # Verify order has final price (delivered and closed)
524+ if not self ._is_order_final (order ):
525+ _LOGGER .debug (f"Order { order .get ('id' )} does not have final price, skipping" )
526+ continue
527+
528+ # Get order ID (unique identifier)
529+ order_id = order .get ('id' )
530+ if not order_id :
531+ _LOGGER .warning (f"Order missing ID, skipping: { order .get ('orderTime' )} " )
532+ continue
533+
534+ order_key = str (order_id )
535+
536+ # Skip if already processed
537+ if order_key in self ._processed_orders :
538+ continue
539+
540+ # Get the final price
541+ amount = float (order ['priceComposition' ]['total' ]['amount' ])
542+
543+ # Add to total and mark as processed
544+ self ._monthly_total += amount
545+ self ._processed_orders .add (order_key )
546+ new_orders_count += 1
547+
548+ _LOGGER .debug (f"Added order { order_id } with amount { amount } CZK. New total: { self ._monthly_total } CZK" )
549+
550+ except (KeyError , ValueError , TypeError ) as e :
551+ _LOGGER .warning (f"Skipping order due to error: { e } , order ID: { order .get ('id' )} " )
552+ continue
553+
554+ if new_orders_count > 0 :
555+ _LOGGER .info (f"Processed { new_orders_count } new order(s). Monthly total: { self ._monthly_total } CZK" )
556+
557+ async def _handle_coordinator_update (self ) -> None :
558+ """Handle coordinator updates by processing new orders and updating state."""
559+ self ._check_and_reset_month ()
560+ self ._process_new_orders ()
561+ await self .async_write_ha_state ()
432562
433563 @property
434564 def native_value (self ) -> float | None :
435- """Returns amount spend within last month."""
436- return calculate_current_month_orders_total (self ._rohlik_account .data .get ('delivered_orders' , []))
565+ """Returns amount spent in current month."""
566+ return self ._monthly_total if self ._monthly_total > 0 else 0.0
567+
568+ @property
569+ def extra_state_attributes (self ) -> Mapping [str , Any ] | None :
570+ """Store state for restoration."""
571+ return {
572+ "monthly_total" : self ._monthly_total ,
573+ "processed_orders" : list (self ._processed_orders ),
574+ "current_month" : self ._current_month ,
575+ "last_reset" : self ._last_reset .isoformat () if self ._last_reset else None ,
576+ "processed_count" : len (self ._processed_orders )
577+ }
437578
438579 @property
439580 def icon (self ) -> str :
440581 return ICON_MONTHLY_SPENT
441582
442- async def async_added_to_hass (self ) -> None :
443- self ._rohlik_account .register_callback (self .async_write_ha_state )
444-
445583 async def async_will_remove_from_hass (self ) -> None :
446- self ._rohlik_account .remove_callback (self .async_write_ha_state )
584+ self ._rohlik_account .remove_callback (self ._handle_coordinator_update )
447585
448586
449587class NoLimitOrders (BaseEntity , SensorEntity ):
@@ -598,13 +736,12 @@ class NextOrderSince(BaseEntity, SensorEntity):
598736
599737 @property
600738 def native_value (self ) -> datetime | None :
601- """Returns remaining orders without limit."""
602- if len (self ._rohlik_account .data ['next_order' ]) > 0 :
603- slot_start = datetime .strptime (self ._rohlik_account .data ["next_order" ][0 ].get ("deliverySlot" , {}).get ("since" , None ),
604- "%Y-%m-%dT%H:%M:%S.%f%z" )
605- return slot_start
606- else :
607- return None
739+ """Returns start of delivery window for the earliest order."""
740+ earliest_order = get_earliest_order (self ._rohlik_account .data .get ('next_order' , []))
741+ if earliest_order :
742+ since_str = earliest_order .get ("deliverySlot" , {}).get ("since" , None )
743+ return parse_delivery_datetime_string (since_str )
744+ return None
608745
609746 @property
610747 def icon (self ) -> str :
@@ -625,13 +762,12 @@ class NextOrderTill(BaseEntity, SensorEntity):
625762
626763 @property
627764 def native_value (self ) -> datetime | None :
628- """Returns remaining orders without limit."""
629- if len (self ._rohlik_account .data ['next_order' ]) > 0 :
630- slot_start = datetime .strptime (self ._rohlik_account .data ["next_order" ][0 ].get ("deliverySlot" , {}).get ("till" , None ),
631- "%Y-%m-%dT%H:%M:%S.%f%z" )
632- return slot_start
633- else :
634- return None
765+ """Returns end of delivery window for the earliest order."""
766+ earliest_order = get_earliest_order (self ._rohlik_account .data .get ('next_order' , []))
767+ if earliest_order :
768+ till_str = earliest_order .get ("deliverySlot" , {}).get ("till" , None )
769+ return parse_delivery_datetime_string (till_str )
770+ return None
635771
636772 @property
637773 def icon (self ) -> str :
0 commit comments