From 6e94380f51b6b4bad46c8868e3286ed56ac9b68e Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 3 Apr 2026 08:11:51 +0000 Subject: [PATCH] feat: pluggable bank sync connector architecture (#75) - BankConnector ABC with validate_credentials, fetch_transactions, get_balance, refresh interface - ConnectorConfig, SyncTransaction, SyncResult dataclasses - Connector registry with @register_connector decorator and create_connector factory - Built-in connectors: CSVFileConnector, MockBankConnector, PlaidConnector (skeleton) - REST API: GET /bank-sync/connectors, POST /bank-sync/validate, POST /bank-sync/import, POST /bank-sync/balance - CSV connector: column alias detection, date filtering, expense/income type detection - Mock connector: deterministic synthetic transactions for testing, balance support - Plaid connector: credential validation skeleton ready for real API integration - 31 tests (24 pass, 7 skip Redis) Closes #75 --- packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/bank_sync.py | 108 +++++++ packages/backend/app/services/bank_sync.py | 335 +++++++++++++++++++++ packages/backend/tests/test_bank_sync.py | 305 +++++++++++++++++++ 4 files changed, 750 insertions(+) create mode 100644 packages/backend/app/routes/bank_sync.py create mode 100644 packages/backend/app/services/bank_sync.py create mode 100644 packages/backend/tests/test_bank_sync.py diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..b28c44dd 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .bank_sync import bp as bank_sync_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(bank_sync_bp, url_prefix="") diff --git a/packages/backend/app/routes/bank_sync.py b/packages/backend/app/routes/bank_sync.py new file mode 100644 index 00000000..89cf6c85 --- /dev/null +++ b/packages/backend/app/routes/bank_sync.py @@ -0,0 +1,108 @@ +"""Routes for Bank Sync Connector Architecture (issue #75).""" +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from app.services.bank_sync import ( + create_connector, + list_connectors, + get_connector_class, + SyncResult, +) + +bp = Blueprint("bank_sync", __name__) + + +@bp.route("/bank-sync/connectors", methods=["GET"]) +def get_connectors(): + """List all available bank connectors.""" + connectors = list_connectors() + return jsonify({"connectors": connectors, "count": len(connectors)}), 200 + + +@bp.route("/bank-sync/validate", methods=["POST"]) +@jwt_required() +def validate_connector(): + """Validate connector credentials without importing data.""" + data = request.get_json() or {} + connector_name = data.get("connector") + credentials = data.get("credentials", {}) + + if not connector_name: + return jsonify({"error": "connector name is required"}), 400 + + connector = create_connector(connector_name, credentials, data.get("options", {})) + if not connector: + return jsonify({"error": f"Unknown connector: {connector_name}"}), 404 + + is_valid = connector.validate_credentials() + return jsonify({ + "connector": connector_name, + "valid": is_valid, + "message": "Credentials valid" if is_valid else "Invalid credentials", + }), 200 + + +@bp.route("/bank-sync/import", methods=["POST"]) +@jwt_required() +def import_transactions(): + """Import transactions using the specified connector.""" + data = request.get_json() or {} + connector_name = data.get("connector") + account_id = data.get("account_id", "default") + credentials = data.get("credentials", {}) + options = data.get("options", {}) + + if not connector_name: + return jsonify({"error": "connector name is required"}), 400 + + connector = create_connector(connector_name, credentials, options) + if not connector: + return jsonify({"error": f"Unknown connector: {connector_name}"}), 404 + + if not connector.validate_credentials(): + return jsonify({"error": "Invalid credentials for connector"}), 401 + + # Parse optional date filters + since = None + until = None + try: + from datetime import date as date_cls + if data.get("since"): + since = date_cls.fromisoformat(data["since"]) + if data.get("until"): + until = date_cls.fromisoformat(data["until"]) + except ValueError as e: + return jsonify({"error": f"Invalid date format: {e}"}), 400 + + result = connector.refresh(account_id) + return jsonify(result.to_dict()), 200 if result.success else 500 + + +@bp.route("/bank-sync/balance", methods=["POST"]) +@jwt_required() +def get_account_balance(): + """Get current account balance via connector.""" + data = request.get_json() or {} + connector_name = data.get("connector") + account_id = data.get("account_id", "default") + credentials = data.get("credentials", {}) + + if not connector_name: + return jsonify({"error": "connector name is required"}), 400 + + connector = create_connector(connector_name, credentials, data.get("options", {})) + if not connector: + return jsonify({"error": f"Unknown connector: {connector_name}"}), 404 + + cls = connector.__class__ + if not cls.SUPPORTS_BALANCE: + return jsonify({ + "error": f"Connector '{connector_name}' does not support balance queries" + }), 400 + + balance = connector.get_balance(account_id) + return jsonify({ + "connector": connector_name, + "account_id": account_id, + "balance": str(balance) if balance is not None else None, + "currency": credentials.get("currency", "USD"), + }), 200 \ No newline at end of file diff --git a/packages/backend/app/services/bank_sync.py b/packages/backend/app/services/bank_sync.py new file mode 100644 index 00000000..2347dd9c --- /dev/null +++ b/packages/backend/app/services/bank_sync.py @@ -0,0 +1,335 @@ +""" +Bank Sync Connector Architecture (issue #75) + +Pluggable architecture for bank integrations. +Each connector implements a standard interface for import & refresh. +""" +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, date, timedelta +from typing import Optional, Any +from decimal import Decimal + + +# ----------------------------------------------------------------------- +# Data types +# ----------------------------------------------------------------------- + +@dataclass +class SyncTransaction: + """Normalized transaction from any bank connector.""" + external_id: str + date: date + amount: Decimal + currency: str + description: str + transaction_type: str = "expense" # "expense" | "income" | "transfer" + balance: Optional[Decimal] = None + reference: Optional[str] = None + category_hint: Optional[str] = None + raw_data: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "external_id": self.external_id, + "date": self.date.isoformat(), + "amount": str(self.amount), + "currency": self.currency, + "description": self.description, + "transaction_type": self.transaction_type, + "balance": str(self.balance) if self.balance is not None else None, + "reference": self.reference, + "category_hint": self.category_hint, + } + + +@dataclass +class SyncResult: + """Result of a sync operation.""" + connector_name: str + account_id: str + synced_at: str + transactions: list[SyncTransaction] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + new_count: int = 0 + duplicate_count: int = 0 + error_count: int = 0 + success: bool = True + + def to_dict(self) -> dict: + return { + "connector": self.connector_name, + "account_id": self.account_id, + "synced_at": self.synced_at, + "transactions": [t.to_dict() for t in self.transactions], + "new_count": self.new_count, + "duplicate_count": self.duplicate_count, + "error_count": self.error_count, + "errors": self.errors, + "success": self.success, + } + + +@dataclass +class ConnectorConfig: + """Configuration for a bank connector instance.""" + connector_name: str + credentials: dict = field(default_factory=dict) + options: dict = field(default_factory=dict) + + +# ----------------------------------------------------------------------- +# Base connector interface +# ----------------------------------------------------------------------- + +class BankConnector(ABC): + """ + Abstract base class for bank/financial institution connectors. + All connectors must implement this interface. + """ + + NAME: str = "abstract" + SUPPORTS_BALANCE: bool = False + SUPPORTS_REFRESH: bool = False + + def __init__(self, config: ConnectorConfig): + self.config = config + self._validated = False + + @abstractmethod + def validate_credentials(self) -> bool: + """Validate that the credentials are correct and connection works.""" + ... + + @abstractmethod + def fetch_transactions( + self, + account_id: str, + since: Optional[date] = None, + until: Optional[date] = None, + ) -> list[SyncTransaction]: + """Fetch transactions for an account in a date range.""" + ... + + def get_balance(self, account_id: str) -> Optional[Decimal]: + """Get current account balance. Override if SUPPORTS_BALANCE=True.""" + return None + + def refresh(self, account_id: str) -> SyncResult: + """ + High-level sync: fetch recent transactions and return structured result. + Default: fetches last 30 days. + """ + until = date.today() + since = until - timedelta(days=30) + + synced_at = datetime.utcnow().isoformat() + errors = [] + transactions = [] + + try: + transactions = self.fetch_transactions(account_id, since=since, until=until) + except Exception as exc: + errors.append(str(exc)) + return SyncResult( + connector_name=self.NAME, + account_id=account_id, + synced_at=synced_at, + errors=errors, + success=False, + ) + + return SyncResult( + connector_name=self.NAME, + account_id=account_id, + synced_at=synced_at, + transactions=transactions, + new_count=len(transactions), + errors=errors, + success=True, + ) + + @classmethod + def get_name(cls) -> str: + return cls.NAME + + +# ----------------------------------------------------------------------- +# Connector registry +# ----------------------------------------------------------------------- + +_CONNECTOR_REGISTRY: dict[str, type[BankConnector]] = {} + + +def register_connector(cls: type[BankConnector]) -> type[BankConnector]: + """Decorator to register a connector class.""" + _CONNECTOR_REGISTRY[cls.NAME] = cls + return cls + + +def get_connector_class(name: str) -> Optional[type[BankConnector]]: + return _CONNECTOR_REGISTRY.get(name) + + +def list_connectors() -> list[dict]: + return [ + { + "name": cls.NAME, + "supports_balance": cls.SUPPORTS_BALANCE, + "supports_refresh": cls.SUPPORTS_REFRESH, + } + for cls in _CONNECTOR_REGISTRY.values() + ] + + +def create_connector(name: str, credentials: dict, options: dict = None) -> Optional[BankConnector]: + """Factory function to instantiate a connector by name.""" + cls = get_connector_class(name) + if not cls: + return None + config = ConnectorConfig( + connector_name=name, + credentials=credentials, + options=options or {}, + ) + return cls(config) + + +# ----------------------------------------------------------------------- +# Built-in connector implementations +# ----------------------------------------------------------------------- + +@register_connector +class CSVFileConnector(BankConnector): + """ + CSV file connector: import transactions from a CSV string. + Credentials: none required. Options: csv_content (str), delimiter (str). + """ + NAME = "csv_file" + SUPPORTS_BALANCE = False + SUPPORTS_REFRESH = False + + def validate_credentials(self) -> bool: + return True # No credentials needed for CSV + + def fetch_transactions( + self, + account_id: str, + since: Optional[date] = None, + until: Optional[date] = None, + ) -> list[SyncTransaction]: + import csv + import io + content = self.config.options.get("csv_content", "") + delimiter = self.config.options.get("delimiter", ",") + if not content: + return [] + + transactions = [] + reader = csv.DictReader(io.StringIO(content), delimiter=delimiter) + for i, row in enumerate(reader): + try: + # Try common column names + date_val = row.get("date") or row.get("Date") or row.get("DATE", "") + amount_val = row.get("amount") or row.get("Amount") or row.get("AMOUNT", "0") + desc_val = row.get("description") or row.get("Description") or row.get("memo", "") + + txn_date = datetime.strptime(date_val.strip(), "%Y-%m-%d").date() + + if since and txn_date < since: + continue + if until and txn_date > until: + continue + + amount = Decimal(str(amount_val).replace(",", "").strip()) + transactions.append(SyncTransaction( + external_id=f"csv_{account_id}_{i}", + date=txn_date, + amount=abs(amount), + currency=self.config.options.get("currency", "USD"), + description=desc_val.strip(), + transaction_type="expense" if amount < 0 else "income", + raw_data=dict(row), + )) + except Exception: + continue + + return transactions + + +@register_connector +class MockBankConnector(BankConnector): + """ + Mock/test bank connector. + Credentials: api_key (any non-empty string = valid). + Generates synthetic transactions for testing. + """ + NAME = "mock_bank" + SUPPORTS_BALANCE = True + SUPPORTS_REFRESH = True + + def validate_credentials(self) -> bool: + return bool(self.config.credentials.get("api_key")) + + def fetch_transactions( + self, + account_id: str, + since: Optional[date] = None, + until: Optional[date] = None, + ) -> list[SyncTransaction]: + # Generate deterministic mock transactions + until = until or date.today() + since = since or (until - timedelta(days=30)) + + txns = [] + current = since + idx = 0 + while current <= until: + if idx % 3 == 0: + txns.append(SyncTransaction( + external_id=f"mock_{account_id}_{idx}", + date=current, + amount=Decimal("50.00"), + currency="USD", + description="Mock Grocery Store", + transaction_type="expense", + raw_data={"source": "mock"}, + )) + current += timedelta(days=2) + idx += 1 + return txns + + def get_balance(self, account_id: str) -> Optional[Decimal]: + return Decimal("1234.56") + + +@register_connector +class PlaidConnector(BankConnector): + """ + Plaid API connector (skeleton with real interface). + Credentials: client_id, secret, access_token. + """ + NAME = "plaid" + SUPPORTS_BALANCE = True + SUPPORTS_REFRESH = True + + def validate_credentials(self) -> bool: + creds = self.config.credentials + return all(k in creds for k in ("client_id", "secret", "access_token")) + + def fetch_transactions( + self, + account_id: str, + since: Optional[date] = None, + until: Optional[date] = None, + ) -> list[SyncTransaction]: + # Real implementation would call https://sandbox.plaid.com/transactions/get + # Returning empty list as placeholder (no real API call without credentials) + return [] + + def get_balance(self, account_id: str) -> Optional[Decimal]: + # Real implementation: call /accounts/balance/get + return None \ No newline at end of file diff --git a/packages/backend/tests/test_bank_sync.py b/packages/backend/tests/test_bank_sync.py new file mode 100644 index 00000000..4cdcc7e1 --- /dev/null +++ b/packages/backend/tests/test_bank_sync.py @@ -0,0 +1,305 @@ +"""Tests for Bank Sync Connector Architecture (issue #75).""" +import pytest +from datetime import date, timedelta +from decimal import Decimal +from app.services.bank_sync import ( + BankConnector, + ConnectorConfig, + SyncTransaction, + SyncResult, + CSVFileConnector, + MockBankConnector, + PlaidConnector, + create_connector, + list_connectors, + get_connector_class, + register_connector, +) + +try: + import redis as _redis_lib + _r = _redis_lib.Redis.from_url("redis://localhost:6379/15") + _r.ping() + _redis_available = True +except Exception: + _redis_available = False + +requires_redis = pytest.mark.skipif( + not _redis_available, reason="Redis not available" +) + +SAMPLE_CSV = """date,amount,description +2026-01-15,-50.00,Grocery Store +2026-01-16,1000.00,Salary Deposit +2026-01-17,-25.50,Gas Station +2026-01-18,-12.99,Netflix +""" + + +# ----------------------------------------------------------------------- +# Unit tests for data types +# ----------------------------------------------------------------------- + +class TestSyncTransaction: + def test_to_dict(self): + txn = SyncTransaction( + external_id="abc123", + date=date(2026, 1, 15), + amount=Decimal("50.00"), + currency="USD", + description="Grocery", + ) + d = txn.to_dict() + assert d["external_id"] == "abc123" + assert d["date"] == "2026-01-15" + assert d["amount"] == "50.00" + assert d["currency"] == "USD" + + def test_balance_optional(self): + txn = SyncTransaction("x", date.today(), Decimal("10"), "USD", "test") + d = txn.to_dict() + assert d["balance"] is None + + +class TestSyncResult: + def test_to_dict(self): + result = SyncResult( + connector_name="mock_bank", + account_id="acc1", + synced_at="2026-01-15T00:00:00", + ) + d = result.to_dict() + assert d["connector"] == "mock_bank" + assert d["success"] is True + assert d["transactions"] == [] + + +# ----------------------------------------------------------------------- +# Connector registry tests +# ----------------------------------------------------------------------- + +class TestConnectorRegistry: + def test_list_connectors_includes_builtins(self): + connectors = list_connectors() + names = [c["name"] for c in connectors] + assert "csv_file" in names + assert "mock_bank" in names + assert "plaid" in names + + def test_get_connector_class(self): + cls = get_connector_class("mock_bank") + assert cls == MockBankConnector + + def test_get_unknown_connector_returns_none(self): + cls = get_connector_class("unknown_xyz") + assert cls is None + + def test_create_connector_factory(self): + connector = create_connector("mock_bank", {"api_key": "test"}) + assert connector is not None + assert isinstance(connector, MockBankConnector) + + def test_create_unknown_connector_returns_none(self): + connector = create_connector("doesnt_exist", {}) + assert connector is None + + def test_register_new_connector(self): + @register_connector + class TestConnector(BankConnector): + NAME = "test_connector_unit" + def validate_credentials(self): return True + def fetch_transactions(self, account_id, since=None, until=None): return [] + + cls = get_connector_class("test_connector_unit") + assert cls == TestConnector + + def test_connector_metadata_in_list(self): + connectors = list_connectors() + mock = next(c for c in connectors if c["name"] == "mock_bank") + assert mock["supports_balance"] is True + assert mock["supports_refresh"] is True + + +# ----------------------------------------------------------------------- +# CSV connector tests +# ----------------------------------------------------------------------- + +class TestCSVConnector: + def _make_connector(self, csv_content=SAMPLE_CSV, delimiter=","): + config = ConnectorConfig( + connector_name="csv_file", + options={"csv_content": csv_content, "delimiter": delimiter, "currency": "USD"}, + ) + return CSVFileConnector(config) + + def test_validate_always_true(self): + conn = self._make_connector() + assert conn.validate_credentials() is True + + def test_fetch_all_transactions(self): + conn = self._make_connector() + txns = conn.fetch_transactions("acc1") + assert len(txns) == 4 + + def test_transaction_types(self): + conn = self._make_connector() + txns = conn.fetch_transactions("acc1") + expense_txns = [t for t in txns if t.transaction_type == "expense"] + income_txns = [t for t in txns if t.transaction_type == "income"] + assert len(expense_txns) == 3 + assert len(income_txns) == 1 + + def test_date_filter_since(self): + conn = self._make_connector() + txns = conn.fetch_transactions("acc1", since=date(2026, 1, 17)) + assert len(txns) == 2 + + def test_date_filter_until(self): + conn = self._make_connector() + txns = conn.fetch_transactions("acc1", until=date(2026, 1, 16)) + assert len(txns) == 2 + + def test_empty_csv_returns_empty(self): + conn = self._make_connector(csv_content="") + txns = conn.fetch_transactions("acc1") + assert txns == [] + + def test_amount_is_absolute(self): + conn = self._make_connector() + txns = conn.fetch_transactions("acc1") + for t in txns: + assert t.amount >= 0 + + +# ----------------------------------------------------------------------- +# Mock bank connector tests +# ----------------------------------------------------------------------- + +class TestMockBankConnector: + def _make_connector(self): + config = ConnectorConfig( + connector_name="mock_bank", + credentials={"api_key": "test-key"}, + ) + return MockBankConnector(config) + + def test_validate_with_api_key(self): + conn = self._make_connector() + assert conn.validate_credentials() is True + + def test_validate_without_api_key(self): + config = ConnectorConfig("mock_bank", credentials={}) + conn = MockBankConnector(config) + assert conn.validate_credentials() is False + + def test_fetch_returns_transactions(self): + conn = self._make_connector() + since = date(2026, 1, 1) + until = date(2026, 1, 31) + txns = conn.fetch_transactions("acc1", since=since, until=until) + assert len(txns) > 0 + + def test_get_balance(self): + conn = self._make_connector() + balance = conn.get_balance("acc1") + assert balance == Decimal("1234.56") + + def test_refresh_returns_sync_result(self): + conn = self._make_connector() + result = conn.refresh("acc1") + assert result.success is True + assert result.connector_name == "mock_bank" + assert len(result.transactions) > 0 + + +# ----------------------------------------------------------------------- +# Plaid connector tests +# ----------------------------------------------------------------------- + +class TestPlaidConnector: + def test_validate_with_full_credentials(self): + config = ConnectorConfig( + "plaid", + credentials={"client_id": "x", "secret": "y", "access_token": "z"}, + ) + conn = PlaidConnector(config) + assert conn.validate_credentials() is True + + def test_validate_missing_credential(self): + config = ConnectorConfig( + "plaid", + credentials={"client_id": "x"}, + ) + conn = PlaidConnector(config) + assert conn.validate_credentials() is False + + +# ----------------------------------------------------------------------- +# API tests (require Redis) +# ----------------------------------------------------------------------- + +@requires_redis +class TestBankSyncAPI: + def test_list_connectors(self, client): + resp = client.get("/bank-sync/connectors") + assert resp.status_code == 200 + data = resp.get_json() + assert "connectors" in data + names = [c["name"] for c in data["connectors"]] + assert "mock_bank" in names + + def test_validate_mock_connector(self, client, auth_header): + resp = client.post("/bank-sync/validate", json={ + "connector": "mock_bank", + "credentials": {"api_key": "test"}, + }, headers=auth_header) + assert resp.status_code == 200 + data = resp.get_json() + assert data["valid"] is True + + def test_validate_unknown_connector(self, client, auth_header): + resp = client.post("/bank-sync/validate", json={ + "connector": "totally_unknown", + "credentials": {}, + }, headers=auth_header) + assert resp.status_code == 404 + + def test_import_mock_transactions(self, client, auth_header): + resp = client.post("/bank-sync/import", json={ + "connector": "mock_bank", + "account_id": "test_account", + "credentials": {"api_key": "test-key"}, + }, headers=auth_header) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert data["connector"] == "mock_bank" + assert len(data["transactions"]) > 0 + + def test_import_csv_connector(self, client, auth_header): + resp = client.post("/bank-sync/import", json={ + "connector": "csv_file", + "account_id": "csv_acc", + "credentials": {}, + "options": {"csv_content": SAMPLE_CSV}, + }, headers=auth_header) + assert resp.status_code == 200 + data = resp.get_json() + assert data["new_count"] == 4 + + def test_balance_mock_connector(self, client, auth_header): + resp = client.post("/bank-sync/balance", json={ + "connector": "mock_bank", + "account_id": "acc1", + "credentials": {"api_key": "test"}, + }, headers=auth_header) + assert resp.status_code == 200 + data = resp.get_json() + assert data["balance"] == "1234.56" + + def test_balance_csv_connector_unsupported(self, client, auth_header): + resp = client.post("/bank-sync/balance", json={ + "connector": "csv_file", + "credentials": {}, + }, headers=auth_header) + assert resp.status_code == 400 \ No newline at end of file