From 264a7dd42f9e80f9fe7527f45f5318a1239b548c Mon Sep 17 00:00:00 2001 From: Kaden Napper Date: Sun, 27 Sep 2026 17:36:54 +1000 Subject: [PATCH] Initial Up Banking Home Assistant integration --- .github/workflows/validate.yml | 17 ++++ .gitignore | 7 ++ README.md | 68 ++++++++++++++++ custom_components/up_banking/__init__.py | 31 +++++++ custom_components/up_banking/api.py | 74 +++++++++++++++++ custom_components/up_banking/config_flow.py | 58 +++++++++++++ custom_components/up_banking/const.py | 12 +++ custom_components/up_banking/coordinator.py | 81 +++++++++++++++++++ custom_components/up_banking/exceptions.py | 11 +++ custom_components/up_banking/manifest.json | 12 +++ custom_components/up_banking/models.py | 28 +++++++ custom_components/up_banking/sensor.py | 54 +++++++++++++ custom_components/up_banking/strings.json | 4 + .../up_banking/translations/en.json | 1 + hacs.json | 5 ++ pyproject.toml | 7 ++ tests/test_models.py | 16 ++++ 17 files changed, 486 insertions(+) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 custom_components/up_banking/__init__.py create mode 100644 custom_components/up_banking/api.py create mode 100644 custom_components/up_banking/config_flow.py create mode 100644 custom_components/up_banking/const.py create mode 100644 custom_components/up_banking/coordinator.py create mode 100644 custom_components/up_banking/exceptions.py create mode 100644 custom_components/up_banking/manifest.json create mode 100644 custom_components/up_banking/models.py create mode 100644 custom_components/up_banking/sensor.py create mode 100644 custom_components/up_banking/strings.json create mode 100644 custom_components/up_banking/translations/en.json create mode 100644 hacs.json create mode 100644 pyproject.toml create mode 100644 tests/test_models.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..16b1f26 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,17 @@ +name: Validate + +on: + push: + pull_request: + +jobs: + syntax: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - run: python -m compileall custom_components + - run: python -m json.tool hacs.json + - run: python -m json.tool custom_components/up_banking/manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acf9a04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +.storage/ +config/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..35b77e6 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Up for Home Assistant + +**Up Banking** is a small, read-only Home Assistant custom integration for the [Up Banking API](https://developer.up.com.au/). It is designed for balances in dashboards and transaction activity in automations; Up remains the source of truth for accounts, categories, budgets, and history. + +## What it provides + +- One AUD balance sensor for each account selected during setup, including Savers and 2Up (joint) accounts. +- Stable entity identity based on Up's account ID, so changing an account name does not create a new entity. +- The `up_banking_transaction` Home Assistant event for new activity. Its compact data includes `transaction_id`, `event_type` (`created`, `settled`, or `deleted`), `status` (`held`, `settled`, or `deleted`), `account_id`, `amount`, `currency`, `description`, and `created_at`. +- A five-minute coordinated poll. It reads changed activity since the last successful poll, so a Home Assistant restart or brief outage catches up. It retains at most 500 small de-duplication records in HA storage and does **not** put transaction history on entities or into Recorder attributes. + +## Install with HACS + +1. In HACS, open the three-dot menu, choose **Custom repositories**, then add `https://git.napper.au/klnap0/up-ha` as an **Integration**. +2. Search for **Up Banking**, install it, then restart Home Assistant. +3. Go to **Settings → Devices & services → Add integration**, select **Up Banking**, and enter an Up personal access token from the [Up developer portal](https://developer.up.com.au/). +4. Select the accounts to expose. Open the integration's **Configure** option later to change that selection. + +The token is held in Home Assistant's config-entry storage; this integration deliberately never logs it. Do not paste it into YAML, automations, issues, or chat. + +For a manual install, copy `custom_components/up_banking` into `/config/custom_components/`, restart Home Assistant, then follow steps 3–4. + +## Automation example + +```yaml +automation: + - alias: Tell me about newly held purchases + triggers: + - trigger: event + event_type: up_banking_transaction + event_data: + event_type: created + status: held + actions: + - action: notify.mobile_app_phone + data: + message: >- + {{ trigger.event.data.description }}: + {{ trigger.event.data.amount }} {{ trigger.event.data.currency }} +``` + +Amounts use Up's signed decimal representation: an outgoing purchase is normally negative. + +## Webhooks and polling + +Up webhooks are signed using `X-Up-Authenticity-Signature` with an HMAC-SHA256 secret returned only when the webhook is created. They are useful for faster events, but the target must be a public URL that Up can reach and respond to with HTTP 200. That requires exposing Home Assistant through a securely configured public URL or using a separate relay; it also needs secure secret lifecycle handling. This initial release deliberately does not create webhooks or expose an endpoint. The default five-minute polling is private, requires no inbound access, and catches up after downtime. + +## Limits and behaviour + +- Up is a cloud API, so values cannot update while the API or Internet connection is unavailable. +- Polling is not instant. A transaction can be held, settled, or (for an abandoned hold) deleted; those changes generate distinct events when observed. A very large backlog beyond the API pagination safety limit may need a later refresh; normal outages are handled. +- Home loans are displayed if selected because the current Up API lists them; the original focus is personal, 2Up, and Saver accounts. +- This integration is read-only. It does not create transactions, modify categories, sync budgets, or mirror full financial history into Home Assistant. + +## Updating + +Use HACS's update button when a release is available, then restart Home Assistant. For a manual install, replace the integration directory and restart. Before updating, make a Home Assistant backup as usual. + +## Development + +The implementation uses Home Assistant's built-in HTTP client and coordinator—there is no third-party API client to maintain. Run the checks with a Home Assistant development environment: + +```powershell +python -m compileall custom_components +python -m pytest +``` + +`tests/test_models.py` covers the account UI label and the compact automation event contract. Integration behaviour should additionally be exercised against Up's demo API token in an isolated Home Assistant development instance. diff --git a/custom_components/up_banking/__init__.py b/custom_components/up_banking/__init__.py new file mode 100644 index 0000000..8b0f0fe --- /dev/null +++ b/custom_components/up_banking/__init__.py @@ -0,0 +1,31 @@ +"""Up Banking integration setup.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import UpApi +from .const import CONF_TOKEN, DOMAIN, PLATFORMS +from .coordinator import UpCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + coordinator = UpCoordinator(hass, entry, UpApi(async_get_clientsession(hass), entry.data[CONF_TOKEN])) + await coordinator.async_load() + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id, None) + return unloaded diff --git a/custom_components/up_banking/api.py b/custom_components/up_banking/api.py new file mode 100644 index 0000000..9283a80 --- /dev/null +++ b/custom_components/up_banking/api.py @@ -0,0 +1,74 @@ +"""Small, dependency-free asynchronous client for the Up API.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp + +from .const import API_URL +from .exceptions import UpApiError, UpAuthError + +_LOGGER = logging.getLogger(__name__) + + +class UpApi: + """Read-only Up API client. It deliberately never logs request headers.""" + + def __init__(self, session: aiohttp.ClientSession, token: str) -> None: + self._session = session + self._headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + async def accounts(self) -> list[dict[str, Any]]: + return await self._list("/accounts", {"page[size]": "100"}) + + async def transactions_since(self, since: str | None) -> list[dict[str, Any]]: + params = {"page[size]": "100"} + if since: + params["filter[since]"] = since + return await self._list("/transactions", params, max_pages=10) + + async def held_transactions(self) -> list[dict[str, Any]]: + """Return the small current set of pending transactions for deletion detection.""" + return await self._list("/transactions", {"page[size]": "100", "filter[status]": "HELD"}) + + async def transaction(self, transaction_id: str) -> dict[str, Any] | None: + """Retrieve a known held transaction; ``None`` means Up deleted it.""" + url = f"{API_URL}/transactions/{transaction_id}" + try: + async with self._session.get(url, headers=self._headers, timeout=20) as response: + if response.status == 404: + return None + if response.status in (401, 403): + raise UpAuthError("Personal access token was rejected") + if response.status >= 400: + raise UpApiError(f"Up API request failed ({response.status})") + return (await response.json())["data"] + except aiohttp.ClientError as err: + raise UpApiError("Unable to reach the Up API") from err + + async def _list( + self, path: str, params: dict[str, str], max_pages: int = 2 + ) -> list[dict[str, Any]]: + url: str | None = f"{API_URL}{path}" + result: list[dict[str, Any]] = [] + for page in range(max_pages): + if not url: + break + payload = await self._get(url, params if page == 0 else None) + result.extend(payload.get("data", [])) + url = payload.get("links", {}).get("next") + return result + + async def _get(self, url: str, params: dict[str, str] | None) -> dict[str, Any]: + try: + async with self._session.get(url, headers=self._headers, params=params, timeout=20) as response: + if response.status in (401, 403): + raise UpAuthError("Personal access token was rejected") + if response.status == 429: + raise UpApiError("Up API rate limit reached; will retry on the next update") + if response.status >= 400: + raise UpApiError(f"Up API request failed ({response.status})") + return await response.json() + except aiohttp.ClientError as err: + raise UpApiError("Unable to reach the Up API") from err diff --git a/custom_components/up_banking/config_flow.py b/custom_components/up_banking/config_flow.py new file mode 100644 index 0000000..df7c481 --- /dev/null +++ b/custom_components/up_banking/config_flow.py @@ -0,0 +1,58 @@ +"""UI configuration and options flow for Up Banking.""" +from __future__ import annotations + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv + +from .api import UpApi +from .const import CONF_ACCOUNTS, CONF_TOKEN, DOMAIN +from .exceptions import UpApiError +from .models import account_label + + +class UpBankingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Configure a single Up personal access token.""" + VERSION = 1 + + def __init__(self) -> None: + self._token: str | None = None + self._accounts: list[dict] = [] + + async def async_step_user(self, user_input=None): + errors = {} + if user_input: + self._token = user_input[CONF_TOKEN].strip() + try: + self._accounts = await UpApi(async_get_clientsession(self.hass), self._token).accounts() + except UpApiError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id("up_banking") + self._abort_if_unique_id_configured() + return await self.async_step_accounts() + return self.async_show_form(step_id="user", data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), errors=errors) + + async def async_step_accounts(self, user_input=None): + choices = {item["id"]: account_label(item) for item in self._accounts} + if user_input is not None: + return self.async_create_entry(title="Up Banking", data={CONF_TOKEN: self._token, CONF_ACCOUNTS: user_input[CONF_ACCOUNTS]}) + return self.async_show_form(step_id="accounts", data_schema=vol.Schema({vol.Required(CONF_ACCOUNTS, default=list(choices)): cv.multi_select(choices)})) + + @staticmethod + def async_get_options_flow(config_entry): + return UpBankingOptionsFlow() + + +class UpBankingOptionsFlow(config_entries.OptionsFlow): + async def async_step_init(self, user_input=None): + try: + accounts = await UpApi(async_get_clientsession(self.hass), self.config_entry.data[CONF_TOKEN]).accounts() + except UpApiError: + return self.async_abort(reason="cannot_connect") + choices = {item["id"]: account_label(item) for item in accounts} + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + selected = self.config_entry.options.get(CONF_ACCOUNTS, self.config_entry.data.get(CONF_ACCOUNTS, [])) + return self.async_show_form(step_id="init", data_schema=vol.Schema({vol.Required(CONF_ACCOUNTS, default=selected): cv.multi_select(choices)})) diff --git a/custom_components/up_banking/const.py b/custom_components/up_banking/const.py new file mode 100644 index 0000000..8ff5f79 --- /dev/null +++ b/custom_components/up_banking/const.py @@ -0,0 +1,12 @@ +"""Constants for Up Banking.""" +from datetime import timedelta + +DOMAIN = "up_banking" +PLATFORMS = ["sensor"] +CONF_TOKEN = "token" +CONF_ACCOUNTS = "accounts" +EVENT_TRANSACTION = "up_banking_transaction" +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) +STORAGE_VERSION = 1 +STORAGE_KEY = f"{DOMAIN}.seen_transactions" +API_URL = "https://api.up.com.au/api/v1" diff --git a/custom_components/up_banking/coordinator.py b/custom_components/up_banking/coordinator.py new file mode 100644 index 0000000..af1446a --- /dev/null +++ b/custom_components/up_banking/coordinator.py @@ -0,0 +1,81 @@ +"""Data coordinator and bounded transaction de-duplication.""" +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import UpApi +from .const import DEFAULT_SCAN_INTERVAL, EVENT_TRANSACTION, STORAGE_KEY, STORAGE_VERSION +from .exceptions import UpApiError +from .models import transaction_event + + +class UpCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Fetch account balances and emit only newly observed transaction events.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: UpApi) -> None: + super().__init__(hass, __import__("logging").getLogger(__name__), name="Up Banking", update_interval=DEFAULT_SCAN_INTERVAL) + self.entry = entry + self.api = api + self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}") + self._seen: dict[str, dict[str, Any]] = {} + self._last_success: str | None = None + self._primed = False + + async def async_load(self) -> None: + saved = await self._store.async_load() or {} + self._seen = saved.get("seen", {}) + self._last_success = saved.get("last_success") + self._primed = bool(self._seen) + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + try: + accounts = await self.api.accounts() + enabled = set(self.entry.options.get("accounts", self.entry.data.get("accounts", []))) + selected = {item["id"]: item for item in accounts if item["id"] in enabled} + # On first ever setup, establish a baseline rather than replaying banking history. + since = self._last_success or (datetime.now(UTC) - timedelta(minutes=10)).isoformat() + transactions = await self.api.transactions_since(since) + except UpApiError as err: + raise UpdateFailed(str(err)) from err + + for transaction in reversed(transactions): + tx_id = transaction["id"] + status = transaction["attributes"]["status"] + previous = self._seen.get(tx_id) + account_id = transaction.get("relationships", {}).get("account", {}).get("data", {}).get("id") + if self._primed and previous and previous["status"] != status and account_id in selected: + kind = "settled" if previous["status"] == "HELD" and status == "SETTLED" else "created" + self.hass.bus.async_fire(EVENT_TRANSACTION, transaction_event(transaction, kind)) + elif self._primed and not previous and account_id in selected: + self.hass.bus.async_fire(EVENT_TRANSACTION, transaction_event(transaction, "created")) + self._seen[tx_id] = {"status": status, "event": transaction_event(transaction, "created")} + + for tx_id, saved in list(self._seen.items()): + if saved["status"] != "HELD": + continue + current = await self.api.transaction(tx_id) + if current is None: + # A held item that no longer exists was deleted by Up. + payload = dict(saved["event"]) + payload.update({"event_type": "deleted", "status": "deleted"}) + if self._primed and payload.get("account_id") in selected: + self.hass.bus.async_fire(EVENT_TRANSACTION, payload) + self._seen.pop(tx_id) + elif current["attributes"]["status"] == "SETTLED": + account_id = current.get("relationships", {}).get("account", {}).get("data", {}).get("id") + if self._primed and account_id in selected: + self.hass.bus.async_fire(EVENT_TRANSACTION, transaction_event(current, "settled")) + self._seen[tx_id] = {"status": "SETTLED", "event": transaction_event(current, "created")} + + self._primed = True + self._last_success = datetime.now(UTC).isoformat() + # Retain only a bounded de-duplication window; account/transaction history stays in Up. + self._seen = dict(list(self._seen.items())[-500:]) + self._store.async_delay_save(lambda: {"seen": self._seen, "last_success": self._last_success}, 10) + return selected diff --git a/custom_components/up_banking/exceptions.py b/custom_components/up_banking/exceptions.py new file mode 100644 index 0000000..f739048 --- /dev/null +++ b/custom_components/up_banking/exceptions.py @@ -0,0 +1,11 @@ +"""Up Banking integration exceptions.""" +from homeassistant.exceptions import HomeAssistantError + + +class UpApiError(HomeAssistantError): + """Base error returned by the Up API.""" + + +class UpAuthError(UpApiError): + """The personal access token was rejected.""" + diff --git a/custom_components/up_banking/manifest.json b/custom_components/up_banking/manifest.json new file mode 100644 index 0000000..55feb61 --- /dev/null +++ b/custom_components/up_banking/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "up_banking", + "name": "Up Banking", + "config_flow": true, + "integration_type": "hub", + "iot_class": "cloud_polling", + "version": "0.1.0", + "documentation": "https://git.napper.au/klnap0/up-ha", + "issue_tracker": "https://git.napper.au/klnap0/up-ha/issues", + "codeowners": ["@klnap0"], + "requirements": [] +} diff --git a/custom_components/up_banking/models.py b/custom_components/up_banking/models.py new file mode 100644 index 0000000..30f29a2 --- /dev/null +++ b/custom_components/up_banking/models.py @@ -0,0 +1,28 @@ +"""Pure data conversion helpers kept independent of Home Assistant.""" +from __future__ import annotations + +from typing import Any + + +def account_label(account: dict[str, Any]) -> str: + """Human friendly account label used only in UI, never as an identifier.""" + attrs = account["attributes"] + return f"{attrs['displayName']} ({attrs['accountType'].title()}, {attrs['ownershipType'].title()})" + + +def transaction_event(transaction: dict[str, Any], event_type: str) -> dict[str, Any]: + """Return the deliberately small, automation-oriented event payload.""" + attrs = transaction["attributes"] + money = attrs["amount"] + account = transaction.get("relationships", {}).get("account", {}).get("data", {}) + return { + "transaction_id": transaction["id"], + "event_type": event_type, + "status": attrs["status"].lower(), + "account_id": account.get("id"), + "amount": money["value"], + "currency": money["currencyCode"], + "description": attrs.get("description", ""), + "created_at": attrs.get("createdAt"), + "raw_text": attrs.get("rawText"), + } diff --git a/custom_components/up_banking/sensor.py b/custom_components/up_banking/sensor.py new file mode 100644 index 0000000..0ab851b --- /dev/null +++ b/custom_components/up_banking/sensor.py @@ -0,0 +1,54 @@ +"""Balance sensors for selected Up accounts.""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_AUSTRALIAN_DOLLAR +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import UpCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: + coordinator: UpCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(UpBalanceSensor(coordinator, account_id) for account_id in coordinator.data) + + +class UpBalanceSensor(CoordinatorEntity[UpCoordinator], SensorEntity): + _attr_device_class = SensorDeviceClass.MONETARY + _attr_state_class = SensorStateClass.TOTAL + _attr_native_unit_of_measurement = CURRENCY_AUSTRALIAN_DOLLAR + _attr_has_entity_name = True + + def __init__(self, coordinator: UpCoordinator, account_id: str) -> None: + super().__init__(coordinator) + self._account_id = account_id + self._attr_unique_id = f"{account_id}_balance" + + @property + def _account(self) -> dict[str, Any]: + return self.coordinator.data[self._account_id] + + @property + def name(self) -> str: + return f"{self._account['attributes']['displayName']} balance" + + @property + def native_value(self) -> Decimal: + return Decimal(self._account["attributes"]["balance"]["value"]) + + @property + def extra_state_attributes(self) -> dict[str, str]: + attrs = self._account["attributes"] + return {"account_type": attrs["accountType"].lower(), "ownership_type": attrs["ownershipType"].lower()} + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo(identifiers={(DOMAIN, self.coordinator.entry.entry_id)}, name="Up Banking", manufacturer="Up Banking") diff --git a/custom_components/up_banking/strings.json b/custom_components/up_banking/strings.json new file mode 100644 index 0000000..e5572f0 --- /dev/null +++ b/custom_components/up_banking/strings.json @@ -0,0 +1,4 @@ +{ + "config": {"step": {"user": {"title": "Connect to Up Banking", "description": "Enter an Up personal access token. It is stored by Home Assistant and never written to logs.", "data": {"token": "Personal access token"}}, "accounts": {"title": "Choose accounts", "description": "Only selected accounts receive balance entities.", "data": {"accounts": "Accounts"}}}, "error": {"cannot_connect": "Could not connect or the token was rejected."}}, + "options": {"step": {"init": {"title": "Choose accounts", "data": {"accounts": "Accounts"}}}} +} diff --git a/custom_components/up_banking/translations/en.json b/custom_components/up_banking/translations/en.json new file mode 100644 index 0000000..0daab31 --- /dev/null +++ b/custom_components/up_banking/translations/en.json @@ -0,0 +1 @@ +{"config":{"step":{"user":{"title":"Connect to Up Banking","description":"Enter an Up personal access token. It is stored by Home Assistant and never written to logs.","data":{"token":"Personal access token"}},"accounts":{"title":"Choose accounts","description":"Only selected accounts receive balance entities.","data":{"accounts":"Accounts"}}},"error":{"cannot_connect":"Could not connect or the token was rejected."}},"options":{"step":{"init":{"title":"Choose accounts","data":{"accounts":"Accounts"}}}}} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..7bc7dfa --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Up Banking", + "render_readme": true, + "homeassistant": "2025.1.0" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6da7794 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "up-ha" +version = "0.1.0" +requires-python = ">=3.13" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..779a2ec --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,16 @@ +"""Tests for event payloads: no Home Assistant runtime is required.""" +from custom_components.up_banking.models import account_label, transaction_event + + +def test_account_label_includes_saver_and_ownership(): + account = {"attributes": {"displayName": "Rainy day", "accountType": "SAVER", "ownershipType": "JOINT"}} + assert account_label(account) == "Rainy day (Saver, Joint)" + + +def test_transaction_event_is_small_and_automation_ready(): + transaction = { + "id": "tx-1", + "attributes": {"status": "HELD", "amount": {"value": "-12.34", "currencyCode": "AUD"}, "description": "Coffee", "createdAt": "2026-01-01T00:00:00Z", "rawText": "Cafe"}, + "relationships": {"account": {"data": {"id": "account-1"}}}, + } + assert transaction_event(transaction, "created") == {"transaction_id": "tx-1", "event_type": "created", "status": "held", "account_id": "account-1", "amount": "-12.34", "currency": "AUD", "description": "Coffee", "created_at": "2026-01-01T00:00:00Z", "raw_text": "Cafe"}