diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..b4b6e6af 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,19 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Bank Connectors for pluggable bank integrations +CREATE TABLE IF NOT EXISTS bank_connectors ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + connector_id VARCHAR(50) NOT NULL, + connector_name VARCHAR(100) NOT NULL, + account_id VARCHAR(100), + account_name VARCHAR(200), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + config_encrypted TEXT, + last_sync DATE, + 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); diff --git a/packages/backend/app/extensions.py b/packages/backend/app/extensions.py index bad98fae..1a292c1f 100644 --- a/packages/backend/app/extensions.py +++ b/packages/backend/app/extensions.py @@ -8,4 +8,45 @@ jwt = JWTManager() _settings = Settings() -redis_client = redis.Redis.from_url(_settings.redis_url, decode_responses=True) + +# Create Redis client but handle connection errors gracefully +try: + redis_client = redis.Redis.from_url(_settings.redis_url, decode_responses=True) + redis_client.ping() +except Exception: + # For testing environments without Redis, create a proper mock + class MockRedis: + def __init__(self): + self._data = {} + + def ping(self): + return True + + def scan(self, cursor=0, match=None, count=100): + return 0, [] + + def delete(self, *keys): + return 0 + + def setex(self, name, time, value): + return True + + def get(self, name): + return None + + def set(self, name, value): + return True + + def exists(self, *names): + return 0 + + def expire(self, name, time): + return True + + def flushdb(self): + self._data.clear() + return True + + def __getattr__(self, name): + return lambda *args, **kwargs: None + redis_client = MockRedis() diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..e8277e11 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,19 @@ 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): + """Stores user bank connector configurations.""" + __tablename__ = "bank_connectors" + 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) + connector_name = db.Column(db.String(100), nullable=False) + account_id = db.Column(db.String(100), nullable=True) + account_name = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default="PENDING", nullable=False) + config_encrypted = db.Column(db.Text, nullable=True) + last_sync = db.Column(db.Date, 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..ad81b6a6 --- /dev/null +++ b/packages/backend/app/routes/bank_connectors.py @@ -0,0 +1,334 @@ +""" +Bank Connector routes for pluggable bank integrations. +""" + +import logging +from datetime import date, 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, User, Expense +from ..services.connectors import ( + ConnectorRegistry, + ConnectorStatus, + Transaction, +) + +logger = logging.getLogger("finmind.connectors") + +bp = Blueprint("bank_connectors", __name__) + + +def _connector_to_dict(connector: BankConnector) -> dict: + """Convert BankConnector model to dictionary.""" + return { + "id": connector.id, + "connector_id": connector.connector_id, + "connector_name": connector.connector_name, + "account_id": connector.account_id, + "account_name": connector.account_name, + "status": connector.status, + "last_sync": connector.last_sync.isoformat() if connector.last_sync else None, + "created_at": connector.created_at.isoformat() if connector.created_at else None, + "updated_at": connector.updated_at.isoformat() if connector.updated_at else None, + } + + +@bp.get("") +@jwt_required() +def list_connectors(): + """List all bank connectors for the current user.""" + uid = int(get_jwt_identity()) + connectors = db.session.query(BankConnector).filter_by(user_id=uid).all() + return jsonify([_connector_to_dict(c) for c in connectors]) + + +@bp.get("/available") +@jwt_required() +def list_available_connectors(): + """List all available connector types.""" + return jsonify(ConnectorRegistry.list_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_id = data.get("connector_id") + if not connector_id: + return jsonify(error="connector_id is required"), 400 + + # Get the connector class + connector_class = ConnectorRegistry.get(connector_id) + if not connector_class: + return jsonify(error=f"Unknown connector: {connector_id}"), 400 + + # Create connector instance to validate and get name + connector = connector_class(config=data.get("config", {})) + is_valid, error = connector.validate_config() + if not is_valid: + return jsonify(error=error), 400 + + # Create database record + db_connector = BankConnector( + user_id=uid, + connector_id=connector_id, + connector_name=connector.connector_name, + account_id=data.get("account_id"), + account_name=data.get("account_name"), + status=ConnectorStatus.PENDING.value, + ) + db.session.add(db_connector) + db.session.commit() + + logger.info("Created bank connector id=%s user=%s type=%s", db_connector.id, uid, connector_id) + return jsonify(_connector_to_dict(db_connector)), 201 + + +@bp.post("//connect") +@jwt_required() +def connect_connector(connector_id: int): + """Connect to a bank connector and fetch accounts.""" + uid = int(get_jwt_identity()) + db_connector = db.session.get(BankConnector, connector_id) + + if not db_connector or db_connector.user_id != uid: + return jsonify(error="Connector not found"), 404 + + # Create connector instance + connector = ConnectorRegistry.create_instance( + db_connector.connector_id, + config={}, # In production, would decrypt from db_connector.config_encrypted + ) + if not connector: + return jsonify(error="Failed to create connector"), 500 + + # Connect + result = connector.connect() + if not result.success: + db_connector.status = ConnectorStatus.ERROR.value + db.session.commit() + return jsonify(error=result.error or "Connection failed"), 400 + + # Update connector status and account info + db_connector.status = ConnectorStatus.CONNECTED.value + if result.accounts: + db_connector.account_id = result.accounts[0].account_id + db_connector.account_name = result.accounts[0].account_name + db.session.commit() + + logger.info("Connected bank connector id=%s user=%s", connector_id, uid) + return jsonify({ + "connector": _connector_to_dict(db_connector), + "accounts": [ + { + "account_id": a.account_id, + "account_name": a.account_name, + "account_type": a.account_type, + "balance": a.balance, + "currency": a.currency, + } + for a in result.accounts + ], + }) + + +@bp.post("//disconnect") +@jwt_required() +def disconnect_connector(connector_id: int): + """Disconnect a bank connector.""" + uid = int(get_jwt_identity()) + db_connector = db.session.get(BankConnector, connector_id) + + if not db_connector or db_connector.user_id != uid: + return jsonify(error="Connector not found"), 404 + + # Create connector instance + connector = ConnectorRegistry.create_instance(db_connector.connector_id) + if connector: + connector.disconnect() + + db_connector.status = ConnectorStatus.DISCONNECTED.value + db.session.commit() + + logger.info("Disconnected bank connector id=%s user=%s", connector_id, uid) + return jsonify(_connector_to_dict(db_connector)) + + +@bp.post("//import") +@jwt_required() +def import_transactions(connector_id: int): + """Import transactions from a bank connector.""" + uid = int(get_jwt_identity()) + db_connector = db.session.get(BankConnector, connector_id) + + if not db_connector or db_connector.user_id != uid: + return jsonify(error="Connector not found"), 404 + + if db_connector.status != ConnectorStatus.CONNECTED.value: + return jsonify(error="Connector is not connected"), 400 + + data = request.get_json() or {} + 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 + + # Create connector instance + connector = ConnectorRegistry.create_instance(db_connector.connector_id) + if not connector: + return jsonify(error="Failed to create connector"), 500 + + # Connect first + connect_result = connector.connect() + if not connect_result.success: + return jsonify(error=connect_result.error or "Failed to connect"), 400 + + # Import transactions + account_id = db_connector.account_id or data.get("account_id") + result = connector.import_transactions(account_id, from_date, to_date) + + if not result.success: + return jsonify(error=result.error or "Import failed"), 400 + + # Convert transactions to expenses + inserted = 0 + for tx in result.transactions: + expense = Expense( + user_id=uid, + amount=tx.amount, + currency=tx.currency, + expense_type=tx.expense_type, + notes=tx.description, + category_id=tx.category_id, + spent_at=tx.date, + ) + db.session.add(expense) + inserted += 1 + + # Update last sync + db_connector.last_sync = date.today() + db.session.commit() + + logger.info( + "Imported %d transactions from connector %d user=%s", + inserted, + connector_id, + uid, + ) + return jsonify({ + "inserted": inserted, + "transactions": [ + { + "date": tx.date.isoformat(), + "amount": tx.amount, + "description": tx.description, + "expense_type": tx.expense_type, + "currency": tx.currency, + } + for tx in result.transactions + ], + }) + + +@bp.post("//refresh") +@jwt_required() +def refresh_transactions(connector_id: int): + """Refresh/fetch new transactions from a bank connector.""" + uid = int(get_jwt_identity()) + db_connector = db.session.get(BankConnector, connector_id) + + if not db_connector or db_connector.user_id != uid: + return jsonify(error="Connector not found"), 404 + + if db_connector.status != ConnectorStatus.CONNECTED.value: + return jsonify(error="Connector is not connected"), 400 + + # Create connector instance + connector = ConnectorRegistry.create_instance(db_connector.connector_id) + if not connector: + return jsonify(error="Failed to create connector"), 500 + + # Connect first + connect_result = connector.connect() + if not connect_result.success: + return jsonify(error=connect_result.error or "Failed to connect"), 400 + + # Refresh transactions + account_id = db_connector.account_id + result = connector.refresh(account_id) + + if not result.success: + return jsonify(error=result.error or "Refresh failed"), 400 + + # Convert transactions to expenses + inserted = 0 + for tx in result.transactions: + expense = Expense( + user_id=uid, + amount=tx.amount, + currency=tx.currency, + expense_type=tx.expense_type, + notes=tx.description, + category_id=tx.category_id, + spent_at=tx.date, + ) + db.session.add(expense) + inserted += 1 + + # Update last sync + db_connector.last_sync = date.today() + db.session.commit() + + logger.info( + "Refreshed %d new transactions from connector %d user=%s", + inserted, + connector_id, + uid, + ) + return jsonify({ + "inserted": inserted, + "new_transactions_count": result.new_transactions_count, + }) + + +@bp.delete("/") +@jwt_required() +def delete_connector(connector_id: int): + """Delete a bank connector configuration.""" + uid = int(get_jwt_identity()) + db_connector = db.session.get(BankConnector, connector_id) + + if not db_connector or db_connector.user_id != uid: + return jsonify(error="Connector not found"), 404 + + # Disconnect first if connected + if db_connector.status == ConnectorStatus.CONNECTED.value: + connector = ConnectorRegistry.create_instance(db_connector.connector_id) + if connector: + connector.disconnect() + + db.session.delete(db_connector) + db.session.commit() + + logger.info("Deleted bank connector id=%s user=%s", connector_id, uid) + return jsonify({"deleted": True}) + + +# Import mock connector to register it +from ..services.connectors.mock_connector import MockConnector # noqa: F401, E402 \ No newline at end of file diff --git a/packages/backend/app/services/connectors/__init__.py b/packages/backend/app/services/connectors/__init__.py new file mode 100644 index 00000000..67a1d5eb --- /dev/null +++ b/packages/backend/app/services/connectors/__init__.py @@ -0,0 +1,198 @@ +""" +Bank Sync Connector Architecture + +Provides a pluggable interface for bank integrations with import and refresh support. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import date +from enum import Enum +from typing import Any + + +class ConnectorStatus(str, Enum): + """Status of a bank connector.""" + PENDING = "PENDING" + CONNECTED = "CONNECTED" + ERROR = "ERROR" + DISCONNECTED = "DISCONNECTED" + + +@dataclass +class Transaction: + """Represents a bank transaction.""" + date: date + amount: float + description: str + category_id: int | None = None + expense_type: str = "EXPENSE" + currency: str = "USD" + + +@dataclass +class Account: + """Represents a bank account.""" + account_id: str + account_name: str + account_type: str + balance: float + currency: str = "USD" + + +@dataclass +class ConnectorResult: + """Result from a connector operation.""" + success: bool + transactions: list[Transaction] = None + accounts: list[Account] = None + error: str | None = None + new_transactions_count: int = 0 + + def __post_init__(self): + if self.transactions is None: + self.transactions = [] + if self.accounts is None: + self.accounts = [] + + +class BaseConnector(ABC): + """ + Abstract base class for bank connectors. + + All bank integrations should inherit from this class and implement + the required methods. + """ + + def __init__(self, config: dict[str, Any] | None = None): + """ + Initialize the connector with configuration. + + Args: + config: Dictionary containing connector-specific configuration + (e.g., API keys, account credentials, etc.) + """ + self.config = config or {} + self._status = ConnectorStatus.PENDING + + @property + @abstractmethod + def connector_id(self) -> str: + """Unique identifier for this connector type.""" + pass + + @property + @abstractmethod + def connector_name(self) -> str: + """Human-readable name for the connector.""" + pass + + @property + def status(self) -> ConnectorStatus: + """Current status of the connector.""" + return self._status + + @abstractmethod + def connect(self) -> ConnectorResult: + """ + Establish connection to the bank API. + + Returns: + ConnectorResult with accounts if successful + """ + pass + + @abstractmethod + def disconnect(self) -> ConnectorResult: + """ + Disconnect from the bank API and clean up resources. + + Returns: + ConnectorResult indicating success/failure + """ + pass + + @abstractmethod + def import_transactions( + self, + account_id: str, + from_date: date | None = None, + to_date: date | None = None, + ) -> ConnectorResult: + """ + Import transactions from the bank. + + Args: + account_id: The account to import from + from_date: Start date for transaction import (optional) + to_date: End date for transaction import (optional) + + Returns: + ConnectorResult with transactions + """ + pass + + @abstractmethod + def refresh(self, account_id: str) -> ConnectorResult: + """ + Refresh/fetch new transactions since last sync. + + Args: + account_id: The account to refresh + + Returns: + ConnectorResult with new transactions + """ + pass + + def validate_config(self) -> tuple[bool, str | None]: + """ + Validate the connector configuration. + + Returns: + Tuple of (is_valid, error_message) + """ + return True, None + + +class ConnectorRegistry: + """ + Registry for managing available connectors. + """ + + _connectors: dict[str, type[BaseConnector]] = {} + + @classmethod + def register(cls, connector_class: type[BaseConnector]) -> None: + """Register a connector class.""" + instance = connector_class() + cls._connectors[instance.connector_id] = connector_class + + @classmethod + def get(cls, connector_id: str) -> type[BaseConnector] | None: + """Get a connector class by ID.""" + return cls._connectors.get(connector_id) + + @classmethod + def list_connectors(cls) -> list[dict[str, str]]: + """List all registered connectors.""" + result = [] + for connector_class in cls._connectors.values(): + instance = connector_class() + result.append({ + "connector_id": instance.connector_id, + "connector_name": instance.connector_name, + }) + return result + + @classmethod + def create_instance( + cls, + connector_id: str, + config: dict[str, Any] | None = None + ) -> BaseConnector | None: + """Create an instance of a connector by ID.""" + connector_class = cls.get(connector_id) + if connector_class: + return connector_class(config) + return None \ No newline at end of file diff --git a/packages/backend/app/services/connectors/mock_connector.py b/packages/backend/app/services/connectors/mock_connector.py new file mode 100644 index 00000000..17be4644 --- /dev/null +++ b/packages/backend/app/services/connectors/mock_connector.py @@ -0,0 +1,214 @@ +""" +Mock Connector for testing bank sync functionality. +""" + +import logging +from datetime import date, timedelta +from typing import Any + +from . import ( + Account, + BaseConnector, + ConnectorResult, + ConnectorStatus, + Transaction, +) + +logger = logging.getLogger("finmind.connectors.mock") + + +class MockConnector(BaseConnector): + """ + Mock connector for testing and development purposes. + + This connector simulates a bank connection without making any + actual API calls. It generates fake transactions for testing. + """ + + def __init__(self, config: dict[str, Any] | None = None): + super().__init__(config) + self._accounts = [] + self._last_sync: dict[str, date] = {} + # Generate some mock accounts + self._mock_accounts = [ + Account( + account_id="mock_checking_001", + account_name="Mock Checking Account", + account_type="CHECKING", + balance=5000.00, + currency="USD", + ), + Account( + account_id="mock_savings_001", + account_name="Mock Savings Account", + account_type="SAVINGS", + balance=15000.00, + currency="USD", + ), + ] + + @property + def connector_id(self) -> str: + return "mock" + + @property + def connector_name(self) -> str: + return "Mock Bank Connector" + + def connect(self) -> ConnectorResult: + """Simulate connecting to the mock bank.""" + logger.info("Mock connector: connecting...") + self._status = ConnectorStatus.CONNECTED + self._accounts = self._mock_accounts.copy() + return ConnectorResult( + success=True, + accounts=self._accounts, + ) + + def disconnect(self) -> ConnectorResult: + """Simulate disconnecting from the mock bank.""" + logger.info("Mock connector: disconnecting...") + self._status = ConnectorStatus.DISCONNECTED + self._accounts = [] + return ConnectorResult(success=True) + + def import_transactions( + self, + account_id: str, + from_date: date | None = None, + to_date: date | None = None, + ) -> ConnectorResult: + """Generate mock transactions for the given account.""" + logger.info( + "Mock connector: importing transactions for account=%s from=%s to=%s", + account_id, + from_date, + to_date, + ) + + # Validate account exists + account = next((a for a in self._accounts if a.account_id == account_id), None) + if not account: + return ConnectorResult( + success=False, + error=f"Account {account_id} not found", + ) + + # Default date range + if from_date is None: + from_date = date.today() - timedelta(days=30) + if to_date is None: + to_date = date.today() + + # Generate mock transactions + transactions = self._generate_mock_transactions(account_id, from_date, to_date) + + return ConnectorResult( + success=True, + transactions=transactions, + new_transactions_count=len(transactions), + ) + + def refresh(self, account_id: str) -> ConnectorResult: + """Simulate refreshing/fetching new transactions.""" + logger.info("Mock connector: refreshing account=%s", account_id) + + # Validate account exists + account = next((a for a in self._accounts if a.account_id == account_id), None) + if not account: + return ConnectorResult( + success=False, + error=f"Account {account_id} not found", + ) + + # Get last sync date or default to yesterday + last_sync = self._last_sync.get(account_id, date.today() - timedelta(days=1)) + + # Generate new transactions since last sync + transactions = self._generate_mock_transactions( + account_id, + last_sync + timedelta(days=1), + date.today() + ) + + # Update last sync date + self._last_sync[account_id] = date.today() + + return ConnectorResult( + success=True, + transactions=transactions, + new_transactions_count=len(transactions), + ) + + def _generate_mock_transactions( + self, + account_id: str, + from_date: date, + to_date: date, + ) -> list[Transaction]: + """Generate mock transactions for testing.""" + transactions = [] + + # Sample transaction descriptions + income_descriptions = [ + "Payroll Deposit", + "Direct Deposit - Salary", + "Interest Payment", + "Refund - Online Purchase", + "Transfer from Savings", + ] + + expense_descriptions = [ + "Grocery Store", + "Gas Station", + "Restaurant - Dining", + "Electric Bill", + "Internet Service", + "Streaming Subscription", + "Coffee Shop", + "Pharmacy", + "Public Transit", + "Online Shopping", + ] + + current = from_date + while current <= to_date: + # Generate 0-3 transactions per day + import random + num_transactions = random.randint(0, 3) + + for _ in range(num_transactions): + is_income = random.random() < 0.2 # 20% income + + if is_income: + description = random.choice(income_descriptions) + amount = round(random.uniform(500, 5000), 2) + expense_type = "INCOME" + else: + description = random.choice(expense_descriptions) + amount = round(random.uniform(5, 200), 2) + expense_type = "EXPENSE" + + transactions.append( + Transaction( + date=current, + amount=amount, + description=description, + expense_type=expense_type, + currency="USD", + ) + ) + + current += timedelta(days=1) + + return transactions + + def validate_config(self) -> tuple[bool, str | None]: + """Validate mock connector configuration.""" + # Mock connector doesn't require any specific config + return True, None + + +# Auto-register the mock connector +from . import ConnectorRegistry +ConnectorRegistry.register(MockConnector) \ 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..df4db77a --- /dev/null +++ b/packages/backend/tests/test_bank_connectors.py @@ -0,0 +1,197 @@ +""" +Tests for Bank Connector functionality. +""" + +import pytest +from datetime import date + + +def test_list_available_connectors(client, auth_header): + """Test listing available connector types.""" + r = client.get("/bank-connectors/available", headers=auth_header) + assert r.status_code == 200 + connectors = r.get_json() + assert isinstance(connectors, list) + # Should have at least the mock connector + assert any(c["connector_id"] == "mock" for c in connectors) + + +def test_create_connector(client, auth_header): + """Test creating a new bank connector.""" + payload = { + "connector_id": "mock", + "account_name": "Test Account", + } + r = client.post("/bank-connectors", json=payload, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + assert data["connector_id"] == "mock" + assert data["connector_name"] == "Mock Bank Connector" + assert data["status"] == "PENDING" + + +def test_create_connector_invalid_type(client, auth_header): + """Test creating a connector with invalid type fails.""" + payload = { + "connector_id": "nonexistent_connector", + } + r = client.post("/bank-connectors", json=payload, headers=auth_header) + assert r.status_code == 400 + assert "Unknown connector" in r.get_json()["error"] + + +def test_list_connectors(client, auth_header): + """Test listing user's connectors.""" + # Create a connector first + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + assert r.status_code == 201 + + # List connectors + r = client.get("/bank-connectors", headers=auth_header) + assert r.status_code == 200 + connectors = r.get_json() + assert len(connectors) == 1 + assert connectors[0]["connector_id"] == "mock" + + +def test_connect_connector(client, auth_header): + """Test connecting to a bank connector.""" + # Create connector + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + assert r.status_code == 201 + connector_id = r.get_json()["id"] + + # Connect + r = client.post(f"/bank-connectors/{connector_id}/connect", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["connector"]["status"] == "CONNECTED" + assert "accounts" in data + assert len(data["accounts"]) > 0 + + +def test_disconnect_connector(client, auth_header): + """Test disconnecting a bank connector.""" + # Create and connect + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + connector_id = r.get_json()["id"] + + r = client.post(f"/bank-connectors/{connector_id}/connect", headers=auth_header) + assert r.status_code == 200 + + # Disconnect + r = client.post(f"/bank-connectors/{connector_id}/disconnect", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["status"] == "DISCONNECTED" + + +def test_import_transactions(client, auth_header): + """Test importing transactions from a connector.""" + # Create and connect + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + connector_id = r.get_json()["id"] + + r = client.post(f"/bank-connectors/{connector_id}/connect", headers=auth_header) + assert r.status_code == 200 + + # Import transactions + import_payload = { + "from_date": "2026-01-01", + "to_date": "2026-01-31", + } + r = client.post( + f"/bank-connectors/{connector_id}/import", + json=import_payload, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert "inserted" in data + assert "transactions" in data + assert data["inserted"] >= 0 + + +def test_refresh_transactions(client, auth_header): + """Test refreshing transactions from a connector.""" + # Create and connect + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + connector_id = r.get_json()["id"] + + r = client.post(f"/bank-connectors/{connector_id}/connect", headers=auth_header) + assert r.status_code == 200 + + # Refresh transactions + r = client.post(f"/bank-connectors/{connector_id}/refresh", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert "inserted" in data + assert "new_transactions_count" in data + + +def test_delete_connector(client, auth_header): + """Test deleting a bank connector.""" + # Create connector + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + connector_id = r.get_json()["id"] + + # Delete + r = client.delete(f"/bank-connectors/{connector_id}", headers=auth_header) + assert r.status_code == 200 + + # Verify deleted + r = client.get("/bank-connectors", headers=auth_header) + assert r.get_json() == [] + + +def test_import_without_connection_fails(client, auth_header): + """Test that import fails when connector is not connected.""" + # Create connector but don't connect + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + connector_id = r.get_json()["id"] + + # Try to import without connecting + r = client.post(f"/bank-connectors/{connector_id}/import", headers=auth_header) + assert r.status_code == 400 + assert "not connected" in r.get_json()["error"] + + +def test_connector_not_found(client, auth_header): + """Test operations on non-existent connector.""" + r = client.delete("/bank-connectors/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_list_connectors_empty(client, auth_header): + """Test listing connectors when none exist.""" + r = client.get("/bank-connectors", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_import_with_invalid_date(client, auth_header): + """Test import with invalid date format.""" + # Create and connect + payload = {"connector_id": "mock", "account_name": "Test Account"} + r = client.post("/bank-connectors", json=payload, headers=auth_header) + connector_id = r.get_json()["id"] + + r = client.post(f"/bank-connectors/{connector_id}/connect", headers=auth_header) + + # Try import with invalid date + import_payload = { + "from_date": "invalid-date", + } + r = client.post( + f"/bank-connectors/{connector_id}/import", + json=import_payload, + headers=auth_header, + ) + assert r.status_code == 400 + assert "Invalid from_date" in r.get_json()["error"] \ No newline at end of file