add bankclient
This commit is contained in:
+5
-2
@@ -1,6 +1,9 @@
|
|||||||
# Enable Banking Credentials
|
# Enable Banking Credentials
|
||||||
EB_CLIENT_ID=your_client_id
|
EB_APP_ID=your_app_id # This should be the Application ID provided by Enable Banking when you register your application.
|
||||||
EB_CLIENT_SECRET=your_client_secret
|
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 Budget Configuration
|
||||||
ACTUAL_SERVER_URL=https://your-actual-server.com
|
ACTUAL_SERVER_URL=https://your-actual-server.com
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Automate your financial tracking without sacrificing privacy. Actualize pulls yo
|
|||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
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:**
|
3. **Configure environment variables:**
|
||||||
@@ -60,7 +60,13 @@ Automate your financial tracking without sacrificing privacy. Actualize pulls yo
|
|||||||
2. **Sync:**
|
2. **Sync:**
|
||||||
Run the sync script to pull transactions and push them to Actual.
|
Run the sync script to pull transactions and push them to Actual.
|
||||||
```bash
|
```bash
|
||||||
python sync.py
|
python sync.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **API Mode:**
|
||||||
|
Start the FastAPI server.
|
||||||
|
```bash
|
||||||
|
uvicorn src.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 Tech Stack
|
## 📦 Tech Stack
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
EMFD4S9908gAmV1/qNe4pZKB6c26xmh0
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
actualpy
|
actualpy
|
||||||
enablebanking_sdk
|
enablebanking_sdk
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
PyJWT
|
||||||
|
|||||||
+2539
File diff suppressed because it is too large
Load Diff
+5
-14
@@ -1,17 +1,8 @@
|
|||||||
import enablebanking
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from src.client.httpclient import BankClient as HttpBankClient
|
||||||
|
|
||||||
class BankClient:
|
class BankClient(HttpBankClient):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_client = enablebanking.ApiClient(
|
super().__init__()
|
||||||
client_id=Config.EB_CLIENT_ID,
|
def get_application_details(self):
|
||||||
client_secret=Config.EB_CLIENT_SECRET
|
return super().get_application_details()
|
||||||
)
|
|
||||||
|
|
||||||
def authenticate(self):
|
|
||||||
# Placeholder for bank authentication logic
|
|
||||||
pass
|
|
||||||
|
|
||||||
def fetch_transactions(self, account_id, start_date):
|
|
||||||
# Placeholder for fetching transactions from bank
|
|
||||||
pass
|
|
||||||
@@ -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
|
||||||
+8
-3
@@ -4,10 +4,15 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
EB_CLIENT_ID = os.getenv("EB_CLIENT_ID")
|
EB_APP_ID = os.getenv("EB_APP_ID")
|
||||||
EB_CLIENT_SECRET = os.getenv("EB_CLIENT_SECRET")
|
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_SERVER_URL = os.getenv("ACTUAL_SERVER_URL")
|
||||||
ACTUAL_PASSWORD = os.getenv("ACTUAL_PASSWORD")
|
ACTUAL_PASSWORD = os.getenv("ACTUAL_PASSWORD")
|
||||||
ACTUAL_BUDGET_ID = os.getenv("ACTUAL_BUDGET_ID")
|
ACTUAL_BUDGET_ID = os.getenv("ACTUAL_BUDGET_ID")
|
||||||
ACTUAL_ENCRYPTION_PASSWORD = os.getenv("ACTUAL_ENCRYPTION_PASSWORD")
|
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
@@ -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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user