From 6b320b2f29b2c989212a1b82c74b162950b78a0b Mon Sep 17 00:00:00 2001 From: Entr0zy <267706715+Entr0zy@users.noreply.github.com> Date: Fri, 22 May 2026 14:01:47 +0100 Subject: [PATCH] Add pluggable bank connector architecture --- .../backend/app/services/bank_connectors.py | 190 ++++++++++++++++++ .../backend/tests/test_bank_connectors.py | 106 ++++++++++ 2 files changed, 296 insertions(+) create mode 100644 packages/backend/app/services/bank_connectors.py create mode 100644 packages/backend/tests/test_bank_connectors.py diff --git a/packages/backend/app/services/bank_connectors.py b/packages/backend/app/services/bank_connectors.py new file mode 100644 index 000000000..684ab0cb0 --- /dev/null +++ b/packages/backend/app/services/bank_connectors.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from typing import Any, Protocol + +from .expense_import import normalize_import_rows + + +class BankConnector(Protocol): + """Interface implemented by pluggable bank integrations.""" + + name: str + + def import_transactions( + self, + *, + account_id: str, + since: date | None = None, + until: date | None = None, + ) -> list[dict[str, Any]]: + """Return transaction-like rows from the provider.""" + + def refresh_transactions(self, *, account_id: str) -> list[dict[str, Any]]: + """Return the most recent provider rows for an account.""" + + +@dataclass(frozen=True) +class BankTransaction: + date: str + amount: float + description: str + currency: str = "USD" + category_id: int | None = None + expense_type: str | None = None + provider_transaction_id: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_import_row(self) -> dict[str, Any]: + row: dict[str, Any] = { + "date": self.date, + "amount": self.amount, + "description": self.description, + "currency": self.currency, + "category_id": self.category_id, + } + if self.expense_type: + row["expense_type"] = self.expense_type + if self.provider_transaction_id: + row["provider_transaction_id"] = self.provider_transaction_id + if self.metadata: + row["metadata"] = dict(self.metadata) + return row + + +class ConnectorRegistry: + def __init__(self) -> None: + self._connectors: dict[str, BankConnector] = {} + + def register(self, connector: BankConnector) -> None: + key = _normalize_connector_name(connector.name) + self._connectors[key] = connector + + def get(self, name: str) -> BankConnector: + key = _normalize_connector_name(name) + try: + return self._connectors[key] + except KeyError as exc: + raise ValueError(f"unknown bank connector: {name}") from exc + + def names(self) -> list[str]: + return sorted(self._connectors) + + +class MockBankConnector: + name = "mock" + + def __init__( + self, + transactions: list[BankTransaction | dict[str, Any]] | None = None, + ) -> None: + self._transactions = transactions or [ + BankTransaction( + date="2026-02-10", + amount=-12.45, + description="Mock Coffee", + currency="USD", + provider_transaction_id="mock_txn_1", + ), + BankTransaction( + date="2026-02-11", + amount=2500.00, + description="Mock Payroll", + currency="USD", + expense_type="INCOME", + provider_transaction_id="mock_txn_2", + ), + ] + + def import_transactions( + self, + *, + account_id: str, + since: date | None = None, + until: date | None = None, + ) -> list[dict[str, Any]]: + _require_account_id(account_id) + rows = [self._to_row(tx) for tx in self._transactions] + return _filter_rows_by_date(rows, since=since, until=until) + + def refresh_transactions(self, *, account_id: str) -> list[dict[str, Any]]: + _require_account_id(account_id) + return [self._to_row(tx) for tx in self._transactions] + + @staticmethod + def _to_row(tx: BankTransaction | dict[str, Any]) -> dict[str, Any]: + if isinstance(tx, BankTransaction): + return tx.to_import_row() + return dict(tx) + + +def import_bank_transactions( + *, + connector_name: str, + account_id: str, + since: date | None = None, + until: date | None = None, + registry: ConnectorRegistry | None = None, +) -> list[dict[str, Any]]: + connector = _registry(registry).get(connector_name) + rows = connector.import_transactions( + account_id=account_id, + since=since, + until=until, + ) + return normalize_import_rows(rows) + + +def refresh_bank_transactions( + *, + connector_name: str, + account_id: str, + registry: ConnectorRegistry | None = None, +) -> list[dict[str, Any]]: + connector = _registry(registry).get(connector_name) + rows = connector.refresh_transactions(account_id=account_id) + return normalize_import_rows(rows) + + +def default_registry() -> ConnectorRegistry: + registry = ConnectorRegistry() + registry.register(MockBankConnector()) + return registry + + +def _registry(registry: ConnectorRegistry | None) -> ConnectorRegistry: + return registry if registry is not None else default_registry() + + +def _normalize_connector_name(name: str) -> str: + key = (name or "").strip().lower() + if not key: + raise ValueError("connector name required") + return key + + +def _require_account_id(account_id: str) -> None: + if not (account_id or "").strip(): + raise ValueError("account_id required") + + +def _filter_rows_by_date( + rows: list[dict[str, Any]], + *, + since: date | None, + until: date | None, +) -> list[dict[str, Any]]: + filtered: list[dict[str, Any]] = [] + for row in rows: + raw_date = str(row.get("date") or "") + try: + row_date = date.fromisoformat(raw_date) + except ValueError: + continue + if since and row_date < since: + continue + if until and row_date > until: + continue + filtered.append(row) + return filtered diff --git a/packages/backend/tests/test_bank_connectors.py b/packages/backend/tests/test_bank_connectors.py new file mode 100644 index 000000000..05e485bff --- /dev/null +++ b/packages/backend/tests/test_bank_connectors.py @@ -0,0 +1,106 @@ +from datetime import date + +import pytest + +from app.services.bank_connectors import ( + BankTransaction, + ConnectorRegistry, + MockBankConnector, + default_registry, + import_bank_transactions, + refresh_bank_transactions, +) + + +def test_default_registry_includes_mock_connector(): + registry = default_registry() + + assert registry.names() == ["mock"] + assert registry.get("MOCK").name == "mock" + + +def test_import_bank_transactions_uses_connector_and_normalizes_rows(): + registry = ConnectorRegistry() + registry.register( + MockBankConnector( + [ + BankTransaction( + date="2026-02-10", + amount="-10.50", + description="Coffee", + currency="USD", + ), + BankTransaction( + date="2026-02-11", + amount=2500.00, + description="Payroll Deposit", + currency="USD", + expense_type="INCOME", + ), + ] + ) + ) + + rows = import_bank_transactions( + connector_name="mock", + account_id="checking-1", + registry=registry, + ) + + assert rows == [ + { + "date": "2026-02-10", + "amount": 10.5, + "description": "Coffee", + "category_id": None, + "expense_type": "EXPENSE", + "currency": "USD", + }, + { + "date": "2026-02-11", + "amount": 2500.0, + "description": "Payroll Deposit", + "category_id": None, + "expense_type": "INCOME", + "currency": "USD", + }, + ] + + +def test_import_bank_transactions_supports_date_windows(): + registry = ConnectorRegistry() + registry.register( + MockBankConnector( + [ + {"date": "2026-02-09", "amount": 5, "description": "Before"}, + {"date": "2026-02-10", "amount": 6, "description": "Inside"}, + {"date": "2026-02-12", "amount": 7, "description": "After"}, + ] + ) + ) + + rows = import_bank_transactions( + connector_name="mock", + account_id="checking-1", + since=date(2026, 2, 10), + until=date(2026, 2, 11), + registry=registry, + ) + + assert [row["description"] for row in rows] == ["Inside"] + + +def test_refresh_bank_transactions_uses_connector_refresh_path(): + rows = refresh_bank_transactions(connector_name="mock", account_id="checking-1") + + assert len(rows) == 2 + assert rows[0]["description"] == "Mock Coffee" + assert rows[1]["expense_type"] == "INCOME" + + +def test_unknown_connector_and_blank_account_are_rejected(): + with pytest.raises(ValueError, match="unknown bank connector"): + import_bank_transactions(connector_name="missing", account_id="checking-1") + + with pytest.raises(ValueError, match="account_id required"): + refresh_bank_transactions(connector_name="mock", account_id=" ")