diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..4b2eb4e6 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,18 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Bank Connectors Configuration +CREATE TABLE IF NOT EXISTS bank_connectors ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + connector_type VARCHAR(50) NOT NULL, + connector_name VARCHAR(100) NOT NULL, + credentials_encrypted TEXT, + options JSONB DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_sync TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_bank_connectors_user ON bank_connectors(user_id, is_active); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..f5b9cace 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,17 @@ 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 BankConnector(db.Model): + __tablename__ = "bank_connectors" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + connector_type = db.Column(db.String(50), nullable=False) + connector_name = db.Column(db.String(100), nullable=False) + credentials_encrypted = db.Column(db.Text, nullable=True) + options = db.Column(db.JSON, default=dict, nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + last_sync = 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..3fe00c6a 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-connectors") diff --git a/packages/backend/app/routes/bank_connectors.py b/packages/backend/app/routes/bank_connectors.py new file mode 100644 index 00000000..632f8268 --- /dev/null +++ b/packages/backend/app/routes/bank_connectors.py @@ -0,0 +1,345 @@ +""" +Bank Connectors API Routes + +Provides REST endpoints for managing bank connector configurations +and importing/refreshing 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 BankConnector as BankConnectorModel, User +from ..services.bank_connectors import ( + ConnectorConfig, + get_connector, + list_connectors, + get_connector_info, +) + +logger = logging.getLogger("finmind.bank_connectors") + +bp = Blueprint("bank_connectors", __name__) + + +@bp.get("/types") +@jwt_required() +def list_connector_types(): + """List all available bank connector types.""" + connectors = list_connectors() + info_list = [] + for conn_type in connectors: + info = get_connector_info(conn_type) + if info: + info_list.append(info) + return jsonify(info_list) + + +@bp.get("") +@jwt_required() +def list_user_connectors(): + """List all bank connectors for the current user.""" + uid = int(get_jwt_identity()) + connectors = ( + db.session.query(BankConnectorModel) + .filter_by(user_id=uid, is_active=True) + .order_by(BankConnectorModel.created_at.desc()) + .all() + ) + return jsonify([_connector_to_dict(c) for c in connectors]) + + +@bp.post("") +@jwt_required() +def create_connector(): + """Create a new bank connector configuration.""" + 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 + + connector_name = data.get("connector_name") or connector_type + credentials = data.get("credentials", {}) + options = data.get("options", {}) + + # Validate connector type exists + info = get_connector_info(connector_type) + if not info: + return jsonify(error="Unknown connector type"), 400 + + # Create connector config to validate + config = ConnectorConfig( + connector_id="", + user_id=uid, + credentials=credentials, + options=options, + ) + + # Try to create and validate the connector + connector = get_connector(connector_type, config) + if not connector: + return jsonify(error="Failed to create connector"), 400 + + if not connector.validate_credentials(): + return jsonify(error="Invalid credentials"), 400 + + # Store encrypted credentials (in production, use proper encryption) + # For now, store as JSON string + credentials_json = json.dumps(credentials) + + model = BankConnectorModel( + user_id=uid, + connector_type=connector_type, + connector_name=connector_name, + credentials_encrypted=credentials_json, + options=options, + is_active=True, + ) + db.session.add(model) + db.session.commit() + + logger.info("Created bank connector id=%s type=%s user=%s", model.id, connector_type, uid) + return jsonify(_connector_to_dict(model)), 201 + + +@bp.get("/") +@jwt_required() +def get_connector_detail(connector_id: int): + """Get details of a specific connector.""" + uid = int(get_jwt_identity()) + model = db.session.get(BankConnectorModel, connector_id) + + if not model or model.user_id != uid: + return jsonify(error="Not found"), 404 + + return jsonify(_connector_to_dict(model)) + + +@bp.delete("/") +@jwt_required() +def delete_connector(connector_id: int): + """Delete a bank connector configuration.""" + uid = int(get_jwt_identity()) + model = db.session.get(BankConnectorModel, connector_id) + + if not model or model.user_id != uid: + return jsonify(error="Not found"), 404 + + # Soft delete - just mark as inactive + model.is_active = False + db.session.commit() + + logger.info("Deleted bank connector id=%s user=%s", connector_id, uid) + return jsonify({"deleted": True}) + + +@bp.post("//import") +@jwt_required() +def import_transactions(connector_id: int): + """Import transactions from a bank connector.""" + uid = int(get_jwt_identity()) + model = db.session.get(BankConnectorModel, connector_id) + + if not model or model.user_id != uid or not model.is_active: + return jsonify(error="Not found"), 404 + + data = request.get_json() or {} + from_date = data.get("from_date") + to_date = data.get("to_date") + + # Parse dates + from_date_parsed = None + to_date_parsed = None + if from_date: + try: + from_date_parsed = datetime.strptime(from_date, "%Y-%m-%d").date() + except ValueError: + return jsonify(error="Invalid from_date format"), 400 + if to_date: + try: + to_date_parsed = datetime.strptime(to_date, "%Y-%m-%d").date() + except ValueError: + return jsonify(error="Invalid to_date format"), 400 + + # Load credentials + credentials = {} + if model.credentials_encrypted: + try: + credentials = json.loads(model.credentials_encrypted) + except json.JSONDecodeError: + pass + + config = ConnectorConfig( + connector_id=str(model.id), + user_id=uid, + credentials=credentials, + options=model.options or {}, + last_sync=model.last_sync, + ) + + connector = get_connector(model.connector_type, config) + if not connector: + return jsonify(error="Failed to create connector"), 400 + + if not connector.connect(): + return jsonify(error="Failed to connect to bank"), 400 + + try: + transactions = connector.import_transactions( + from_date=from_date_parsed, + to_date=to_date_parsed, + ) + + # Update last sync time + model.last_sync = datetime.utcnow() + db.session.commit() + + logger.info( + "Imported %d transactions from connector %d user=%s", + len(transactions), + connector_id, + uid, + ) + + return jsonify({ + "imported": len(transactions), + "transactions": [t.to_dict() for t in transactions], + }) + finally: + connector.disconnect() + + +@bp.post("//refresh") +@jwt_required() +def refresh_transactions(connector_id: int): + """Refresh (get new) transactions from a bank connector.""" + uid = int(get_jwt_identity()) + model = db.session.get(BankConnectorModel, connector_id) + + if not model or model.user_id != uid or not model.is_active: + return jsonify(error="Not found"), 404 + + data = request.get_json() or {} + from_date = data.get("from_date") + + # Parse date + from_date_parsed = None + if from_date: + try: + from_date_parsed = datetime.strptime(from_date, "%Y-%m-%d").date() + except ValueError: + return jsonify(error="Invalid from_date format"), 400 + + # Load credentials + credentials = {} + if model.credentials_encrypted: + try: + credentials = json.loads(model.credentials_encrypted) + except json.JSONDecodeError: + pass + + config = ConnectorConfig( + connector_id=str(model.id), + user_id=uid, + credentials=credentials, + options=model.options or {}, + last_sync=model.last_sync, + ) + + connector = get_connector(model.connector_type, config) + if not connector: + return jsonify(error="Failed to create connector"), 400 + + if not connector.connect(): + return jsonify(error="Failed to connect to bank"), 400 + + try: + transactions = connector.refresh_transactions( + from_date=from_date_parsed, + ) + + # Update last sync time + model.last_sync = datetime.utcnow() + db.session.commit() + + logger.info( + "Refreshed %d new transactions from connector %d user=%s", + len(transactions), + connector_id, + uid, + ) + + return jsonify({ + "new_transactions": len(transactions), + "transactions": [t.to_dict() for t in transactions], + }) + finally: + connector.disconnect() + + +@bp.get("//balance") +@jwt_required() +def get_balance(connector_id: int): + """Get account balance from a bank connector.""" + uid = int(get_jwt_identity()) + model = db.session.get(BankConnectorModel, connector_id) + + if not model or model.user_id != uid or not model.is_active: + return jsonify(error="Not found"), 404 + + # Load credentials + credentials = {} + if model.credentials_encrypted: + try: + credentials = json.loads(model.credentials_encrypted) + except json.JSONDecodeError: + pass + + config = ConnectorConfig( + connector_id=str(model.id), + user_id=uid, + credentials=credentials, + options=model.options or {}, + ) + + connector = get_connector(model.connector_type, config) + if not connector: + return jsonify(error="Failed to create connector"), 400 + + if not connector.connect(): + return jsonify(error="Failed to connect to bank"), 400 + + try: + balance = connector.get_account_balance() + if balance is None: + return jsonify(error="Balance not supported"), 400 + + return jsonify({ + "amount": float(balance.amount), + "currency": balance.currency, + "available": float(balance.available) if balance.available else None, + "account_name": balance.account_name, + "account_number": balance.account_number, + }) + finally: + connector.disconnect() + + +def _connector_to_dict(model: BankConnectorModel) -> dict: + """Convert BankConnector model to dictionary.""" + return { + "id": model.id, + "connector_type": model.connector_type, + "connector_name": model.connector_name, + "options": model.options or {}, + "is_active": model.is_active, + "last_sync": model.last_sync.isoformat() if model.last_sync else None, + "created_at": model.created_at.isoformat(), + "updated_at": model.updated_at.isoformat(), + } \ No newline at end of file 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..52978df5 --- /dev/null +++ b/packages/backend/app/services/bank_connectors/__init__.py @@ -0,0 +1,30 @@ +""" +Bank Connectors Module + +Provides a pluggable architecture for bank integrations with import and refresh support. +""" + +from .base import BankConnector, ConnectorConfig, Transaction, TransactionType, AccountBalance +from .registry import ( + BankConnectorRegistry, + get_connector, + list_connectors, + get_connector_info, + register_connector, +) + +# Import mock connector to register it +from . import mock # noqa: F401 - registers the mock connector + +__all__ = [ + "BankConnector", + "ConnectorConfig", + "Transaction", + "TransactionType", + "AccountBalance", + "BankConnectorRegistry", + "get_connector", + "list_connectors", + "get_connector_info", + "register_connector", +] \ No newline at end of file diff --git a/packages/backend/app/services/bank_connectors/base.py b/packages/backend/app/services/bank_connectors/base.py new file mode 100644 index 00000000..4a89982f --- /dev/null +++ b/packages/backend/app/services/bank_connectors/base.py @@ -0,0 +1,189 @@ +""" +Base Bank Connector Interface + +Defines the abstract base class and data models for bank connectors. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from typing import Any + + +class TransactionType(str, Enum): + """Type of transaction.""" + INCOME = "INCOME" + EXPENSE = "EXPENSE" + TRANSFER = "TRANSFER" + + +@dataclass +class Transaction: + """Represents a single bank transaction.""" + amount: Decimal + currency: str + description: str + transaction_date: date + transaction_type: TransactionType = TransactionType.EXPENSE + category_id: int | None = None + notes: str | None = None + reference_id: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "amount": float(self.amount), + "currency": self.currency, + "description": self.description, + "transaction_date": self.transaction_date.isoformat(), + "transaction_type": self.transaction_type.value, + "category_id": self.category_id, + "notes": self.notes, + "reference_id": self.reference_id, + "metadata": self.metadata, + } + + +@dataclass +class AccountBalance: + """Represents an account balance.""" + amount: Decimal + currency: str + available: Decimal | None = None + account_name: str | None = None + account_number: str | None = None + + +@dataclass +class ConnectorConfig: + """Configuration for a bank connector.""" + connector_id: str + user_id: int + # Authentication credentials (encrypted in production) + credentials: dict[str, str] = field(default_factory=dict) + # Additional configuration options + options: dict[str, Any] = field(default_factory=dict) + # Last sync timestamp + last_sync: datetime | None = None + + +class BankConnector(ABC): + """ + Abstract base class for bank connectors. + + All bank connectors must implement these methods to provide + a consistent interface for importing and refreshing transactions. + """ + + @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 subclasses.""" + return ["import", "refresh"] + + def __init__(self, config: ConnectorConfig): + """ + Initialize the connector with configuration. + + Args: + config: Connector configuration including credentials + """ + self.config = config + + @abstractmethod + def connect(self) -> bool: + """ + Establish connection to the bank API. + + Returns: + True if connection successful, False otherwise + """ + pass + + @abstractmethod + def disconnect(self) -> None: + """Close any open connections or sessions.""" + pass + + @abstractmethod + def import_transactions( + self, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[Transaction]: + """ + Import transactions from the bank. + + Args: + from_date: Start date for transaction import + to_date: End date for transaction import + + Returns: + List of imported transactions + """ + pass + + @abstractmethod + def refresh_transactions( + self, + from_date: date | None = None, + ) -> list[Transaction]: + """ + Refresh (fetch new) transactions since last sync. + + Args: + from_date: Override the default 'since last sync' date + + Returns: + List of new/updated transactions + """ + pass + + def get_account_balance(self) -> AccountBalance | None: + """ + Get current account balance. + + Override in subclasses if supported. + + Returns: + Account balance or None if not supported + """ + return None + + def validate_credentials(self) -> bool: + """ + Validate the provided credentials. + + Override in subclasses to provide custom validation. + + Returns: + True if credentials are valid + """ + return bool(self.config.credentials) + + def get_metadata(self) -> dict[str, Any]: + """ + Get connector metadata. + + Returns: + Dictionary of metadata about the connector + """ + return { + "connector_type": self.connector_type, + "display_name": self.display_name, + "supported_features": self.supported_features, + } \ No newline at end of file diff --git a/packages/backend/app/services/bank_connectors/mock.py b/packages/backend/app/services/bank_connectors/mock.py new file mode 100644 index 00000000..56e6a570 --- /dev/null +++ b/packages/backend/app/services/bank_connectors/mock.py @@ -0,0 +1,203 @@ +""" +Mock Bank Connector + +A mock connector for testing and development purposes. +""" + +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Any +import random + +from .base import BankConnector, ConnectorConfig, Transaction, TransactionType, AccountBalance +from .registry import register_connector + + +@register_connector +class MockBankConnector(BankConnector): + """ + Mock bank connector for testing and development. + + This connector simulates a bank API and returns fake transaction data. + Useful for testing the connector architecture without real bank credentials. + """ + + @property + def connector_type(self) -> str: + return "mock" + + @property + def display_name(self) -> str: + return "Mock Bank (Test)" + + @property + def supported_features(self) -> list[str]: + return ["import", "refresh", "balance"] + + def __init__(self, config: ConnectorConfig): + super().__init__(config) + self._connected = False + self._transactions: list[Transaction] = [] + self._generate_mock_transactions() + + def _generate_mock_transactions(self) -> None: + """Generate mock transactions for testing.""" + # Generate transactions for the past 90 days + today = date.today() + descriptions = [ + ("GROCERY STORE", TransactionType.EXPENSE), + ("SALARY DEPOSIT", TransactionType.INCOME), + ("ELECTRICITY BILL", TransactionType.EXPENSE), + ("RESTAURANT", TransactionType.EXPENSE), + ("ONLINE SHOPPING", TransactionType.EXPENSE), + ("TRANSFER TO SAVINGS", TransactionType.TRANSFER), + ("INTEREST PAYMENT", TransactionType.INCOME), + ("MOBILE RECHARGE", TransactionType.EXPENSE), + ("SUBSCRIPTION SERVICE", TransactionType.EXPENSE), + ("RENT PAYMENT", TransactionType.EXPENSE), + ("DINNER", TransactionType.EXPENSE), + ("FUEL STATION", TransactionType.EXPENSE), + ("PHARMACY", TransactionType.EXPENSE), + ("COFFEE SHOP", TransactionType.EXPENSE), + ("FREELANCE INCOME", TransactionType.INCOME), + ] + + currency = self.config.options.get("currency", "USD") + + for i in range(60): + days_ago = random.randint(0, 90) + tx_date = today - timedelta(days=days_ago) + + desc, tx_type = random.choice(descriptions) + + # Generate random amount based on type + if tx_type == TransactionType.INCOME: + amount = Decimal(str(random.randint(1000, 10000))) + elif tx_type == TransactionType.TRANSFER: + amount = Decimal(str(random.randint(500, 5000))) + else: + amount = Decimal(str(random.randint(10, 500))) + + self._transactions.append( + Transaction( + amount=amount, + currency=currency, + description=desc, + transaction_date=tx_date, + transaction_type=tx_type, + reference_id=f"MOCK-{i:04d}", + metadata={"mock": True, "index": i}, + ) + ) + + def connect(self) -> bool: + """Simulate connecting to the mock bank.""" + # Check if we have at least some credentials (can be fake) + if not self.config.credentials: + # Allow empty credentials for testing + pass + self._connected = True + return True + + def disconnect(self) -> None: + """Simulate disconnecting from the mock bank.""" + self._connected = False + + def import_transactions( + self, + from_date: date | None = None, + to_date: date | None = None, + ) -> list[Transaction]: + """Return mock transactions within the date range.""" + if not self._connected: + self.connect() + + filtered = self._transactions + + if from_date: + filtered = [t for t in filtered if t.transaction_date >= from_date] + if to_date: + filtered = [t for t in filtered if t.transaction_date <= to_date] + + # Sort by date descending + filtered = sorted(filtered, key=lambda t: t.transaction_date, reverse=True) + + return filtered + + def refresh_transactions( + self, + from_date: date | None = None, + ) -> list[Transaction]: + """Return new mock transactions since last sync.""" + if not self._connected: + self.connect() + + # Use last_sync if from_date not provided + sync_date = from_date or (self.config.last_sync.date() if self.config.last_sync else date.today() - timedelta(days=7)) + + # Generate some "new" transactions + today = date.today() + new_transactions = [] + + # Simulate 1-5 new transactions + num_new = random.randint(1, 5) + for i in range(num_new): + days_ago = random.randint(0, 3) + tx_date = today - timedelta(days=days_ago) + + if tx_date >= sync_date: + descriptions = [ + ("NEW GROCERY PURCHASE", TransactionType.EXPENSE), + ("SALARY DEPOSIT", TransactionType.INCOME), + ("DINNER PAYMENT", TransactionType.EXPENSE), + ] + desc, tx_type = random.choice(descriptions) + + amount = Decimal(str(random.randint(50, 2000))) + + new_transactions.append( + Transaction( + amount=amount, + currency=self.config.options.get("currency", "USD"), + description=desc, + transaction_date=tx_date, + transaction_type=tx_type, + reference_id=f"MOCK-NEW-{random.randint(10000, 99999)}", + metadata={"mock": True, "new": True}, + ) + ) + + return sorted(new_transactions, key=lambda t: t.transaction_date, reverse=True) + + def get_account_balance(self) -> AccountBalance | None: + """Return a mock account balance.""" + if not self._connected: + self.connect() + + # Calculate balance from transactions + total_income = sum(t.amount for t in self._transactions if t.transaction_type == TransactionType.INCOME) + total_expense = sum(t.amount for t in self._transactions if t.transaction_type == TransactionType.EXPENSE) + + balance = total_income - total_expense + + return AccountBalance( + amount=balance, + currency=self.config.options.get("currency", "USD"), + available=balance - Decimal("500"), # Simulate some available credit + account_name="Mock Checking Account", + account_number="****1234", + ) + + def validate_credentials(self) -> bool: + """Mock validation always returns True for testing.""" + return True + + def get_metadata(self) -> dict[str, Any]: + """Return extended metadata for mock connector.""" + base = super().get_metadata() + base.update({ + "description": "Mock connector for testing and development", + "requires_credentials": False, + "test_mode": True, + }) + return base \ No newline at end of file diff --git a/packages/backend/app/services/bank_connectors/registry.py b/packages/backend/app/services/bank_connectors/registry.py new file mode 100644 index 00000000..8e12597f --- /dev/null +++ b/packages/backend/app/services/bank_connectors/registry.py @@ -0,0 +1,160 @@ +""" +Bank Connector Registry + +Manages registration and retrieval of bank connectors. +""" + +from typing import Any, Type + +from .base import BankConnector, ConnectorConfig + + +class BankConnectorRegistry: + """ + Registry for bank connectors. + + Provides a centralized way to register and retrieve connector instances. + """ + + _connectors: dict[str, Type[BankConnector]] = {} + + @classmethod + def register(cls, connector_class: Type[BankConnector]) -> Type[BankConnector]: + """ + Register a bank connector class. + + Args: + connector_class: The connector class to register + + Returns: + The registered connector class + """ + # Create temporary instance to get connector_type + # This is a bit of a hack but avoids requiring a config for registration + instance = connector_class( + ConnectorConfig( + connector_id="", + user_id=0, + ) + ) + cls._connectors[instance.connector_type] = connector_class + return connector_class + + @classmethod + def get_connector_class(cls, connector_type: str) -> Type[BankConnector] | None: + """ + Get a connector class by type. + + Args: + connector_type: The type identifier of the connector + + Returns: + The connector class or None if not found + """ + return cls._connectors.get(connector_type) + + @classmethod + def list_connectors(cls) -> list[str]: + """ + List all registered connector types. + + Returns: + List of registered connector type identifiers + """ + return list(cls._connectors.keys()) + + @classmethod + def create_connector( + cls, + connector_type: str, + config: ConnectorConfig, + ) -> BankConnector | None: + """ + Create a connector instance. + + Args: + connector_type: The type of connector to create + config: Configuration for the connector + + Returns: + Connector instance or None if type not found + """ + connector_class = cls.get_connector_class(connector_type) + if connector_class is None: + return None + return connector_class(config) + + @classmethod + def get_connector_info(cls, connector_type: str) -> dict[str, Any] | None: + """ + Get metadata about a connector. + + Args: + connector_type: The type of connector + + Returns: + Dictionary of connector metadata or None + """ + connector_class = cls.get_connector_class(connector_type) + if connector_class is None: + return None + config = ConnectorConfig(connector_id="", user_id=0) + instance = connector_class(config) + return instance.get_metadata() + + +# Global registry instance +_registry = BankConnectorRegistry() + + +def get_connector( + connector_type: str, + config: ConnectorConfig, +) -> BankConnector | None: + """ + Convenience function to get a connector instance. + + Args: + connector_type: The type of connector to get + config: Configuration for the connector + + Returns: + Connector instance or None if not found + """ + return _registry.create_connector(connector_type, config) + + +def register_connector(connector_class: Type[BankConnector]) -> Type[BankConnector]: + """ + Convenience function to register a connector. + + Args: + connector_class: The connector class to register + + Returns: + The registered connector class + """ + return _registry.register(connector_class) + + +def list_connectors() -> list[str]: + """ + List all registered connector types. + + Returns: + List of registered connector type identifiers + """ + return _registry.list_connectors() + + +def get_connector_info(connector_type: str) -> dict[str, Any] | None: + """ + Get metadata about a connector. + + Args: + connector_type: The type of connector + + Returns: + Dictionary of connector metadata or None + """ + return _registry.get_connector_info(connector_type) \ No newline at end of file diff --git a/packages/backend/tests/test_bank_connectors.py b/packages/backend/tests/test_bank_connectors.py new file mode 100644 index 00000000..01612075 --- /dev/null +++ b/packages/backend/tests/test_bank_connectors.py @@ -0,0 +1,178 @@ +""" +Tests for Bank Connectors Module +""" + +from datetime import date, timedelta + +import pytest + +from app.services.bank_connectors import ( + ConnectorConfig, + get_connector, + list_connectors, + get_connector_info, + register_connector, + Transaction, + TransactionType, + AccountBalance, +) +from app.services.bank_connectors.base import BankConnector + + +class TestConnectorRegistry: + """Test the connector registry.""" + + def test_list_connectors_returns_mock(self): + """Mock connector should be registered by default.""" + connectors = list_connectors() + assert "mock" in connectors + + def test_get_connector_info_returns_metadata(self): + """Should return metadata for mock connector.""" + info = get_connector_info("mock") + assert info is not None + assert info["connector_type"] == "mock" + assert "display_name" in info + assert "supported_features" in info + + def test_get_connector_info_unknown_type(self): + """Should return None for unknown connector type.""" + info = get_connector_info("unknown_bank") + assert info is None + + +class TestMockConnector: + """Test the mock bank connector.""" + + @pytest.fixture + def config(self): + return ConnectorConfig( + connector_id="test-connector", + user_id=1, + credentials={"api_key": "test-key"}, + options={"currency": "USD"}, + ) + + @pytest.fixture + def connector(self, config): + return get_connector("mock", config) + + def test_connector_type(self, connector): + """Should have correct connector type.""" + assert connector.connector_type == "mock" + + def test_display_name(self, connector): + """Should have human-readable display name.""" + assert connector.display_name == "Mock Bank (Test)" + + def test_supported_features(self, connector): + """Should list supported features.""" + features = connector.supported_features + assert "import" in features + assert "refresh" in features + + def test_connect_disconnect(self, connector): + """Should connect and disconnect successfully.""" + assert connector.connect() is True + connector.disconnect() + + def test_import_transactions(self, connector): + """Should import transactions.""" + connector.connect() + transactions = connector.import_transactions() + assert len(transactions) > 0 + assert all(isinstance(t, Transaction) for t in transactions) + + def test_import_with_date_filter(self, connector): + """Should filter transactions by date.""" + connector.connect() + to_date = date.today() + from_date = to_date - timedelta(days=30) + + transactions = connector.import_transactions( + from_date=from_date, + to_date=to_date, + ) + + for t in transactions: + assert from_date <= t.transaction_date <= to_date + + def test_refresh_transactions(self, connector): + """Should refresh (get new) transactions.""" + connector.connect() + transactions = connector.refresh_transactions() + assert isinstance(transactions, list) + + def test_get_account_balance(self, connector): + """Should return account balance.""" + connector.connect() + balance = connector.get_account_balance() + assert balance is not None + assert isinstance(balance, AccountBalance) + assert balance.currency == "USD" + + def test_validate_credentials(self, connector): + """Should validate credentials.""" + assert connector.validate_credentials() is True + + def test_get_metadata(self, connector): + """Should return metadata.""" + metadata = connector.get_metadata() + assert metadata["connector_type"] == "mock" + assert metadata["test_mode"] is True + + +class TestTransactionModel: + """Test the Transaction dataclass.""" + + def test_transaction_to_dict(self): + """Should convert transaction to dictionary.""" + tx = Transaction( + amount=100.50, + currency="USD", + description="Test transaction", + transaction_date=date.today(), + transaction_type=TransactionType.EXPENSE, + category_id=1, + notes="Test note", + reference_id="REF-001", + metadata={"key": "value"}, + ) + + d = tx.to_dict() + assert d["amount"] == 100.50 + assert d["currency"] == "USD" + assert d["description"] == "Test transaction" + assert d["transaction_type"] == "EXPENSE" + assert d["category_id"] == 1 + assert d["notes"] == "Test note" + assert d["reference_id"] == "REF-001" + assert d["metadata"]["key"] == "value" + + +class TestConnectorConfig: + """Test the ConnectorConfig dataclass.""" + + def test_default_values(self): + """Should have default values.""" + config = ConnectorConfig( + connector_id="test", + user_id=1, + ) + assert config.credentials == {} + assert config.options == {} + assert config.last_sync is None + + def test_custom_values(self): + """Should accept custom values.""" + from datetime import datetime + config = ConnectorConfig( + connector_id="test", + user_id=1, + credentials={"key": "value"}, + options={"option1": True}, + last_sync=datetime.now(), + ) + assert config.credentials == {"key": "value"} + assert config.options == {"option1": True} + assert config.last_sync is not None \ No newline at end of file