From e4347e552042faebac5635c2126df2b1a6b28984 Mon Sep 17 00:00:00 2001 From: Jeef Date: Wed, 11 Sep 2024 09:09:16 -0600 Subject: [PATCH] Add Monarch Money Integration (#124014) * Initial commit * Second commit - with some coverage but errors abount * Updated testing coverage * Should be just about ready for PR * Adding some error handling for wonky acocunts * Adding USD hardcoded as this is all that is currently supported i believe * updating snapshots * updating entity descrition a little * Addign cashflow in * adding aggregate sensors * tweak icons * refactor some type stuff as well as initialize the pr comment addressing process * remove empty fields from manifest * Update homeassistant/components/monarchmoney/sensor.py Co-authored-by: Joost Lekkerkerker * move stuff * get logging out of try block * get logging out of try block * using Subscription ID as stored in config entry for unique id soon * new unique id * giving cashflow a better unique id * Moving subscription id stuff into setup of coordinator * Update homeassistant/components/monarchmoney/config_flow.py Co-authored-by: Joost Lekkerkerker * ruff ruff * ruff ruff * split ot value and balance sensors... need to go tos leep * removed icons * Moved summary into a data class * efficenty increase * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarchmoney/entity.py Co-authored-by: Joost Lekkerkerker * refactor continues * removed a comment * forgot to add a little bit of info * updated snapshot * Updates to monarch money using the new typed/wrapper setup * backing lib update * fixing manifest * fixing manifest * fixing manifest * Version 0.2.0 * fixing some types * more type fixes * cleanup and bump * no check * i think i got it all * the last thing * update domain name * i dont know what is in this commit * The Great Renaming * Moving to dict style accounting - as per request * updating backing deps * Update homeassistant/components/monarch_money/entity.py Co-authored-by: Joost Lekkerkerker * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarch_money/sensor.py Co-authored-by: Joost Lekkerkerker * some changes * fixing capitalizaton * test test test * Adding dupe test * addressing pr stuff * forgot snapshot * Fix * Fix * Update homeassistant/components/monarch_money/sensor.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/monarch_money/__init__.py | 35 + .../components/monarch_money/config_flow.py | 157 +++ .../components/monarch_money/const.py | 10 + .../components/monarch_money/coordinator.py | 91 ++ .../components/monarch_money/entity.py | 83 ++ .../components/monarch_money/icons.json | 10 + .../components/monarch_money/manifest.json | 9 + .../components/monarch_money/sensor.py | 182 +++ .../components/monarch_money/strings.json | 46 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/monarch_money/__init__.py | 13 + tests/components/monarch_money/conftest.py | 79 ++ .../monarch_money/fixtures/get_accounts.json | 516 ++++++++ .../fixtures/get_cashflow_summary.json | 14 + .../fixtures/get_subscription_details.json | 10 + .../monarch_money/snapshots/test_sensor.ambr | 1112 +++++++++++++++++ .../monarch_money/test_config_flow.py | 166 +++ tests/components/monarch_money/test_sensor.py | 27 + 22 files changed, 2575 insertions(+) create mode 100644 homeassistant/components/monarch_money/__init__.py create mode 100644 homeassistant/components/monarch_money/config_flow.py create mode 100644 homeassistant/components/monarch_money/const.py create mode 100644 homeassistant/components/monarch_money/coordinator.py create mode 100644 homeassistant/components/monarch_money/entity.py create mode 100644 homeassistant/components/monarch_money/icons.json create mode 100644 homeassistant/components/monarch_money/manifest.json create mode 100644 homeassistant/components/monarch_money/sensor.py create mode 100644 homeassistant/components/monarch_money/strings.json create mode 100644 tests/components/monarch_money/__init__.py create mode 100644 tests/components/monarch_money/conftest.py create mode 100644 tests/components/monarch_money/fixtures/get_accounts.json create mode 100644 tests/components/monarch_money/fixtures/get_cashflow_summary.json create mode 100644 tests/components/monarch_money/fixtures/get_subscription_details.json create mode 100644 tests/components/monarch_money/snapshots/test_sensor.ambr create mode 100644 tests/components/monarch_money/test_config_flow.py create mode 100644 tests/components/monarch_money/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fdb7069069d..2ce30a52e18 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -926,6 +926,8 @@ build.json @home-assistant/supervisor /tests/components/modern_forms/ @wonderslug /homeassistant/components/moehlenhoff_alpha2/ @j-a-n /tests/components/moehlenhoff_alpha2/ @j-a-n +/homeassistant/components/monarch_money/ @jeeftor +/tests/components/monarch_money/ @jeeftor /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund /homeassistant/components/monzo/ @jakemartin-icl diff --git a/homeassistant/components/monarch_money/__init__.py b/homeassistant/components/monarch_money/__init__.py new file mode 100644 index 00000000000..5f9aba7dd07 --- /dev/null +++ b/homeassistant/components/monarch_money/__init__.py @@ -0,0 +1,35 @@ +"""The Monarch Money integration.""" + +from __future__ import annotations + +from typedmonarchmoney import TypedMonarchMoney + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import MonarchMoneyDataUpdateCoordinator + +type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: MonarchMoneyConfigEntry +) -> bool: + """Set up Monarch Money from a config entry.""" + monarch_client = TypedMonarchMoney(token=entry.data.get(CONF_TOKEN)) + + mm_coordinator = MonarchMoneyDataUpdateCoordinator(hass, monarch_client) + await mm_coordinator.async_config_entry_first_refresh() + entry.runtime_data = mm_coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: MonarchMoneyConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py new file mode 100644 index 00000000000..410630c7cd8 --- /dev/null +++ b/homeassistant/components/monarch_money/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Monarch Money integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from monarchmoney import LoginFailedException, RequireMFAException +from monarchmoney.monarchmoney import SESSION_FILE +from typedmonarchmoney import TypedMonarchMoney +from typedmonarchmoney.models import MonarchSubscription +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_ID, CONF_PASSWORD, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_MFA_CODE, DOMAIN, LOGGER + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) + +STEP_MFA_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_MFA_CODE): str, + } +) + + +async def validate_login( + hass: HomeAssistant, + data: dict[str, Any], + email: str | None = None, + password: str | None = None, +) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. Upon success a session will be saved + """ + + if not email: + email = data[CONF_EMAIL] + if not password: + password = data[CONF_PASSWORD] + monarch_client = TypedMonarchMoney() + if CONF_MFA_CODE in data: + mfa_code = data[CONF_MFA_CODE] + LOGGER.debug("Attempting to authenticate with MFA code") + try: + await monarch_client.multi_factor_authenticate(email, password, mfa_code) + except KeyError as err: + # A bug in the backing lib that I don't control throws a KeyError if the MFA code is wrong + LOGGER.debug("Bad MFA Code") + raise BadMFA from err + else: + LOGGER.debug("Attempting to authenticate") + try: + await monarch_client.login( + email=email, + password=password, + save_session=False, + use_saved_session=False, + ) + except RequireMFAException: + raise + except LoginFailedException as err: + raise InvalidAuth from err + + LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") + LOGGER.debug("Obtaining subscription id") + subs: MonarchSubscription = await monarch_client.get_subscription_details() + assert subs is not None + subscription_id = subs.id + return { + CONF_TOKEN: monarch_client.token, + CONF_ID: subscription_id, + } + + +class MonarchMoneyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Monarch Money.""" + + VERSION = 1 + + def __init__(self): + """Initialize config flow.""" + self.email: str | None = None + self.password: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await validate_login( + self.hass, user_input, email=self.email, password=self.password + ) + except RequireMFAException: + self.email = user_input[CONF_EMAIL] + self.password = user_input[CONF_PASSWORD] + + return self.async_show_form( + step_id="user", + data_schema=STEP_MFA_DATA_SCHEMA, + errors={"base": "mfa_required"}, + ) + except BadMFA: + return self.async_show_form( + step_id="user", + data_schema=STEP_MFA_DATA_SCHEMA, + errors={"base": "bad_mfa"}, + ) + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(info[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Monarch Money", + data={CONF_TOKEN: info[CONF_TOKEN]}, + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class BadMFA(HomeAssistantError): + """Error to indicate the MFA code was bad.""" diff --git a/homeassistant/components/monarch_money/const.py b/homeassistant/components/monarch_money/const.py new file mode 100644 index 00000000000..f450f123179 --- /dev/null +++ b/homeassistant/components/monarch_money/const.py @@ -0,0 +1,10 @@ +"""Constants for the Monarch Money integration.""" + +import logging + +DOMAIN = "monarch_money" + +LOGGER = logging.getLogger(__package__) + +CONF_MFA_SECRET = "mfa_secret" +CONF_MFA_CODE = "mfa_code" diff --git a/homeassistant/components/monarch_money/coordinator.py b/homeassistant/components/monarch_money/coordinator.py new file mode 100644 index 00000000000..8eb15d448ec --- /dev/null +++ b/homeassistant/components/monarch_money/coordinator.py @@ -0,0 +1,91 @@ +"""Data coordinator for monarch money.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientResponseError +from gql.transport.exceptions import TransportServerError +from monarchmoney import LoginFailedException +from typedmonarchmoney import TypedMonarchMoney +from typedmonarchmoney.models import ( + MonarchAccount, + MonarchCashflowSummary, + MonarchSubscription, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + + +@dataclass +class MonarchData: + """Data class to hold monarch data.""" + + account_data: dict[str, MonarchAccount] + cashflow_summary: MonarchCashflowSummary + + +class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]): + """Data update coordinator for Monarch Money.""" + + config_entry: ConfigEntry + subscription_id: str + + def __init__( + self, + hass: HomeAssistant, + client: TypedMonarchMoney, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name="monarchmoney", + update_interval=timedelta(hours=4), + ) + self.client = client + + async def _async_setup(self) -> None: + """Obtain subscription ID in setup phase.""" + try: + sub_details: MonarchSubscription = ( + await self.client.get_subscription_details() + ) + except (TransportServerError, LoginFailedException, ClientResponseError) as err: + raise ConfigEntryError("Authentication failed") from err + self.subscription_id = sub_details.id + + async def _async_update_data(self) -> MonarchData: + """Fetch data for all accounts.""" + + account_data, cashflow_summary = await asyncio.gather( + self.client.get_accounts_as_dict_with_id_key(), + self.client.get_cashflow_summary(), + ) + + return MonarchData(account_data=account_data, cashflow_summary=cashflow_summary) + + @property + def cashflow_summary(self) -> MonarchCashflowSummary: + """Return cashflow summary.""" + return self.data.cashflow_summary + + @property + def accounts(self) -> list[MonarchAccount]: + """Return accounts.""" + return list(self.data.account_data.values()) + + @property + def value_accounts(self) -> list[MonarchAccount]: + """Return value accounts.""" + return [x for x in self.accounts if x.is_value_account] + + @property + def balance_accounts(self) -> list[MonarchAccount]: + """Return accounts that aren't assets.""" + return [x for x in self.accounts if x.is_balance_account] diff --git a/homeassistant/components/monarch_money/entity.py b/homeassistant/components/monarch_money/entity.py new file mode 100644 index 00000000000..49a24385782 --- /dev/null +++ b/homeassistant/components/monarch_money/entity.py @@ -0,0 +1,83 @@ +"""Monarch money entity definition.""" + +from typedmonarchmoney.models import MonarchAccount, MonarchCashflowSummary + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MonarchMoneyDataUpdateCoordinator + + +class MonarchMoneyEntityBase(CoordinatorEntity[MonarchMoneyDataUpdateCoordinator]): + """Base entity for Monarch Money with entity name attribute.""" + + _attr_has_entity_name = True + + +class MonarchMoneyCashFlowEntity(MonarchMoneyEntityBase): + """Entity for Cashflow sensors.""" + + def __init__( + self, + coordinator: MonarchMoneyDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the Monarch Money Entity.""" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{coordinator.subscription_id}_cashflow_{description.key}" + ) + self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.subscription_id))}, + name="Cashflow", + ) + + @property + def summary_data(self) -> MonarchCashflowSummary: + """Return cashflow summary data.""" + return self.coordinator.cashflow_summary + + +class MonarchMoneyAccountEntity(MonarchMoneyEntityBase): + """Entity for Account Sensors.""" + + def __init__( + self, + coordinator: MonarchMoneyDataUpdateCoordinator, + description: EntityDescription, + account: MonarchAccount, + ) -> None: + """Initialize the Monarch Money Entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._account_id = account.id + self._attr_attribution = ( + f"Data provided by Monarch Money API via {account.data_provider}" + ) + self._attr_unique_id = ( + f"{coordinator.subscription_id}_{account.id}_{description.translation_key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(account.id))}, + name=f"{account.institution_name} {account.name}", + entry_type=DeviceEntryType.SERVICE, + manufacturer=account.data_provider, + model=f"{account.institution_name} - {account.type_name} - {account.subtype_name}", + configuration_url=account.institution_url, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and ( + self._account_id in self.coordinator.data.account_data + ) + + @property + def account_data(self) -> MonarchAccount: + """Return the account data.""" + return self.coordinator.data.account_data[self._account_id] diff --git a/homeassistant/components/monarch_money/icons.json b/homeassistant/components/monarch_money/icons.json new file mode 100644 index 00000000000..95c5eb3cca4 --- /dev/null +++ b/homeassistant/components/monarch_money/icons.json @@ -0,0 +1,10 @@ +{ + "entity": { + "sensor": { + "sum_income": { "default": "mdi:cash-plus" }, + "sum_expense": { "default": "mdi:cash-minus" }, + "savings": { "default": "mdi:piggy-bank-outline" }, + "savings_rate": { "default": "mdi:cash-sync" } + } + } +} diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json new file mode 100644 index 00000000000..ed28f825bcf --- /dev/null +++ b/homeassistant/components/monarch_money/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "monarch_money", + "name": "Monarch Money", + "codeowners": ["@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/monarchmoney", + "iot_class": "cloud_polling", + "requirements": ["typedmonarchmoney==0.3.1"] +} diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py new file mode 100644 index 00000000000..fe7c728cf41 --- /dev/null +++ b/homeassistant/components/monarch_money/sensor.py @@ -0,0 +1,182 @@ +"""Sensor config - monarch money.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from typedmonarchmoney.models import MonarchAccount, MonarchCashflowSummary + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import MonarchMoneyConfigEntry +from .entity import MonarchMoneyAccountEntity, MonarchMoneyCashFlowEntity + + +@dataclass(frozen=True, kw_only=True) +class MonarchMoneyAccountSensorEntityDescription(SensorEntityDescription): + """Describe an account sensor entity.""" + + value_fn: Callable[[MonarchAccount], StateType | datetime] + picture_fn: Callable[[MonarchAccount], str | None] | None = None + + +@dataclass(frozen=True, kw_only=True) +class MonarchMoneyCashflowSensorEntityDescription(SensorEntityDescription): + """Describe a cashflow sensor entity.""" + + summary_fn: Callable[[MonarchCashflowSummary], StateType] + + +# These sensors include assets like a boat that might have value +MONARCH_MONEY_VALUE_SENSORS: tuple[MonarchMoneyAccountSensorEntityDescription, ...] = ( + MonarchMoneyAccountSensorEntityDescription( + key="value", + translation_key="value", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda account: account.balance, + picture_fn=lambda account: account.logo_url, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), +) + +# Most accounts are balance sensors +MONARCH_MONEY_SENSORS: tuple[MonarchMoneyAccountSensorEntityDescription, ...] = ( + MonarchMoneyAccountSensorEntityDescription( + key="currentBalance", + translation_key="balance", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda account: account.balance, + picture_fn=lambda account: account.logo_url, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), +) + +MONARCH_MONEY_AGE_SENSORS: tuple[MonarchMoneyAccountSensorEntityDescription, ...] = ( + MonarchMoneyAccountSensorEntityDescription( + key="age", + translation_key="age", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda account: account.last_update, + ), +) + +MONARCH_CASHFLOW_SENSORS: tuple[MonarchMoneyCashflowSensorEntityDescription, ...] = ( + MonarchMoneyCashflowSensorEntityDescription( + key="sum_income", + translation_key="sum_income", + summary_fn=lambda summary: summary.income, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), + MonarchMoneyCashflowSensorEntityDescription( + key="sum_expense", + translation_key="sum_expense", + summary_fn=lambda summary: summary.expenses, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), + MonarchMoneyCashflowSensorEntityDescription( + key="savings", + translation_key="savings", + summary_fn=lambda summary: summary.savings, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), + MonarchMoneyCashflowSensorEntityDescription( + key="savings_rate", + translation_key="savings_rate", + summary_fn=lambda summary: summary.savings_rate * 100, + suggested_display_precision=1, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MonarchMoneyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Monarch Money sensors for config entries.""" + mm_coordinator = config_entry.runtime_data + + entity_list: list[MonarchMoneySensor | MonarchMoneyCashFlowSensor] = [ + MonarchMoneyCashFlowSensor( + mm_coordinator, + sensor_description, + ) + for sensor_description in MONARCH_CASHFLOW_SENSORS + ] + entity_list.extend( + MonarchMoneySensor( + mm_coordinator, + sensor_description, + account, + ) + for account in mm_coordinator.balance_accounts + for sensor_description in MONARCH_MONEY_SENSORS + ) + entity_list.extend( + MonarchMoneySensor( + mm_coordinator, + sensor_description, + account, + ) + for account in mm_coordinator.accounts + for sensor_description in MONARCH_MONEY_AGE_SENSORS + ) + entity_list.extend( + MonarchMoneySensor( + mm_coordinator, + sensor_description, + account, + ) + for account in mm_coordinator.value_accounts + for sensor_description in MONARCH_MONEY_VALUE_SENSORS + ) + + async_add_entities(entity_list) + + +class MonarchMoneyCashFlowSensor(MonarchMoneyCashFlowEntity, SensorEntity): + """Cashflow summary sensor.""" + + entity_description: MonarchMoneyCashflowSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.entity_description.summary_fn(self.summary_data) + + +class MonarchMoneySensor(MonarchMoneyAccountEntity, SensorEntity): + """Define a monarch money sensor.""" + + entity_description: MonarchMoneyAccountSensorEntityDescription + + @property + def native_value(self) -> StateType | datetime: + """Return the state.""" + return self.entity_description.value_fn(self.account_data) + + @property + def entity_picture(self) -> str | None: + """Return the picture of the account as provided by monarch money if it exists.""" + if self.entity_description.picture_fn is not None: + return self.entity_description.picture_fn(self.account_data) + return None diff --git a/homeassistant/components/monarch_money/strings.json b/homeassistant/components/monarch_money/strings.json new file mode 100644 index 00000000000..d7a28940d7a --- /dev/null +++ b/homeassistant/components/monarch_money/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Monarch Money email and password, if required you will also be prompted for your MFA code.", + "data": { + "mfa_secret": "Add your MFA Secret. See docs for help.", + "mfa_code": "Enter your MFA code", + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "mfa_required": "Multi-factor authentication required.", + "bad_mfa": "Your code was invalid, please try again or use a recovery token." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "balance": { "name": "Balance" }, + "value": { "name": "Value" }, + + "age": { + "name": "Data age" + }, + + "sum_income": { + "name": "Income year to date" + }, + "sum_expense": { + "name": "Expense year to date" + }, + "savings": { + "name": "Savings year to date" + }, + "savings_rate": { + "name": "Savings rate" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a0fb9a48a17..b26519c6319 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -370,6 +370,7 @@ FLOWS = { "modem_callerid", "modern_forms", "moehlenhoff_alpha2", + "monarch_money", "monoprice", "monzo", "moon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 62e77d0edb1..8dde030a0d3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3802,6 +3802,12 @@ "config_flow": true, "iot_class": "local_push" }, + "monarch_money": { + "name": "Monarch Money", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "monessen": { "name": "Monessen", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 248275e6707..36f35060907 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2856,6 +2856,9 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 +# homeassistant.components.monarch_money +typedmonarchmoney==0.3.1 + # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 186b2c50f23..e7f356f88cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2263,6 +2263,9 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 +# homeassistant.components.monarch_money +typedmonarchmoney==0.3.1 + # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/tests/components/monarch_money/__init__.py b/tests/components/monarch_money/__init__.py new file mode 100644 index 00000000000..f08addf2ec6 --- /dev/null +++ b/tests/components/monarch_money/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Monarch Money integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/monarch_money/conftest.py b/tests/components/monarch_money/conftest.py new file mode 100644 index 00000000000..7d6a965a009 --- /dev/null +++ b/tests/components/monarch_money/conftest.py @@ -0,0 +1,79 @@ +"""Common fixtures for the Monarch Money tests.""" + +from collections.abc import Generator +import json +from typing import Any +from unittest.mock import AsyncMock, PropertyMock, patch + +import pytest +from typedmonarchmoney.models import ( + MonarchAccount, + MonarchCashflowSummary, + MonarchSubscription, +) + +from homeassistant.components.monarch_money.const import DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.monarch_money.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def mock_config_entry() -> MockConfigEntry: + """Fixture for mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_TOKEN: "fake_token_of_doom"}, + unique_id="222260252323873333", + version=1, + ) + + +@pytest.fixture +def mock_config_api() -> Generator[AsyncMock]: + """Mock the MonarchMoney class.""" + + account_json: dict[str, Any] = load_json_object_fixture("get_accounts.json", DOMAIN) + account_data = [MonarchAccount(data) for data in account_json["accounts"]] + account_data_dict: dict[str, MonarchAccount] = { + acc["id"]: MonarchAccount(acc) for acc in account_json["accounts"] + } + + cashflow_json: dict[str, Any] = json.loads( + load_fixture("get_cashflow_summary.json", DOMAIN) + ) + cashflow_summary = MonarchCashflowSummary(cashflow_json) + subscription_details = MonarchSubscription( + json.loads(load_fixture("get_subscription_details.json", DOMAIN)) + ) + + with ( + patch( + "homeassistant.components.monarch_money.config_flow.TypedMonarchMoney", + autospec=True, + ) as mock_class, + patch( + "homeassistant.components.monarch_money.TypedMonarchMoney", new=mock_class + ), + ): + instance = mock_class.return_value + type(instance).token = PropertyMock(return_value="mocked_token") + instance.login = AsyncMock(return_value=None) + instance.multi_factor_authenticate = AsyncMock(return_value=None) + instance.get_subscription_details = AsyncMock(return_value=subscription_details) + instance.get_accounts = AsyncMock(return_value=account_data) + instance.get_accounts_as_dict_with_id_key = AsyncMock( + return_value=account_data_dict + ) + instance.get_cashflow_summary = AsyncMock(return_value=cashflow_summary) + instance.get_subscription_details = AsyncMock(return_value=subscription_details) + yield mock_class diff --git a/tests/components/monarch_money/fixtures/get_accounts.json b/tests/components/monarch_money/fixtures/get_accounts.json new file mode 100644 index 00000000000..ddaecc1721b --- /dev/null +++ b/tests/components/monarch_money/fixtures/get_accounts.json @@ -0,0 +1,516 @@ +{ + "accounts": [ + { + "id": "900000000", + "displayName": "Brokerage", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": "0189", + "createdAt": "2021-10-15T01:32:33.809450+00:00", + "updatedAt": "2022-05-26T00:56:41.322045+00:00", + "displayLastUpdatedAt": "2022-05-26T00:56:41.321928+00:00", + "currentBalance": 1000.5, + "displayBalance": 1000.5, + "includeInNetWorth": true, + "hideFromList": true, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": false, + "includeInGoalBalance": false, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 11, + "icon": "trending-up", + "logoUrl": "base64Nonce", + "type": { + "name": "brokerage", + "display": "Investments", + "__typename": "AccountType" + }, + "subtype": { + "name": "brokerage", + "display": "Brokerage", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "900000001", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "700000000", + "plaidInstitutionId": "ins_0", + "name": "Rando Brokerage", + "status": "DEGRADED", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "700000000", + "name": "Rando Brokerage", + "logo": "base64Nonce", + "primaryColor": "#0075a3", + "url": "https://rando.brokerage/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "900000002", + "displayName": "Checking", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": "2602", + "createdAt": "2021-10-15T01:32:33.900521+00:00", + "updatedAt": "2024-02-17T11:21:05.228959+00:00", + "displayLastUpdatedAt": "2024-02-17T11:21:05.228721+00:00", + "currentBalance": 1000.02, + "displayBalance": 1000.02, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": true, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 1403, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 0, + "icon": "dollar-sign", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "depository", + "display": "Cash", + "__typename": "AccountType" + }, + "subtype": { + "name": "checking", + "display": "Checking", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "900000003", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "7000000002", + "plaidInstitutionId": "ins_01", + "name": "Rando Bank", + "status": "DEGRADED", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "7000000005", + "name": "Rando Bank", + "logo": "base64Nonce", + "primaryColor": "#0075a3", + "url": "https://rando.bank/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + + { + "id": "121212192626186051", + "displayName": "2050 Toyota RAV8", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2024-08-16T17:37:21.885036+00:00", + "updatedAt": "2024-08-16T17:37:21.885057+00:00", + "displayLastUpdatedAt": "2024-08-16T17:37:21.885057+00:00", + "currentBalance": 11075.58, + "displayBalance": 11075.58, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "vin_audit", + "dataProviderAccountId": "1111111v5cw252004", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 0, + "logoUrl": "https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644", + "type": { + "name": "vehicle", + "display": "Vehicles", + "__typename": "AccountType" + }, + "subtype": { + "name": "car", + "display": "Car", + "__typename": "AccountSubtype" + }, + "credential": null, + "institution": { + "id": "123456789853802644", + "name": "VinAudit", + "primaryColor": "#74ab16", + "url": "https://www.vinaudit.com/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "9000000007", + "displayName": "Credit Card", + "syncDisabled": true, + "deactivatedAt": null, + "isHidden": true, + "isAsset": false, + "mask": "3542", + "createdAt": "2021-10-15T01:33:46.646459+00:00", + "updatedAt": "2022-12-10T18:17:06.129456+00:00", + "displayLastUpdatedAt": "2022-10-15T08:34:34.815239+00:00", + "currentBalance": -200.0, + "displayBalance": -200.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": false, + "includeInGoalBalance": true, + "dataProvider": "finicity", + "dataProviderAccountId": "50001", + "isManual": false, + "transactionsCount": 1138, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 1, + "icon": "credit-card", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "credit", + "display": "Credit Cards", + "__typename": "AccountType" + }, + "subtype": { + "name": "credit_card", + "display": "Credit Card", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "9000000009", + "updateRequired": true, + "disconnectedFromDataProviderAt": null, + "dataProvider": "FINICITY", + "institution": { + "id": "7000000002", + "plaidInstitutionId": "ins_9", + "name": "Rando Credit", + "status": null, + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000010", + "name": "Rando Credit", + "logo": "base64Nonce", + "primaryColor": "#004966", + "url": "https://rando.credit/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "900000000012", + "displayName": "Roth IRA", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": "1052", + "createdAt": "2021-10-15T01:35:59.299450+00:00", + "updatedAt": "2024-02-17T13:32:21.072711+00:00", + "displayLastUpdatedAt": "2024-02-17T13:32:21.072453+00:00", + "currentBalance": 10000.43, + "displayBalance": 10000.43, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 28, + "holdingsCount": 24, + "manualInvestmentsTrackingMethod": null, + "order": 4, + "icon": "trending-up", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "brokerage", + "display": "Investments", + "__typename": "AccountType" + }, + "subtype": { + "name": "roth", + "display": "Roth IRA", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "90000000014", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "70000000016", + "plaidInstitutionId": "ins_02", + "name": "Rando Investments", + "status": null, + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000018", + "name": "Rando Investments", + "logo": "base64Nonce", + "primaryColor": "#40a829", + "url": "https://rando.investments/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "90000000020", + "displayName": "House", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2021-10-15T01:39:29.370279+00:00", + "updatedAt": "2024-02-12T09:00:25.451425+00:00", + "displayLastUpdatedAt": "2024-02-12T09:00:25.451425+00:00", + "currentBalance": 123000.0, + "displayBalance": 123000.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "zillow", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 2, + "icon": "home", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "real_estate", + "display": "Real Estate", + "__typename": "AccountType" + }, + "subtype": { + "name": "primary_home", + "display": "Primary Home", + "__typename": "AccountSubtype" + }, + "credential": null, + "institution": { + "id": "800000000", + "name": "Zillow", + "logo": "base64Nonce", + "primaryColor": "#006AFF", + "url": "https://www.zillow.com/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "90000000022", + "displayName": "401.k", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2021-10-15T01:41:54.593239+00:00", + "updatedAt": "2024-02-17T08:13:10.554296+00:00", + "displayLastUpdatedAt": "2024-02-17T08:13:10.554029+00:00", + "currentBalance": 100000.35, + "displayBalance": 100000.35, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "finicity", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 100, + "manualInvestmentsTrackingMethod": null, + "order": 3, + "icon": "trending-up", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "brokerage", + "display": "Investments", + "__typename": "AccountType" + }, + "subtype": { + "name": "st_401k", + "display": "401k", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "90000000024", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "FINICITY", + "institution": { + "id": "70000000026", + "plaidInstitutionId": "ins_03", + "name": "Rando Employer Investments", + "status": "HEALTHY", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000028", + "name": "Rando Employer Investments", + "logo": "base64Nonce", + "primaryColor": "#408800", + "url": "https://rando-employer.investments/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "90000000030", + "displayName": "Mortgage", + "syncDisabled": true, + "deactivatedAt": "2023-08-15", + "isHidden": true, + "isAsset": false, + "mask": "0973", + "createdAt": "2021-10-15T01:45:25.244570+00:00", + "updatedAt": "2023-08-16T01:41:36.115588+00:00", + "displayLastUpdatedAt": "2023-08-15T18:11:09.134874+00:00", + "currentBalance": 0.0, + "displayBalance": -0.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": false, + "includeInGoalBalance": false, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 1, + "icon": "home", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "loan", + "display": "Loans", + "__typename": "AccountType" + }, + "subtype": { + "name": "mortgage", + "display": "Mortgage", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "90000000032", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "70000000034", + "plaidInstitutionId": "ins_04", + "name": "Rando Mortgage", + "status": "HEALTHY", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000036", + "name": "Rando Mortgage", + "logo": "base64Nonce", + "primaryColor": "#095aa6", + "url": "https://rando.mortgage/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "186321412999033223", + "displayName": "Wallet", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2024-08-16T14:22:10.440514+00:00", + "updatedAt": "2024-08-16T14:22:10.512731+00:00", + "displayLastUpdatedAt": "2024-08-16T14:22:10.512731+00:00", + "currentBalance": 20.0, + "displayBalance": 20.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": true, + "dataProvider": "", + "dataProviderAccountId": null, + "isManual": true, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 14, + "logoUrl": null, + "type": { + "name": "depository", + "display": "Cash", + "__typename": "AccountType" + }, + "subtype": { + "name": "prepaid", + "display": "Prepaid", + "__typename": "AccountSubtype" + }, + "credential": null, + "institution": null, + "__typename": "Account" + } + ], + "householdPreferences": { + "id": "900000000022", + "accountGroupOrder": [], + "__typename": "HouseholdPreferences" + } +} diff --git a/tests/components/monarch_money/fixtures/get_cashflow_summary.json b/tests/components/monarch_money/fixtures/get_cashflow_summary.json new file mode 100644 index 00000000000..a223782469a --- /dev/null +++ b/tests/components/monarch_money/fixtures/get_cashflow_summary.json @@ -0,0 +1,14 @@ +{ + "summary": [ + { + "summary": { + "sumIncome": 15000.0, + "sumExpense": -9000.0, + "savings": 6000.0, + "savingsRate": 0.4, + "__typename": "TransactionsSummary" + }, + "__typename": "AggregateData" + } + ] +} diff --git a/tests/components/monarch_money/fixtures/get_subscription_details.json b/tests/components/monarch_money/fixtures/get_subscription_details.json new file mode 100644 index 00000000000..16f90a2ca38 --- /dev/null +++ b/tests/components/monarch_money/fixtures/get_subscription_details.json @@ -0,0 +1,10 @@ +{ + "subscription": { + "id": "222260252323873333", + "paymentSource": "STRIPE", + "referralCode": "go3dpvrdmw", + "isOnFreeTrial": true, + "hasPremiumEntitlement": true, + "__typename": "HouseholdSubscription" + } +} diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..cf7e0cb7b2f --- /dev/null +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -0,0 +1,1112 @@ +# serializer version: 1 +# name: test_all_entities[sensor.cashflow_expense_year_to_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_expense_year_to_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expense year to date', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sum_expense', + 'unique_id': '222260252323873333_cashflow_sum_expense', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.cashflow_expense_year_to_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Cashflow Expense year to date', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.cashflow_expense_year_to_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-9000.0', + }) +# --- +# name: test_all_entities[sensor.cashflow_income_year_to_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_income_year_to_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Income year to date', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sum_income', + 'unique_id': '222260252323873333_cashflow_sum_income', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.cashflow_income_year_to_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Cashflow Income year to date', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.cashflow_income_year_to_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15000.0', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_savings_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Savings rate', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'savings_rate', + 'unique_id': '222260252323873333_cashflow_savings_rate', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cashflow Savings rate', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cashflow_savings_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_year_to_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_savings_year_to_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Savings year to date', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'savings', + 'unique_id': '222260252323873333_cashflow_savings', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_year_to_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Cashflow Savings year to date', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.cashflow_savings_year_to_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6000.0', + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.manual_entry_wallet_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_186321412999033223_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'monetary', + 'friendly_name': 'Manual entry Wallet Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.manual_entry_wallet_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.manual_entry_wallet_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_186321412999033223_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'timestamp', + 'friendly_name': 'Manual entry Wallet Data age', + }), + 'context': , + 'entity_id': 'sensor.manual_entry_wallet_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-16T14:22:10+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_bank_checking_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_900000002_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Bank Checking Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_bank_checking_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.02', + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_bank_checking_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_900000002_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Bank Checking Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_bank_checking_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-17T11:21:05+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_brokerage_brokerage_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_900000000_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'base64Nonce', + 'friendly_name': 'Rando Brokerage Brokerage Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_brokerage_brokerage_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.5', + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_brokerage_brokerage_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_900000000_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Brokerage Brokerage Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_brokerage_brokerage_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-05-26T00:56:41+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_credit_credit_card_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_9000000007_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Credit Credit Card Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_credit_credit_card_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-200.0', + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_credit_credit_card_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_9000000007_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Credit Credit Card Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_credit_credit_card_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-10T18:17:06+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_employer_investments_401_k_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_90000000022_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Employer Investments 401.k Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_employer_investments_401_k_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100000.35', + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_employer_investments_401_k_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_90000000022_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Employer Investments 401.k Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_employer_investments_401_k_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-17T08:13:10+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_investments_roth_ira_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_900000000012_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Investments Roth IRA Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_investments_roth_ira_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000.43', + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_investments_roth_ira_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_900000000012_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Investments Roth IRA Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_investments_roth_ira_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-17T13:32:21+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_mortgage_mortgage_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_90000000030_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Mortgage Mortgage Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_mortgage_mortgage_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_mortgage_mortgage_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_90000000030_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Mortgage Mortgage Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_mortgage_mortgage_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-08-16T01:41:36+00:00', + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_121212192626186051_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'timestamp', + 'friendly_name': 'VinAudit 2050 Toyota RAV8 Data age', + }), + 'context': , + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-16T17:37:21+00:00', + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Value', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'value', + 'unique_id': '222260252323873333_121212192626186051_value', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'monetary', + 'entity_picture': 'https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644', + 'friendly_name': 'VinAudit 2050 Toyota RAV8 Value', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11075.58', + }) +# --- +# name: test_all_entities[sensor.zillow_house_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zillow_house_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_90000000020_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.zillow_house_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Zillow House Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.zillow_house_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123000.0', + }) +# --- +# name: test_all_entities[sensor.zillow_house_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zillow_house_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_90000000020_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zillow_house_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'timestamp', + 'friendly_name': 'Zillow House Data age', + }), + 'context': , + 'entity_id': 'sensor.zillow_house_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-12T09:00:25+00:00', + }) +# --- diff --git a/tests/components/monarch_money/test_config_flow.py b/tests/components/monarch_money/test_config_flow.py new file mode 100644 index 00000000000..03f0df0c526 --- /dev/null +++ b/tests/components/monarch_money/test_config_flow.py @@ -0,0 +1,166 @@ +"""Test the Monarch Money config flow.""" + +from unittest.mock import AsyncMock + +from monarchmoney import LoginFailedException, RequireMFAException + +from homeassistant.components.monarch_money.const import CONF_MFA_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form_simple( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test simple case (no MFA / no errors).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert result["result"].unique_id == "222260252323873333" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_add_duplicate_entry( + hass: HomeAssistant, + mock_config_entry, + mock_setup_entry: AsyncMock, + mock_config_api: AsyncMock, +) -> None: + """Test a duplicate error config flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test config flow with a login error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # Change the login mock to raise an MFA required error + mock_config_api.return_value.login.side_effect = LoginFailedException( + "Invalid Auth" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_config_api.return_value.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert result["context"]["unique_id"] == "222260252323873333" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_mfa( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test MFA enabled on account configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # Change the login mock to raise an MFA required error + mock_config_api.return_value.login.side_effect = RequireMFAException("mfa_required") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "mfa_required"} + assert result["step_id"] == "user" + + # Add a bad MFA Code response + mock_config_api.return_value.multi_factor_authenticate.side_effect = KeyError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MFA_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "bad_mfa"} + assert result["step_id"] == "user" + + # Use a good MFA Code - Clear mock + mock_config_api.return_value.multi_factor_authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MFA_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert result["result"].unique_id == "222260252323873333" + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/monarch_money/test_sensor.py b/tests/components/monarch_money/test_sensor.py new file mode 100644 index 00000000000..aac1eaefb2d --- /dev/null +++ b/tests/components/monarch_money/test_sensor.py @@ -0,0 +1,27 @@ +"""Test sensors.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_config_api: AsyncMock, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.monarch_money.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)