diff --git a/custom_components/ynab_custom/__init__.py b/custom_components/ynab_custom/__init__.py index 0037dcb..0ed63c3 100644 --- a/custom_components/ynab_custom/__init__.py +++ b/custom_components/ynab_custom/__init__.py @@ -84,7 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) + await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "number"]) # Set up options update listener entry.async_on_unload(entry.add_update_listener(async_update_options)) @@ -148,7 +148,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if coordinator: await coordinator.async_shutdown() - result = await hass.config_entries.async_unload_platforms(entry, ["sensor"]) + result = await hass.config_entries.async_unload_platforms(entry, ["sensor", "number"]) if removed_entities: _LOGGER.debug(f"Unload complete for {entry.entry_id} - removed {len(removed_entities)} entities") diff --git a/custom_components/ynab_custom/coordinator.py b/custom_components/ynab_custom/coordinator.py index afffff6..64c872b 100644 --- a/custom_components/ynab_custom/coordinator.py +++ b/custom_components/ynab_custom/coordinator.py @@ -58,6 +58,16 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, budget_id: str, budg # Create persistent data key for this budget self.persistent_data_key = f"ynab_data_{entry.entry_id}" + + # Store user-managed credit settings separately from API payload snapshots. + self._user_store = Store( + hass, + version=1, + key=f"{DOMAIN}_userdata_{entry.entry_id}", + ) + self.credit_limits: dict[str, float] = {} + self.aprs: dict[str, float] = {} + self.due_days: dict[str, int] = {} # Get the user-defined update interval with fallbacks: options -> data -> default update_interval = ( @@ -83,8 +93,87 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, budget_id: str, budg async def async_load_persistent_data(self): """Load persistent data during coordinator initialization.""" + await self._load_user_values() await self._load_persistent_data() + async def _load_user_values(self) -> None: + """Load persisted user-edited credit settings.""" + stored = await self._user_store.async_load() or {} + + self.credit_limits = { + key: float(value) for key, value in stored.get("credit_limits", {}).items() + } + self.aprs = { + key: float(value) for key, value in stored.get("aprs", {}).items() + } + self.due_days = { + key: int(value) for key, value in stored.get("due_days", {}).items() + } + + # One-time migration from old config-entry options. + migrated = False + opts = self.entry.options + if not self.credit_limits and "credit_limits" in opts: + self.credit_limits = { + key: float(value) for key, value in opts["credit_limits"].items() + } + migrated = True + + if not self.aprs and "aprs" in opts: + self.aprs = { + key: float(value) for key, value in opts["aprs"].items() + } + migrated = True + + if not self.due_days and "due_days" in opts: + self.due_days = { + key: int(value) for key, value in opts["due_days"].items() + } + migrated = True + + if migrated: + new_opts = dict(opts) + new_opts.pop("credit_limits", None) + new_opts.pop("aprs", None) + new_opts.pop("due_days", None) + self.hass.config_entries.async_update_entry(self.entry, options=new_opts) + + await self.async_save_user_values() + + async def async_save_user_values(self) -> None: + """Persist user-edited credit settings.""" + await self._user_store.async_save( + { + "credit_limits": self.credit_limits, + "aprs": self.aprs, + "due_days": self.due_days, + } + ) + + def get_credit_limit(self, account_id: str) -> float: + return float(self.credit_limits.get(account_id, 0.0)) + + def get_apr(self, account_id: str) -> float: + return float(self.aprs.get(account_id, 0.0)) + + def get_due_day(self, account_id: str) -> int | None: + return self.due_days.get(account_id) + + async def async_set_credit_limit(self, account_id: str, value: float) -> None: + self.credit_limits[account_id] = float(value) + await self.async_save_user_values() + self.async_set_updated_data(self.data) + + async def async_set_due_day(self, account_id: str, value: int) -> None: + self.due_days[account_id] = int(value) + await self.async_save_user_values() + self.async_set_updated_data(self.data) + + async def async_set_apr(self, account_id: str, value: float) -> None: + self.aprs[account_id] = float(value) + await self.async_save_user_values() + self.async_set_updated_data(self.data) + def get_current_month(self): """Returns the current month in YYYY-MM-01 format.""" return datetime.now().strftime("%Y-%m-01") @@ -185,6 +274,15 @@ async def _async_update_data(self): budget_data["accounts"] = [ a for a in accounts.get("accounts", []) if a["id"] in self.selected_accounts ] + + for account in budget_data["accounts"]: + account_id = account["id"] + if account_id in self.credit_limits: + account["credit_limit"] = self.credit_limits[account_id] + if account_id in self.aprs: + account["apr"] = self.aprs[account_id] + if account_id in self.due_days: + account["due_day"] = self.due_days[account_id] _LOGGER.debug(f"🔹 Filtered Accounts: {budget_data['accounts']}") diff --git a/custom_components/ynab_custom/number.py b/custom_components/ynab_custom/number.py new file mode 100644 index 0000000..c300963 --- /dev/null +++ b/custom_components/ynab_custom/number.py @@ -0,0 +1,136 @@ +"""Number entities for YNAB credit account settings.""" + +from homeassistant.components.number import NumberEntity +from homeassistant.const import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +class YNABCreditLimitNumber(CoordinatorEntity, NumberEntity): + """Editable credit limit for credit/line of credit accounts.""" + + _attr_icon = "mdi:account-credit-card" + _attr_mode = "box" + _attr_native_min_value = 0 + _attr_native_max_value = 20000 + _attr_native_step = 1 + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator, account, entry): + super().__init__(coordinator) + self.coordinator = coordinator + self.account = account + + account_id = account["id"] + budget_id = entry.data["budget_id"] + + self._attr_name = f"{account['name']} Credit Limit" + self._attr_unique_id = f"{budget_id}_{account_id}_credit_limit" + self._attr_native_unit_of_measurement = coordinator.currency_symbol + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{budget_id}_{account_id}")}, + "name": account["name"], + "manufacturer": "YNAB", + "model": "Credit Card", + } + + @property + def native_value(self): + return self.coordinator.get_credit_limit(self.account["id"]) + + async def async_set_native_value(self, value: float) -> None: + self.account["credit_limit"] = value + await self.coordinator.async_set_credit_limit(self.account["id"], value) + self.async_write_ha_state() + + +class YNABDueDayNumber(CoordinatorEntity, NumberEntity): + """Monthly credit card payment due day (1-28).""" + + _attr_icon = "mdi:calendar-month" + _attr_mode = "box" + _attr_native_min_value = 1 + _attr_native_max_value = 28 + _attr_native_step = 1 + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator, account, entry): + super().__init__(coordinator) + self.coordinator = coordinator + self.account = account + + account_id = account["id"] + budget_id = entry.data["budget_id"] + + self._attr_name = f"{account['name']} Due Day" + self._attr_unique_id = f"{budget_id}_{account_id}_due_day" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{budget_id}_{account_id}")}, + "name": account["name"], + "manufacturer": "YNAB", + "model": "Credit Card", + } + + @property + def native_value(self): + return self.coordinator.get_due_day(self.account["id"]) + + async def async_set_native_value(self, value: float) -> None: + await self.coordinator.async_set_due_day(self.account["id"], int(value)) + self.async_write_ha_state() + + +class YNABAPRNumber(CoordinatorEntity, NumberEntity): + """Editable APR for credit accounts.""" + + _attr_icon = "mdi:percent" + _attr_mode = "box" + _attr_native_unit_of_measurement = "%" + _attr_native_min_value = 0 + _attr_native_max_value = 40 + _attr_native_step = 0.01 + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, coordinator, account, entry): + super().__init__(coordinator) + self.coordinator = coordinator + self.account = account + + account_id = account["id"] + budget_id = entry.data["budget_id"] + + self._attr_name = f"{account['name']} APR" + self._attr_unique_id = f"{budget_id}_{account_id}_apr" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{budget_id}_{account_id}")}, + "name": account["name"], + "manufacturer": "YNAB", + "model": "Credit Card", + } + + @property + def native_value(self): + return self.coordinator.get_apr(self.account["id"]) + + async def async_set_native_value(self, value: float) -> None: + self.account["apr"] = value + await self.coordinator.async_set_apr(self.account["id"], value) + self.async_write_ha_state() + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up YNAB number entities.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for account in coordinator.data.get("accounts", []): + account_type = account.get("type") + if account_type in ("creditCard", "lineOfCredit"): + entities.append(YNABCreditLimitNumber(coordinator, account, entry)) + entities.append(YNABDueDayNumber(coordinator, account, entry)) + + if account_type in ("creditCard", "personalLoan"): + entities.append(YNABAPRNumber(coordinator, account, entry)) + + async_add_entities(entities) diff --git a/custom_components/ynab_custom/sensor.py b/custom_components/ynab_custom/sensor.py index 7578baf..497d157 100644 --- a/custom_components/ynab_custom/sensor.py +++ b/custom_components/ynab_custom/sensor.py @@ -54,6 +54,9 @@ async def async_setup_entry(hass, entry, async_add_entities): # Ensure diagnostics sensors are always added entities.append(YNABAPIStatusSensor(coordinator, raw_budget_name)) + entities.append(YNABTotalCreditLimitSensor(coordinator, currency_symbol, raw_budget_name)) + entities.append(YNABTotalAvailableCreditSensor(coordinator, currency_symbol, raw_budget_name)) + entities.append(YNABTotalCreditUtilizationSensor(coordinator, raw_budget_name)) _LOGGER.debug(f"🔹 Coordinator Accounts Data: {coordinator.data.get('accounts', [])}") @@ -62,6 +65,9 @@ async def async_setup_entry(hass, entry, async_add_entities): if account["id"] in coordinator.selected_accounts: _LOGGER.debug(f"🔹 Adding Account Sensor: {account}") entities.append(YNABAccountSensor(coordinator, account, entry, currency_symbol, raw_budget_name)) + if account.get("type") == "creditCard": + entities.append(YNABUtilizationSensor(coordinator, account, entry)) + entities.append(YNABAvailableCreditSensor(coordinator, account, currency_symbol, entry)) # Create category sensors for category in coordinator.data.get("categories", []): @@ -236,6 +242,171 @@ def extra_state_attributes(self): "note": "Counts include all YNAB integrations using the same API token", } + +class YNABTotalCreditLimitSensor(CoordinatorEntity, SensorEntity): + """Total credit limit across all credit card accounts.""" + + _attr_icon = "mdi:credit-card-multiple" + _attr_has_entity_name = True + + def __init__(self, coordinator, currency_symbol, instance_name): + super().__init__(coordinator) + self.coordinator = coordinator + self._attr_name = "Total Credit Limit" + self._attr_unique_id = f"{coordinator.entry.entry_id}_total_credit_limit" + self._attr_native_unit_of_measurement = currency_symbol + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{coordinator.entry.entry_id}_extras")}, + "name": f"YNAB {instance_name} - Extras", + "manufacturer": "YNAB", + "model": "YNAB Extras", + "entry_type": "service", + } + + @property + def native_value(self): + total = 0.0 + for account in self.coordinator.data.get("accounts", []): + if account.get("type") == "creditCard": + total += self.coordinator.get_credit_limit(account["id"]) + return round(total, 2) + + +class YNABTotalCreditUtilizationSensor(CoordinatorEntity, SensorEntity): + """Overall credit utilization across all credit card accounts.""" + + _attr_icon = "mdi:percent" + _attr_has_entity_name = True + _attr_native_unit_of_measurement = "%" + + def __init__(self, coordinator, instance_name): + super().__init__(coordinator) + self.coordinator = coordinator + self._attr_name = "Total Credit Utilization" + self._attr_unique_id = f"{coordinator.entry.entry_id}_total_credit_utilization" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{coordinator.entry.entry_id}_extras")}, + "name": f"YNAB {instance_name} - Extras", + "manufacturer": "YNAB", + "model": "YNAB Extras", + "entry_type": "service", + } + + @property + def native_value(self): + total_balance = 0.0 + total_limit = 0.0 + for account in self.coordinator.data.get("accounts", []): + if account.get("type") != "creditCard": + continue + total_balance -= (account.get("balance", 0) / 1000) + total_limit += self.coordinator.get_credit_limit(account["id"]) + + if total_limit <= 0: + return None + return round((total_balance / total_limit) * 100, 1) + + +class YNABTotalAvailableCreditSensor(CoordinatorEntity, SensorEntity): + """Total available credit across all credit card accounts.""" + + _attr_icon = "mdi:credit-card-check" + _attr_has_entity_name = True + + def __init__(self, coordinator, currency_symbol, instance_name): + super().__init__(coordinator) + self.coordinator = coordinator + self._attr_name = "Total Available Credit" + self._attr_unique_id = f"{coordinator.entry.entry_id}_total_available_credit" + self._attr_native_unit_of_measurement = currency_symbol + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{coordinator.entry.entry_id}_extras")}, + "name": f"YNAB {instance_name} - Extras", + "manufacturer": "YNAB", + "model": "YNAB Extras", + "entry_type": "service", + } + + @property + def native_value(self): + total_available = 0.0 + for account in self.coordinator.data.get("accounts", []): + if account.get("type") != "creditCard": + continue + limit = self.coordinator.get_credit_limit(account["id"]) + if not limit: + continue + balance = account.get("balance") + if balance is None: + continue + total_available += limit + (balance / 1000) + + return round(total_available, 2) + + +class YNABUtilizationSensor(CoordinatorEntity, SensorEntity): + """Credit utilization per credit card account.""" + + def __init__(self, coordinator, account, entry): + super().__init__(coordinator) + self.coordinator = coordinator + self.account = account + + account_id = account["id"] + budget_id = entry.data["budget_id"] + self._attr_name = f"{account['name']} Utilization" + self._attr_unique_id = f"{budget_id}_{account_id}_utilization" + self._attr_native_unit_of_measurement = "%" + self._attr_icon = "mdi:percent" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{budget_id}_{account_id}")}, + "name": account["name"], + "manufacturer": "YNAB", + "model": "Credit Card", + } + + @property + def native_value(self): + balance = self.account.get("balance") or self.account.get("cleared_balance") + if balance is None: + return None + limit = self.coordinator.get_credit_limit(self.account["id"]) + if not limit: + return None + return round((abs(balance / 1000) / limit) * 100, 1) + + +class YNABAvailableCreditSensor(CoordinatorEntity, SensorEntity): + """Available credit per credit card account.""" + + def __init__(self, coordinator, account, currency_symbol, entry): + super().__init__(coordinator) + self.coordinator = coordinator + self.account = account + + account_id = account["id"] + budget_id = entry.data["budget_id"] + self._attr_name = f"{account['name']} Available Credit" + self._attr_unique_id = f"{budget_id}_{account_id}_available_credit" + self._attr_native_unit_of_measurement = currency_symbol + self._attr_icon = "mdi:account-credit-card-outline" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{budget_id}_{account_id}")}, + "name": account["name"], + "manufacturer": "YNAB", + "model": "Credit Card", + } + + @property + def native_value(self): + balance = self.account.get("balance") or self.account.get("cleared_balance") + if balance is None: + return None + limit = self.coordinator.get_credit_limit(self.account["id"]) + if not limit: + return None + return round(limit + (balance / 1000), 2) + class YNABAccountSensor(CoordinatorEntity, SensorEntity): """YNAB Account Sensor."""