Compare commits

...

9 Commits
0.1.1 ... 0.2.1

Author SHA1 Message Date
Elisiário Couto
f3ad639a01 chore(ci): Bump version to 0.2.1 2024-02-29 17:15:57 +00:00
Elisiário Couto
facf6ac94e fix: Deduplicate accounts. 2024-02-29 17:15:52 +00:00
Elisiário Couto
d8fde49da4 docs: Add NocoDB information to README.md. 2024-02-27 00:51:45 +00:00
Elisiário Couto
460fed3ed0 fix: Fix compose volumes and dependencies. 2024-02-27 00:49:48 +00:00
Elisiário Couto
78b08c17ee chore(ci): Bump version to 0.2.0 2024-02-27 00:31:16 +00:00
Elisiário Couto
f9ab3ae0a8 feat: Change default database engine to SQLite, change schema. 2024-02-27 00:29:19 +00:00
Elisiário Couto
433d17371e fix(compose): Fix ofelia configuration, add sync command as the default. 2024-02-20 00:30:23 +00:00
Elisiário Couto
de17cf44ec docs: Improve README.md. 2024-02-19 00:53:56 +00:00
Elisiário Couto
91c74b0412 feat: Add periodic sync, handled by ofelia. 2024-02-19 00:26:59 +00:00
13 changed files with 362 additions and 82 deletions

2
.gitignore vendored
View File

@@ -160,3 +160,5 @@ cython_debug/
#.idea/ #.idea/
data/ data/
docker-compose.dev.yml docker-compose.dev.yml
nocodb/
sql/

View File

