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
15 changes: 15 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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 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)
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 .bank_connectors import bp as bank_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(bank_connectors_bp, url_prefix="/bank-connectors")
345 changes: 345 additions & 0 deletions packages/backend/app/routes/bank_connectors.py
Original file line number Diff line number Diff line change
@@ -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("/<int:connector_id>")
@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("/<int:connector_id>")
@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("/<int:connector_id>/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("/<int:connector_id>/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("/<int:connector_id>/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(),
}
Loading