mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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 <joostlek@outlook.com> * 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 <joostlek@outlook.com> * 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 <joostlek@outlook.com> * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/monarchmoney/entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * 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 <joostlek@outlook.com> * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/monarch_money/sensor.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * 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 <joostlek@outlook.com>
This commit is contained in:
@ -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
|
||||
|
35
homeassistant/components/monarch_money/__init__.py
Normal file
35
homeassistant/components/monarch_money/__init__.py
Normal file
@ -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)
|
157
homeassistant/components/monarch_money/config_flow.py
Normal file
157
homeassistant/components/monarch_money/config_flow.py
Normal file
@ -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."""
|
10
homeassistant/components/monarch_money/const.py
Normal file
10
homeassistant/components/monarch_money/const.py
Normal file
@ -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"
|
91
homeassistant/components/monarch_money/coordinator.py
Normal file
91
homeassistant/components/monarch_money/coordinator.py
Normal file
@ -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]
|
83
homeassistant/components/monarch_money/entity.py
Normal file
83
homeassistant/components/monarch_money/entity.py
Normal file
@ -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]
|
10
homeassistant/components/monarch_money/icons.json
Normal file
10
homeassistant/components/monarch_money/icons.json
Normal file
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
9
homeassistant/components/monarch_money/manifest.json
Normal file
9
homeassistant/components/monarch_money/manifest.json
Normal file
@ -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"]
|
||||
}
|
182
homeassistant/components/monarch_money/sensor.py
Normal file
182
homeassistant/components/monarch_money/sensor.py
Normal file
@ -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
|
46
homeassistant/components/monarch_money/strings.json
Normal file
46
homeassistant/components/monarch_money/strings.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -370,6 +370,7 @@ FLOWS = {
|
||||
"modem_callerid",
|
||||
"modern_forms",
|
||||
"moehlenhoff_alpha2",
|
||||
"monarch_money",
|
||||
"monoprice",
|
||||
"monzo",
|
||||
"moon",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
13
tests/components/monarch_money/__init__.py
Normal file
13
tests/components/monarch_money/__init__.py
Normal file
@ -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()
|
79
tests/components/monarch_money/conftest.py
Normal file
79
tests/components/monarch_money/conftest.py
Normal file
@ -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
|
516
tests/components/monarch_money/fixtures/get_accounts.json
Normal file
516
tests/components/monarch_money/fixtures/get_accounts.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"summary": {
|
||||
"sumIncome": 15000.0,
|
||||
"sumExpense": -9000.0,
|
||||
"savings": 6000.0,
|
||||
"savingsRate": 0.4,
|
||||
"__typename": "TransactionsSummary"
|
||||
},
|
||||
"__typename": "AggregateData"
|
||||
}
|
||||
]
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
{
|
||||
"subscription": {
|
||||
"id": "222260252323873333",
|
||||
"paymentSource": "STRIPE",
|
||||
"referralCode": "go3dpvrdmw",
|
||||
"isOnFreeTrial": true,
|
||||
"hasPremiumEntitlement": true,
|
||||
"__typename": "HouseholdSubscription"
|
||||
}
|
||||
}
|
1112
tests/components/monarch_money/snapshots/test_sensor.ambr
Normal file
1112
tests/components/monarch_money/snapshots/test_sensor.ambr
Normal file
File diff suppressed because it is too large
Load Diff
166
tests/components/monarch_money/test_config_flow.py
Normal file
166
tests/components/monarch_money/test_config_flow.py
Normal file
@ -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
|
27
tests/components/monarch_money/test_sensor.py
Normal file
27
tests/components/monarch_money/test_sensor.py
Normal file
@ -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)
|
Reference in New Issue
Block a user