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.

