diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..76b9307b 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,26 @@ 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 BankConnection(db.Model): + """Stores a user's connection to a bank via a connector provider.""" + + __tablename__ = "bank_connections" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + provider_id = db.Column(db.String(50), nullable=False) + account_id = db.Column(db.String(200), nullable=False) + account_name = db.Column(db.String(200), nullable=False) + account_type = db.Column(db.String(50), nullable=False) + institution_name = db.Column(db.String(200), nullable=False) + masked_account_number = db.Column(db.String(50), nullable=True) + currency = db.Column(db.String(10), default="INR", nullable=False) + # Encrypted / serialised credentials – store as JSON text. + # In production use envelope encryption; for now stored as-is. + credentials_json = db.Column(db.Text, nullable=False, default="{}") + # Opaque cursor tracking the last successful sync position + sync_cursor = db.Column(db.String(500), nullable=True) + last_synced_at = db.Column(db.DateTime, nullable=True) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..7c2aef26 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .bank_sync import bp as bank_sync_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(bank_sync_bp, url_prefix="/bank-sync") diff --git a/packages/backend/app/routes/bank_sync.py b/packages/backend/app/routes/bank_sync.py new file mode 100644 index 00000000..c27f4d9a --- /dev/null +++ b/packages/backend/app/routes/bank_sync.py @@ -0,0 +1,355 @@ +"""Bank sync connector routes. + +Endpoints +--------- +GET /bank-sync/providers + List all registered connector providers. + +POST /bank-sync/providers//accounts + Fetch accounts for a provider using supplied credentials. + +POST /bank-sync/connections + Create (connect) a new bank connection for the authenticated user. + +GET /bank-sync/connections + List the authenticated user's bank connections. + +DELETE /bank-sync/connections/ + Remove a bank connection. + +POST /bank-sync/connections//import + Full import of transactions for a date range. + +POST /bank-sync/connections//refresh + Incremental sync using the stored cursor. +""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime + +from flask import Blueprint, current_app, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db +from ..models import BankConnection, Expense, User +from ..services import bank_connectors +from ..services.expense_import import normalize_import_rows + +bp = Blueprint("bank_sync", __name__) +logger = logging.getLogger("finmind.bank_sync") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _connection_to_dict(conn: BankConnection) -> dict: + return { + "id": conn.id, + "provider_id": conn.provider_id, + "account_id": conn.account_id, + "account_name": conn.account_name, + "account_type": conn.account_type, + "institution_name": conn.institution_name, + "masked_account_number": conn.masked_account_number, + "currency": conn.currency, + "sync_cursor": conn.sync_cursor, + "last_synced_at": conn.last_synced_at.isoformat() if conn.last_synced_at else None, + "active": conn.active, + "created_at": conn.created_at.isoformat(), + } + + +def _tx_to_expense_row(tx, currency: str) -> dict: + return { + "date": tx.transaction_date.isoformat(), + "amount": tx.amount, + "description": tx.description, + "category_id": None, + "expense_type": "INCOME" if tx.transaction_type == "CREDIT" else "EXPENSE", + "currency": tx.currency or currency, + } + + +def _is_duplicate(uid: int, row: dict) -> bool: + from decimal import Decimal + + try: + amount = Decimal(str(row["amount"])).quantize(Decimal("0.01")) + except Exception: + return False + return ( + db.session.query(Expense) + .filter_by( + user_id=uid, + spent_at=date.fromisoformat(row["date"]), + amount=amount, + notes=row["description"], + ) + .first() + is not None + ) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@bp.get("/providers") +@jwt_required() +def list_providers(): + """Return all available connector providers.""" + providers = [ + {"provider_id": c.provider_id, "display_name": c.display_name} + for c in bank_connectors.list_connectors() + ] + return jsonify(providers) + + +@bp.post("/providers//accounts") +@jwt_required() +def fetch_provider_accounts(provider_id: str): + """Fetch accounts for *provider_id* using the supplied credentials.""" + connector = bank_connectors.get_connector(provider_id) + if not connector: + return jsonify(error=f"unknown provider: {provider_id}"), 404 + credentials = request.get_json() or {} + try: + accounts = connector.fetch_accounts(credentials) + except Exception as exc: + logger.exception("fetch_accounts failed provider=%s", provider_id) + return jsonify(error=f"failed to fetch accounts: {exc}"), 502 + return jsonify( + [ + { + "account_id": a.account_id, + "account_name": a.account_name, + "account_type": a.account_type, + "balance": a.balance, + "currency": a.currency, + "institution_name": a.institution_name, + "masked_account_number": a.masked_account_number, + } + for a in accounts + ] + ) + + +@bp.post("/connections") +@jwt_required() +def create_connection(): + """Connect a bank account for the authenticated user.""" + uid = int(get_jwt_identity()) + data = request.get_json() or {} + provider_id = (data.get("provider_id") or "").strip() + account_id = (data.get("account_id") or "").strip() + if not provider_id: + return jsonify(error="provider_id required"), 400 + if not account_id: + return jsonify(error="account_id required"), 400 + connector = bank_connectors.get_connector(provider_id) + if not connector: + return jsonify(error=f"unknown provider: {provider_id}"), 404 + credentials = data.get("credentials") or {} + # Verify the account exists + try: + accounts = connector.fetch_accounts(credentials) + except Exception as exc: + logger.exception("create_connection fetch_accounts failed provider=%s", provider_id) + return jsonify(error=f"failed to verify credentials: {exc}"), 502 + account = next((a for a in accounts if a.account_id == account_id), None) + if not account: + return jsonify(error="account_id not found for given credentials"), 404 + # Check for duplicate connection + existing = ( + db.session.query(BankConnection) + .filter_by(user_id=uid, provider_id=provider_id, account_id=account_id, active=True) + .first() + ) + if existing: + return jsonify(error="connection already exists"), 409 + conn = BankConnection( + user_id=uid, + provider_id=provider_id, + account_id=account.account_id, + account_name=account.account_name, + account_type=account.account_type, + institution_name=account.institution_name, + masked_account_number=account.masked_account_number, + currency=account.currency, + credentials_json=json.dumps(credentials), + ) + db.session.add(conn) + db.session.commit() + logger.info("BankConnection created id=%s user=%s provider=%s", conn.id, uid, provider_id) + return jsonify(_connection_to_dict(conn)), 201 + + +@bp.get("/connections") +@jwt_required() +def list_connections(): + """List bank connections for the authenticated user.""" + uid = int(get_jwt_identity()) + conns = ( + db.session.query(BankConnection) + .filter_by(user_id=uid, active=True) + .order_by(BankConnection.created_at.desc()) + .all() + ) + return jsonify([_connection_to_dict(c) for c in conns]) + + +@bp.delete("/connections/") +@jwt_required() +def delete_connection(connection_id: int): + """Soft-delete a bank connection.""" + uid = int(get_jwt_identity()) + conn = db.session.get(BankConnection, connection_id) + if not conn or conn.user_id != uid: + return jsonify(error="not found"), 404 + conn.active = False + db.session.commit() + return jsonify(message="connection removed") + + +@bp.post("/connections//import") +@jwt_required() +def import_transactions(connection_id: int): + """Full import of transactions for a date range. + + Request body:: + + { + "from_date": "2024-01-01", + "to_date": "2024-03-31", + "commit": true // if false (default) returns preview only + } + """ + uid = int(get_jwt_identity()) + conn = db.session.get(BankConnection, connection_id) + if not conn or conn.user_id != uid or not conn.active: + return jsonify(error="not found"), 404 + connector = bank_connectors.get_connector(conn.provider_id) + if not connector: + return jsonify(error=f"provider {conn.provider_id} no longer available"), 404 + data = request.get_json() or {} + from_raw = data.get("from_date") + to_raw = data.get("to_date") + if not from_raw or not to_raw: + return jsonify(error="from_date and to_date required"), 400 + try: + from_date = date.fromisoformat(from_raw) + to_date = date.fromisoformat(to_raw) + except ValueError: + return jsonify(error="invalid date format, use YYYY-MM-DD"), 400 + if from_date > to_date: + return jsonify(error="from_date must be on or before to_date"), 400 + commit = bool(data.get("commit", False)) + try: + credentials = json.loads(conn.credentials_json or "{}") + result = connector.import_transactions( + credentials, conn.account_id, from_date, to_date + ) + except Exception as exc: + logger.exception("import_transactions failed connection=%s", connection_id) + return jsonify(error=f"connector error: {exc}"), 502 + rows = [_tx_to_expense_row(tx, conn.currency) for tx in result.transactions] + normalized = normalize_import_rows(rows) + duplicates_count = sum(1 for r in normalized if _is_duplicate(uid, r)) + if not commit: + return jsonify( + total=len(normalized), + duplicates=duplicates_count, + transactions=normalized, + cursor=result.cursor, + ) + # Commit + user = db.session.get(User, uid) + inserted = 0 + skipped = 0 + from decimal import Decimal + from ..models import Expense as ExpenseModel + for r in normalized: + if _is_duplicate(uid, r): + skipped += 1 + continue + exp = ExpenseModel( + user_id=uid, + amount=Decimal(str(r["amount"])), + currency=r.get("currency") or (user.preferred_currency if user else "INR"), + expense_type=str(r.get("expense_type") or "EXPENSE").upper(), + category_id=r.get("category_id"), + notes=r["description"], + spent_at=date.fromisoformat(r["date"]), + ) + db.session.add(exp) + inserted += 1 + # Update connection sync cursor + if result.cursor: + conn.sync_cursor = result.cursor + conn.last_synced_at = datetime.utcnow() + db.session.commit() + logger.info( + "import_transactions committed connection=%s inserted=%s skipped=%s", + connection_id, inserted, skipped, + ) + return jsonify(inserted=inserted, duplicates=skipped, cursor=result.cursor), 201 + + +@bp.post("/connections//refresh") +@jwt_required() +def refresh_connection(connection_id: int): + """Incrementally sync new transactions using the stored cursor. + + Fetches transactions since the last sync cursor and commits them as + expenses. Returns ``inserted`` and ``cursor`` in the response. + """ + uid = int(get_jwt_identity()) + conn = db.session.get(BankConnection, connection_id) + if not conn or conn.user_id != uid or not conn.active: + return jsonify(error="not found"), 404 + connector = bank_connectors.get_connector(conn.provider_id) + if not connector: + return jsonify(error=f"provider {conn.provider_id} no longer available"), 404 + try: + credentials = json.loads(conn.credentials_json or "{}") + result = connector.refresh(credentials, conn.account_id, cursor=conn.sync_cursor) + except Exception as exc: + logger.exception("refresh failed connection=%s", connection_id) + return jsonify(error=f"connector error: {exc}"), 502 + rows = [_tx_to_expense_row(tx, conn.currency) for tx in result.transactions] + normalized = normalize_import_rows(rows) + user = db.session.get(User, uid) + inserted = 0 + skipped = 0 + from decimal import Decimal + from ..models import Expense as ExpenseModel + for r in normalized: + if _is_duplicate(uid, r): + skipped += 1 + continue + exp = ExpenseModel( + user_id=uid, + amount=Decimal(str(r["amount"])), + currency=r.get("currency") or (user.preferred_currency if user else "INR"), + expense_type=str(r.get("expense_type") or "EXPENSE").upper(), + category_id=r.get("category_id"), + notes=r["description"], + spent_at=date.fromisoformat(r["date"]), + ) + db.session.add(exp) + inserted += 1 + if result.cursor: + conn.sync_cursor = result.cursor + conn.last_synced_at = datetime.utcnow() + db.session.commit() + logger.info( + "refresh committed connection=%s inserted=%s skipped=%s cursor=%s", + connection_id, inserted, skipped, result.cursor, + ) + return jsonify(inserted=inserted, duplicates=skipped, cursor=result.cursor), 200 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..fc01e771 --- /dev/null +++ b/packages/backend/app/services/bank_connectors/__init__.py @@ -0,0 +1,45 @@ +"""Bank connector registry. + +Usage:: + + from app.services.bank_connectors import get_connector, list_connectors + + connector = get_connector("mock") + accounts = connector.fetch_accounts({}) +""" + +from __future__ import annotations + +from .base import BankAccount, BankConnector, BankTransaction, ImportResult +from .mock import MockBankConnector + +__all__ = [ + "BankAccount", + "BankConnector", + "BankTransaction", + "ImportResult", + "get_connector", + "list_connectors", + "register_connector", +] + +_registry: dict[str, BankConnector] = {} + + +def register_connector(connector: BankConnector) -> None: + """Register a connector instance under its :attr:`~BankConnector.provider_id`.""" + _registry[connector.provider_id] = connector + + +def get_connector(provider_id: str) -> BankConnector | None: + """Return the registered connector for *provider_id*, or ``None``.""" + return _registry.get(provider_id) + + +def list_connectors() -> list[BankConnector]: + """Return all registered connectors.""" + return list(_registry.values()) + + +# Register built-in connectors +register_connector(MockBankConnector()) 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..e5698e2d --- /dev/null +++ b/packages/backend/app/services/bank_connectors/base.py @@ -0,0 +1,124 @@ +"""Bank connector interface definition.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import date +from typing import Any + + +@dataclass +class BankAccount: + """Represents a bank account returned by a connector.""" + + account_id: str + account_name: str + account_type: str + balance: float + currency: str + institution_name: str + masked_account_number: str | None = None + + +@dataclass +class BankTransaction: + """Represents a single bank transaction returned by a connector.""" + + transaction_id: str + account_id: str + amount: float + currency: str + description: str + transaction_date: date + transaction_type: str # DEBIT or CREDIT + category: str | None = None + reference: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ImportResult: + """Result of an import or refresh operation.""" + + transactions: list[BankTransaction] + cursor: str | None = None # opaque cursor for incremental sync + has_more: bool = False + + +class BankConnector(ABC): + """Abstract base class for all bank connector implementations. + + Subclasses must implement :meth:`fetch_accounts`, :meth:`import_transactions`, + and :meth:`refresh` along with the :attr:`provider_id` and + :attr:`display_name` properties. + """ + + @property + @abstractmethod + def provider_id(self) -> str: + """Unique slug that identifies this connector (e.g. ``mock``, ``finvu``).""" + ... + + @property + @abstractmethod + def display_name(self) -> str: + """Human-readable name shown in the UI.""" + ... + + @abstractmethod + def fetch_accounts(self, credentials: dict[str, Any]) -> list[BankAccount]: + """Return the list of accounts accessible via *credentials*. + + Args: + credentials: Provider-specific auth data (tokens, API keys, etc.). + + Returns: + List of :class:`BankAccount` objects. + """ + ... + + @abstractmethod + def import_transactions( + self, + credentials: dict[str, Any], + account_id: str, + from_date: date, + to_date: date, + cursor: str | None = None, + ) -> ImportResult: + """Perform a full or date-ranged import of transactions. + + Args: + credentials: Provider-specific auth data. + account_id: The account to fetch from. + from_date: Inclusive start date for the fetch window. + to_date: Inclusive end date for the fetch window. + cursor: Optional opaque cursor returned by a previous call for + pagination. + + Returns: + :class:`ImportResult` containing transactions and a next cursor. + """ + ... + + @abstractmethod + def refresh( + self, + credentials: dict[str, Any], + account_id: str, + cursor: str | None = None, + ) -> ImportResult: + """Incrementally sync new transactions since the last cursor. + + Implementations should fetch only transactions that occurred after the + position described by *cursor*. When *cursor* is ``None`` the + connector should return the most recent page of transactions. + + Args: + credentials: Provider-specific auth data. + account_id: The account to sync. + cursor: Opaque cursor from the previous sync (or ``None``). + + Returns: + :class:`ImportResult` with new transactions and an updated cursor. + """ + ... 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..50623f6c --- /dev/null +++ b/packages/backend/app/services/bank_connectors/mock.py @@ -0,0 +1,196 @@ +"""Mock bank connector for testing and local development.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import date, timedelta +from typing import Any + +from .base import BankAccount, BankConnector, BankTransaction, ImportResult + +_MOCK_ACCOUNTS = [ + BankAccount( + account_id="mock-savings-001", + account_name="Mock Savings Account", + account_type="SAVINGS", + balance=50000.0, + currency="INR", + institution_name="Mock Bank", + masked_account_number="XXXX1234", + ), + BankAccount( + account_id="mock-current-002", + account_name="Mock Current Account", + account_type="CURRENT", + balance=120000.0, + currency="INR", + institution_name="Mock Bank", + masked_account_number="XXXX5678", + ), +] + +_MOCK_TRANSACTIONS_TEMPLATE = [ + { + "description": "Salary Credit", + "amount": 80000.0, + "transaction_type": "CREDIT", + "category": "INCOME", + "offset_days": 1, + }, + { + "description": "Grocery Store", + "amount": 2500.0, + "transaction_type": "DEBIT", + "category": "FOOD", + "offset_days": 3, + }, + { + "description": "Electricity Bill", + "amount": 1800.0, + "transaction_type": "DEBIT", + "category": "UTILITIES", + "offset_days": 5, + }, + { + "description": "Online Transfer", + "amount": 5000.0, + "transaction_type": "DEBIT", + "category": "TRANSFER", + "offset_days": 7, + }, + { + "description": "ATM Withdrawal", + "amount": 3000.0, + "transaction_type": "DEBIT", + "category": "CASH", + "offset_days": 10, + }, + { + "description": "Mobile Recharge", + "amount": 299.0, + "transaction_type": "DEBIT", + "category": "TELECOM", + "offset_days": 12, + }, + { + "description": "Restaurant", + "amount": 750.0, + "transaction_type": "DEBIT", + "category": "FOOD", + "offset_days": 14, + }, + { + "description": "Interest Credit", + "amount": 350.0, + "transaction_type": "CREDIT", + "category": "INCOME", + "offset_days": 15, + }, +] + + +def _make_transaction( + account_id: str, + tmpl: dict[str, Any], + base_date: date, + idx: int, +) -> BankTransaction: + tx_date = base_date + timedelta(days=tmpl["offset_days"]) + raw = f"{account_id}:{tmpl['description']}:{tx_date.isoformat()}:{idx}" + tid = "mock-" + hashlib.md5(raw.encode()).hexdigest()[:12] + return BankTransaction( + transaction_id=tid, + account_id=account_id, + amount=tmpl["amount"], + currency="INR", + description=tmpl["description"], + transaction_date=tx_date, + transaction_type=tmpl["transaction_type"], + category=tmpl.get("category"), + reference=f"REF{tid.upper()}", + ) + + +def _generate_transactions( + account_id: str, + from_date: date, + to_date: date, +) -> list[BankTransaction]: + """Generate deterministic fake transactions within the date window.""" + transactions: list[BankTransaction] = [] + cursor = from_date.replace(day=1) + idx = 0 + while cursor <= to_date: + for tmpl in _MOCK_TRANSACTIONS_TEMPLATE: + tx = _make_transaction(account_id, tmpl, cursor, idx) + if from_date <= tx.transaction_date <= to_date: + transactions.append(tx) + idx += 1 + # Move to next month + if cursor.month == 12: + cursor = cursor.replace(year=cursor.year + 1, month=1) + else: + cursor = cursor.replace(month=cursor.month + 1) + transactions.sort(key=lambda t: t.transaction_date) + return transactions + + +class MockBankConnector(BankConnector): + """In-memory mock connector useful for development and testing. + + This connector does not contact any real banking service. All returned + accounts and transactions are generated deterministically so tests remain + reproducible. + + Credentials accepted (all optional): + - ``user``: any string – ignored, present for interface compliance. + """ + + @property + def provider_id(self) -> str: + return "mock" + + @property + def display_name(self) -> str: + return "Mock Bank (demo)" + + def fetch_accounts(self, credentials: dict[str, Any]) -> list[BankAccount]: # noqa: ARG002 + return list(_MOCK_ACCOUNTS) + + def import_transactions( + self, + credentials: dict[str, Any], # noqa: ARG002 + account_id: str, + from_date: date, + to_date: date, + cursor: str | None = None, + ) -> ImportResult: + txns = _generate_transactions(account_id, from_date, to_date) + # Cursor encodes the last transaction_date seen (ISO format) + if cursor: + try: + last_seen = date.fromisoformat(cursor) + txns = [t for t in txns if t.transaction_date > last_seen] + except ValueError: + pass + new_cursor = txns[-1].transaction_date.isoformat() if txns else cursor + return ImportResult(transactions=txns, cursor=new_cursor, has_more=False) + + def refresh( + self, + credentials: dict[str, Any], + account_id: str, + cursor: str | None = None, + ) -> ImportResult: + to_date = date.today() + if cursor: + try: + from_date = date.fromisoformat(cursor) + timedelta(days=1) + except ValueError: + from_date = to_date - timedelta(days=30) + else: + from_date = to_date - timedelta(days=30) + return self.import_transactions( + credentials, account_id, from_date, to_date, cursor=None + ) diff --git a/packages/backend/tests/test_bank_sync.py b/packages/backend/tests/test_bank_sync.py new file mode 100644 index 00000000..ce10780c --- /dev/null +++ b/packages/backend/tests/test_bank_sync.py @@ -0,0 +1,324 @@ +"""Tests for the bank sync connector architecture.""" + +from __future__ import annotations + +import pytest +from datetime import date + + +# --------------------------------------------------------------------------- +# Unit tests – connector interface & mock +# --------------------------------------------------------------------------- + + +class TestBankConnectorInterface: + """Verify the abstract interface cannot be instantiated directly.""" + + def test_cannot_instantiate_base(self): + from app.services.bank_connectors.base import BankConnector + + with pytest.raises(TypeError): + BankConnector() # type: ignore[abstract] + + +class TestMockConnector: + def setup_method(self): + from app.services.bank_connectors.mock import MockBankConnector + + self.connector = MockBankConnector() + + def test_provider_id(self): + assert self.connector.provider_id == "mock" + + def test_display_name(self): + assert "mock" in self.connector.display_name.lower() + + def test_fetch_accounts_returns_list(self): + accounts = self.connector.fetch_accounts({}) + assert isinstance(accounts, list) + assert len(accounts) >= 1 + + def test_fetch_accounts_fields(self): + accounts = self.connector.fetch_accounts({}) + for acc in accounts: + assert acc.account_id + assert acc.account_name + assert acc.account_type + assert acc.currency + + def test_import_transactions_returns_result(self): + from app.services.bank_connectors.base import ImportResult + + result = self.connector.import_transactions( + {}, "mock-savings-001", date(2024, 1, 1), date(2024, 1, 31) + ) + assert isinstance(result, ImportResult) + assert isinstance(result.transactions, list) + + def test_import_transactions_within_date_range(self): + from_date = date(2024, 2, 1) + to_date = date(2024, 2, 28) + result = self.connector.import_transactions({}, "mock-savings-001", from_date, to_date) + for tx in result.transactions: + assert from_date <= tx.transaction_date <= to_date + + def test_import_transactions_cursor_filtering(self): + # Import with cursor should return only transactions after cursor date + cursor = date(2024, 1, 10).isoformat() + result = self.connector.import_transactions( + {}, "mock-savings-001", date(2024, 1, 1), date(2024, 1, 31), cursor=cursor + ) + for tx in result.transactions: + assert tx.transaction_date > date(2024, 1, 10) + + def test_import_transactions_cursor_is_updated(self): + result = self.connector.import_transactions( + {}, "mock-savings-001", date(2024, 1, 1), date(2024, 1, 31) + ) + if result.transactions: + assert result.cursor is not None + + def test_refresh_returns_result(self): + from app.services.bank_connectors.base import ImportResult + + result = self.connector.refresh({}, "mock-savings-001") + assert isinstance(result, ImportResult) + + def test_refresh_with_cursor(self): + cursor = date(2024, 1, 15).isoformat() + result = self.connector.refresh({}, "mock-savings-001", cursor=cursor) + assert isinstance(result.transactions, list) + + def test_transactions_have_required_fields(self): + result = self.connector.import_transactions( + {}, "mock-savings-001", date(2024, 1, 1), date(2024, 3, 31) + ) + for tx in result.transactions: + assert tx.transaction_id + assert tx.account_id == "mock-savings-001" + assert tx.amount > 0 + assert tx.currency + assert tx.description + assert tx.transaction_date + assert tx.transaction_type in ("DEBIT", "CREDIT") + + +class TestConnectorRegistry: + def test_mock_registered_by_default(self): + from app.services.bank_connectors import get_connector + + c = get_connector("mock") + assert c is not None + assert c.provider_id == "mock" + + def test_get_unknown_returns_none(self): + from app.services.bank_connectors import get_connector + + assert get_connector("nonexistent-provider-xyz") is None + + def test_list_connectors_includes_mock(self): + from app.services.bank_connectors import list_connectors + + ids = [c.provider_id for c in list_connectors()] + assert "mock" in ids + + def test_register_custom_connector(self): + from app.services.bank_connectors import register_connector, get_connector + from app.services.bank_connectors.base import ( + BankAccount, BankConnector, BankTransaction, ImportResult + ) + + class _DummyConnector(BankConnector): + @property + def provider_id(self): + return "dummy-test-xyz" + + @property + def display_name(self): + return "Dummy Test" + + def fetch_accounts(self, credentials): + return [] + + def import_transactions(self, credentials, account_id, from_date, to_date, cursor=None): + return ImportResult(transactions=[]) + + def refresh(self, credentials, account_id, cursor=None): + return ImportResult(transactions=[]) + + register_connector(_DummyConnector()) + assert get_connector("dummy-test-xyz") is not None + + +# --------------------------------------------------------------------------- +# Integration tests – HTTP endpoints +# --------------------------------------------------------------------------- + + +class TestBankSyncRoutes: + def test_list_providers_requires_auth(self, client): + r = client.get("/bank-sync/providers") + assert r.status_code == 401 + + def test_list_providers(self, client, auth_header): + r = client.get("/bank-sync/providers", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert isinstance(data, list) + ids = [p["provider_id"] for p in data] + assert "mock" in ids + + def test_fetch_provider_accounts(self, client, auth_header): + r = client.post( + "/bank-sync/providers/mock/accounts", + json={}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert isinstance(data, list) + assert len(data) >= 1 + assert "account_id" in data[0] + + def test_fetch_provider_accounts_unknown(self, client, auth_header): + r = client.post( + "/bank-sync/providers/nope/accounts", + json={}, + headers=auth_header, + ) + assert r.status_code == 404 + + def test_create_connection(self, client, auth_header): + r = client.post( + "/bank-sync/connections", + json={"provider_id": "mock", "account_id": "mock-savings-001"}, + headers=auth_header, + ) + assert r.status_code == 201 + data = r.get_json() + assert data["provider_id"] == "mock" + assert data["account_id"] == "mock-savings-001" + + def test_create_connection_duplicate(self, client, auth_header): + payload = {"provider_id": "mock", "account_id": "mock-savings-001"} + client.post("/bank-sync/connections", json=payload, headers=auth_header) + r = client.post("/bank-sync/connections", json=payload, headers=auth_header) + assert r.status_code == 409 + + def test_create_connection_missing_provider(self, client, auth_header): + r = client.post( + "/bank-sync/connections", + json={"account_id": "mock-savings-001"}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_create_connection_unknown_provider(self, client, auth_header): + r = client.post( + "/bank-sync/connections", + json={"provider_id": "nope", "account_id": "x"}, + headers=auth_header, + ) + assert r.status_code == 404 + + def test_list_connections(self, client, auth_header): + client.post( + "/bank-sync/connections", + json={"provider_id": "mock", "account_id": "mock-savings-001"}, + headers=auth_header, + ) + r = client.get("/bank-sync/connections", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert any(c["account_id"] == "mock-savings-001" for c in data) + + def test_delete_connection(self, client, auth_header): + r = client.post( + "/bank-sync/connections", + json={"provider_id": "mock", "account_id": "mock-savings-001"}, + headers=auth_header, + ) + conn_id = r.get_json()["id"] + r = client.delete(f"/bank-sync/connections/{conn_id}", headers=auth_header) + assert r.status_code == 200 + # Should no longer appear in list + r = client.get("/bank-sync/connections", headers=auth_header) + ids = [c["id"] for c in r.get_json()] + assert conn_id not in ids + + def test_delete_connection_not_found(self, client, auth_header): + r = client.delete("/bank-sync/connections/99999", headers=auth_header) + assert r.status_code == 404 + + def _create_connection(self, client, auth_header) -> int: + r = client.post( + "/bank-sync/connections", + json={"provider_id": "mock", "account_id": "mock-savings-001"}, + headers=auth_header, + ) + return r.get_json()["id"] + + def test_import_preview(self, client, auth_header): + conn_id = self._create_connection(client, auth_header) + r = client.post( + f"/bank-sync/connections/{conn_id}/import", + json={"from_date": "2024-01-01", "to_date": "2024-01-31", "commit": False}, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert "total" in data + assert "transactions" in data + + def test_import_commit(self, client, auth_header): + conn_id = self._create_connection(client, auth_header) + r = client.post( + f"/bank-sync/connections/{conn_id}/import", + json={"from_date": "2024-01-01", "to_date": "2024-01-31", "commit": True}, + headers=auth_header, + ) + assert r.status_code == 201 + data = r.get_json() + assert "inserted" in data + + def test_import_missing_dates(self, client, auth_header): + conn_id = self._create_connection(client, auth_header) + r = client.post( + f"/bank-sync/connections/{conn_id}/import", + json={"commit": False}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_import_invalid_dates(self, client, auth_header): + conn_id = self._create_connection(client, auth_header) + r = client.post( + f"/bank-sync/connections/{conn_id}/import", + json={"from_date": "bad", "to_date": "2024-01-31"}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_refresh(self, client, auth_header): + conn_id = self._create_connection(client, auth_header) + r = client.post( + f"/bank-sync/connections/{conn_id}/refresh", + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert "inserted" in data + assert "cursor" in data + + def test_refresh_idempotent(self, client, auth_header): + conn_id = self._create_connection(client, auth_header) + r1 = client.post(f"/bank-sync/connections/{conn_id}/refresh", headers=auth_header) + inserted1 = r1.get_json()["inserted"] + r2 = client.post(f"/bank-sync/connections/{conn_id}/refresh", headers=auth_header) + inserted2 = r2.get_json()["inserted"] + # second refresh should find no new transactions (all duplicates) + assert inserted2 == 0 or inserted2 <= inserted1 + + def test_refresh_not_found(self, client, auth_header): + r = client.post("/bank-sync/connections/99999/refresh", headers=auth_header) + assert r.status_code == 404