Subscription Tracker

Simple Subscription Tracker

I’ve taken from this forum for years, and until now given very little back. I’d be delighted to get your feed back on this, more in terms of; is it safe, robust, clean, well structured, does it follow best practices - during the development, I ended up crashing my HA due to a nasty loop, so I’d very much like your feedback on ensuring this can’t happen again.

homeassistant/
│── config/
│   ├── configuration.yaml
│   ├── custom_components/
│   │   ├── subscription_tracker/  # Custom integration folder
│   │   │   ├── __init__.py        # Required for a custom component
│   │   │   ├── calendar.py        # Calendar entity (main logic)
│   ├── subscriptions.json         # JSON file storing subscription data

Configuration:

calendar:
  - platform: subscription_tracker

init.py

"""Initialize the Subscription Tracker integration."""
from homeassistant.core import HomeAssistant

DOMAIN = "subscription_tracker"

async def async_setup(hass: HomeAssistant, config: dict):
    """Set up the subscription tracker component."""
    hass.data[DOMAIN] = {}
    return True

calendar.py : the main logic is based on HA calendar entries. Only adds entries to calendar after current period expires. Here I took inspiration from the calendar code found in Waste Collection Schedule integration by mampfes.

import logging
import json
import os
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import homeassistant.util.dt as dt_util
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.event import async_track_time_interval

_LOGGER = logging.getLogger(__name__)

SUBSCRIPTIONS_FILE = "/config/subscriptions.json"


async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    add_entities: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None
):
    """Set up the subscription tracker as a single calendar entity."""
    entity = SubscriptionTrackerCalendarEntity(hass)
    add_entities([entity])
    await entity.async_load_subscriptions()


class SubscriptionTrackerCalendarEntity(CalendarEntity):
    """A single calendar entity that holds all subscription events."""

    should_poll = False  # Prevent unnecessary polling

    def __init__(self, hass: HomeAssistant):
        """Initialize the subscription tracker calendar."""
        self.hass = hass
        self._name = "Subscription Tracker"
        self._subscriptions = []
        self._events = []
        self._attributes = {}

        # Automatically update once per day at midnight
        async_track_time_interval(self.hass, self.async_update, timedelta(days=1))

    async def async_load_subscriptions(self):
        """Load subscriptions asynchronously, handling errors gracefully."""
        try:
            self._subscriptions = await self.hass.async_add_executor_job(self._read_subscriptions)
            await self.async_update()
        except Exception as e:
            _LOGGER.error(f"Failed to load subscriptions: {e}")
            self._subscriptions = []

    def _read_subscriptions(self):
        """Safely read the subscription file without blocking HA's event loop."""
        try:
            if not os.path.exists(SUBSCRIPTIONS_FILE):
                _LOGGER.warning(f"Subscriptions file not found: {SUBSCRIPTIONS_FILE}")
                return []

            with open(SUBSCRIPTIONS_FILE, "r", encoding="utf-8") as file:
                data = json.load(file)
                subscriptions = data.get("subscriptions", [])

                # Validate each subscription entry
                valid_subscriptions = []
                for sub in subscriptions:
                    if all(key in sub for key in ("name", "start_date", "cycle", "cost", "currency")):
                        valid_subscriptions.append(sub)
                    else:
                        _LOGGER.warning(f"Skipping invalid subscription entry: {sub}")

                return valid_subscriptions

        except Exception as e:
            _LOGGER.error(f"Error reading subscriptions file: {e}")
        return []

    def _calculate_next_payment(self, start_date, cycle):
        """Calculate the next payment date safely, ensuring no infinite loops."""
        start_date = dt_util.parse_datetime(start_date)
        if not start_date:
            _LOGGER.warning(f"Invalid start date: {start_date}")
            return None

        if start_date.tzinfo is None:
            start_date = dt_util.as_local(start_date)

        today = dt_util.now()
        max_cycles = 36  # Failsafe to prevent infinite loops

        for _ in range(max_cycles):
            if cycle == "monthly":
                start_date += relativedelta(months=1)
            elif cycle == "yearly":
                start_date += relativedelta(years=1)
            else:
                _LOGGER.error(f"Unsupported subscription cycle: {cycle}")
                return None

            if start_date >= today:
                return start_date  # Found the next valid date

        _LOGGER.error(f"Exceeded max iterations for subscription cycle: {cycle}")
        return None

    def _generate_events(self):
        """Generate calendar events for all subscriptions."""
        from homeassistant.util.dt import as_local

        events = []
        today = dt_util.now()

        subscriptions_list = []
        total_weekly_cost = 0
        total_monthly_cost = 0
        total_yearly_cost = 0

        for sub in self._subscriptions:
            next_payment = self._calculate_next_payment(sub["start_date"], sub["cycle"])
            if next_payment:
                start_time = as_local(next_payment)
                end_time = start_time + timedelta(days=1)

                summary = f"{sub['name']} Subscription Due ({sub['currency']} {sub['cost']})"
                events.append(CalendarEvent(start=start_time, end=end_time, summary=summary))

                days_until = (next_payment - today).days

                subscriptions_list.append({
                    "name": sub["name"],
                    "cost": float(sub["cost"]),
                    "currency": sub["currency"],
                    "days_until": days_until
                })

                cost = float(sub["cost"])
                if sub["cycle"] == "monthly":
                    total_monthly_cost += cost
                    total_weekly_cost += cost / 4.33
                    total_yearly_cost += cost * 12
                elif sub["cycle"] == "yearly":
                    total_yearly_cost += cost
                    total_monthly_cost += cost / 12
                    total_weekly_cost += cost / 52

        self._attributes = {
            "subscriptions_list": subscriptions_list,
            "total_weekly_cost": round(total_weekly_cost, 2),
            "total_monthly_cost": round(total_monthly_cost, 2),
            "total_yearly_cost": round(total_yearly_cost, 2),
        }

        return events

    async def async_get_events(self, hass, start_date, end_date):
        """Return calendar events within the requested time range."""
        return [event for event in self._events if start_date <= event.start <= end_date]

    @property
    def name(self):
        return self._name

    @property
    def event(self):
        """Return the next upcoming event."""
        return min(self._events, key=lambda e: e.start) if self._events else None

    @property
    def extra_state_attributes(self):
        """Return additional attributes."""
        return self._attributes

    async def async_update(self, *_):
        """Safely update subscription events."""
        try:
            self._subscriptions = await self.hass.async_add_executor_job(self._read_subscriptions)
            self._events = await self.hass.async_add_executor_job(self._generate_events)
            self.async_write_ha_state()
        except Exception as e:
            _LOGGER.error(f"Update failed: {e}")

