Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,16 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

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,
credentials TEXT,
settings TEXT,
enabled 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);
14 changes: 14 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 BankConnectorConfig(db.Model):
"""Stores configuration for bank connectors."""
__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)
credentials = db.Column(db.Text, nullable=True) # JSON encrypted storage
settings = db.Column(db.Text, nullable=True) # JSON storage
enabled = 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)
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 .connectors import bp as connectors_bp


def register_routes(app: Flask):
Expand All @@ -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(connectors_bp, url_prefix="/connectors")
307 changes: 307 additions & 0 deletions packages/backend/app/routes/connectors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
"""
Routes for bank connector management.
"""

import json
import logging
from datetime import datetime

from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required

from ..extensions import db
from ..models import BankConnectorConfig
from ..services.connectors import (
BankConnector,
ConnectorConfig,
ConnectorRegistry,
get_connector,
)

logger = logging.getLogger("finmind.connectors")

bp = Blueprint("connectors", __name__)


@bp.get("")
@jwt_required()
def list_connectors():
"""List all bank connectors for the current user."""
uid = int(get_jwt_identity())
connectors = db.session.query(BankConnectorConfig).filter_by(user_id=uid).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

# Check if connector type is registered
if not ConnectorRegistry.get_connector_class(connector_type):
return jsonify(
error=f"Unknown connector type: {connector_type}. "
f"Available: {ConnectorRegistry.list_connectors()}"
), 400

credentials = data.get("credentials", {})
settings = data.get("settings", {})

# Create connector config in database
config = BankConnectorConfig(
user_id=uid,
connector_type=connector_type,
credentials=json.dumps(credentials),
settings=json.dumps(settings),
enabled=data.get("enabled", True),
)
db.session.add(config)
db.session.commit()

logger.info("Created connector id=%s type=%s user=%s", config.id, connector_type, uid)
return jsonify(_connector_to_dict(config)), 201


@bp.get("/types")
@jwt_required()
def list_connector_types():
"""List all available connector types."""
return jsonify(ConnectorRegistry.list_connectors())


@bp.get("/<int:connector_id>")
@jwt_required()
def get_connector_detail(connector_id: int):
"""Get details of a specific connector."""
uid = int(get_jwt_identity())
config = db.session.get(BankConnectorConfig, connector_id)
if not config or config.user_id != uid:
return jsonify(error="not found"), 404

# Try to test the connection
connector = _create_connector_from_db(config)
connection_status = {"connected": False}

if connector:
connection_status = {
"connected": connector.test_connection(),
"connector_type": config.connector_type,
}

result = _connector_to_dict(config)
result["connection_status"] = connection_status
return jsonify(result)


@bp.patch("/<int:connector_id>")
@jwt_required()
def update_connector(connector_id: int):
"""Update a connector configuration."""
uid = int(get_jwt_identity())
config = db.session.get(BankConnectorConfig, connector_id)
if not config or config.user_id != uid:
return jsonify(error="not found"), 404

data = request.get_json() or {}

if "credentials" in data:
config.credentials = json.dumps(data["credentials"])
if "settings" in data:
config.settings = json.dumps(data["settings"])
if "enabled" in data:
config.enabled = bool(data["enabled"])

config.updated_at = datetime.utcnow()
db.session.commit()

logger.info("Updated connector id=%s user=%s", connector_id, uid)
return jsonify(_connector_to_dict(config))


@bp.delete("/<int:connector_id>")
@jwt_required()
def delete_connector(connector_id: int):
"""Delete a connector configuration."""
uid = int(get_jwt_identity())
config = db.session.get(BankConnectorConfig, connector_id)
if not config or config.user_id != uid:
return jsonify(error="not found"), 404

db.session.delete(config)
db.session.commit()

logger.info("Deleted connector id=%s user=%s", connector_id, uid)
return jsonify({"deleted": True})


@bp.post("/<int:connector_id>/test")
@jwt_required()
def test_connector(connector_id: int):
"""Test a connector connection."""
uid = int(get_jwt_identity())
config = db.session.get(BankConnectorConfig, connector_id)
if not config or config.user_id != uid:
return jsonify(error="not found"), 404

