Skip to content
Open
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions fix_issue_75.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```json
{
"solution_code": "### File: app/connectors/__init__.py\n```python\n\"\"\"\nBank Sync Connector Architecture\nPluggable connector system for bank integrations.\n\"\"\"\nfrom .base import BaseConnector, ConnectorError, ImportResult, RefreshResult\nfrom .mock import MockBankConnector\nfrom .registry import ConnectorRegistry, registry\n\n__all__ = [\n \"BaseConnector\",\n \"ConnectorError\",\n \"ImportResult\",\n \"RefreshResult\",\n \"MockBankConnector\",\n \"ConnectorRegistry\",\n \"registry\",\n]\n```\n\n### File: app/connectors/base.py\n```python\n\"\"\"\nAbstract base class for all bank sync connectors.\nAll real and mock connectors must implement this interface.\n\"\"\"\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom typing import Any, Dict, List, Optional\n\n\nclass ConnectorError(Exception):\n \"\"\"Raised when a connector operation fails.\"\"\"\n\n def __init__(self, message: str, code: Optional[str] = None):\n super().__init__(message)\n self.code = code\n\n\n@dataclass\nclass RawTransaction:\n \"\"\"Normalised representation of a bank transaction.\"\"\"\n\n external_id: str # Unique ID from the bank\n amount: float # Positive = credit, negative = debit\n currency: str\n description: str\n date: datetime\n category_hint: Optional[str] = None # Bank-supplied category (optional)\n metadata: Dict[str, Any] = field(default_factory=dict)\n\n\n@dataclass\nclass ImportResult:\n \"\"\"Result returned from import_transactions().\"\"\"\n\n transactions: List[RawTransaction] = field(default_factory=list)\n has_more: bool = False # Pagination flag\n next_cursor: Optional[str] = None # Opaque cursor for the next page\n errors: List[str] = field(default_factory=list)\n\n\n@dataclass\nclass RefreshResult:\n \"\"\"Result returned from refresh_transactions().\"\"\"\n\n new_transactions: List[RawTransaction] = field(default_factory=list)\n updated_transactions: List[RawTransaction] = field(default_factory=list)\n removed_external_ids: List[str] = field(default_factory=list) # Reversed / deleted\n errors: List[str] = field(default_factory=list)\n\n\nclass BaseConnector(ABC):\n \"\"\"\n Abstract base class for all bank sync connectors.\n\n Implementors must provide:\n - ``import_transactions`` — full historical import (supports pagination)\n - ``refresh_transactions`` — incremental update since last sync\n\n Optional hooks:\n - ``authenticate`` — called once before any data fetch\n - ``validate_credentials`` — lightweight credential check\n - ``disconnect`` — cleanup / token revocation\n \"\"\"\n\n # ------------------------------------------------------------------ #\n # Connector metadata — override in subclasses\n # ------------------------------------------------------------------ #\n CONNECTOR_ID: str = \"base\" # Unique slug, e.g. \"plaid\", \"mock\"\n DISPLAY_NAME: str = \"Base\" # Human-readable name\n SUPPORTS_REFRESH: bool = True # Set False if full re-import is needed\n SUPPORTS_PAGINATION: bool = False\n\n def __init__(self, config: Dict[str, Any]):\n \"\"\"\n :param config: Connector-specific configuration dict.\n May include API keys, institution IDs, etc.\n \"\"\"\n self.config = config\n self._authenticated = False\n\n # ------------------------------------------------------------------ #\n # Required interface\n # ------------------------------------------------------------------ #\n\n @abstractmethod\n def import_transactions(\n self,\n account_id: str,\n start_date: datetime,\n end_date: datetime,\n cursor: Optional[str] = None,\n ) -> ImportResult:\n \"\"\"\n Perform a (potentially paginated) full import of transactions.\n\n :param account_id: Institution-level account identifier.\n :param start_date: Inclusive lower bound.\n :param end_date: Inclusive upper bound.\n :param cursor: If ``SUPPORTS_PAGINATION`` is True, pass the\n ``next_cursor`` from the previous page.\n :returns: :class:`ImportResult`\n :raises ConnectorError: On unrecoverable errors.\n \"\"\"\n\n @abstractmethod\n def refresh_transactions(\n self,\n account_id: str,\n last_synced_at: datetime,\n ) -> RefreshResult:\n \"\"\"\n Fetch only new / changed / removed transactions since *last_synced_at*.\n\n :param account_id: Institution-level account identifier.\n :param last_synced_at: Timestamp of the most recent successful sync.\n :returns: :class:`RefreshResult`\n :raises ConnectorError: On unrecoverable errors.\n \"\"\"\n\n # ------------------------------------------------------------------ #\n # Optional hooks\n # ------------------------------------------------------------------ #\n\n def authenticate(self) -> None:\n \"\"\"\n Perform any necessary authentication / token exchange.\n Called automatically before the first data request if\n ``_authenticated`` is False. Override in subclasses that need it.\n \"\"\"\n self._authenticated = True\n\n def validate_credentials(self) -> bool:\n \"\"\"\n Lightweight check that stored credentials are still valid.\n Returns True by default — override when the bank provides a\n dedicated endpoint.\n \"\"\"\n return True\n\n def disconnect(self) -> None:\n \"\"\"\n Revoke tokens / clean up resources. No-op by default.\n \"\"\"\n self._authenticated = False\n\n # ------------------------------------------------------------------ #\n # Helpers\n # ------------------------------------------------------------------ #\n\n def _ensure_authenticated(self) -> None:\n if not self._authenticated:\n self.authenticate()\n\n def __repr__(self) -> str:\n return f\"<{self.__class__.__name__} connector_id={self.CONNECTOR_ID!r}>\"\n```\n\n### File: app/connectors/mock.py\n```python\n\"\"\"\nMock bank connector — deterministic, no external calls.\nUsed for unit tests, CI, and local development.\n\"\"\"\nfrom datetime import datetime, timedelta\nfrom typing import Any, Dict, List, Optional\n\nfrom .base import (\n BaseConnector,\n ConnectorError,\n ImportResult,\n RawTransaction,\n RefreshResult,\n)\n\n# ---------------------------------------------------------------------------\n# Hardcoded fixture data\n# ---------------------------------------------------------------------------\n_MOCK_TRANSACTIONS: List[Dict[str, Any]] = [\n {\n \"external_id\": \"mock-txn-001\",\n \"amount\": -42.50,\n \"currency\": \"USD\",\n \"description\": \"WHOLE FOODS MARKET\",\n \"date_offset_days\": 0,\n \"category_hint\": \"Groceries\",\n },\n {\n \"external_id\": \"mock-txn-002\",\n \"amount\": -15.00,\n \"currency\": \"USD\",\n \"description\": \"NETFLIX.COM\",\n \"date_offset_days\": -3,\n \"category_hint\": \"Entertainment\",\n },\n {\n \"external_id\": \"mock-txn-003\",\n \"amount\": 2500.00,\n \"currency\": \"USD\",\n \"description\": \"EMPLOYER PAYROLL\",\n \"date_offset_days\": -7,\n \"category_hint\": \"Income\",\n },\n {\n \"external_id\": \"mock-txn-004\",\n \"amount\": -120.00,\n \"currency\": \"USD\",\n \"description\": \"ELECTRIC UTILITY CO\",\n \"date_offset_days\": -10,\n \"category_hint\": \"Utilities\",\n },\n {\n \"external_id\": \"mock-txn-005\",\n \"amount\": -8.99,\n \"currency\": \"USD\",\n \"description\": \"SPOTIFY PREMIUM\",\n \"date_offset_days\": -14,\n \"category_hint\": \"Entertainment\",\n },\n]\n\n\nclass MockBankConnector(BaseConnector):\n \"\"\"\n In-memory mock connector.\n\n Config keys (all optional):\n ``raise_on_import`` (bool) — force ConnectorError on import\n ``raise_on_refresh`` (bool) — force ConnectorError on refresh\n ``extra_transactions`` (list) — additional RawTransaction dicts\n to include in results\n \"\"\"\n\n CONNECTOR_ID = \"mock\"\n DISPLAY_NAME = \"Mock Bank (Test)\"\n SUPPORTS_REFRESH = True\n SUPPORTS_PAGINATION = False\n\n def __init__(self, config: Optional[Dict[str, Any]] = None):\n super().__init__(config or {})\n\n # ------------------------------------------------------------------\n\n def authenticate(self) -> None:\n \"\"\"No-op — mock never needs real auth.\"\"\"\n self._authenticated = True\n\n def validate_credentials(self) -> bool:\n return True\n\n # ------------------------------------------------------------------\n\n def import_transactions(\n self,\n account_id: str,\n start_date: datetime,\n end_date: datetime,\n cursor: Optional[str] = None,\n ) -> ImportResult:\n self._ensure_authenticated()\n\n if self.config.get(\"raise_on_import\"):\n raise ConnectorError(\n \"Mock import error (raise_on_import=True)\", code=\"MOCK_ERROR\"\n )\n\n now = datetime.utcnow()\n txns: List[RawTransaction] = []\n\n for raw in _MOCK_TRANSACTIONS:\n txn_date = now + timedelta(days=raw[\"date_offset_days\"])\n if not (start_date <= txn_date <= end_date):\n continue\n txns.append(\n RawTransaction(\n external_id=f\"{account_id}_{raw['external_id']}\",\n amount=raw[\"amount\"],\n currency=raw[\"currency\"],\n description=raw[\"description\"],\n date=txn_date,\n category_hint=raw.get(\"category_hint\"),\n metadata={\"source\": \"mock\"},\n )\n )\n\n # Support injecting extra transactions via config\n for extra in self.config.get(\"extra_transactions\", []):\n txns.append(\n RawTransaction(\n external_id=extra.get(\"external_id\", \"mock-extra\"),\n amount=extra[