diff --git a/CODEOWNERS b/CODEOWNERS index fb1bf8517f6..25a21c55b63 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1275,6 +1275,8 @@ build.json @home-assistant/supervisor /tests/components/sighthound/ @robmarkcole /homeassistant/components/signal_messenger/ @bbernhard /tests/components/signal_messenger/ @bbernhard +/homeassistant/components/simplefin/ @scottg489 @jeeftor +/tests/components/simplefin/ @scottg489 @jeeftor /homeassistant/components/simplepush/ @engrbm87 /tests/components/simplepush/ @engrbm87 /homeassistant/components/simplisafe/ @bachya diff --git a/homeassistant/components/simplefin/__init__.py b/homeassistant/components/simplefin/__init__.py new file mode 100644 index 00000000000..0aa33dec9ac --- /dev/null +++ b/homeassistant/components/simplefin/__init__.py @@ -0,0 +1,33 @@ +"""The SimpleFIN integration.""" + +from __future__ import annotations + +from simplefin4py import SimpleFin + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_ACCESS_URL +from .coordinator import SimpleFinDataUpdateCoordinator + +PLATFORMS: list[str] = [Platform.SENSOR] + + +type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool: + """Set up from a config entry.""" + access_url = entry.data[CONF_ACCESS_URL] + sf_client = SimpleFin(access_url) + sf_coordinator = SimpleFinDataUpdateCoordinator(hass, sf_client) + await sf_coordinator.async_config_entry_first_refresh() + entry.runtime_data = sf_coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/simplefin/config_flow.py b/homeassistant/components/simplefin/config_flow.py new file mode 100644 index 00000000000..ce7a5039a20 --- /dev/null +++ b/homeassistant/components/simplefin/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for SimpleFIN integration.""" + +from typing import Any + +from simplefin4py import SimpleFin +from simplefin4py.exceptions import ( + SimpleFinAuthError, + SimpleFinClaimError, + SimpleFinInvalidAccountURLError, + SimpleFinInvalidClaimTokenError, + SimpleFinPaymentRequiredError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_ACCESS_URL, DOMAIN, LOGGER + + +class SimpleFinConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for the initial setup of a SimpleFIN integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prompt user for SimpleFIN API credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + access_url: str = user_input[CONF_ACCESS_URL] + self._async_abort_entries_match({CONF_ACCESS_URL: access_url}) + + try: + if not access_url.startswith("http"): + # Claim token detected - convert to access url + LOGGER.debug("[Setup Token] - Claiming Access URL") + access_url = await SimpleFin.claim_setup_token(access_url) + else: + LOGGER.debug("[Access Url] - 'http' string detected") + # Validate the access URL + LOGGER.debug("[Access Url] - validating access url") + SimpleFin.decode_access_url(access_url) + + LOGGER.debug("[Access Url] - Fetching data") + simple_fin = SimpleFin(access_url=access_url) + await simple_fin.fetch_data() + + except SimpleFinInvalidAccountURLError: + errors["base"] = "url_error" + except SimpleFinInvalidClaimTokenError: + errors["base"] = "invalid_claim_token" + except SimpleFinClaimError: + errors["base"] = "claim_error" + except SimpleFinPaymentRequiredError: + errors["base"] = "payment_required" + except SimpleFinAuthError: + errors["base"] = "invalid_auth" + else: + # We passed validation + user_input[CONF_ACCESS_URL] = access_url + + return self.async_create_entry( + title="SimpleFIN", + data={CONF_ACCESS_URL: user_input[CONF_ACCESS_URL]}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ACCESS_URL): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/simplefin/const.py b/homeassistant/components/simplefin/const.py new file mode 100644 index 00000000000..9052971e6a5 --- /dev/null +++ b/homeassistant/components/simplefin/const.py @@ -0,0 +1,12 @@ +"""Constants for the SimpleFIN integration.""" + +from __future__ import annotations + +import logging +from typing import Final + +DOMAIN: Final = "simplefin" + +LOGGER = logging.getLogger(__package__) + +CONF_ACCESS_URL = "access_url" diff --git a/homeassistant/components/simplefin/coordinator.py b/homeassistant/components/simplefin/coordinator.py new file mode 100644 index 00000000000..7fa5aedb7a1 --- /dev/null +++ b/homeassistant/components/simplefin/coordinator.py @@ -0,0 +1,45 @@ +"""Data update coordinator for the SimpleFIN integration.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from simplefin4py import FinancialData, SimpleFin +from simplefin4py.exceptions import SimpleFinAuthError, SimpleFinPaymentRequiredError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +class SimpleFinDataUpdateCoordinator(DataUpdateCoordinator[FinancialData]): + """Data update coordinator for the SimpleFIN integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: SimpleFin) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name="simplefin", + update_interval=timedelta(hours=4), + ) + self.client = client + + async def _async_update_data(self) -> Any: + """Fetch data for all accounts.""" + try: + return await self.client.fetch_data() + except SimpleFinAuthError as err: + raise ConfigEntryError("Authentication failed") from err + + except SimpleFinPaymentRequiredError as err: + LOGGER.warning( + "There is a billing issue with your SimpleFin account, contact Simplefin to address this issue" + ) + raise UpdateFailed from err diff --git a/homeassistant/components/simplefin/entity.py b/homeassistant/components/simplefin/entity.py new file mode 100644 index 00000000000..eed9c053bbe --- /dev/null +++ b/homeassistant/components/simplefin/entity.py @@ -0,0 +1,43 @@ +"""SimpleFin Base Entity.""" + +from simplefin4py import Account + +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 SimpleFinDataUpdateCoordinator + + +class SimpleFinEntity(CoordinatorEntity[SimpleFinDataUpdateCoordinator]): + """Define a generic class for SimpleFIN entities.""" + + _attr_attribution = "Data provided by SimpleFIN API" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SimpleFinDataUpdateCoordinator, + description: EntityDescription, + account: Account, + ) -> None: + """Class initializer.""" + super().__init__(coordinator) + self.entity_description = description + self._account_id = account.id + + self._attr_unique_id = f"account_{account.id}_{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, account.id)}, + name=f"{account.org.name} {account.name}", + entry_type=DeviceEntryType.SERVICE, + manufacturer=account.org.name, + model=account.name, + ) + + @property + def account_data(self) -> Account: + """Return the account data.""" + return self.coordinator.data.get_account_for_id(self._account_id) diff --git a/homeassistant/components/simplefin/manifest.json b/homeassistant/components/simplefin/manifest.json new file mode 100644 index 00000000000..f3e312d9de5 --- /dev/null +++ b/homeassistant/components/simplefin/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "simplefin", + "name": "SimpleFin", + "codeowners": ["@scottg489", "@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/simplefin", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["simplefin"], + "requirements": ["simplefin4py==0.0.16"] +} diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py new file mode 100644 index 00000000000..2fac42cbac5 --- /dev/null +++ b/homeassistant/components/simplefin/sensor.py @@ -0,0 +1,91 @@ +"""Platform for sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from simplefin4py import Account + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import SimpleFinConfigEntry +from .entity import SimpleFinEntity + + +@dataclass(frozen=True, kw_only=True) +class SimpleFinSensorEntityDescription(SensorEntityDescription): + """Describes a sensor entity.""" + + value_fn: Callable[[Account], StateType] + icon_fn: Callable[[Account], str] | None = None + unit_fn: Callable[[Account], str] | None = None + + +SIMPLEFIN_SENSORS: tuple[SimpleFinSensorEntityDescription, ...] = ( + SimpleFinSensorEntityDescription( + key="balance", + translation_key="balance", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda account: account.balance, + unit_fn=lambda account: account.currency, + icon_fn=lambda account: account.inferred_account_type, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SimpleFinConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SimpleFIN sensors for config entries.""" + + sf_coordinator = config_entry.runtime_data + accounts = sf_coordinator.data.accounts + + async_add_entities( + SimpleFinSensor( + sf_coordinator, + sensor_description, + account, + ) + for account in accounts + for sensor_description in SIMPLEFIN_SENSORS + ) + + +class SimpleFinSensor(SimpleFinEntity, SensorEntity): + """Defines a SimpleFIN sensor.""" + + entity_description: SimpleFinSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.entity_description.value_fn(self.account_data) + + @property + def icon(self) -> str | None: + """Return the icon of this account.""" + + if self.entity_description.icon_fn is not None: + return self.entity_description.icon_fn(self.account_data) + return None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the currency of this account.""" + if self.entity_description.unit_fn: + return self.entity_description.unit_fn(self.account_data) + + return None diff --git a/homeassistant/components/simplefin/strings.json b/homeassistant/components/simplefin/strings.json new file mode 100644 index 00000000000..c54520a0451 --- /dev/null +++ b/homeassistant/components/simplefin/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter either a Claim Token or an Access URL.", + "data": { + "api_token": "Claim Token or Access URL" + } + } + }, + "error": { + "invalid_auth": "Authentication failed: This could be due to revoked access or incorrect credentials", + "claim_error": "The claim token either does not exist or has already been used claimed by someone else. Receiving this could mean that the user\u2019s transaction information has been compromised", + "invalid_claim_token": "The claim token is invalid and could not be decoded", + "payment_required": "You presented a valid access url, however payment is required before you can obtain data", + "url_error": "There was an issue parsing the Account URL" + }, + "abort": { + "missing_access_url": "Access URL or Claim Token missing", + "already_configured": "This Access URL is already configured." + } + }, + "entity": { + "sensor": { + "balance": { + "name": "Balance" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ac4045df76e..308b27c8975 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -499,6 +499,7 @@ FLOWS = { "shelly", "shopping_list", "sia", + "simplefin", "simplepush", "simplisafe", "skybell", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d710522e87d..313c0cf24ca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5432,6 +5432,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "simplefin": { + "name": "SimpleFin", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "simplepush": { "name": "Simplepush", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 939a587727e..4424e2b5ae5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2567,6 +2567,9 @@ sharp_aquos_rc==0.3.2 # homeassistant.components.shodan shodan==1.28.0 +# homeassistant.components.simplefin +simplefin4py==0.0.16 + # homeassistant.components.sighthound simplehound==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b58416ff55..d17d734b30d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1995,6 +1995,9 @@ sfrbox-api==0.0.8 # homeassistant.components.sharkiq sharkiq==1.0.2 +# homeassistant.components.simplefin +simplefin4py==0.0.16 + # homeassistant.components.sighthound simplehound==0.3 diff --git a/tests/components/simplefin/__init__.py b/tests/components/simplefin/__init__.py new file mode 100644 index 00000000000..e4c7848ba9a --- /dev/null +++ b/tests/components/simplefin/__init__.py @@ -0,0 +1,13 @@ +"""Tests for SimpleFin.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/simplefin/conftest.py b/tests/components/simplefin/conftest.py new file mode 100644 index 00000000000..b4bef07d4e1 --- /dev/null +++ b/tests/components/simplefin/conftest.py @@ -0,0 +1,83 @@ +"""Test fixtures for SimpleFIN.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from simplefin4py import FinancialData +from simplefin4py.exceptions import SimpleFinInvalidClaimTokenError + +from homeassistant.components.simplefin import CONF_ACCESS_URL +from homeassistant.components.simplefin.const import DOMAIN + +from tests.common import MockConfigEntry, load_fixture + +MOCK_ACCESS_URL = "https://i:am@yomama.house.com" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.simplefin.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +async def mock_config_entry() -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCESS_URL: MOCK_ACCESS_URL}, + version=1, + ) + + +@pytest.fixture +def mock_claim_setup_token() -> str: + """Fixture to mock the claim_setup_token method of SimpleFin.""" + with patch( + "homeassistant.components.simplefin.config_flow.SimpleFin.claim_setup_token", + ) as mock_claim_setup_token: + mock_claim_setup_token.return_value = "https://i:am@yomama.comma" + yield + + +@pytest.fixture +def mock_decode_claim_token_invalid_then_good() -> str: + """Fixture to mock the decode_claim_token method of SimpleFin.""" + return_values = [SimpleFinInvalidClaimTokenError, "valid_return_value"] + with patch( + "homeassistant.components.simplefin.config_flow.SimpleFin.decode_claim_token", + new_callable=lambda: MagicMock(side_effect=return_values), + ): + yield + + +@pytest.fixture +def mock_simplefin_client() -> Generator[AsyncMock]: + """Mock a SimpleFin client.""" + + with ( + patch( + "homeassistant.components.simplefin.SimpleFin", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.simplefin.config_flow.SimpleFin", + new=mock_client, + ), + ): + mock_client.claim_setup_token.return_value = MOCK_ACCESS_URL + client = mock_client.return_value + + fixture_data = load_fixture("fin_data.json", DOMAIN) + fin_data = FinancialData.from_json(fixture_data) + + assert fin_data.accounts != [] + client.fetch_data.return_value = fin_data + + client.access_url = MOCK_ACCESS_URL + + yield mock_client diff --git a/tests/components/simplefin/fixtures/fin_data.json b/tests/components/simplefin/fixtures/fin_data.json new file mode 100644 index 00000000000..bd35945c12b --- /dev/null +++ b/tests/components/simplefin/fixtures/fin_data.json @@ -0,0 +1,173 @@ +{ + "errors": [ + "Connection to Investments may need attention", + "Connection to The Bank of Go may need attention" + ], + "accounts": [ + { + "org": { + "domain": "www.newwealthfront.com", + "name": "The Bank of Go", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.newwealthfront.com" + }, + "id": "ACT-1a2b3c4d-5e6f-7g8h-9i0j", + "name": "The Bank", + "currency": "USD", + "balance": "7777.77", + "available-balance": "7777.77", + "balance-date": 1705413843, + "transactions": [ + { + "id": "12394832938403", + "posted": 793090572, + "amount": "-1234.56", + "description": "Enchanted Bait Shop", + "payee": "Uncle Frank", + "memo": "Some memo", + "transacted_at": 793080572 + } + ], + "extra": { + "account-open-date": 978360153 + }, + "holdings": [] + }, + { + "org": { + "domain": "www.newfidelity.com", + "name": "Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.newfidelity.com" + }, + "id": "ACT-1k2l3m4n-5o6p-7q8r-9s0t", + "name": "My Checking", + "currency": "USD", + "balance": "12345.67", + "available-balance": "5432.10", + "balance-date": 1705413319, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.newhfcu.org", + "name": "The Bank of Go", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.newhfcu.org/" + }, + "id": "ACT-2a3b4c5d-6e7f-8g9h-0i1j", + "name": "PRIME SAVINGS", + "currency": "EUR", + "balance": "9876.54", + "available-balance": "8765.43", + "balance-date": 1705428861, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.randombank2.com", + "name": "Random Bank", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.randombank2.com/" + }, + "id": "ACT-3a4b5c6d-7e8f-9g0h-1i2j", + "name": "Costco Anywhere Visa® Card", + "currency": "USD", + "balance": "-532.69", + "available-balance": "4321.98", + "balance-date": 1705429002, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.newfidelity.com", + "name": "Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.newfidelity.com" + }, + "id": "ACT-4k5l6m7n-8o9p-1q2r-3s4t", + "name": "Dr Evil", + "currency": "USD", + "balance": "1000000.00", + "available-balance": "13579.24", + "balance-date": 1705413319, + "transactions": [], + "holdings": [ + { + "id": "HOL-62eb5bb6-4aed-4fe1-bdbe-f28e127e359b", + "created": 1705413320, + "currency": "", + "cost_basis": "10000.00", + "description": "Fantastic FID GROWTH CO K6", + "market_value": "15000.00", + "purchase_price": "0.00", + "shares": "200.00", + "symbol": "FGKFX" + } + ] + }, + { + "org": { + "domain": "www.newfidelity.com", + "name": "Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.newfidelity.com" + }, + "id": "ACT-5k6l7m8n-9o0p-1q2r-3s4t", + "name": "NerdCorp Series B", + "currency": "EUR", + "balance": "13579.24", + "available-balance": "9876.54", + "balance-date": 1705413319, + "transactions": [], + "holdings": [ + { + "id": "HOL-08f775cd-eedf-4ee5-9f53-241c8efa5bf3", + "created": 1705413321, + "currency": "", + "cost_basis": "7500.00", + "description": "Mythical FID GROWTH CO K6", + "market_value": "9876.54", + "purchase_price": "0.00", + "shares": "150.00", + "symbol": "FGKFX" + } + ] + }, + { + "org": { + "domain": "www.randombank2.com", + "name": "Mythical RandomSavings", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.randombank2.com/" + }, + "id": "ACT-6a7b8c9d-0e1f-2g3h-4i5j", + "name": "Unicorn Pot", + "currency": "USD", + "balance": "10000.00", + "available-balance": "7500.00", + "balance-date": 1705429002, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.randombank2.com", + "name": "Mythical RandomSavings", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.randombank2.com/" + }, + "id": "ACT-7a8b9c0d-1e2f-3g4h-5i6j", + "name": "Castle Mortgage", + "currency": "USD", + "balance": "7500.00", + "available-balance": "5000.00", + "balance-date": 1705429002, + "transactions": [], + "holdings": [] + } + ] +} diff --git a/tests/components/simplefin/snapshots/test_sensor.ambr b/tests/components/simplefin/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2660bbd74ca --- /dev/null +++ b/tests/components/simplefin/snapshots/test_sensor.ambr @@ -0,0 +1,425 @@ +# serializer version: 1 +# name: test_all_entities[sensor.investments_dr_evil_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.investments_dr_evil_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-4k5l6m7n-8o9p-1q2r-3s4t_balance', + 'unit_of_measurement': 'USD', + }) +# --- +# name: test_all_entities[sensor.investments_dr_evil_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'Investments Dr Evil Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'USD', + }), + 'context': , + 'entity_id': 'sensor.investments_dr_evil_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000000.00', + }) +# --- +# name: test_all_entities[sensor.investments_my_checking_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.investments_my_checking_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-1k2l3m4n-5o6p-7q8r-9s0t_balance', + 'unit_of_measurement': 'USD', + }) +# --- +# name: test_all_entities[sensor.investments_my_checking_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'Investments My Checking Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'USD', + }), + 'context': , + 'entity_id': 'sensor.investments_my_checking_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12345.67', + }) +# --- +# name: test_all_entities[sensor.investments_nerdcorp_series_b_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.investments_nerdcorp_series_b_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-5k6l7m8n-9o0p-1q2r-3s4t_balance', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_all_entities[sensor.investments_nerdcorp_series_b_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'Investments NerdCorp Series B Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.investments_nerdcorp_series_b_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13579.24', + }) +# --- +# name: test_all_entities[sensor.mythical_randomsavings_castle_mortgage_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mythical_randomsavings_castle_mortgage_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-7a8b9c0d-1e2f-3g4h-5i6j_balance', + 'unit_of_measurement': 'USD', + }) +# --- +# name: test_all_entities[sensor.mythical_randomsavings_castle_mortgage_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'Mythical RandomSavings Castle Mortgage Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'USD', + }), + 'context': , + 'entity_id': 'sensor.mythical_randomsavings_castle_mortgage_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7500.00', + }) +# --- +# name: test_all_entities[sensor.mythical_randomsavings_unicorn_pot_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mythical_randomsavings_unicorn_pot_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-6a7b8c9d-0e1f-2g3h-4i5j_balance', + 'unit_of_measurement': 'USD', + }) +# --- +# name: test_all_entities[sensor.mythical_randomsavings_unicorn_pot_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'Mythical RandomSavings Unicorn Pot Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'USD', + }), + 'context': , + 'entity_id': 'sensor.mythical_randomsavings_unicorn_pot_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000.00', + }) +# --- +# name: test_all_entities[sensor.random_bank_costco_anywhere_visa_r_card_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.random_bank_costco_anywhere_visa_r_card_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-3a4b5c6d-7e8f-9g0h-1i2j_balance', + 'unit_of_measurement': 'USD', + }) +# --- +# name: test_all_entities[sensor.random_bank_costco_anywhere_visa_r_card_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'Random Bank Costco Anywhere Visa® Card Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'USD', + }), + 'context': , + 'entity_id': 'sensor.random_bank_costco_anywhere_visa_r_card_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-532.69', + }) +# --- +# name: test_all_entities[sensor.the_bank_of_go_prime_savings_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.the_bank_of_go_prime_savings_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-2a3b4c5d-6e7f-8g9h-0i1j_balance', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_all_entities[sensor.the_bank_of_go_prime_savings_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'The Bank of Go PRIME SAVINGS Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.the_bank_of_go_prime_savings_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9876.54', + }) +# --- +# name: test_all_entities[sensor.the_bank_of_go_the_bank_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.the_bank_of_go_the_bank_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': , + 'original_name': 'Balance', + 'platform': 'simplefin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'account_ACT-1a2b3c4d-5e6f-7g8h-9i0j_balance', + 'unit_of_measurement': 'USD', + }) +# --- +# name: test_all_entities[sensor.the_bank_of_go_the_bank_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by SimpleFIN API', + 'device_class': 'monetary', + 'friendly_name': 'The Bank of Go The Bank Balance', + 'icon': , + 'state_class': , + 'unit_of_measurement': 'USD', + }), + 'context': , + 'entity_id': 'sensor.the_bank_of_go_the_bank_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7777.77', + }) +# --- diff --git a/tests/components/simplefin/test_config_flow.py b/tests/components/simplefin/test_config_flow.py new file mode 100644 index 00000000000..c83f2aed62e --- /dev/null +++ b/tests/components/simplefin/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from simplefin4py.exceptions import ( + SimpleFinAuthError, + SimpleFinClaimError, + SimpleFinInvalidAccountURLError, + SimpleFinInvalidClaimTokenError, + SimpleFinPaymentRequiredError, +) + +from homeassistant.components.simplefin import CONF_ACCESS_URL +from homeassistant.components.simplefin.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_ACCESS_URL + +from tests.common import MockConfigEntry + + +async def test_successful_claim( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_simplefin_client: AsyncMock, +) -> None: + """Test successful token claim in config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: "donJulio"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "SimpleFIN" + assert result["data"] == {CONF_ACCESS_URL: MOCK_ACCESS_URL} + + +async def test_already_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_simplefin_client: AsyncMock, +) -> None: + """Test all entities.""" + 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 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: MOCK_ACCESS_URL}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_access_url( + hass: HomeAssistant, + mock_simplefin_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test standard config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: "http://user:password@string"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_ACCESS_URL] == "http://user:password@string" + assert result["title"] == "SimpleFIN" + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (SimpleFinInvalidAccountURLError, "url_error"), + (SimpleFinPaymentRequiredError, "payment_required"), + (SimpleFinAuthError, "invalid_auth"), + ], +) +async def test_access_url_errors( + hass: HomeAssistant, + mock_simplefin_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test the various errors we can get in access_url mode.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_simplefin_client.claim_setup_token.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: "donJulio"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_simplefin_client.claim_setup_token.side_effect = None + + # Pass the entry creation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: "http://user:password@string"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ACCESS_URL: "http://user:password@string"} + assert result["title"] == "SimpleFIN" + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (SimpleFinInvalidClaimTokenError, "invalid_claim_token"), + (SimpleFinClaimError, "claim_error"), + ], +) +async def test_claim_token_errors( + hass: HomeAssistant, + mock_simplefin_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test config flow with various token claim errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_simplefin_client.claim_setup_token.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: "donJulio"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_simplefin_client.claim_setup_token.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_URL: "donJulio"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ACCESS_URL: "https://i:am@yomama.house.com"} + assert result["title"] == "SimpleFIN" diff --git a/tests/components/simplefin/test_sensor.py b/tests/components/simplefin/test_sensor.py new file mode 100644 index 00000000000..495f249d4e1 --- /dev/null +++ b/tests/components/simplefin/test_sensor.py @@ -0,0 +1,94 @@ +"""Test SimpleFin Sensor with Snapshot data.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from simplefin4py.exceptions import SimpleFinAuthError, SimpleFinPaymentRequiredError +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_simplefin_client: AsyncMock, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.simplefin.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("side_effect"), + [ + (SimpleFinAuthError), + (SimpleFinPaymentRequiredError), + ], +) +async def test_update_errors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_simplefin_client: AsyncMock, + freezer: FrozenDateTimeFactory, + side_effect: Exception, +) -> None: + """Test connection error.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.the_bank_of_go_the_bank_balance").state == "7777.77" + assert hass.states.get("sensor.investments_my_checking_balance").state == "12345.67" + assert ( + hass.states.get("sensor.the_bank_of_go_prime_savings_balance").state + == "9876.54" + ) + assert ( + hass.states.get("sensor.random_bank_costco_anywhere_visa_r_card_balance").state + == "-532.69" + ) + assert hass.states.get("sensor.investments_dr_evil_balance").state == "1000000.00" + assert ( + hass.states.get("sensor.investments_nerdcorp_series_b_balance").state + == "13579.24" + ) + assert ( + hass.states.get("sensor.mythical_randomsavings_unicorn_pot_balance").state + == "10000.00" + ) + assert ( + hass.states.get("sensor.mythical_randomsavings_castle_mortgage_balance").state + == "7500.00" + ) + + mock_simplefin_client.return_value.fetch_data.side_effect = side_effect + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + sensors = [ + "sensor.the_bank_of_go_the_bank_balance", + "sensor.investments_my_checking_balance", + "sensor.the_bank_of_go_prime_savings_balance", + "sensor.random_bank_costco_anywhere_visa_r_card_balance", + "sensor.investments_dr_evil_balance", + "sensor.investments_nerdcorp_series_b_balance", + "sensor.mythical_randomsavings_unicorn_pot_balance", + "sensor.mythical_randomsavings_castle_mortgage_balance", + ] + + for sensor in sensors: + assert hass.states.get(sensor).state == STATE_UNAVAILABLE