Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Monarch Money Integration #124014

Merged
merged 68 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 64 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
ff5ffd2
Initial commit
jeeftor Aug 15, 2024
609aee2
Second commit - with some coverage but errors abount
jeeftor Aug 16, 2024
6e0d7b2
Updated testing coverage
jeeftor Aug 16, 2024
170a077
Should be just about ready for PR
jeeftor Aug 16, 2024
0b7e704
Adding some error handling for wonky acocunts
jeeftor Aug 16, 2024
a9fa064
Adding USD hardcoded as this is all that is currently supported i bel…
jeeftor Aug 16, 2024
6d97ed0
updating snapshots
jeeftor Aug 16, 2024
63fb8c4
updating entity descrition a little
jeeftor Aug 16, 2024
7bca93f
Addign cashflow in
jeeftor Aug 16, 2024
5a3d18d
adding aggregate sensors
jeeftor Aug 16, 2024
0e7a80c
tweak icons
jeeftor Aug 16, 2024
d0bb94a
refactor some type stuff as well as initialize the pr comment address…
jeeftor Aug 20, 2024
972fa94
remove empty fields from manifest
jeeftor Aug 20, 2024
2cc69b6
Update homeassistant/components/monarchmoney/sensor.py
jeeftor Aug 20, 2024
296fd4f
move stuff
jeeftor Aug 20, 2024
817f40a
get logging out of try block
jeeftor Aug 20, 2024
1acadab
get logging out of try block
jeeftor Aug 20, 2024
dc6d69e
using Subscription ID as stored in config entry for unique id soon
jeeftor Aug 20, 2024
bd7fb3a
new unique id
jeeftor Aug 20, 2024
93320f8
giving cashflow a better unique id
jeeftor Aug 20, 2024
3c6f228
Moving subscription id stuff into setup of coordinator
jeeftor Aug 20, 2024
92600f9
Update homeassistant/components/monarchmoney/config_flow.py
jeeftor Aug 20, 2024
a7985fd
ruff ruff
jeeftor Aug 20, 2024
14dc300
ruff ruff
jeeftor Aug 20, 2024
157e859
split ot value and balance sensors... need to go tos leep
jeeftor Aug 20, 2024
e9149a4
removed icons
jeeftor Aug 20, 2024
0ae48fa
Moved summary into a data class
jeeftor Aug 20, 2024
2121e73
efficenty increase
jeeftor Aug 20, 2024
314cfbc
Update homeassistant/components/monarchmoney/coordinator.py
jeeftor Aug 20, 2024
68e51fb
Update homeassistant/components/monarchmoney/coordinator.py
jeeftor Aug 20, 2024
4f3502d
Update homeassistant/components/monarchmoney/coordinator.py
jeeftor Aug 20, 2024
069b10d
Update homeassistant/components/monarchmoney/entity.py
jeeftor Aug 20, 2024
0796628
refactor continues
jeeftor Aug 20, 2024
d4bb1fb
removed a comment
jeeftor Aug 20, 2024
b7532cb
forgot to add a little bit of info
jeeftor Aug 20, 2024
ada4ac2
updated snapshot
jeeftor Aug 20, 2024
b063db6
Updates to monarch money using the new typed/wrapper setup
jeeftor Sep 5, 2024
68b4038
backing lib update
jeeftor Sep 5, 2024
6b3675f
fixing manifest
jeeftor Sep 5, 2024
2aa2669
fixing manifest
jeeftor Sep 5, 2024
364ba09
fixing manifest
jeeftor Sep 5, 2024
d7751eb
Version 0.2.0
jeeftor Sep 5, 2024
2ec65ad
fixing some types
jeeftor Sep 6, 2024
0cfd952
more type fixes
jeeftor Sep 6, 2024
222a0b6
cleanup and bump
jeeftor Sep 6, 2024
6c8e905
no check
jeeftor Sep 6, 2024
944798c
i think i got it all
jeeftor Sep 6, 2024
9cdda9d
the last thing
jeeftor Sep 6, 2024
81ae87d
update domain name
jeeftor Sep 6, 2024
3fdce2e
i dont know what is in this commit
jeeftor Sep 7, 2024
8447407
The Great Renaming
jeeftor Sep 8, 2024
7efd4be
Moving to dict style accounting - as per request
jeeftor Sep 9, 2024
2280de7
updating backing deps
jeeftor Sep 9, 2024
58c0e05
Update homeassistant/components/monarch_money/entity.py
jeeftor Sep 10, 2024
e658a55
Update tests/components/monarch_money/test_config_flow.py
jeeftor Sep 10, 2024
11b20b1
Update tests/components/monarch_money/test_config_flow.py
jeeftor Sep 10, 2024
8b9a2eb
Update tests/components/monarch_money/test_config_flow.py
jeeftor Sep 10, 2024
aaa028a
Update homeassistant/components/monarch_money/sensor.py
jeeftor Sep 10, 2024
8e53908
some changes
jeeftor Sep 10, 2024
d54450d
fixing capitalizaton
jeeftor Sep 10, 2024
a0e44ee
test test test
jeeftor Sep 10, 2024
2089b7c
Adding dupe test
jeeftor Sep 10, 2024
bc2afab
addressing pr stuff
jeeftor Sep 11, 2024
3953645
forgot snapshot
jeeftor Sep 11, 2024
1bb4374
Fix
joostlek Sep 11, 2024
8e06bb4
Merge branch 'dev' into monarchmoney
joostlek Sep 11, 2024
ba63f81
Fix
joostlek Sep 11, 2024
0727754
Update homeassistant/components/monarch_money/sensor.py
joostlek Sep 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,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
Expand Down
35 changes: 35 additions & 0 deletions homeassistant/components/monarch_money/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 157 additions & 0 deletions homeassistant/components/monarch_money/config_flow.py
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions homeassistant/components/monarch_money/const.py
Original file line number Diff line number Diff line change
@@ -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 changes: 91 additions & 0 deletions homeassistant/components/monarch_money/coordinator.py
Original file line number Diff line number Diff line change
@@ -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 changes: 83 additions & 0 deletions homeassistant/components/monarch_money/entity.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading