diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..9438ba98 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,26 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +DO $$ BEGIN + CREATE TYPE bank_connection_status AS ENUM ('ACTIVE','EXPIRED','ERROR','DISCONNECTED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS bank_connections ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + connector_id VARCHAR(50) NOT NULL, + account_id VARCHAR(100), + account_name VARCHAR(200), + status bank_connection_status NOT NULL DEFAULT 'ACTIVE', + credentials TEXT, + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMP, + last_sync_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_bank_connections_user ON bank_connections(user_id); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..e5496606 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,46 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class BankConnectionStatus(str, Enum): + """Status of a bank connection.""" + ACTIVE = "ACTIVE" + EXPIRED = "EXPIRED" + ERROR = "ERROR" + DISCONNECTED = "DISCONNECTED" + + +class BankConnection(db.Model): + """ + Stores bank connector configurations for users. + + This model tracks which bank connectors users have connected + and their authentication status. + """ + __tablename__ = "bank_connections" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + connector_id = db.Column(db.String(50), nullable=False) + account_id = db.Column(db.String(100), nullable=True) + account_name = db.Column(db.String(200), nullable=True) + status = db.Column( + SAEnum(BankConnectionStatus), + default=BankConnectionStatus.ACTIVE, + nullable=False + ) + # Encrypted credentials stored as JSON + credentials = db.Column(db.Text, nullable=True) + # Token information + access_token = db.Column(db.Text, nullable=True) + refresh_token = db.Column(db.Text, nullable=True) + token_expires_at = db.Column(db.DateTime, nullable=True) + # Metadata + last_sync_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..23440973 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_connectors import bp as bank_connectors_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_connectors_bp, url_prefix="/bank") diff --git a/packages/backend/app/routes/bank_connectors.py b/packages/backend/app/routes/bank_connectors.py new file mode 100644 index 00000000..acf3b525 --- /dev/null +++ b/packages/backend/app/routes/bank_connectors.py @@ -0,0 +1,388 @@ +""" +Bank connector routes. + +Provides endpoints for managing bank connections and importing transactions. +""" + +import json +import logging +from datetime import datetime + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..extensions import db +from ..models import BankConnection, BankConnectionStatus, User +from ..services.bank_connectors import ( + AuthenticationError, + BankConnector, + CONNECTOR_REGISTRY, + get_connector, + list_connectors, +) + +logger = logging.getLogger("finmind.bank_connectors") + +bp = Blueprint("bank_connectors", __name__) + + +def _get_connector_instance(connector_id: str, credentials: dict | None = None) -> BankConnector: + """Get a connector instance by ID.""" + connector_class = get_connector(connector_id) + if not connector_class: + raise ValueError(f"Unknown connector: {connector_id}") + return connector_class(config=credentials) + + +@bp.get("/connectors") +@jwt_required() +def list_available_connectors(): + """ + List all available bank connectors. + + Returns: + List of connector information. + """ + connectors = list_connectors() + return jsonify(connectors) + + +@bp.get("/connections") +@jwt_required() +def list_connections(): + """ + List all bank connections for the current user. + + Returns: + List of bank connections. + """ + uid = int(get_jwt_identity()) + connections = ( + db.session.query(BankConnection) + .filter_by(user_id=uid) + .order_by(BankConnection.created_at.desc()) + .all() + ) + return jsonify([_connection_to_dict(c) for c in connections]) + + +@bp.post("/connections") +@jwt_required() +def create_connection(): + """ + Create a new bank connection. + + Request body: + connector_id: The connector to use + credentials: Credentials for the connector + account_id: Optional account ID to connect + + Returns: + The created connection. + """ + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + connector_id = data.get("connector_id") + if not connector_id: + return jsonify(error="connector_id required"), 400 + + # Validate connector exists + connector_class = get_connector(connector_id) + if not connector_class: + return jsonify(error="Unknown connector"), 400 + + # Get credentials + credentials = data.get("credentials", {}) + + # Create connector instance and validate + try: + connector = _get_connector_instance(connector_id, credentials) + if not connector.validate_config(): + return jsonify(error="Invalid credentials"), 400 + except Exception as e: + logger.warning("Connector validation failed: %s", e) + return jsonify(error="Invalid connector configuration"), 400 + + # Refresh auth to get tokens + try: + auth_result = connector.refresh_auth() + except AuthenticationError as e: + logger.warning("Auth refresh failed: %s", e) + return jsonify(error="Authentication failed"), 401 + except Exception as e: + logger.error("Unexpected auth error: %s", e) + return jsonify(error="Authentication failed"), 401 + + # Get accounts + try: + accounts = connector.get_accounts() + except AuthenticationError: + return jsonify(error="Failed to fetch accounts"), 401 + except Exception as e: + logger.error("Failed to fetch accounts: %s", e) + return jsonify(error="Failed to fetch accounts"), 500 + + # Use first account if none specified + account_id = data.get("account_id") + account_name = None + if not account_id and accounts: + account_id = accounts[0].account_id + account_name = accounts[0].account_name + + # Create connection record + token_expires = auth_result.get("expires_in") + token_expires_at = None + if token_expires: + token_expires_at = datetime.utcnow().timestamp() + token_expires + token_expires_at = datetime.fromtimestamp(token_expires_at) + + connection = BankConnection( + user_id=uid, + connector_id=connector_id, + account_id=account_id, + account_name=account_name, + status=BankConnectionStatus.ACTIVE, + credentials=json.dumps(credentials), + access_token=auth_result.get("access_token"), + refresh_token=auth_result.get("refresh_token"), + token_expires_at=token_expires_at, + ) + db.session.add(connection) + db.session.commit() + + logger.info("Created bank connection id=%s connector=%s user=%s", + connection.id, connector_id, uid) + return jsonify(_connection_to_dict(connection)), 201 + + +@bp.get("/connections/") +@jwt_required() +def get_connection(connection_id: int): + """ + Get a specific bank connection. + + Returns: + The bank connection. + """ + uid = int(get_jwt_identity()) + connection = db.session.get(BankConnection, connection_id) + + if not connection or connection.user_id != uid: + return jsonify(error="Not found"), 404 + + return jsonify(_connection_to_dict(connection)) + + +@bp.delete("/connections/") +@jwt_required() +def delete_connection(connection_id: int): + """ + Delete a bank connection. + + Returns: + Success message. + """ + uid = int(get_jwt_identity()) + connection = db.session.get(BankConnection, connection_id) + + if not connection or connection.user_id != uid: + return jsonify(error="Not found"), 404 + + db.session.delete(connection) + db.session.commit() + + logger.info("Deleted bank connection id=%s user=%s", connection_id, uid) + return jsonify({"message": "Connection deleted"}) + + +@bp.post("/connections//refresh") +@jwt_required() +def refresh_connection(connection_id: int): + """ + Refresh authentication for a bank connection. + + Returns: + Updated connection with new tokens. + """ + uid = int(get_jwt_identity()) + connection = db.session.get(BankConnection, connection_id) + + if not connection or connection.user_id != uid: + return jsonify(error="Not found"), 404 + + # Get credentials + credentials = {} + if connection.credentials: + try: + credentials = json.loads(connection.credentials) + except json.JSONDecodeError: + pass + + # Create connector instance + try: + connector = _get_connector_instance(connection.connector_id, credentials) + except Exception as e: + logger.error("Failed to create connector: %s", e) + connection.status = BankConnectionStatus.ERROR + db.session.commit() + return jsonify(error="Connector error"), 500 + + # Refresh auth + try: + auth_result = connector.refresh_auth() + except AuthenticationError as e: + logger.warning("Auth refresh failed: %s", e) + connection.status = BankConnectionStatus.ERROR + db.session.commit() + return jsonify(error="Authentication failed"), 401 + except Exception as e: + logger.error("Unexpected auth error: %s", e) + connection.status = BankConnectionStatus.ERROR + db.session.commit() + return jsonify(error="Authentication failed"), 500 + + # Update connection + token_expires = auth_result.get("expires_in") + if token_expires: + connection.token_expires_at = datetime.fromtimestamp( + datetime.utcnow().timestamp() + token_expires + ) + + connection.access_token = auth_result.get("access_token") + connection.refresh_token = auth_result.get("refresh_token") + connection.status = BankConnectionStatus.ACTIVE + connection.updated_at = datetime.utcnow() + db.session.commit() + + logger.info("Refreshed bank connection id=%s user=%s", connection_id, uid) + return jsonify(_connection_to_dict(connection)) + + +@bp.get("/connections//transactions") +@jwt_required() +def import_transactions(connection_id: int): + """ + Import transactions from a bank connection. + + Query parameters: + from_date: Start date (ISO format) + to_date: End date (ISO format) + + Returns: + List of imported transactions. + """ + uid = int(get_jwt_identity()) + connection = db.session.get(BankConnection, connection_id) + + if not connection or connection.user_id != uid: + return jsonify(error="Not found"), 404 + + if connection.status != BankConnectionStatus.ACTIVE: + return jsonify(error="Connection is not active"), 400 + + # Parse date filters + from_date = None + to_date = None + try: + from_raw = request.args.get("from_date") + if from_raw: + from_date = datetime.fromisoformat(from_raw) + to_raw = request.args.get("to_date") + if to_raw: + to_date = datetime.fromisoformat(to_raw) + except ValueError: + return jsonify(error="Invalid date format"), 400 + + # Get credentials + credentials = {} + if connection.credentials: + try: + credentials = json.loads(connection.credentials) + except json.JSONDecodeError: + pass + + # Create connector instance + try: + connector = _get_connector_instance(connection.connector_id, credentials) + except Exception as e: + logger.error("Failed to create connector: %s", e) + return jsonify(error="Connector error"), 500 + + # Import transactions + try: + transactions = connector.import_transactions( + account_id=connection.account_id, + from_date=from_date, + to_date=to_date, + ) + except Exception as e: + logger.error("Transaction import failed: %s", e) + return jsonify(error="Import failed"), 500 + + # Update last sync + connection.last_sync_at = datetime.utcnow() + connection.updated_at = datetime.utcnow() + db.session.commit() + + logger.info("Imported %d transactions from connection=%s user=%s", + len(transactions), connection_id, uid) + + return jsonify({ + "transactions": [tx.to_dict() for tx in transactions], + "count": len(transactions), + }) + + +@bp.get("/connections//accounts") +@jwt_required() +def get_connection_accounts(connection_id: int): + """ + Get accounts for a bank connection. + + Returns: + List of accounts. + """ + uid = int(get_jwt_identity()) + connection = db.session.get(BankConnection, connection_id) + + if not connection or connection.user_id != uid: + return jsonify(error="Not found"), 404 + + # Get credentials + credentials = {} + if connection.credentials: + try: + credentials = json.loads(connection.credentials) + except json.JSONDecodeError: + pass + + # Create connector instance + try: + connector = _get_connector_instance(connection.connector_id, credentials) + except Exception as e: + logger.error("Failed to create connector: %s", e) + return jsonify(error="Connector error"), 500 + + # Get accounts + try: + accounts = connector.get_accounts() + except Exception as e: + logger.error("Failed to fetch accounts: %s", e) + return jsonify(error="Failed to fetch accounts"), 500 + + return jsonify([acc.to_dict() for acc in accounts]) + + +def _connection_to_dict(connection: BankConnection) -> dict: + """Convert a BankConnection to a dictionary.""" + return { + "id": connection.id, + "connector_id": connection.connector_id, + "account_id": connection.account_id, + "account_name": connection.account_name, + "status": connection.status.value if connection.status else None, + "last_sync_at": connection.last_sync_at.isoformat() if connection.last_sync_at else None, + "created_at": connection.created_at.isoformat(), + "updated_at": connection.updated_at.isoformat(), + } diff --git a/packages/backend/app/services/bank_connectors/__init__.py b/packages/backend/app/services/bank_connectors/__init__.py new file mode 100644 index 00000000..e3f917ef --- /dev/null +++ b/packages/backend/app/services/bank_connectors/__init__.py @@ -0,0 +1,223 @@ +""" +Bank connector pluggable architecture. + +This module provides a base class for bank connectors and includes +a mock connector for testing purposes. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import Any + + +class BankConnectorError(Exception): + """Base exception for bank connector errors.""" + pass + + +class AuthenticationError(BankConnectorError): + """Raised when authentication fails.""" + pass + + +class ImportError(BankConnectorError): + """Raised when transaction import fails.""" + pass + + +class TransactionType(str, Enum): + """Transaction type enumeration.""" + EXPENSE = "EXPENSE" + INCOME = "INCOME" + + +@dataclass +class Transaction: + """Represents a bank transaction.""" + date: datetime + amount: Decimal + description: str + transaction_type: TransactionType + currency: str = "USD" + category_id: int | None = None + reference_id: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert transaction to dictionary.""" + return { + "date": self.date.isoformat(), + "amount": float(self.amount), + "description": self.description, + "expense_type": self.transaction_type.value, + "currency": self.currency, + "category_id": self.category_id, + "reference_id": self.reference_id, + } + + +@dataclass +class BankAccount: + """Represents a bank account.""" + account_id: str + account_name: str + account_type: str + currency: str + balance: Decimal | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert account to dictionary.""" + return { + "account_id": self.account_id, + "account_name": self.account_name, + "account_type": self.account_type, + "currency": self.currency, + "balance": float(self.balance) if self.balance is not None else None, + } + + +class BankConnector(ABC): + """ + Abstract base class for bank connectors. + + All bank connectors must implement these methods: + - refresh_auth(): Refresh authentication tokens + - import_transactions(): Import transactions from the bank + - get_accounts(): Get list of connected accounts + """ + + def __init__(self, config: dict[str, Any] | None = None): + """ + Initialize the connector with configuration. + + Args: + config: Configuration dictionary containing credentials and settings. + """ + self.config = config or {} + self._authenticated = False + + @property + @abstractmethod + def connector_id(self) -> str: + """Unique identifier for this connector.""" + pass + + @property + @abstractmethod + def display_name(self) -> str: + """Human-readable name for the connector.""" + pass + + @abstractmethod + def refresh_auth(self) -> dict[str, Any]: + """ + Refresh authentication tokens. + + Returns: + Dictionary containing new auth tokens and metadata. + + Raises: + AuthenticationError: If authentication fails. + """ + pass + + @abstractmethod + def get_accounts(self) -> list[BankAccount]: + """ + Get list of connected accounts. + + Returns: + List of BankAccount objects. + + Raises: + AuthenticationError: If not authenticated. + """ + pass + + @abstractmethod + def import_transactions( + self, + account_id: str, + from_date: datetime | None = None, + to_date: datetime | None = None, + ) -> list[Transaction]: + """ + Import transactions from the specified account. + + Args: + account_id: The account to import from. + from_date: Start date for transaction import. + to_date: End date for transaction import. + + Returns: + List of Transaction objects. + + Raises: + AuthenticationError: If not authenticated. + ImportError: If import fails. + """ + pass + + def validate_config(self) -> bool: + """ + Validate the connector configuration. + + Returns: + True if configuration is valid, False otherwise. + """ + return True + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} connector_id={self.connector_id}>" + + +# Registry for available connectors +CONNECTOR_REGISTRY: dict[str, type[BankConnector]] = {} + + +def register_connector(connector_class: type[BankConnector]) -> type[BankConnector]: + """ + Register a bank connector class. + + Args: + connector_class: The connector class to register. + + Returns: + The connector class (for use as a decorator). + """ + # Create a temporary instance to get the connector_id + instance = connector_class() + CONNECTOR_REGISTRY[instance.connector_id] = connector_class + return connector_class + + +def get_connector(connector_id: str) -> type[BankConnector] | None: + """ + Get a connector class by its ID. + + Args: + connector_id: The connector's unique identifier. + + Returns: + The connector class or None if not found. + """ + return CONNECTOR_REGISTRY.get(connector_id) + + +def list_connectors() -> list[dict[str, str]]: + """ + List all registered connectors. + + Returns: + List of connector info dictionaries. + """ + result = [] + for connector_id, connector_class in CONNECTOR_REGISTRY.items(): + instance = connector_class() + result.append({ + "connector_id": instance.connector_id, + "display_name": instance.display_name, + }) + return result diff --git a/packages/backend/app/services/bank_connectors/mock_connector.py b/packages/backend/app/services/bank_connectors/mock_connector.py new file mode 100644 index 00000000..9da0fe18 --- /dev/null +++ b/packages/backend/app/services/bank_connectors/mock_connector.py @@ -0,0 +1,209 @@ +""" +Mock bank connector for testing and development. + +This connector simulates a real bank connection and returns +test data without making any external API calls. +""" + +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any + +from . import ( + BankAccount, + BankConnector, + BankConnectorError, + Transaction, + TransactionType, + register_connector, +) + + +class MockBankConnector(BankConnector): + """ + Mock bank connector for testing purposes. + + This connector simulates a bank connection and returns + predefined test data. It does not make any external API calls. + """ + + @property + def connector_id(self) -> str: + return "mock_bank" + + @property + def display_name(self) -> str: + return "Mock Bank (Test)" + + def refresh_auth(self) -> dict[str, Any]: + """ + Simulate token refresh. + + Returns: + Dictionary with mock auth tokens. + """ + # Simulate successful auth refresh + return { + "access_token": "mock_access_token_" + datetime.now().isoformat(), + "refresh_token": "mock_refresh_token_" + datetime.now().isoformat(), + "expires_in": 3600, + "token_type": "Bearer", + } + + def get_accounts(self) -> list[BankAccount]: + """ + Get mock accounts. + + Returns: + List of mock BankAccount objects. + """ + return [ + BankAccount( + account_id="ACC001", + account_name="Primary Checking", + account_type="CHECKING", + currency="USD", + balance=Decimal("5234.56"), + ), + BankAccount( + account_id="ACC002", + account_name="Savings Account", + account_type="SAVINGS", + currency="USD", + balance=Decimal("15000.00"), + ), + BankAccount( + account_id="ACC003", + account_name="Credit Card", + account_type="CREDIT_CARD", + currency="USD", + balance=Decimal("-1250.75"), + ), + ] + + def import_transactions( + self, + account_id: str, + from_date: datetime | None = None, + to_date: datetime | None = None, + ) -> list[Transaction]: + """ + Import mock transactions. + + Args: + account_id: The account to import from. + from_date: Start date for transaction import. + to_date: End date for transaction import. + + Returns: + List of mock Transaction objects. + + Raises: + BankConnectorError: If account_id is invalid. + """ + # Validate account_id + valid_accounts = {"ACC001", "ACC002", "ACC003"} + if account_id not in valid_accounts: + raise BankConnectorError(f"Invalid account_id: {account_id}") + + # Generate mock transactions + today = datetime.now().date() + transactions = [] + + # Sample transaction data + mock_data = [ + { + "date": today - timedelta(days=1), + "amount": Decimal("-45.99"), + "description": "Grocery Store", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=2), + "amount": Decimal("-125.00"), + "description": "Gas Station", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=3), + "amount": Decimal("3500.00"), + "description": "Salary Deposit", + "transaction_type": TransactionType.INCOME, + }, + { + "date": today - timedelta(days=4), + "amount": Decimal("-89.50"), + "description": "Electric Bill", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=5), + "amount": Decimal("-15.99"), + "description": "Netflix Subscription", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=6), + "amount": Decimal("-250.00"), + "description": "Rent Payment", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=7), + "amount": Decimal("-42.50"), + "description": "Restaurant - Dinner", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=8), + "amount": Decimal("100.00"), + "description": "Transfer from Savings", + "transaction_type": TransactionType.INCOME, + }, + { + "date": today - timedelta(days=9), + "amount": Decimal("-65.00"), + "description": "Internet Bill", + "transaction_type": TransactionType.EXPENSE, + }, + { + "date": today - timedelta(days=10), + "amount": Decimal("-12.99"), + "description": "Spotify Premium", + "transaction_type": TransactionType.EXPENSE, + }, + ] + + for item in mock_data: + tx_date = item["date"] + + # Apply date filters + if from_date and tx_date < from_date.date(): + continue + if to_date and tx_date > to_date.date(): + continue + + transactions.append( + Transaction( + date=datetime.combine(tx_date, datetime.min.time()), + amount=abs(item["amount"]), + description=item["description"], + transaction_type=item["transaction_type"], + currency="USD", + reference_id=f"REF_{account_id}_{tx_date.isoformat()}", + ) + ) + + return transactions + + def validate_config(self) -> bool: + """ + Validate mock configuration. + + The mock connector always has valid configuration. + """ + return True + + +# Register the mock connector +register_connector(MockBankConnector) diff --git a/packages/backend/tests/test_bank_connectors.py b/packages/backend/tests/test_bank_connectors.py new file mode 100644 index 00000000..4ee45849 --- /dev/null +++ b/packages/backend/tests/test_bank_connectors.py @@ -0,0 +1,122 @@ +""" +Tests for bank connector functionality. +""" + +from datetime import datetime +from decimal import Decimal + +from app.services.bank_connectors import ( + BankConnector, + Transaction, + TransactionType, + BankAccount, + list_connectors, + get_connector, +) +from app.services.bank_connectors.mock_connector import MockBankConnector + + +def test_connector_registry(): + """Test that connectors are registered.""" + connectors = list_connectors() + assert len(connectors) > 0 + assert any(c["connector_id"] == "mock_bank" for c in connectors) + + +def test_get_connector(): + """Test getting a connector by ID.""" + connector_class = get_connector("mock_bank") + assert connector_class is not None + assert connector_class == MockBankConnector + + +def test_mock_connector_properties(): + """Test mock connector properties.""" + connector = MockBankConnector() + assert connector.connector_id == "mock_bank" + assert connector.display_name == "Mock Bank (Test)" + + +def test_mock_connector_refresh_auth(): + """Test mock connector auth refresh.""" + connector = MockBankConnector() + result = connector.refresh_auth() + assert "access_token" in result + assert "refresh_token" in result + assert result["expires_in"] == 3600 + + +def test_mock_connector_get_accounts(): + """Test getting accounts from mock connector.""" + connector = MockBankConnector() + accounts = connector.get_accounts() + assert len(accounts) == 3 + assert all(isinstance(a, BankAccount) for a in accounts) + assert accounts[0].account_id == "ACC001" + assert accounts[0].account_name == "Primary Checking" + + +def test_mock_connector_import_transactions(): + """Test importing transactions from mock connector.""" + connector = MockBankConnector() + transactions = connector.import_transactions("ACC001") + assert len(transactions) == 10 + assert all(isinstance(t, Transaction) for t in transactions) + # First transaction should be most recent + assert transactions[0].description == "Grocery Store" + assert transactions[0].transaction_type == TransactionType.EXPENSE + + +def test_mock_connector_import_with_date_filter(): + """Test importing transactions with date filters.""" + connector = MockBankConnector() + from_date = datetime(2026, 3, 1) + to_date = datetime(2026, 3, 15) + transactions = connector.import_transactions( + "ACC001", from_date=from_date, to_date=to_date + ) + # Should filter transactions within date range + for t in transactions: + assert t.date.date() >= from_date.date() + assert t.date.date() <= to_date.date() + + +def test_mock_connector_invalid_account(): + """Test importing from invalid account raises error.""" + connector = MockBankConnector() + try: + connector.import_transactions("INVALID") + assert False, "Should have raised error" + except Exception as e: + assert "Invalid account_id" in str(e) + + +def test_transaction_to_dict(): + """Test transaction serialization.""" + tx = Transaction( + date=datetime(2026, 3, 15), + amount=Decimal("100.50"), + description="Test transaction", + transaction_type=TransactionType.INCOME, + currency="USD", + ) + d = tx.to_dict() + assert d["amount"] == 100.50 + assert d["description"] == "Test transaction" + assert d["expense_type"] == "INCOME" + assert d["currency"] == "USD" + + +def test_account_to_dict(): + """Test account serialization.""" + account = BankAccount( + account_id="ACC001", + account_name="Test Account", + account_type="CHECKING", + currency="USD", + balance=Decimal("1000.00"), + ) + d = account.to_dict() + assert d["account_id"] == "ACC001" + assert d["account_name"] == "Test Account" + assert d["balance"] == 1000.00 \ No newline at end of file