diff --git a/README.md b/README.md index 5352eec..bcb48a0 100644 --- a/README.md +++ b/README.md @@ -29,24 +29,48 @@ Having your bank data in a database, gives you the power to backup, analyze and - Sync all transactions with a SQLite or MongoDB database - Visualize and query transactions using NocoDB - Schedule regular syncs with the database using Ofelia + - Send notifications to Discrod when transactions match certain filters ## 🚀 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/. -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. + +### Example Configuration + +Create a configuration file at with the following content: + +```toml +[gocardless] +key = "your-api-key" +secret = "your-secret-key" +url = "https://bankaccountdata.gocardless.com/api/v2" + +[database] +sqlite = true + +[notifications.discord] +webhook = "https://discord.com/api/webhooks/..." + +[filters] +enabled = true + +[filters.case-insensitive] +filter1 = "company-name" +``` + +### Running Leggen with Docker + +After adapting the compose file, run the following command: ```bash $ 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 since you didn't connect any bank accounts yet. -```bash -$ docker compose run leggen init -``` - -Now you need to connect your bank accounts. Run the following command and follow the instructions: +Run the following command and follow the instructions: ```bash $ docker compose run leggen bank add @@ -67,15 +91,17 @@ Usage: leggen [OPTIONS] COMMAND [ARGS]... Leggen: An Open Banking CLI Options: - --version Show the version and exit. - -h, --help Show this message and exit. + --version Show the version and exit. + -c, --config FILE Path to TOML configuration file + [env var: LEGGEN_CONFIG_FILE; + default: ~/.config/leggen/config.toml] + -h, --help Show this message and exit. Command Groups: bank Manage banks connections Commands: balances List balances of all connected accounts - init Create configuration file status List all connected banks and their status sync Sync all transactions with database transactions List transactions diff --git a/docker-compose.yml b/docker-compose.yml index 91cd228..31c3648 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,13 +6,8 @@ services: image: elisiariocouto/leggen:latest command: sync restart: "no" - environment: - LEGGEN_GC_API_KEY: "changeme" - LEGGEN_GC_API_SECRET: "changeme" - # Uncomment the following lines if you use MongoDB - # LEGGEN_MONGO_URI: "mongodb://leggen:changeme@mongo:27017/leggen" volumes: - - "./leggen:/root/.config/leggen" + - "./leggen:/root/.config/leggen" # Default configuration file should be in this directory, named `config.toml` - "./db:/app" nocodb: diff --git a/leggen/commands/init.py b/leggen/commands/init.py deleted file mode 100644 index 7881020..0000000 --- a/leggen/commands/init.py +++ /dev/null @@ -1,72 +0,0 @@ -import click - -from leggen.main import cli -from leggen.utils.auth import get_token -from leggen.utils.config import save_config - - -@cli.command() -@click.option( - "--api-key", - prompt=True, - help="GoCardless API Key", - envvar="LEGGEN_GC_API_KEY", - show_envvar=True, -) -@click.option( - "--api-secret", - prompt=True, - help="GoCardless API Secret", - hide_input=True, - envvar="LEGGEN_GC_API_SECRET", - show_envvar=True, -) -@click.option( - "--api-url", - default="https://bankaccountdata.gocardless.com/api/v2", - help="GoCardless API URL", - show_default=True, - 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.pass_context -def init( - ctx: click.Context, - api_key: str, - api_secret: str, - api_url: str, - sqlite: bool, - mongo_uri: str, -): - """ - Create configuration file - """ - config = { - "api_key": api_key, - "api_secret": api_secret, - "api_url": api_url, - "sqlite": sqlite, - "mongo_uri": mongo_uri, - } - - # Just make sure this API credentials are valid - # if so, it will save the token in the auth file - _ = get_token(config) - - # Save the configuration - save_config(config) diff --git a/leggen/commands/sync.py b/leggen/commands/sync.py index 2f8baff..398b15a 100644 --- a/leggen/commands/sync.py +++ b/leggen/commands/sync.py @@ -92,7 +92,7 @@ def save_transactions(ctx: click.Context, account: str): } transactions.append(t) - sqlite = ctx.obj["sqlite"] + sqlite = ctx.obj.get("database", {}).get("sqlite", True) info( f"[{account}] Fetched {len(transactions)} transactions, saving to {'SQLite' if sqlite else 'MongoDB'}" ) @@ -119,5 +119,5 @@ def sync(ctx: click.Context): for account in accounts: try: save_transactions(ctx, account) - except Exception: - error(f"[{account}] Error: Sync failed, skipping account.") + except Exception as e: + error(f"[{account}] Error: Sync failed, skipping account, exception: {e}") diff --git a/leggen/main.py b/leggen/main.py index 0ddf4b9..b416c4b 100644 --- a/leggen/main.py +++ b/leggen/main.py @@ -74,30 +74,33 @@ class Group(click.Group): return getattr(mod, name) -@click.group(cls=Group, context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "-c", + "--config", + type=click.Path(dir_okay=False), + default=click.get_app_dir("leggen") / Path("config.toml"), + show_default=True, + callback=load_config, + is_eager=True, + expose_value=False, + envvar="LEGGEN_CONFIG_FILE", + show_envvar=True, + help="Path to TOML configuration file", +) +@click.group( + cls=Group, + context_settings={"help_option_names": ["-h", "--help"]}, +) @click.version_option(package_name="leggen") @click.pass_context def cli(ctx: click.Context): """ Leggen: An Open Banking CLI """ - ctx.ensure_object(dict) # Do not require authentication when printing help messages if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]: return - # or when running the init command - if ctx.invoked_subcommand == "init": - if (click.get_app_dir("leggen") / Path("config.json")).is_file(): - click.confirm( - "Configuration file already exists. Do you want to overwrite it?", - abort=True, - ) - return - config = load_config() - token = get_token(config) - ctx.obj["api_url"] = config["api_url"] - ctx.obj["sqlite"] = config["sqlite"] - ctx.obj["mongo_uri"] = config["mongo_uri"] + token = get_token(ctx) ctx.obj["headers"] = {"Authorization": f"Bearer {token}"} diff --git a/leggen/notifications/discord.py b/leggen/notifications/discord.py new file mode 100644 index 0000000..1107b6f --- /dev/null +++ b/leggen/notifications/discord.py @@ -0,0 +1,30 @@ +import click +from discord_webhook import DiscordEmbed, DiscordWebhook + +from leggen.utils.text import info + + +def send_message(ctx: click.Context, transactions: list): + info(f"Got {len(transactions)} new transactions, sending message to Discord") + webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"]) + + embed = DiscordEmbed( + title="", + description=f"{len(transactions)} new transaction matches", + color="03b2f8", + ) + embed.set_author( + name="Leggen", + url="https://github.com/elisiariocouto/leggen", + ) + embed.set_footer(text="Case-insensitive filters") + embed.set_timestamp() + for transaction in transactions: + embed.add_embed_field( + name=transaction["name"], + value=f"{transaction['value']}{transaction['currency']} ({transaction['date']})", + ) + + webhook.add_embed(embed) + response = webhook.execute() + response.raise_for_status() diff --git a/leggen/utils/auth.py b/leggen/utils/auth.py index 2f8b875..0ea0c75 100644 --- a/leggen/utils/auth.py +++ b/leggen/utils/auth.py @@ -7,13 +7,16 @@ import requests from leggen.utils.text import warning -def create_token(config: dict) -> str: +def create_token(ctx: click.Context) -> str: """ Create a new token """ res = requests.post( - f"{config['api_url']}/token/new/", - json={"secret_id": config["api_key"], "secret_key": config["api_secret"]}, + f"{ctx.obj['gocardless']['url']}/token/new/", + json={ + "secret_id": ctx.obj["gocardless"]["key"], + "secret_key": ctx.obj["gocardless"]["secret"], + }, ) res.raise_for_status() auth = res.json() @@ -21,7 +24,7 @@ def create_token(config: dict) -> str: return auth["access"] -def get_token(config: dict) -> str: +def get_token(ctx: click.Context) -> str: """ Get the token from the auth file or request a new one """ @@ -30,10 +33,11 @@ def get_token(config: dict) -> str: with click.open_file(str(auth_file), "r") as f: auth = json.load(f) if not auth.get("access"): - return create_token(config) + return create_token(ctx) res = requests.post( - f"{config['api_url']}/token/refresh/", json={"refresh": auth["refresh"]} + f"{ctx.obj['gocardless']['url']}/token/refresh/", + json={"refresh": auth["refresh"]}, ) try: res.raise_for_status() @@ -44,9 +48,9 @@ def get_token(config: dict) -> str: warning( f"Token probably expired, requesting a new one.\nResponse: {res.status_code}\n{res.text}" ) - return create_token(config) + return create_token(ctx) else: - return create_token(config) + return create_token(ctx) def save_auth(d: dict): diff --git a/leggen/utils/config.py b/leggen/utils/config.py index 3ec3eae..03a6f32 100644 --- a/leggen/utils/config.py +++ b/leggen/utils/config.py @@ -1,29 +1,18 @@ -import json import sys -from pathlib import Path import click +import tomllib -from leggen.utils.text import error, info +from leggen.utils.text import error -def save_config(d: dict): - Path.mkdir(Path(click.get_app_dir("leggen")), exist_ok=True) - config_file = click.get_app_dir("leggen") / Path("config.json") - - with click.open_file(str(config_file), "w") as f: - json.dump(d, f) - info(f"Wrote configuration file at '{config_file}'") - - -def load_config() -> dict: - config_file = click.get_app_dir("leggen") / Path("config.json") +def load_config(ctx: click.Context, _, filename): try: - with click.open_file(str(config_file), "r") as f: - config = json.load(f) - return config + with click.open_file(str(filename), "rb") as f: + # TODO: Implement configuration file validation (use pydantic?) + ctx.obj = tomllib.load(f) except FileNotFoundError: error( - "Configuration file not found. Run `leggen init` to configure your account." + "Configuration file not found. Provide a valid configuration file path with leggen --config or LEGGEN_CONFIG= environment variable." ) sys.exit(1) diff --git a/leggen/utils/mongo.py b/leggen/utils/mongo.py index 8d271bd..556aec4 100644 --- a/leggen/utils/mongo.py +++ b/leggen/utils/mongo.py @@ -2,12 +2,13 @@ import click from pymongo import MongoClient from pymongo.errors import DuplicateKeyError +from leggen.notifications.discord import send_message 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"] + mongo_uri = ctx.obj.get("database", {}).get("mongodb", {}).get("uri") client = MongoClient(mongo_uri) db = client["leggen"] transactions_collection = db["transactions"] @@ -19,14 +20,38 @@ def save_transactions(ctx: click.Context, account: str, transactions: list): new_transactions_count = 0 duplicates_count = 0 + notification_transactions = [] + filters_case_insensitive = {} + if ctx.obj.get("filters", {}).get("enabled", False): + filters_case_insensitive = ctx.obj.get("filters", {}).get( + "case-insensitive", {} + ) + for transaction in transactions: try: transactions_collection.insert_one(transaction) new_transactions_count += 1 + + # Add transaction to the list of transactions to be sent as a notification + for _, v in filters_case_insensitive.items(): + if v.lower() in transaction["description"].lower(): + notification_transactions.append( + { + "name": transaction["description"], + "value": transaction["transactionValue"], + "currency": transaction["transactionCurrency"], + "date": transaction["transactionDate"], + } + ) + except DuplicateKeyError: # A transaction with the same ID already exists, skip insertion duplicates_count += 1 + # Send a notification with the transactions that match the filters + if notification_transactions: + send_message(ctx, notification_transactions) + success(f"[{account}] Inserted {new_transactions_count} new transactions") if duplicates_count: warning(f"[{account}] Skipped {duplicates_count} duplicate transactions") diff --git a/leggen/utils/network.py b/leggen/utils/network.py index 4e673d0..242fd7c 100644 --- a/leggen/utils/network.py +++ b/leggen/utils/network.py @@ -9,7 +9,7 @@ def get(ctx: click.Context, path: str, params: dict = {}): GET request to the GoCardless API """ - url = f"{ctx.obj['api_url']}{path}" + url = f"{ctx.obj['gocardless']['url']}{path}" res = requests.get(url, headers=ctx.obj["headers"], params=params) try: res.raise_for_status() @@ -24,7 +24,7 @@ def post(ctx: click.Context, path: str, data: dict = {}): POST request to the GoCardless API """ - url = f"{ctx.obj['api_url']}{path}" + url = f"{ctx.obj['gocardless']['url']}{path}" res = requests.post(url, headers=ctx.obj["headers"], json=data) try: res.raise_for_status() @@ -39,7 +39,7 @@ def put(ctx: click.Context, path: str, data: dict = {}): PUT request to the GoCardless API """ - url = f"{ctx.obj['api_url']}{path}" + url = f"{ctx.obj['gocardless']['url']}{path}" res = requests.put(url, headers=ctx.obj["headers"], json=data) try: res.raise_for_status() @@ -54,7 +54,7 @@ def delete(ctx: click.Context, path: str): DELETE request to the GoCardless API """ - url = f"{ctx.obj['api_url']}{path}" + url = f"{ctx.obj['gocardless']['url']}{path}" res = requests.delete(url, headers=ctx.obj["headers"]) try: res.raise_for_status() diff --git a/leggen/utils/sqlite.py b/leggen/utils/sqlite.py index 2c0ac87..d930169 100644 --- a/leggen/utils/sqlite.py +++ b/leggen/utils/sqlite.py @@ -4,6 +4,7 @@ from sqlite3 import IntegrityError import click +from leggen.notifications.discord import send_message from leggen.utils.text import success, warning @@ -48,6 +49,13 @@ def save_transactions(ctx: click.Context, account: str, transactions: list): rawTransaction ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" + notification_transactions = [] + filters_case_insensitive = {} + if ctx.obj.get("filters", {}).get("enabled", False): + filters_case_insensitive = ctx.obj.get("filters", {}).get( + "case-insensitive", {} + ) + for transaction in transactions: try: cursor.execute( @@ -65,8 +73,19 @@ def save_transactions(ctx: click.Context, account: str, transactions: list): json.dumps(transaction["rawTransaction"]), ), ) - new_transactions_count += 1 + + # Add transaction to the list of transactions to be sent as a notification + for _, v in filters_case_insensitive.items(): + if v.lower() in transaction["description"].lower(): + notification_transactions.append( + { + "name": transaction["description"], + "value": transaction["transactionValue"], + "currency": transaction["transactionCurrency"], + "date": transaction["transactionDate"], + } + ) except IntegrityError: # A transaction with the same ID already exists, indicating a duplicate duplicates_count += 1 @@ -75,6 +94,10 @@ def save_transactions(ctx: click.Context, account: str, transactions: list): conn.commit() conn.close() + # Send a notification with the transactions that match the filters + if notification_transactions: + send_message(ctx, notification_transactions) + success(f"[{account}] Inserted {new_transactions_count} new transactions") if duplicates_count: warning(f"[{account}] Skipped {duplicates_count} duplicate transactions") diff --git a/poetry.lock b/poetry.lock index 704162e..25f6ec5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -190,6 +190,23 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "discord-webhook" +version = "1.3.1" +description = "Easily send Discord webhooks with Python" +optional = false +python-versions = ">=3.10,<4.0" +files = [ + {file = "discord_webhook-1.3.1-py3-none-any.whl", hash = "sha256:ede07028316de76d24eb811836e2b818b2017510da786777adcb0d5970e7af79"}, + {file = "discord_webhook-1.3.1.tar.gz", hash = "sha256:ee3e0f3ea4f3dc8dc42be91f75b894a01624c6c13fea28e23ebcf9a6c9a304f7"}, +] + +[package.dependencies] +requests = ">=2.28.1,<3.0.0" + +[package.extras] +async = ["httpx (>=0.23.0,<0.24.0)"] + [[package]] name = "distlib" version = "0.3.8" @@ -643,4 +660,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "eab47ff7c7ac94755c2b2abd62783312b678d8825570d359c8419c54fbff822d" +content-hash = "066efa29ad01e47892f68c061ebfc81eea6f8a541bb266ccef47011fdd4f5e89" diff --git a/pyproject.toml b/pyproject.toml index b13cdf2..66d9595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ requests = "^2.31.0" loguru = "^0.7.2" tabulate = "^0.9.0" pymongo = "^4.6.1" +discord-webhook = "^1.3.1" [tool.poetry.group.dev.dependencies] ruff = "^0.3.0"