subscriptions.json - sidecar file that holds all the subscription information

{
    "subscriptions": [
        {"name": "Netflix", "currency": "CHF", "cost": 18.6, "start_date": "2023-03-10", "cycle": "monthly"},
        {"name": "Patreon", "currency": "USD", "cost": 0, "start_date": "2023-02-28", "cycle": "monthly"},
        {"name": "Proton Mail", "currency": "CHF", "cost": 83.9, "start_date": "2023-04-07", "cycle": "yearly"},
        {"name": "Adobe", "currency": "CHF", "cost": 10.75, "start_date": "2023-03-02", "cycle": "monthly"},
        {"name": "Spotify", "currency": "CHF", "cost": 20.95, "start_date": "2023-02-24", "cycle": "monthly"},
        {"name": "Apple TV+", "currency": "CHF", "cost": 8.0, "start_date": "2023-03-21", "cycle": "monthly"},
        {"name": "NabuCasa", "currency": "USD", "cost": 65.0, "start_date": "2023-05-16", "cycle": "yearly"},
        {"name": "Krystal Domain", "currency": "GBP", "cost": 11.99, "start_date": "2022-10-02", "cycle": "yearly"},
        {"name": "Amazon AWS", "currency": "CHF", "cost": 0.35, "start_date": "2023-03-01", "cycle": "monthly"},
        {"name": "Zwift", "currency": "CHF", "cost": 16.48, "start_date": "2023-03-05", "cycle": "monthly"},
        {"name": "Krystal Webhosting", "currency": "GBP", "cost": 70.0, "start_date": "2022-09-03", "cycle": "yearly"},
        {"name": "Disney+", "currency": "CHF", "cost": 20.90, "start_date": "2022-09-03", "cycle": "monthly"},
        {"name": "ChatGPT", "currency": "USD", "cost": 20.00, "start_date": "2024-02-01", "cycle": "monthly"},
        {"name": "GoDaddy1", "currency": "CHF", "cost": 13.27, "start_date": "2024-10-25", "cycle": "yearly"},
        {"name": "GoDaddy2", "currency": "CHF", "cost": 20.15, "start_date": "2022-11-08", "cycle": "yearly"}
    ]
}

Lovelace button card - first three entries used as example. I manually sorted the order in the ui to have monthly and yearly separated, then split them up into two cards after that. Here I have one block per subscription (15copies!). I fought a lot on this bit, because I couldn’t find a way to loop inside a card. Perhaps someone can improve this part. The first button card below (netflix) has a tap function for demo purposes. Perhaps in the future I’ll add a sensor to track the cost of each individual subscription (for price tracking), but for now it does everything I need.

