add bankclient

This commit is contained in:
2026-04-24 20:30:35 +02:00
parent 5fb5194468
commit c178fbfce8
10 changed files with 2832 additions and 21 deletions
+5 -2
View File
@@ -1,6 +1,9 @@
# Enable Banking Credentials
EB_CLIENT_ID=your_client_id
EB_CLIENT_SECRET=your_client_secret
EB_APP_ID=your_app_id # This should be the Application ID provided by Enable Banking when you register your application.
EB_PEM_FILE=your_app_secret # This should be the path to the PEM file containing your private key for authentication with Enable Banking.
EB_BANK_NAME=your_bank_name # e.g. "Deutsche Bank", "N26", "Revolut", etc.
EB_COUNTRY_CODE=your_country_code # e.g. DE for Germany, US for United States, etc.
EB_API_BASE_URL=https://api.enablebanking.com # Base URL for Enable Banking API
# Actual Budget Configuration
ACTUAL_SERVER_URL=https://your-actual-server.com
+8 -2
View File
@@ -32,7 +32,7 @@ Automate your financial tracking without sacrificing privacy. Actualize pulls yo
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install actualpy enablebanking_sdk python-dotenv
pip install actualpy enablebanking_sdk python-dotenv fastapi "uvicorn[standard]"
```
3. **Configure environment variables:**
@@ -60,7 +60,13 @@ Automate your financial tracking without sacrificing privacy. Actualize pulls yo
2. **Sync:**
Run the sync script to pull transactions and push them to Actual.
```bash
python sync.py
python sync.py
```
3. **API Mode:**
Start the FastAPI server.
```bash
uvicorn src.main:app --reload
```
## 📦 Tech Stack
+52
View File
@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
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
EMFD4S9908gAmV1/qNe4pZKB6c26xmh0
-----END PRIVATE KEY-----
+3
View File
@@ -1,3 +1,6 @@
actualpy
enablebanking_sdk
python-dotenv
fastapi
uvicorn[standard]
PyJWT
+2539
View File
File diff suppressed because it is too large Load Diff
+5 -14
View File
@@ -1,17 +1,8 @@
import enablebanking
from .config import Config
from src.client.httpclient import BankClient as HttpBankClient
class BankClient:
class BankClient(HttpBankClient):
def __init__(self):
self.api_client = enablebanking.ApiClient(
client_id=Config.EB_CLIENT_ID,
client_secret=Config.EB_CLIENT_SECRET
)
def authenticate(self):
# Placeholder for bank authentication logic
pass
def fetch_transactions(self, account_id, start_date):
# Placeholder for fetching transactions from bank
pass
super().__init__()
def get_application_details(self):
return super().get_application_details()
View File
+151
View File
@@ -0,0 +1,151 @@
import os
import uuid
from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse, parse_qs
import requests
import jwt as pyjwt
from src.config import Config
class ActualClient:
def __init__(self):
self.base_url = Config.ACTUAL_SERVER_URL
self.password = Config.ACTUAL_PASSWORD
self.budget_id = Config.ACTUAL_BUDGET_ID
self.encryption_password = Config.ACTUAL_ENCRYPTION_PASSWORD
def push_transactions(self, transactions):
# TODO: Implement actualpy integration
pass
class BankClient:
def __init__(self):
self.app_id = Config.EB_APP_ID
self.pem_file = Config.EB_PEM_FILE
self.bank_name = Config.EB_BANK_NAME
self.country_code = Config.EB_COUNTRY_CODE
self.api_base_url = Config.EB_API_BASE_URL
self._app_details = None
@property
def token(self):
"""Generates a fresh JWT token for Enable Banking API."""
iat = int(datetime.now().timestamp())
payload = {
"iss": "enablebanking.com",
"aud": "api.enablebanking.com",
"iat": iat,
"exp": iat + 3600,
}
with open(self.pem_file, "rb") as f:
private_key = f.read()
return pyjwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": self.app_id},
)
def _get_headers(self):
return {"Authorization": f"Bearer {self.token}"}
def get_application_details(self):
"""Fetches and caches application details."""
if self._app_details:
return self._app_details
r = requests.get(f"{self.api_base_url}/application", headers=self._get_headers())
r.raise_for_status()
self._app_details = r.json()
return self._app_details
def get_auth_url(self, redirect_url=None, state=None):
"""Initiates bank authentication and returns the redirect URL."""
if not redirect_url:
app = self.get_application_details()
redirect_url = app["redirect_urls"][0]
body = {
"access": {
"valid_until": (datetime.now(timezone.utc) + timedelta(days=10)).isoformat()
},
"aspsp": {"name": self.bank_name, "country": self.country_code},
"state": state or str(uuid.uuid4()),
"redirect_url": redirect_url,
"psu_type": "personal",
}
r = requests.post(f"{self.api_base_url}/auth", json=body, headers=self._get_headers())
r.raise_for_status()
return r.json()["url"]
def create_session(self, redirected_url_or_code):
"""Exchanges the redirect URL or code for a user session."""
code = redirected_url_or_code
if "code=" in redirected_url_or_code:
code = parse_qs(urlparse(redirected_url_or_code).query)["code"][0]
r = requests.post(
f"{self.api_base_url}/sessions",
json={"code": code},
headers=self._get_headers()
)
r.raise_for_status()
return r.json()
def get_accounts(self, session_id):
"""Lists accounts associated with a session."""
r = requests.get(
f"{self.api_base_url}/sessions/{session_id}/accounts",
headers=self._get_headers()
)
r.raise_for_status()
return r.json().get("accounts", [])
def get_balances(self, account_id):
"""Retrieves balances for a specific account."""
r = requests.get(
f"{self.api_base_url}/accounts/{account_id}/balances",
headers=self._get_headers()
)
r.raise_for_status()
return r.json()
def fetch_transactions(self, account_id, date_from=None, date_to=None):
"""
Fetches transactions for a specific account with pagination support.
:param account_id: The unique identifier of the account.
:param date_from: ISO date string (YYYY-MM-DD). Defaults to 90 days ago.
:param date_to: ISO date string (YYYY-MM-DD). Defaults to today.
"""
if not date_from:
date_from = (datetime.now(timezone.utc) - timedelta(days=90)).date().isoformat()
query = {"date_from": date_from}
if date_to:
query["date_to"] = date_to
all_transactions = []
continuation_key = None
while True:
params = query.copy()
if continuation_key:
params["continuation_key"] = continuation_key
r = requests.get(
f"{self.api_base_url}/accounts/{account_id}/transactions",
params=params,
headers=self._get_headers(),
)
r.raise_for_status()
data = r.json()
all_transactions.extend(data.get("transactions", []))
continuation_key = data.get("continuation_key")
if not continuation_key:
break
return all_transactions
+7 -2
View File
@@ -4,10 +4,15 @@ from dotenv import load_dotenv
load_dotenv()
class Config:
EB_CLIENT_ID = os.getenv("EB_CLIENT_ID")
EB_CLIENT_SECRET = os.getenv("EB_CLIENT_SECRET")
EB_APP_ID = os.getenv("EB_APP_ID")
EB_PEM_FILE = os.getenv("EB_PEM_FILE")
EB_BANK_NAME = os.getenv("EB_BANK_NAME")
EB_COUNTRY_CODE = os.getenv("EB_COUNTRY_CODE")
EB_API_BASE_URL = os.getenv("EB_API_BASE_URL")
ACTUAL_SERVER_URL = os.getenv("ACTUAL_SERVER_URL")
ACTUAL_PASSWORD = os.getenv("ACTUAL_PASSWORD")
ACTUAL_BUDGET_ID = os.getenv("ACTUAL_BUDGET_ID")
ACTUAL_ENCRYPTION_PASSWORD = os.getenv("ACTUAL_ENCRYPTION_PASSWORD")
SERVER_PUBLIC_URL = os.getenv("SERVER_PUBLIC_URL")
SERVER_PORT = int(os.getenv("SERVER_PORT", 8000))
+61
View File
@@ -0,0 +1,61 @@
from fastapi import FastAPI
from src import client
from .bank_client import BankClient
from .actual_client import ActualClient
app = FastAPI(title="Actualize API")
@app.get("/")
async def root():
return {"message": "Actualize API is running"}
@app.post("/sync")
async def trigger_sync():
# Placeholder for sync logic
return {"status": "Sync initiated"}
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.get("/bank/application")
async def get_bank_application_details():
bank_client = BankClient()
details = bank_client.get_application_details()
return details
@app.get("/bank/auth")
async def get_bank_auth():
"""Returns a login URL for the bank."""
bank_client = BankClient()
auth_url = bank_client.get_auth_url()
return {"auth_url": auth_url}
@app.get("/bank/callback")
async def bank_callback(code: str):
"""Exchanges the 'code' from the bank redirect for a session_id."""
bank_client = BankClient()
session = bank_client.create_session(code)
return session
@app.post("/bank/transactions")
async def sync_bank_transactions(session_id: str):
bank_client = BankClient()
accounts = bank_client.get_accounts(session_id=session_id)
if not accounts:
return {"transactions": [], "message": "No accounts found for this session"}
# Use 'uid' as per Enable Banking API and fetch via the instance
account_uid = accounts[0]["uid"]
transactions = bank_client.fetch_transactions(
account_id=account_uid,
date_from="2024-01-01"
)
return {"transactions": transactions}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)