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:
Jeef
2024-09-11 09:09:16 -06:00
committed by GitHub
parent 2ea8af83bd
commit e4347e5520
22 changed files with 2575 additions and 0 deletions

View File

@ -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

View 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)

View 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."""

View 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"

View 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]

View 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]

View 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" }
}
}
}

View 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"]
}

View 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

View 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"
}
}
}
}

View File

@ -370,6 +370,7 @@ FLOWS = {
"modem_callerid",
"modern_forms",
"moehlenhoff_alpha2",
"monarch_money",
"monoprice",
"monzo",
"moon",

View File

@ -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",

View File

@ -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

View File

@ -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

View 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()

View 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

View 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"
}
}

View File

@ -0,0 +1,14 @@
{
"summary": [
{
"summary": {
"sumIncome": 15000.0,
"sumExpense": -9000.0,
"savings": 6000.0,
"savingsRate": 0.4,
"__typename": "TransactionsSummary"
},
"__typename": "AggregateData"
}
]
}

View File

@ -0,0 +1,10 @@
{
"subscription": {
"id": "222260252323873333",
"paymentSource": "STRIPE",
"referralCode": "go3dpvrdmw",
"isOnFreeTrial": true,
"hasPremiumEntitlement": true,
"__typename": "HouseholdSubscription"
}
}

File diff suppressed because it is too large Load Diff

View 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

View 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)