connector = _create_connector_from_db(config)
if not connector:
return jsonify(error="Connector type not found"), 400

try:
result = connector.test_connection()
return jsonify({"success": result, "connector_type": config.connector_type})
except Exception as e:
logger.error("Connector test failed: %s", str(e))
return jsonify(error=str(e)), 500


@bp.post("/<int:connector_id>/import")
@jwt_required()
def import_transactions(connector_id: int):
"""Import transactions from a connector."""
uid = int(get_jwt_identity())
config = db.session.get(BankConnectorConfig, connector_id)
if not config or config.user_id != uid:
return jsonify(error="not found"), 404

if not config.enabled:
return jsonify(error="Connector is disabled"), 400

connector = _create_connector_from_db(config)
if not connector:
return jsonify(error="Connector type not found"), 400

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. Use YYYY-MM-DD"), 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. Use YYYY-MM-DD"), 400

try:
transactions = connector.import_transactions(from_date_parsed, to_date_parsed)

# Update last sync time
config.last_sync = datetime.utcnow()
db.session.commit()

logger.info(
"Imported %d transactions from connector %s user=%s",
len(transactions),
connector_id,
uid,
)

return jsonify({
"imported": len(transactions),
"transactions": [t.to_dict() for t in transactions],
})
except Exception as e:
logger.error("Import failed: %s", str(e))
return jsonify(error=str(e)), 500


@bp.post("/<int:connector_id>/refresh")
@jwt_required()
def refresh_connector(connector_id: int):
"""Refresh a connector connection."""
uid = int(get_jwt_identity())
config = db.session.get(BankConnectorConfig, connector_id)
if not config or config.user_id != uid:
return jsonify(error="not found"), 404

connector = _create_connector_from_db(config)
if not connector:
return jsonify(error="Connector type not found"), 400

try:
result = connector.refresh()
config.last_sync = datetime.utcnow()
db.session.commit()
return jsonify(result)
except Exception as e:
logger.error("Refresh failed: %s", str(e))
return jsonify(error=str(e)), 500


def _create_connector_from_db(config: BankConnectorConfig) -> "BankConnector | None":
"""Create a connector instance from database config."""
connector_class = ConnectorRegistry.get_connector_class(config.connector_type)
if not connector_class:
return None

credentials = {}
settings = {}

if config.credentials:
try:
credentials = json.loads(config.credentials)
except json.JSONDecodeError:
pass

if config.settings:
try:
settings = json.loads(config.settings)
except json.JSONDecodeError:
pass

connector_config = ConnectorConfig(
id=config.id,
user_id=config.user_id,
connector_type=config.connector_type,
credentials=credentials,
settings=settings,
enabled=config.enabled,
last_sync=config.last_sync,
)

return connector_class(connector_config)


def _connector_to_dict(config: BankConnectorConfig) -> dict:
"""Convert connector config to dictionary."""
credentials = {}
settings = {}

if config.credentials:
try:
credentials = json.loads(config.credentials)
except json.JSONDecodeError:
pass

if config.settings:
try:
settings = json.loads(config.settings)
except json.JSONDecodeError:
pass

return {
"id": config.id,
"user_id": config.user_id,
"connector_type": config.connector_type,
"credentials": credentials,
"settings": settings,
"enabled": config.enabled,
"last_sync": config.last_sync.isoformat() if config.last_sync else None,
"created_at": config.created_at.isoformat(),
"updated_at": config.updated_at.isoformat(),
}
17 changes: 17 additions & 0 deletions packages/backend/app/services/connectors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
Bank Sync Connector Architecture

This module provides a pluggable architecture for bank integrations.
"""

from .base import BankConnector, ConnectorConfig, Transaction, TransactionType
from .registry import ConnectorRegistry, get_connector

__all__ = [
"BankConnector",
"ConnectorConfig",
"Transaction",
"TransactionType",
"ConnectorRegistry",
"get_connector",
]
Loading