@@ -1,3 +1,34 @@
## 0.2.1 (2024/02/29)
### Bug Fixes
- Fix compose volumes and dependencies. ([460fed3e](https://github.com/elisiariocouto/leggen/commit/460fed3ed0ca694eab6e80f98392edbe5d5b83fd))
- Deduplicate accounts. ([facf6ac9](https://github.com/elisiariocouto/leggen/commit/facf6ac94e533087846fca297520c311a81b6692))
### Documentation
- Add NocoDB information to README.md. ([d8fde49d](https://github.com/elisiariocouto/leggen/commit/d8fde49da4e34457a7564655dd42bb6f0d427b4b))
## 0.2.0 (2024/02/27)
### Bug Fixes
- **compose:** Fix ofelia configuration, add sync command as the default. ([433d1737](https://github.com/elisiariocouto/leggen/commit/433d17371ead323ca9b793a2dd5782cca598ffcf))
### Documentation
- Improve README.md. ([de17cf44](https://github.com/elisiariocouto/leggen/commit/de17cf44ec5260305de8aa053582744ec69d705f))
### Features
- Add periodic sync, handled by ofelia. ([91c74b04](https://github.com/elisiariocouto/leggen/commit/91c74b0412713ef8305fbe7fcf7c53e4cf8948fe))
- Change default database engine to SQLite, change schema. ([f9ab3ae0](https://github.com/elisiariocouto/leggen/commit/f9ab3ae0a813f2a512b4f5fa57e0da089f823783))
## 0.1.1 (2024/02/18) ## 0.1.1 (2024/02/18)
### Bug Fixes ### Bug Fixes

View File

@@ -1,22 +1,36 @@
# leggen # 💲 leggen
An Open Banking CLI. An Open Banking CLI.
## Features This tool aims to provide a simple way to connect to banks using the GoCardless Open Banking API.
Having a simple CLI tool to connect to banks and list transactions can be very useful for developers and companies that need to access bank data.
Having your bank data in a database, gives you the power to backup, analyze and create reports with your data.
## 🛠️ Technologies
- Python: for the CLI
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
- [NocoDB](https://github.com/nocodb/nocodb): for visualizing and querying transactions, a simple and easy to use interface for SQLite
- [Ofelia](https://github.com/mcuadros/ofelia): for scheduling regular syncs with the database when using Docker
- [MongoDB](https://www.mongodb.com/docs/): alternative store for transactions, good balance between performance and query capabilities
## ✨ Features
- Connect to banks using GoCardless Open Banking API - Connect to banks using GoCardless Open Banking API
- List all connected banks and their status - List all connected banks and their status
- List balances of all connected accounts - List balances of all connected accounts
- List transactions for an account - List transactions for all connected accounts
- Sync all transactions with a MongoDB database - Sync all transactions with a MongoDB database
## Installation and Configuration ## 🚀 Installation and Configuration
In order to use `leggen`, you need to create a GoCardless account. GoCardless is a service that provides access to Open Banking APIs. You can create an account at https://gocardless.com/bank-account-data/. In order to use `leggen`, you need to create a GoCardless account. GoCardless is a service that provides access to Open Banking APIs. You can create an account at https://gocardless.com/bank-account-data/.
After creating an account and getting your API keys, the best way is to use the [compose file](docker-compose.yml). Open the file and adapt it to your needs. Then run the following command: After creating an account and getting your API keys, the best way is to use the [compose file](docker-compose.yml). Open the file and adapt it to your needs. Then run the following command:
```bash ```bash
$ docker-compose up -d $ docker compose up -d
``` ```
The leggen container will exit, this is expected. Now you can run the following command to create the configuration file: The leggen container will exit, this is expected. Now you can run the following command to create the configuration file:
@@ -37,7 +51,7 @@ To sync all transactions with the database, run the following command:
$ docker compose run leggen sync $ docker compose run leggen sync
``` ```
## Usage ## 👩‍🏫 Usage
``` ```
$ leggen --help $ leggen --help
@@ -60,5 +74,5 @@ Commands:
transactions List transactions for an account transactions List transactions for an account
``` ```
## Caveats ## ⚠️ Caveats
- This project is still in early development. - This project is still in early development, breaking changes may occur.

View File

@@ -1,31 +1,58 @@
version: '3.1' version: '3.1'
services: services:
mongo: # Defaults to `sync` command.
image: mongo:7
restart: "unless-stopped"
# If you want to expose the mongodb port to the host, uncomment the following lines
# ports:
# - 127.0.0.1:27017:27017
volumes:
- "./data:/data/db"
environment:
MONGO_INITDB_ROOT_USERNAME: "leggen"
MONGO_INITDB_ROOT_PASSWORD: "changeme"
leggen: leggen:
image: elisiariocouto/leggen:latest image: elisiariocouto/leggen:latest
command: sync
restart: "no" restart: "no"
environment: environment:
LEGGEN_MONGO_URI: mongodb://leggen:changeme@mongo:27017/
LEGGEN_GC_API_KEY: "changeme" LEGGEN_GC_API_KEY: "changeme"
LEGGEN_GC_API_SECRET: "changeme" LEGGEN_GC_API_SECRET: "changeme"
# Uncomment the following lines if you use MongoDB
# LEGGEN_MONGO_URI: "mongodb://leggen:changeme@mongo:27017/leggen"
volumes: volumes:
- "./leggen:/root/.config/leggen" - "./leggen:/root/.config/leggen"
depends_on: - "./db:/app"
- mongo
# If you want to have an admin interface for your mongodb, uncomment the following lines nocodb:
image: nocodb/nocodb:latest
restart: "unless-stopped"
volumes:
- "./nocodb:/usr/app/data/"
- "./db:/usr/leggen:ro"
ports:
- "127.0.0.1:8080:8080"
depends_on:
- leggen
# Recommended: Run `leggen sync` every day.
ofelia:
image: mcuadros/ofelia:latest
restart: "unless-stopped"
depends_on:
- leggen
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
ofelia.job-run.leggen-sync.schedule: "0 0 3 * * *"
ofelia.job-run.leggen-sync.container: ${COMPOSE_PROJECT_NAME}-leggen-1
# Optional: If you want to have a mongodb, uncomment the following lines
# mongo:
# image: mongo:7
# restart: "unless-stopped"
# # If you want to expose the mongodb port to the host, uncomment the following lines
# # ports:
# # - 127.0.0.1:27017:27017
# volumes:
# - "./data:/data/db"
# environment:
# MONGO_INITDB_ROOT_USERNAME: "leggen"
# MONGO_INITDB_ROOT_PASSWORD: "changeme"
# Optional: If you want to have an admin interface for your mongodb, uncomment the following lines
# mongo-express: # mongo-express:
# image: mongo-express # image: mongo-express
# restart: "unless-stopped" # restart: "unless-stopped"

View File

@@ -13,9 +13,9 @@ def balances(ctx: click.Context):
""" """
res = get(ctx, "/requisitions/") res = get(ctx, "/requisitions/")
accounts = [] accounts = set()
for r in res.get("results", []): for r in res.get("results", []):
accounts += r.get("accounts", []) accounts.update(r.get("accounts", []))
all_balances = [] all_balances = []
for account in accounts: for account in accounts:

View File

@@ -7,7 +7,11 @@ from leggen.utils.config import save_config
@cli.command() @cli.command()
@click.option( @click.option(
"--api-key", prompt=True, help="GoCardless API Key", envvar="LEGGEN_GC_API_KEY" "--api-key",
prompt=True,
help="GoCardless API Key",
envvar="LEGGEN_GC_API_KEY",
show_envvar=True,
) )
@click.option( @click.option(
"--api-secret", "--api-secret",
@@ -15,6 +19,7 @@ from leggen.utils.config import save_config
help="GoCardless API Secret", help="GoCardless API Secret",
hide_input=True, hide_input=True,
envvar="LEGGEN_GC_API_SECRET", envvar="LEGGEN_GC_API_SECRET",
show_envvar=True,
) )
@click.option( @click.option(
"--api-url", "--api-url",
@@ -22,10 +27,32 @@ from leggen.utils.config import save_config
help="GoCardless API URL", help="GoCardless API URL",
show_default=True, show_default=True,
envvar="LEGGEN_GC_API_URL", envvar="LEGGEN_GC_API_URL",
show_envvar=True,
)
@click.option(
"--sqlite/--mongo",
prompt=True,
default=True,
help="Use SQLite or MongoDB",
show_default=True,
)
@click.option(
"--mongo-uri",
prompt=True,
help="MongoDB URI",
envvar="LEGGEN_MONGO_URI",
show_envvar=True,
default="mongodb://localhost:27017",
) )
@click.option("--mongo-uri", prompt=True, help="MongoDB URI", envvar="LEGGEN_MONGO_URI")
@click.pass_context @click.pass_context
def init(ctx: click.Context, api_key, api_secret, api_url, mongo_uri): def init(
ctx: click.Context,
api_key: str,
api_secret: str,
api_url: str,
sqlite: bool,
mongo_uri: str,
):
""" """
Create configuration file Create configuration file
""" """
@@ -33,6 +60,7 @@ def init(ctx: click.Context, api_key, api_secret, api_url, mongo_uri):
"api_key": api_key, "api_key": api_key,
"api_secret": api_secret, "api_secret": api_secret,
"api_url": api_url, "api_url": api_url,
"sqlite": sqlite,
"mongo_uri": mongo_uri, "mongo_uri": mongo_uri,
} }

View File

@@ -15,7 +15,7 @@ def status(ctx: click.Context):
res = get(ctx, "/requisitions/") res = get(ctx, "/requisitions/")
requisitions = [] requisitions = []
accounts = [] accounts = set()
for r in res["results"]: for r in res["results"]:
requisitions.append( requisitions.append(
{ {
@@ -24,7 +24,7 @@ def status(ctx: click.Context):
"Created at": datefmt(r["created"]), "Created at": datefmt(r["created"]),
} }
) )
accounts += r.get("accounts", []) accounts.update(r.get("accounts", []))
info("Banks") info("Banks")
print_table(requisitions) print_table(requisitions)

View File

@@ -1,55 +1,99 @@
from datetime import datetime
import click import click
from pymongo import MongoClient
from pymongo.errors import DuplicateKeyError
from leggen.main import cli from leggen.main import cli
from leggen.utils.mongo import save_transactions as save_transactions_mongo
from leggen.utils.network import get from leggen.utils.network import get
from leggen.utils.text import error, info, success, warning from leggen.utils.sqlite import save_transactions as save_transactions_sqlite
from leggen.utils.text import error, info
def save_transactions(ctx: click.Context, account: str): def save_transactions(ctx: click.Context, account: str):
info(f"[{account}] Getting account details")
account_info = get(ctx, f"/accounts/{account}")
info(f"[{account}] Getting transactions") info(f"[{account}] Getting transactions")
all_transactions = [] transactions = []
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get( account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
"transactions", [] "transactions", []
) )
for transaction in account_transactions.get("booked", []): for transaction in account_transactions.get("booked", []):
transaction["accountId"] = account booked_date = datetime.fromisoformat(
transaction["transactionStatus"] = "booked" transaction.get("bookingDateTime", transaction.get("bookingDate"))
all_transactions.append(transaction) )
value_date = datetime.fromisoformat(
transaction.get("valueDateTime", transaction.get("valueDate"))
)
min_date = min(booked_date, value_date)
transactionValue = float(
transaction.get("transactionAmount", {}).get("amount", 0)
)
currency = transaction.get("transactionAmount", {}).get("currency", "")
description = transaction.get(
"remittanceInformationUnstructured",
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
t = {
"internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
"description": description,
"transactionValue": transactionValue,
"transactionCurrency": currency,
"transactionStatus": "booked",
"accountId": account,
"rawTransaction": transaction,
}
transactions.append(t)
for transaction in account_transactions.get("pending", []): for transaction in account_transactions.get("pending", []):
transaction["accountId"] = account booked_date = datetime.fromisoformat(
transaction["transactionStatus"] = "pending" transaction.get("bookingDateTime", transaction.get("bookingDate"))
all_transactions.append(transaction) )
value_date = datetime.fromisoformat(
transaction.get("valueDateTime", transaction.get("valueDate"))
)
min_date = min(booked_date, value_date)
info(f"[{account}] Fetched {len(all_transactions)} transactions, saving to MongoDB") transactionValue = float(
transaction.get("transactionAmount", {}).get("amount", 0)
)
currency = transaction.get("transactionAmount", {}).get("currency", "")
# Connect to MongoDB description = transaction.get(
mongo_uri = ctx.obj["mongo_uri"] "remittanceInformationUnstructured",
client = MongoClient(mongo_uri) ",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
db = client["leggen"] )
transactions_collection = db["transactions"]
# Create a unique index on transactionId t = {
transactions_collection.create_index("transactionId", unique=True) "internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
"description": description,
"transactionValue": transactionValue,
"transactionCurrency": currency,
"transactionStatus": "pending",
"accountId": account,
"rawTransaction": transaction,
}
transactions.append(t)
# Insert transactions into MongoDB sqlite = ctx.obj["sqlite"]
new_transactions_count = 0 info(
duplicates_count = 0 f"[{account}] Fetched {len(transactions)} transactions, saving to {'SQLite' if sqlite else 'MongoDB'}"
)
for transaction in all_transactions: if sqlite:
try: save_transactions_sqlite(ctx, account, transactions)
transactions_collection.insert_one(transaction) else:
new_transactions_count += 1 save_transactions_mongo(ctx, account, transactions)
except DuplicateKeyError:
# A transaction with the same ID already exists, skip insertion
duplicates_count += 1
success(f"[{account}] Inserted {new_transactions_count} new transactions")
if duplicates_count:
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
@cli.command() @cli.command()
@@ -60,10 +104,9 @@ def sync(ctx: click.Context):
""" """
info("Getting accounts details") info("Getting accounts details")
res = get(ctx, "/requisitions/") res = get(ctx, "/requisitions/")
accounts = [] accounts = set()
for r in res.get("results", []): for r in res.get("results", []):
accounts += r.get("accounts", []) accounts.update(r.get("accounts", []))
accounts = list(set(accounts))
info(f"Syncing transactions for {len(accounts)} accounts") info(f"Syncing transactions for {len(accounts)} accounts")

View File

@@ -2,23 +2,15 @@ import click
from leggen.main import cli from leggen.main import cli
from leggen.utils.network import get from leggen.utils.network import get
from leggen.utils.text import print_table from leggen.utils.text import info, print_table
@cli.command() def print_transactions(
@click.argument("account", type=str) ctx: click.Context, account_info: dict, account_transactions: dict
@click.pass_context ):
def transactions(ctx: click.Context, account: str): info(f"Bank: {account_info['institution_id']}")
""" info(f"IBAN: {account_info.get('iban', 'N/A')}")
List transactions for an account
ACCOUNT is the account id, see 'leggen status' for the account ids
"""
all_transactions = [] all_transactions = []
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
"transactions", []
)
for transaction in account_transactions.get("booked", []): for transaction in account_transactions.get("booked", []):
transaction["TYPE"] = "booked" transaction["TYPE"] = "booked"
all_transactions.append(transaction) all_transactions.append(transaction)
@@ -28,3 +20,33 @@ def transactions(ctx: click.Context, account: str):
all_transactions.append(transaction) all_transactions.append(transaction)
print_table(all_transactions) print_table(all_transactions)
@cli.command()
@click.option("-a", "--account", type=str, help="Account ID")
@click.pass_context
def transactions(ctx: click.Context, account: str):
"""
List transactions
By default, this command lists all transactions for all accounts.
If the --account option is used, it will only list transactions for that account.
"""
if account:
account_info = get(ctx, f"/accounts/{account}")
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
"transactions", []
)
print_transactions(ctx, account_info, account_transactions)
else:
res = get(ctx, "/requisitions/")
accounts = set()
for r in res["results"]:
accounts.update(r.get("accounts", []))
for account in accounts:
account_details = get(ctx, f"/accounts/{account}")
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
"transactions", []
)
print_transactions(ctx, account_details, account_transactions)

View File

@@ -98,5 +98,6 @@ def cli(ctx: click.Context):
config = load_config() config = load_config()
token = get_token(config) token = get_token(config)
ctx.obj["api_url"] = config["api_url"] ctx.obj["api_url"] = config["api_url"]
ctx.obj["sqlite"] = config["sqlite"]
ctx.obj["mongo_uri"] = config["mongo_uri"] ctx.obj["mongo_uri"] = config["mongo_uri"]
ctx.obj["headers"] = {"Authorization": f"Bearer {token}"} ctx.obj["headers"] = {"Authorization": f"Bearer {token}"}

32
leggen/utils/mongo.py Normal file
View File

@@ -0,0 +1,32 @@
import click
from pymongo import MongoClient
from pymongo.errors import DuplicateKeyError
from leggen.utils.text import success, warning
def save_transactions(ctx: click.Context, account: str, transactions: list):
# Connect to MongoDB
mongo_uri = ctx.obj["mongo_uri"]
client = MongoClient(mongo_uri)
db = client["leggen"]
transactions_collection = db["transactions"]
# Create a unique index on internalTransactionId
transactions_collection.create_index("internalTransactionId", unique=True)
# Insert transactions into MongoDB
new_transactions_count = 0
duplicates_count = 0
for transaction in transactions:
try:
transactions_collection.insert_one(transaction)
new_transactions_count += 1
except DuplicateKeyError:
# A transaction with the same ID already exists, skip insertion
duplicates_count += 1
success(f"[{account}] Inserted {new_transactions_count} new transactions")
if duplicates_count:
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")

80
leggen/utils/sqlite.py Normal file
View File

@@ -0,0 +1,80 @@
import json
import sqlite3
from sqlite3 import IntegrityError
import click
from leggen.utils.text import success, warning
def save_transactions(ctx: click.Context, account: str, transactions: list):
# Path to your SQLite database file
# Connect to SQLite database
conn = sqlite3.connect("./leggen.db")
cursor = conn.cursor()
# Create the transactions table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS transactions (
internalTransactionId TEXT PRIMARY KEY,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
accountId TEXT,
rawTransaction JSON
)"""
)
# Insert transactions into SQLite database
new_transactions_count = 0
duplicates_count = 0
# Prepare an SQL statement for inserting data
insert_sql = """INSERT INTO transactions (
internalTransactionId,
institutionId,
iban,
transactionDate,
description,
transactionValue,
transactionCurrency,
transactionStatus,
accountId,
rawTransaction
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
for transaction in transactions:
try:
cursor.execute(
insert_sql,
(
transaction["internalTransactionId"],
transaction["institutionId"],
transaction["iban"],
transaction["transactionDate"],
transaction["description"],
transaction["transactionValue"],
transaction["transactionCurrency"],
transaction["transactionStatus"],
transaction["accountId"],
json.dumps(transaction["rawTransaction"]),
),
)
new_transactions_count += 1
except IntegrityError:
# A transaction with the same ID already exists, indicating a duplicate
duplicates_count += 1
# Commit changes and close the connection
conn.commit()
conn.close()
success(f"[{account}] Inserted {new_transactions_count} new transactions")
if duplicates_count:
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "leggen" name = "leggen"
version = "0.1.1" version = "0.2.1"
description = "An Open Banking CLI" description = "An Open Banking CLI"
authors = ["Elisiário Couto <elisiario@couto.io>"] authors = ["Elisiário Couto <elisiario@couto.io>"]
readme = "README.md" readme = "README.md"