square: false
type: grid
columns: 3
cards:
  - type: custom:button-card
    template: subscription_card
    name: >-
      [[[ return
      states['sensor.subscriptions_list']?.attributes?.subscriptions[0]?.name ||
      'N/A'; ]]]
    icon: mdi:netflix
    label: |
      [[[
        let sub = states['sensor.subscriptions_list']?.attributes?.subscriptions[0];
        return sub ? `${sub.cost} ${sub.currency}` : "N/A";
      ]]]
    state_display: |
      [[[
        let sub = states['sensor.subscriptions_list']?.attributes?.subscriptions[0];
        let days = sub?.days_until ?? 999;
        return `<span style="color: ${days < 7 ? '#FF5555' : days < 30 ? '#FFA500' : '#55FF55'}">${days} days until renewal</span>`;
      ]]]
    tap_action:
      action: more-info
      entity: sensor.total_monthly_subscription_cost
    styles:
      card:
        - border-radius: 15px
        - padding: 12px
        - background: rgba(0, 0, 0, 0.4)
        - box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.25)
      icon:
        - color: "#E50914"
  - type: custom:button-card
    template: subscription_card
    name: >-
      [[[ return
      states['sensor.subscriptions_list']?.attributes?.subscriptions[1]?.name ||
      'N/A'; ]]]
    icon: mdi:patreon
    label: |
      [[[
        let sub = states['sensor.subscriptions_list']?.attributes?.subscriptions[1];
        return sub ? `${sub.cost} ${sub.currency}` : "N/A";
      ]]]
    state_display: |
      [[[
        let sub = states['sensor.subscriptions_list']?.attributes?.subscriptions[1];
        let days = sub?.days_until ?? 999;
        return `<span style="color: ${days < 7 ? '#FF5555' : days < 30 ? '#FFA500' : '#55FF55'}">${days} days until renewal</span>`;
      ]]]
    styles:
      card:
        - background: rgba(0, 0, 0, 0.4)
      icon:
        - color: "#F96854"
  - type: custom:button-card
    template: subscription_card
    name: >-
      [[[ return
      states['sensor.subscriptions_list']?.attributes?.subscriptions[3]?.name ||
      'N/A'; ]]]
    icon: mdi:file-pdf-box
    label: |
      [[[
        let sub = states['sensor.subscriptions_list']?.attributes?.subscriptions[3];
        return sub ? `${sub.cost} ${sub.currency}` : "N/A";
      ]]]
    state_display: |
      [[[
        let sub = states['sensor.subscriptions_list']?.attributes?.subscriptions[3];
        let days = sub?.days_until ?? 999;
        return `<span style="color: ${days < 7 ? '#FF5555' : days < 30 ? '#FFA500' : '#55FF55'}">${days} days until renewal</span>`;
      ]]]
    styles:
      card:
        - background: rgba(0, 0, 0, 0.4)
      icon:
        - color: "#FF0000"

And finally a few template sensors I used to track the costs in the header card:

    # Subscription Tracking Sensors
    subscriptions_list:
      friendly_name: "Subscriptions List"
      value_template: "ok"
      attribute_templates:
        subscriptions: "{{ state_attr('calendar.subscription_tracker', 'subscriptions_list') }}"

    total_weekly_subscription_cost:
      friendly_name: "Total Weekly Subscription Cost"
      unit_of_measurement: "CHF"
      value_template: >-
        {{ state_attr('calendar.subscription_tracker', 'total_weekly_cost') }}

    total_monthly_subscription_cost:
      friendly_name: "Total Monthly Subscription Cost"
      unit_of_measurement: "CHF"
      value_template: >-
        {{ state_attr('calendar.subscription_tracker', 'total_monthly_cost') }}

    total_yearly_subscription_cost:
      friendly_name: "Total Yearly Subscription Cost"
      unit_of_measurement: "CHF"
      value_template: >-
        {{ state_attr('calendar.subscription_tracker', 'total_yearly_cost') }}

edit:

forgot about the refresh. In scripts.yaml, I added this so that any changes in the json file can be loaded without HA restart:

refresh_subscription_tracker:
  alias: "Refresh Subscription Tracker"
  sequence:
    - service: homeassistant.update_entity
      target:
        entity_id: calendar.subscription_tracker
  mode: single

Here’s the button styling to call it (just added to the bottom of the list, along with all the other button cards):

  - type: custom:button-card
    icon: mdi:refresh
    name: Refresh
    tap_action:
      action: call-service
      service: script.refresh_subscription_tracker
    styles:
      card:
        - border-radius: 15px
        - padding: 12px
        - background: rgba(0, 0, 0, 0.4)
        - box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.25)
      icon:
        - color: "#007AFF"
      name:
        - font-size: 16px
        - font-weight: bold
        - color: "#FFFFFF"

edit: updated main logic due to high processor usage. This was because I was polling the json file for changes at at very high frequency. Now it’s only sysnc’d once per day or manually via the script. I’ve been monitoring CPU and Memory for a few days now. Seems good.

3 Likes

will try if i find the time. thanks :wink:

i guess this config does not fit to my install. Im running HAOS (in Proxmox) and there is no /config directory. Hence the install is not working that easily.

Pff, I’ve no clue buddy. I mean, this is just the “general” location to store “custom_components” for me. Surely you have other custom components installed? In the same location, for example, I have things like: HACS, momentary (switch integration), Gardena, Miele, and the waste_collection schedule integration etc etc.

If I go to the Momentary integration (as example) is just says: " Copy the momentary directory into your /config/custom_components directory."

Finally i found such a thing to track all subscription and not to forget smthng.
But, i didn’t make it works for me, idk. I repeated all as mentioned, except

First, can’t understand where i need to put those (tried in configuration.yaml, but that got me errors), and second, even without it, i got warning

Platform error ‘calendar’ from integration ‘subscription_tracker’ - Integration ‘subscription_tracker’ not found.

And, as a result, nothing here

Hope, you can give me some advices