diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..7c2aef26 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="/bank-sync") diff --git a/packages/backend/app/routes/bank_sync.py b/packages/backend/app/routes/bank_sync.py new file mode 100644 index 00000000..f5007c93 --- /dev/null +++ b/packages/backend/app/routes/bank_sync.py @@ -0,0 +1,213 @@ +""" +Bank Sync API Routes + +Provides endpoints for: +- Listing available bank connectors +- Connecting/disconnecting bank accounts +- Importing and refreshing transactions +""" + +from datetime import date, datetime + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..services import bank_sync as bank_sync_service + +bp = Blueprint("bank_sync", __name__) + + +@bp.get("/connectors") +@jwt_required() +def list_connectors(): + """List all available bank connectors.""" + connectors = bank_sync_service.BankSyncService.list_available_connectors() + return jsonify(connectors) + + +@bp.post("/connect") +@jwt_required() +def connect_bank(): + """Connect to a bank connector.""" + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + connector_type = data.get("connector_type") + if not connector_type: + return jsonify(error="connector_type required"), 400 + + credentials = data.get("credentials", {}) + + result = bank_sync_service.BankSyncService.connect( + user_id=uid, + connector_type=connector_type, + credentials=credentials, + ) + + if not result.get("success"): + return jsonify(error=result.get("error", "Connection failed")), 400 + + return jsonify(result), 200 + + +@bp.post("/disconnect") +@jwt_required() +def disconnect_bank(): + """Disconnect from a bank connector.""" + data = request.get_json() or {} + + connector_type = data.get("connector_type") + if not connector_type: + return jsonify(error="connector_type required"), 400 + + result = bank_sync_service.BankSyncService.disconnect(connector_type) + + if not result.get("success"): + return jsonify(error=result.get("error", "Disconnect failed")), 400 + + return jsonify(result), 200 + + +@bp.get("/status") +@jwt_required() +def get_status(): + """Get connection status for a connector.""" + connector_type = request.args.get("connector_type") + if not connector_type: + return jsonify(error="connector_type required"), 400 + + status = bank_sync_service.BankSyncService.get_connection_status(connector_type) + return jsonify(status) + + +@bp.get("/accounts") +@jwt_required() +def get_accounts(): + """Get accounts from a connected connector.""" + connector_type = request.args.get("connector_type") + if not connector_type: + return jsonify(error="connector_type required"), 400 + + accounts = bank_sync_service.BankSyncService.get_accounts(connector_type) + return jsonify(accounts) + + +@bp.post("/import") +@jwt_required() +def import_transactions(): + """Import transactions from a connected bank account.""" + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + connector_type = data.get("connector_type") + account_id = data.get("account_id") + + if not connector_type: + return jsonify(error="connector_type required"), 400 + if not account_id: + return jsonify(error="account_id required"), 400 + + # Parse optional date filters + from_date = None + to_date = None + + if data.get("from_date"): + try: + from_date = date.fromisoformat(data["from_date"]) + except ValueError: + return jsonify(error="invalid from_date format"), 400 + + if data.get("to_date"): + try: + to_date = date.fromisoformat(data["to_date"]) + except ValueError: + return jsonify(error="invalid to_date format"), 400 + + result = bank_sync_service.BankSyncService.import_transactions( + user_id=uid, + connector_type=connector_type, + account_id=account_id, + from_date=from_date, + to_date=to_date, + ) + + if not result.get("success"): + return jsonify(error=result.get("error", "Import failed")), 400 + + return jsonify(result), 200 + + +@bp.post("/refresh") +@jwt_required() +def refresh_transactions(): + """Refresh new transactions from a connected account.""" + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + connector_type = data.get("connector_type") + account_id = data.get("account_id") + since_raw = data.get("since") + + if not connector_type: + return jsonify(error="connector_type required"), 400 + if not account_id: + return jsonify(error="account_id required"), 400 + if not since_raw: + return jsonify(error="since required"), 400 + + try: + since = datetime.fromisoformat(since_raw) + except ValueError: + return jsonify(error="invalid since format"), 400 + + result = bank_sync_service.BankSyncService.refresh_transactions( + user_id=uid, + connector_type=connector_type, + account_id=account_id, + since=since, + ) + + if not result.get("success"): + return jsonify(error=result.get("error", "Refresh failed")), 400 + + return jsonify(result), 200 + + +@bp.post("/commit") +@jwt_required() +def commit_transactions(): + """Commit imported transactions to the database.""" + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + transactions = data.get("transactions", []) + if not transactions: + return jsonify(error="transactions required"), 400 + + result = bank_sync_service.BankSyncService.commit_transactions( + user_id=uid, + transactions=transactions, + ) + + if not result.get("success"): + return jsonify(error=result.get("error", "Commit failed")), 400 + + return jsonify(result), 201 + + +@bp.get("/import/preview") +@jwt_required() +def import_preview(): + """ + Preview imported transactions (alias for /import for compatibility). + """ + return import_transactions() + + +@bp.post("/import/commit") +@jwt_required() +def import_commit(): + """ + Commit imported transactions (alias for /commit for compatibility). + """ + return commit_transactions() \ No newline at end of file diff --git a/packages/backend/app/services/bank_connector.py b/packages/backend/app/services/bank_connector.py new file mode 100644 index 00000000..44ab50df --- /dev/null +++ b/packages/backend/app/services/bank_connector.py @@ -0,0 +1,251 @@ +""" +Bank Connector Architecture + +Provides a pluggable interface for bank integrations with support for: +- Importing transactions from bank APIs +- Refreshing/syncing new transactions +- Mock connector for testing +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import Enum +from typing import Any + + +class TransactionType(str, Enum): + """Types of transactions supported by connectors.""" + DEBIT = "DEBIT" + CREDIT = "CREDIT" + EXPENSE = "EXPENSE" + INCOME = "INCOME" + + +@dataclass +class BankTransaction: + """Represents a single transaction from a bank connector.""" + date: date + amount: float + description: str + transaction_type: TransactionType = TransactionType.EXPENSE + currency: str = "USD" + category_id: int | None = None + notes: str = "" + external_id: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + def to_expense_dict(self) -> dict[str, Any]: + """Convert to expense API format.""" + expense_type = "INCOME" if self.transaction_type in ( + TransactionType.CREDIT, + TransactionType.INCOME, + ) else "EXPENSE" + + return { + "date": self.date.isoformat(), + "amount": abs(self.amount), + "description": self.description[:500], + "category_id": self.category_id, + "expense_type": expense_type, + "currency": self.currency[:10], + "notes": self.notes[:500], + } + + +@dataclass +class BankAccount: + """Represents a bank account from a connector.""" + account_id: str + account_name: str + account_type: str # checking, savings, credit, etc. + currency: str = "USD" + current_balance: float = 0.0 + available_balance: float | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ConnectorConfig: + """Configuration for a bank connector.""" + connector_type: str + user_id: int + credentials: dict[str, str] = field(default_factory=dict) + settings: dict[str, Any] = field(default_factory=dict) + last_sync: datetime | None = None + + +class BankConnector(ABC): + """ + Abstract base class for bank connectors. + + Implement this interface to create a new bank integration. + """ + + @property + @abstractmethod + def connector_type(self) -> str: + """Unique identifier for this connector type.""" + pass + + @property + @abstractmethod + def display_name(self) -> str: + """Human-readable name for the connector.""" + pass + + @property + def supported_features(self) -> list[str]: + """List of supported features. Override in subclass.""" + return ["import", "refresh"] + + @abstractmethod + def connect(self, config: ConnectorConfig) -> bool: + """ + Establish connection to the bank API. + + Args: + config: Connector configuration with credentials + + Returns: + True if connection successful, False otherwise + """ + pass + + @abstractmethod + def disconnect(self) -> bool: + """ + Disconnect from the bank API and clean up resources. + + Returns: + True if disconnection successful + """ + pass + + @abstractmethod + def get_accounts(self) -> list[BankAccount]: + """ + Retrieve all accounts linked to this connector. + + Returns: + List of BankAccount objects + """ + pass + + @abstractmethod + def get_transactions( + self, + account_id: str, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[BankTransaction]: + """ + Retrieve transactions for a specific account. + + Args: + account_id: The account to fetch transactions for + from_date: Start date for transaction retrieval + to_date: End date for transaction retrieval + + Returns: + List of BankTransaction objects + """ + pass + + @abstractmethod + def refresh_transactions( + self, + account_id: str, + since: datetime, + ) -> list[BankTransaction]: + """ + Refresh transactions newer than the given datetime. + + Args: + account_id: The account to refresh transactions for + since: Only return transactions newer than this datetime + + Returns: + List of new BankTransaction objects + """ + pass + + def validate_credentials(self, credentials: dict[str, str]) -> bool: + """ + Validate credentials before attempting connection. + + Override in subclass to provide custom validation. + + Args: + credentials: Dictionary of credential values + + Returns: + True if credentials are valid + """ + return bool(credentials) + + def get_connection_status(self) -> dict[str, Any]: + """ + Get current connection status. + + Override in subclass to provide detailed status. + + Returns: + Dictionary with status information + """ + return { + "connected": False, + "connector_type": self.connector_type, + } + + +class ConnectorRegistry: + """ + Registry for managing bank connectors. + + Provides a central way to register and retrieve connectors. + """ + + _connectors: dict[str, type[BankConnector]] = {} + _instances: dict[str, BankConnector] = {} + + @classmethod + def register(cls, connector_class: type[BankConnector]) -> None: + """Register a connector class.""" + instance = connector_class() + cls._connectors[instance.connector_type] = connector_class + + @classmethod + def get_connector(cls, connector_type: str) -> BankConnector | None: + """Get a connector instance by type.""" + if connector_type not in cls._connectors: + return None + + if connector_type not in cls._instances: + cls._instances[connector_type] = cls._connectors[connector_type]() + + return cls._instances[connector_type] + + @classmethod + def list_connectors(cls) -> list[dict[str, Any]]: + """List all registered connectors.""" + result = [] + for connector_type, connector_class in cls._connectors.items(): + instance = connector_class() + result.append({ + "connector_type": connector_type, + "display_name": instance.display_name, + "supported_features": instance.supported_features, + }) + return result + + @classmethod + def clear_instances(cls) -> None: + """Clear all connector instances (useful for testing).""" + cls._instances.clear() + + +def register_connector(connector_class: type[BankConnector]) -> None: + """Decorator to register a connector class.""" + ConnectorRegistry.register(connector_class) + return connector_class \ 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..0182805b --- /dev/null +++ b/packages/backend/app/services/bank_sync.py @@ -0,0 +1,416 @@ +""" +Bank Sync Service + +Service for managing bank connector connections and syncing transactions. +""" + +import logging +from datetime import date, datetime +from typing import Any + +from ..extensions import db +from ..models import Expense, User +from .bank_connector import ( + BankConnector, + BankTransaction, + ConnectorConfig, + ConnectorRegistry, +) + +logger = logging.getLogger("finmind.bank_sync") + + +class BankSyncService: + """ + Service for managing bank connector operations. + + Handles connection lifecycle, transaction import, and refresh. + """ + + @staticmethod + def list_available_connectors() -> list[dict[str, Any]]: + """List all available bank connectors.""" + return ConnectorRegistry.list_connectors() + + @staticmethod + def get_connector(connector_type: str) -> BankConnector | None: + """Get a connector instance by type.""" + return ConnectorRegistry.get_connector(connector_type) + + @staticmethod + def connect( + user_id: int, + connector_type: str, + credentials: dict[str, str], + ) -> dict[str, Any]: + """ + Connect a user to a bank connector. + + Args: + user_id: The user's ID + connector_type: Type of connector to connect + credentials: Credentials for the connector + + Returns: + Connection result with status + """ + connector = ConnectorRegistry.get_connector(connector_type) + if not connector: + return { + "success": False, + "error": f"Unknown connector type: {connector_type}", + } + + # Validate credentials + if not connector.validate_credentials(credentials): + return { + "success": False, + "error": "Invalid credentials", + } + + # Create config and connect + config = ConnectorConfig( + connector_type=connector_type, + user_id=user_id, + credentials=credentials, + ) + + try: + success = connector.connect(config) + if success: + # Get accounts + accounts = connector.get_accounts() + logger.info( + "Connected user %s to %s connector with %d accounts", + user_id, + connector_type, + len(accounts), + ) + return { + "success": True, + "connector_type": connector_type, + "display_name": connector.display_name, + "accounts": [ + { + "account_id": a.account_id, + "account_name": a.account_name, + "account_type": a.account_type, + "currency": a.currency, + "current_balance": a.current_balance, + } + for a in accounts + ], + } + else: + return { + "success": False, + "error": "Failed to connect to bank", + } + except Exception as e: + logger.error( + "Error connecting user %s to %s: %s", + user_id, + connector_type, + str(e), + ) + return { + "success": False, + "error": f"Connection error: {str(e)}", + } + + @staticmethod + def disconnect(connector_type: str) -> dict[str, Any]: + """ + Disconnect from a bank connector. + + Args: + connector_type: Type of connector to disconnect + + Returns: + Disconnection result + """ + connector = ConnectorRegistry.get_connector(connector_type) + if not connector: + return { + "success": False, + "error": f"Unknown connector type: {connector_type}", + } + + try: + connector.disconnect() + return {"success": True} + except Exception as e: + logger.error("Error disconnecting %s: %s", connector_type, str(e)) + return { + "success": False, + "error": f"Disconnect error: {str(e)}", + } + + @staticmethod + def get_connection_status(connector_type: str) -> dict[str, Any]: + """Get the connection status for a connector.""" + connector = ConnectorRegistry.get_connector(connector_type) + if not connector: + return { + "connected": False, + "error": f"Unknown connector type: {connector_type}", + } + return connector.get_connection_status() + + @staticmethod + def import_transactions( + user_id: int, + connector_type: str, + account_id: str, + from_date: date | None = None, + to_date: date | None = None, + ) -> dict[str, Any]: + """ + Import transactions from a connected bank account. + + Args: + user_id: The user's ID + connector_type: Type of connector + account_id: Account to import from + from_date: Start date for import + to_date: End date for import + + Returns: + Import result with transactions + """ + connector = ConnectorRegistry.get_connector(connector_type) + if not connector: + return { + "success": False, + "error": f"Unknown connector type: {connector_type}", + } + + try: + transactions = connector.get_transactions( + account_id, + from_date=from_date, + to_date=to_date, + ) + + logger.info( + "Imported %d transactions for user %s from %s", + len(transactions), + user_id, + connector_type, + ) + + return { + "success": True, + "total": len(transactions), + "transactions": [ + { + "date": tx.date.isoformat(), + "amount": tx.amount, + "description": tx.description, + "expense_type": ( + "INCOME" + if tx.transaction_type.value in ("CREDIT", "INCOME") + else "EXPENSE" + ), + "currency": tx.currency, + "category_id": tx.category_id, + "external_id": tx.external_id, + } + for tx in transactions + ], + } + except Exception as e: + logger.error( + "Error importing transactions for user %s: %s", + user_id, + str(e), + ) + return { + "success": False, + "error": f"Import error: {str(e)}", + } + + @staticmethod + def refresh_transactions( + user_id: int, + connector_type: str, + account_id: str, + since: datetime, + ) -> dict[str, Any]: + """ + Refresh new transactions from a connected account. + + Args: + user_id: The user's ID + connector_type: Type of connector + account_id: Account to refresh + since: Only get transactions newer than this + + Returns: + Refresh result with new transactions + """ + connector = ConnectorRegistry.get_connector(connector_type) + if not connector: + return { + "success": False, + "error": f"Unknown connector type: {connector_type}", + } + + try: + transactions = connector.refresh_transactions(account_id, since) + + logger.info( + "Refreshed %d new transactions for user %s from %s", + len(transactions), + user_id, + connector_type, + ) + + return { + "success": True, + "new_count": len(transactions), + "transactions": [ + { + "date": tx.date.isoformat(), + "amount": tx.amount, + "description": tx.description, + "expense_type": ( + "INCOME" + if tx.transaction_type.value in ("CREDIT", "INCOME") + else "EXPENSE" + ), + "currency": tx.currency, + "category_id": tx.category_id, + "external_id": tx.external_id, + } + for tx in transactions + ], + } + except Exception as e: + logger.error( + "Error refreshing transactions for user %s: %s", + user_id, + str(e), + ) + return { + "success": False, + "error": f"Refresh error: {str(e)}", + } + + @staticmethod + def commit_transactions( + user_id: int, + transactions: list[dict[str, Any]], + ) -> dict[str, Any]: + """ + Commit imported transactions to the database. + + Args: + user_id: The user's ID + transactions: List of transaction dicts to save + + Returns: + Commit result with inserted/duplicate counts + """ + user = db.session.get(User, user_id) + if not user: + return { + "success": False, + "error": "User not found", + } + + inserted = 0 + duplicates = 0 + errors = 0 + + for tx_data in transactions: + try: + # Check for duplicate by date, amount, and description + existing = ( + db.session.query(Expense) + .filter_by( + user_id=user_id, + spent_at=date.fromisoformat(tx_data["date"]), + amount=tx_data["amount"], + ) + .filter(Expense.notes.ilike(f"%{tx_data['description']}%")) + .first() + ) + + if existing: + duplicates += 1 + continue + + expense = Expense( + user_id=user_id, + amount=tx_data["amount"], + currency=tx_data.get("currency", user.preferred_currency)[:10], + expense_type=tx_data.get("expense_type", "EXPENSE").upper(), + category_id=tx_data.get("category_id"), + notes=tx_data.get("description", "")[:500], + spent_at=date.fromisoformat(tx_data["date"]), + ) + db.session.add(expense) + inserted += 1 + + except Exception as e: + logger.warning( + "Error processing transaction for user %s: %s", + user_id, + str(e), + ) + errors += 1 + + try: + db.session.commit() + logger.info( + "Committed %d transactions for user %s (%d duplicates, %d errors)", + inserted, + user_id, + duplicates, + errors, + ) + return { + "success": True, + "inserted": inserted, + "duplicates": duplicates, + "errors": errors, + } + except Exception as e: + db.session.rollback() + logger.error( + "Error committing transactions for user %s: %s", + user_id, + str(e), + ) + return { + "success": False, + "error": f"Commit error: {str(e)}", + "inserted": inserted, + "duplicates": duplicates, + "errors": errors, + } + + @staticmethod + def get_accounts(connector_type: str) -> list[dict[str, Any]]: + """Get accounts from a connected connector.""" + connector = ConnectorRegistry.get_connector(connector_type) + if not connector: + return [] + + try: + accounts = connector.get_accounts() + return [ + { + "account_id": a.account_id, + "account_name": a.account_name, + "account_type": a.account_type, + "currency": a.currency, + "current_balance": a.current_balance, + "available_balance": a.available_balance, + } + for a in accounts + ] + except Exception as e: + logger.error("Error getting accounts from %s: %s", connector_type, str(e)) + return [] \ No newline at end of file diff --git a/packages/backend/app/services/mock_bank_connector.py b/packages/backend/app/services/mock_bank_connector.py new file mode 100644 index 00000000..eb49881f --- /dev/null +++ b/packages/backend/app/services/mock_bank_connector.py @@ -0,0 +1,277 @@ +""" +Mock Bank Connector + +A mock connector for testing and development purposes. +Simulates bank API responses without making actual network calls. +""" + +import random +from datetime import date, datetime, timedelta +from typing import Any + +from .bank_connector import ( + BankAccount, + BankConnector, + BankTransaction, + ConnectorConfig, + ConnectorRegistry, + TransactionType, + register_connector, +) + + +@register_connector +class MockBankConnector(BankConnector): + """ + Mock bank connector for testing. + + Simulates a bank API with configurable responses. + """ + + # Sample transaction descriptions for realistic mock data + SAMPLE_MERCHANTS = [ + "Amazon.com", + "Walmart", + "Target", + "Starbucks", + "Netflix", + "Spotify", + "Uber", + "Lyft", + "Whole Foods", + "Costco", + "Shell Gas Station", + "Chevron", + "CVS Pharmacy", + "Walgreens", + "Apple Store", + "Best Buy", + "Home Depot", + "Lowes", + "McDonald's", + "Chipotle", + ] + + SAMPLE_INCOME = [ + "Payroll Deposit", + "Direct Deposit - Employer", + "ACH Credit", + "Wire Transfer Received", + "Refund - Amazon", + "Dividend Payment", + "Interest Payment", + ] + + def __init__(self): + self._connected = False + self._config: ConnectorConfig | None = None + self._accounts: list[BankAccount] = [] + self._transactions: dict[str, list[BankTransaction]] = {} + self._seed = random.randint(0, 99999) + + @property + def connector_type(self) -> str: + return "mock" + + @property + def display_name(self) -> str: + return "Mock Bank (Development)" + + @property + def supported_features(self) -> list[str]: + return ["import", "refresh", "accounts"] + + def connect(self, config: ConnectorConfig) -> bool: + """Simulate connecting to the mock bank.""" + self._config = config + self._connected = True + + # Generate mock accounts + self._accounts = self._generate_mock_accounts() + + # Generate mock transactions for each account + self._transactions = {} + for account in self._accounts: + self._transactions[account.account_id] = self._generate_mock_transactions( + account.account_id + ) + + return True + + def disconnect(self) -> bool: + """Simulate disconnecting from the mock bank.""" + self._connected = False + self._config = None + self._accounts = [] + self._transactions = {} + return True + + def _generate_mock_accounts(self) -> list[BankAccount]: + """Generate mock bank accounts.""" + return [ + BankAccount( + account_id=f"ACC{random.randint(1000, 9999)}", + account_name="Primary Checking", + account_type="checking", + currency="USD", + current_balance=random.uniform(1000, 15000), + available_balance=random.uniform(1000, 15000), + ), + BankAccount( + account_id=f"ACC{random.randint(1000, 9999)}", + account_name="Savings Account", + account_type="savings", + currency="USD", + current_balance=random.uniform(5000, 50000), + available_balance=random.uniform(5000, 50000), + ), + BankAccount( + account_id=f"ACC{random.randint(1000, 9999)}", + account_name="Credit Card", + account_type="credit", + currency="USD", + current_balance=random.uniform(-5000, -100), + ), + ] + + def _generate_mock_transactions( + self, + account_id: str, + days_back: int = 90, + ) -> list[BankTransaction]: + """Generate mock transactions.""" + transactions = [] + today = date.today() + + # Generate 30-50 transactions + num_transactions = random.randint(30, 50) + + for i in range(num_transactions): + # Random date within the past N days + days_ago = random.randint(0, days_back) + tx_date = today - timedelta(days=days_ago) + + # Determine if income or expense + is_income = random.random() < 0.2 # 20% income + + if is_income: + amount = random.uniform(500, 5000) + description = random.choice(self.SAMPLE_INCOME) + tx_type = TransactionType.INCOME + else: + amount = random.uniform(5, 500) + description = random.choice(self.SAMPLE_MERCHANTS) + tx_type = TransactionType.EXPENSE + + transactions.append( + BankTransaction( + date=tx_date, + amount=amount, + description=description, + transaction_type=tx_type, + currency="USD", + external_id=f"{account_id}-TX{i:04d}", + ) + ) + + # Sort by date descending + transactions.sort(key=lambda t: t.date, reverse=True) + return transactions + + def get_accounts(self) -> list[BankAccount]: + """Get mock accounts.""" + if not self._connected: + return [] + return self._accounts + + def get_transactions( + self, + account_id: str, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[BankTransaction]: + """Get mock transactions with optional date filtering.""" + if not self._connected: + return [] + + transactions = self._transactions.get(account_id, []) + + filtered = [] + for tx in transactions: + if from_date and tx.date < from_date: + continue + if to_date and tx.date > to_date: + continue + filtered.append(tx) + + return filtered + + def refresh_transactions( + self, + account_id: str, + since: datetime, + ) -> list[BankTransaction]: + """Get new transactions since the given datetime.""" + if not self._connected: + return [] + + # Generate a few new transactions to simulate recent activity + new_transactions = [] + today = date.today() + + for i in range(random.randint(1, 5)): + days_ago = random.randint(0, 7) + tx_date = today - timedelta(days=days_ago) + + is_income = random.random() < 0.15 + if is_income: + amount = random.uniform(500, 3000) + description = random.choice(self.SAMPLE_INCOME) + tx_type = TransactionType.INCOME + else: + amount = random.uniform(10, 200) + description = random.choice(self.SAMPLE_MERCHANTS) + tx_type = TransactionType.EXPENSE + + new_transactions.append( + BankTransaction( + date=tx_date, + amount=amount, + description=description, + transaction_type=tx_type, + currency="USD", + external_id=f"{account_id}-NEW{i:04d}", + ) + ) + + return new_transactions + + def get_connection_status(self) -> dict[str, Any]: + """Get mock connection status.""" + return { + "connected": self._connected, + "connector_type": self.connector_type, + "display_name": self.display_name, + "num_accounts": len(self._accounts), + } + + def set_mock_data( + self, + accounts: list[BankAccount] | None = None, + transactions: dict[str, list[BankTransaction]] | None = None, + ) -> None: + """ + Set custom mock data for testing. + + Args: + accounts: Custom accounts to use + transactions: Custom transactions keyed by account_id + """ + if accounts is not None: + self._accounts = accounts + if transactions is not None: + self._transactions = transactions + + +# Auto-register the mock connector +ConnectorRegistry.register(MockBankConnector) \ 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..1074a1b7 --- /dev/null +++ b/packages/backend/tests/test_bank_sync.py @@ -0,0 +1,583 @@ +""" +Tests for Bank Sync Connector Architecture +""" + +import pytest +from datetime import date, datetime +from unittest.mock import patch, MagicMock + +from app.services.bank_connector import ( + BankConnector, + BankTransaction, + BankAccount, + ConnectorConfig, + ConnectorRegistry, + TransactionType, + register_connector, +) +from app.services.bank_sync import BankSyncService +from app.services.mock_bank_connector import MockBankConnector + + +class TestBankConnector: + """Test the BankConnector base class and registry.""" + + def test_connector_registry_register(self): + """Test registering a connector.""" + # Create a test connector + @register_connector + class TestConnector(BankConnector): + @property + def connector_type(self): + return "test" + + @property + def display_name(self): + return "Test Connector" + + def connect(self, config): + return True + + def disconnect(self): + return True + + def get_accounts(self): + return [] + + def get_transactions(self, account_id, from_date=None, to_date=None): + return [] + + def refresh_transactions(self, account_id, since): + return [] + + # Check it's registered + connector = ConnectorRegistry.get_connector("test") + assert connector is not None + assert connector.connector_type == "test" + assert connector.display_name == "Test Connector" + + def test_connector_registry_list(self): + """Test listing connectors.""" + connectors = ConnectorRegistry.list_connectors() + assert isinstance(connectors, list) + # Should have at least the mock connector + assert any(c["connector_type"] == "mock" for c in connectors) + + def test_connector_registry_clear(self): + """Test clearing connector instances.""" + ConnectorRegistry.clear_instances() + # Should still have the class registered + assert "mock" in ConnectorRegistry._connectors + + +class TestMockBankConnector: + """Test the MockBankConnector.""" + + def test_mock_connector_properties(self): + """Test mock connector properties.""" + connector = MockBankConnector() + assert connector.connector_type == "mock" + assert connector.display_name == "Mock Bank (Development)" + assert "import" in connector.supported_features + assert "refresh" in connector.supported_features + + def test_mock_connect_disconnect(self): + """Test connecting and disconnecting.""" + connector = MockBankConnector() + config = ConnectorConfig( + connector_type="mock", + user_id=1, + credentials={"test": "credentials"}, + ) + + result = connector.connect(config) + assert result is True + assert connector.get_connection_status()["connected"] is True + + accounts = connector.get_accounts() + assert len(accounts) > 0 + assert all(isinstance(a, BankAccount) for a in accounts) + + result = connector.disconnect() + assert result is True + assert connector.get_connection_status()["connected"] is False + + def test_mock_get_transactions(self): + """Test getting transactions.""" + connector = MockBankConnector() + config = ConnectorConfig( + connector_type="mock", + user_id=1, + credentials={}, + ) + connector.connect(config) + + accounts = connector.get_accounts() + assert len(accounts) > 0 + + transactions = connector.get_transactions(accounts[0].account_id) + assert len(transactions) > 0 + assert all(isinstance(t, BankTransaction) for t in transactions) + + def test_mock_get_transactions_with_date_filter(self): + """Test getting transactions with date filtering.""" + connector = MockBankConnector() + config = ConnectorConfig( + connector_type="mock", + user_id=1, + credentials={}, + ) + connector.connect(config) + + accounts = connector.get_accounts() + today = date.today() + from_date = today.replace(day=1) # First of month + + transactions = connector.get_transactions( + accounts[0].account_id, + from_date=from_date, + ) + + for tx in transactions: + assert tx.date >= from_date + + def test_mock_refresh_transactions(self): + """Test refreshing transactions.""" + connector = MockBankConnector() + config = ConnectorConfig( + connector_type="mock", + user_id=1, + credentials={}, + ) + connector.connect(config) + + accounts = connector.get_accounts() + since = datetime.now() + + new_transactions = connector.refresh_transactions( + accounts[0].account_id, + since, + ) + + # Should return some new transactions + assert isinstance(new_transactions, list) + + +class TestBankTransaction: + """Test the BankTransaction dataclass.""" + + def test_to_expense_dict_expense(self): + """Test converting expense transaction to expense dict.""" + tx = BankTransaction( + date=date(2024, 1, 15), + amount=50.00, + description="Test Expense", + transaction_type=TransactionType.EXPENSE, + currency="USD", + ) + + expense_dict = tx.to_expense_dict() + + assert expense_dict["date"] == "2024-01-15" + assert expense_dict["amount"] == 50.00 + assert expense_dict["description"] == "Test Expense" + assert expense_dict["expense_type"] == "EXPENSE" + assert expense_dict["currency"] == "USD" + + def test_to_expense_dict_income(self): + """Test converting income transaction to expense dict.""" + tx = BankTransaction( + date=date(2024, 1, 15), + amount=1000.00, + description="Payroll", + transaction_type=TransactionType.INCOME, + currency="USD", + ) + + expense_dict = tx.to_expense_dict() + + assert expense_dict["expense_type"] == "INCOME" + assert expense_dict["amount"] == 1000.00 + + +class TestBankSyncService: + """Test the BankSyncService.""" + + def test_list_available_connectors(self): + """Test listing available connectors.""" + connectors = BankSyncService.list_available_connectors() + assert isinstance(connectors, list) + assert len(connectors) > 0 + assert any(c["connector_type"] == "mock" for c in connectors) + + def test_get_connector(self): + """Test getting a connector.""" + connector = BankSyncService.get_connector("mock") + assert connector is not None + assert connector.connector_type == "mock" + + def test_connect_mock(self): + """Test connecting to mock connector.""" + result = BankSyncService.connect( + user_id=1, + connector_type="mock", + credentials={}, + ) + + assert result["success"] is True + assert result["connector_type"] == "mock" + assert "accounts" in result + assert len(result["accounts"]) > 0 + + def test_connect_invalid_type(self): + """Test connecting with invalid connector type.""" + result = BankSyncService.connect( + user_id=1, + connector_type="nonexistent", + credentials={}, + ) + + assert result["success"] is False + assert "error" in result + + def test_disconnect(self): + """Test disconnecting.""" + # First connect + BankSyncService.connect( + user_id=1, + connector_type="mock", + credentials={}, + ) + + # Then disconnect + result = BankSyncService.disconnect("mock") + assert result["success"] is True + + def test_get_connection_status(self): + """Test getting connection status.""" + # Connect first + BankSyncService.connect( + user_id=1, + connector_type="mock", + credentials={}, + ) + + status = BankSyncService.get_connection_status("mock") + assert status["connected"] is True + assert status["connector_type"] == "mock" + + def test_import_transactions(self, app_fixture): + """Test importing transactions.""" + # Connect first + BankSyncService.connect( + user_id=1, + connector_type="mock", + credentials={}, + ) + + # Get an account + accounts = BankSyncService.get_accounts("mock") + assert len(accounts) > 0 + + result = BankSyncService.import_transactions( + user_id=1, + connector_type="mock", + account_id=accounts[0]["account_id"], + ) + + assert result["success"] is True + assert result["total"] > 0 + assert "transactions" in result + + def test_import_transactions_with_date_filter(self, app_fixture): + """Test importing transactions with date filter.""" + BankSyncService.connect( + user_id=1, + connector_type="mock", + credentials={}, + ) + + accounts = BankSyncService.get_accounts("mock") + today = date.today() + + result = BankSyncService.import_transactions( + user_id=1, + connector_type="mock", + account_id=accounts[0]["account_id"], + from_date=date(today.year, 1, 1), + to_date=today, + ) + + assert result["success"] is True + + def test_refresh_transactions(self, app_fixture): + """Test refreshing transactions.""" + BankSyncService.connect( + user_id=1, + connector_type="mock", + credentials={}, + ) + + accounts = BankSyncService.get_accounts("mock") + since = datetime.now() + + result = BankSyncService.refresh_transactions( + user_id=1, + connector_type="mock", + account_id=accounts[0]["account_id"], + since=since, + ) + + assert result["success"] is True + assert result["new_count"] >= 0 + + def test_commit_transactions(self, app_fixture): + """Test committing transactions.""" + # Create some test transactions + transactions = [ + { + "date": "2024-01-15", + "amount": 50.00, + "description": "Test Expense", + "expense_type": "EXPENSE", + "currency": "USD", + }, + { + "date": "2024-01-16", + "amount": 100.00, + "description": "Test Income", + "expense_type": "INCOME", + "currency": "USD", + }, + ] + + result = BankSyncService.commit_transactions( + user_id=1, + transactions=transactions, + ) + + assert result["success"] is True + assert result["inserted"] == 2 + assert result["duplicates"] == 0 + + def test_commit_duplicate_transactions(self, app_fixture): + """Test committing duplicate transactions.""" + transactions = [ + { + "date": "2024-01-15", + "amount": 50.00, + "description": "Duplicate Test", + "expense_type": "EXPENSE", + "currency": "USD", + }, + ] + + # First commit + result1 = BankSyncService.commit_transactions( + user_id=1, + transactions=transactions, + ) + assert result1["inserted"] == 1 + + # Second commit (should be duplicate) + result2 = BankSyncService.commit_transactions( + user_id=1, + transactions=transactions, + ) + assert result2["duplicates"] == 1 + assert result2["inserted"] == 0 + + +class TestBankSyncAPI: + """Test the Bank Sync API endpoints.""" + + def test_list_connectors(self, client, auth_header): + """Test GET /bank-sync/connectors.""" + r = client.get("/bank-sync/connectors", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert isinstance(data, list) + assert any(c["connector_type"] == "mock" for c in data) + + def test_connect(self, client, auth_header): + """Test POST /bank-sync/connect.""" + r = client.post( + "/bank-sync/connect", + json={"connector_type": "mock", "credentials": {}}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["success"] is True + assert "accounts" in data + + def test_connect_missing_type(self, client, auth_header): + """Test POST /bank-sync/connect without connector_type.""" + r = client.post( + "/bank-sync/connect", + json={"credentials": {}}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_disconnect(self, client, auth_header): + """Test POST /bank-sync/disconnect.""" + # Connect first + client.post( + "/bank-sync/connect", + json={"connector_type": "mock", "credentials": {}}, + headers=auth_header, + ) + + # Then disconnect + r = client.post( + "/bank-sync/disconnect", + json={"connector_type": "mock"}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["success"] is True + + def test_get_status(self, client, auth_header): + """Test GET /bank-sync/status.""" + # Connect first + client.post( + "/bank-sync/connect", + json={"connector_type": "mock", "credentials": {}}, + headers=auth_header, + ) + + r = client.get( + "/bank-sync/status?connector_type=mock", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["connected"] is True + + def test_get_accounts(self, client, auth_header): + """Test GET /bank-sync/accounts.""" + # Connect first + client.post( + "/bank-sync/connect", + json={"connector_type": "mock", "credentials": {}}, + headers=auth_header, + ) + + r = client.get( + "/bank-sync/accounts?connector_type=mock", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert isinstance(data, list) + assert len(data) > 0 + + def test_import_transactions(self, client, auth_header): + """Test POST /bank-sync/import.""" + # Connect first + client.post( + "/bank-sync/connect", + json={"connector_type": "mock", "credentials": {}}, + headers=auth_header, + ) + + # Get accounts + accounts = client.get( + "/bank-sync/accounts?connector_type=mock", + headers=auth_header, + ).get_json() + + r = client.post( + "/bank-sync/import", + json={ + "connector_type": "mock", + "account_id": accounts[0]["account_id"], + }, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["success"] is True + assert data["total"] > 0 + + def test_import_missing_params(self, client, auth_header): + """Test POST /bank-sync/import with missing params.""" + r = client.post( + "/bank-sync/import", + json={}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_refresh_transactions(self, client, auth_header): + """Test POST /bank-sync/refresh.""" + # Connect first + client.post( + "/bank-sync/connect", + json={"connector_type": "mock", "credentials": {}}, + headers=auth_header, + ) + + accounts = client.get( + "/bank-sync/accounts?connector_type=mock", + headers=auth_header, + ).get_json() + + r = client.post( + "/bank-sync/refresh", + json={ + "connector_type": "mock", + "account_id": accounts[0]["account_id"], + "since": datetime.now().isoformat(), + }, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["success"] is True + + def test_commit_transactions(self, client, auth_header): + """Test POST /bank-sync/commit.""" + transactions = [ + { + "date": "2024-02-01", + "amount": 25.00, + "description": "API Test", + "expense_type": "EXPENSE", + "currency": "USD", + }, + ] + + r = client.post( + "/bank-sync/commit", + json={"transactions": transactions}, + headers=auth_header, + ) + assert r.status_code == 201 + data = r.get_json() + assert data["success"] is True + assert data["inserted"] == 1 + + def test_commit_empty_transactions(self, client, auth_header): + """Test POST /bank-sync/commit with empty transactions.""" + r = client.post( + "/bank-sync/commit", + json={"transactions": []}, + headers=auth_header, + ) + assert r.status_code == 400 + + +# Import the fixtures +pytest.fixture(autouse=True) +def reset_connector(): + """Reset connector state between tests.""" + yield + # Clean up after test + try: + connector = ConnectorRegistry.get_connector("mock") + if connector: + connector.disconnect() + except Exception: + pass \ No newline at end of file