From 4b5773aa131b46db7812ff24cde5c08615b07ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Fri, 23 Sep 2022 20:34:45 +0100 Subject: [PATCH] Restructes project and adds basic Nordigen API To improve the code organization, additional subfolders were added into the pfbudget module. The barebones of renewing the token and downloading transactions directly from the banks through the Nordigen API were also added. Issues #13, #15 --- pfbudget/__init__.py | 6 +- pfbudget/__main__.py | 4 ++ pfbudget/cli/__init__.py | 0 pfbudget/{ => cli}/runnable.py | 73 ++++++++++++++++++++---- pfbudget/core/__init__.py | 0 pfbudget/{ => core}/categories.py | 4 +- pfbudget/core/input/__init__.py | 0 pfbudget/core/input/nordigen.py | 74 +++++++++++++++++++++++++ pfbudget/{ => core/input}/parsers.py | 9 +-- pfbudget/{ => core}/transactions.py | 0 pfbudget/{database.py => db/manager.py} | 2 +- pfbudget/reporting/__init__.py | 0 pfbudget/{ => reporting}/graph.py | 49 ++++++++-------- pfbudget/{ => reporting}/report.py | 21 +++---- pfbudget/utils/__init__.py | 0 pfbudget/{ => utils}/utils.py | 1 - 16 files changed, 184 insertions(+), 59 deletions(-) create mode 100644 pfbudget/__main__.py create mode 100644 pfbudget/cli/__init__.py rename pfbudget/{ => cli}/runnable.py (68%) create mode 100644 pfbudget/core/__init__.py rename pfbudget/{ => core}/categories.py (98%) create mode 100644 pfbudget/core/input/__init__.py create mode 100644 pfbudget/core/input/nordigen.py rename pfbudget/{ => core/input}/parsers.py (96%) rename pfbudget/{ => core}/transactions.py (100%) rename pfbudget/{database.py => db/manager.py} (99%) create mode 100644 pfbudget/reporting/__init__.py rename pfbudget/{ => reporting}/graph.py (79%) rename pfbudget/{ => reporting}/report.py (85%) create mode 100644 pfbudget/utils/__init__.py rename pfbudget/{ => utils}/utils.py (97%) diff --git a/pfbudget/__init__.py b/pfbudget/__init__.py index 6a66eb8..90815a9 100644 --- a/pfbudget/__init__.py +++ b/pfbudget/__init__.py @@ -2,6 +2,6 @@ __all__ = ["run", "parse_data", "categorize_data"] __author__ = "Luís Murta" __version__ = "0.1" -from .categories import categorize_data -from .parsers import parse_data -from .runnable import run +from pfbudget.core.categories import categorize_data +from pfbudget.core.input.parsers import parse_data +from pfbudget.cli.runnable import run diff --git a/pfbudget/__main__.py b/pfbudget/__main__.py new file mode 100644 index 0000000..eba7182 --- /dev/null +++ b/pfbudget/__main__.py @@ -0,0 +1,4 @@ +from pfbudget.cli.runnable import run + +if __name__ == "__main__": + run() diff --git a/pfbudget/cli/__init__.py b/pfbudget/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pfbudget/runnable.py b/pfbudget/cli/runnable.py similarity index 68% rename from pfbudget/runnable.py rename to pfbudget/cli/runnable.py index 365f9f3..63f860d 100644 --- a/pfbudget/runnable.py +++ b/pfbudget/cli/runnable.py @@ -2,13 +2,16 @@ from pathlib import Path import argparse import re -from .categories import categorize_data -from .database import DBManager -from .parsers import parse_data -import pfbudget.graph -import pfbudget.report +from pfbudget.core.categories import categorize_data +from pfbudget.core.input.parsers import parse_data +from pfbudget.db.client import DBManager +import pfbudget.reporting.graph +import pfbudget.reporting.report import pfbudget.utils +from pfbudget.core.input.nordigen import Client + + DEFAULT_DB = "data.db" @@ -150,6 +153,56 @@ def argparser() -> argparse.ArgumentParser: ) p_report.set_defaults(func=report) + """ + Init Nordigen session: get new access token + """ + p_nordigen_access = subparsers.add_parser( + "init", + description="Get new access token", + parents=[help], + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p_nordigen_access.set_defaults(func=lambda args: Client().token()) + + """ + Access to Nordigen API + """ + p_nordigen_access = subparsers.add_parser( + "renew", + description="Renew the requisition ID", + parents=[help], + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p_nordigen_access.add_argument("institution", nargs=1, type=str) + p_nordigen_access.add_argument("country", nargs=1, type=str) + p_nordigen_access.set_defaults( + func=lambda args: Client().requisition(args.institution[0], args.country[0]) + ) + + """ + Downloading through Nordigen API + """ + p_nordigen_download = subparsers.add_parser( + "download", + description="Downloads transactions using Nordigen API", + parents=[help], + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p_nordigen_download.add_argument("id", nargs=1, type=str) + p_nordigen_download.set_defaults(func=lambda args: Client().download(args.id[0])) + + """ + List available banks to download from + """ + p_nordigen_list = subparsers.add_parser( + "list", + description="Lists banks in {country}", + parents=[help], + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p_nordigen_list.add_argument("country", nargs=1, type=str) + p_nordigen_list.set_defaults(func=lambda args: Client().banks(args.country[0])) + return parser @@ -178,11 +231,11 @@ def graph(args): """ start, end = pfbudget.utils.parse_args_period(args) if args.option == "monthly": - pfbudget.graph.monthly(DBManager(args.database), vars(args), start, end) + pfbudget.reporting.graph.monthly(DBManager(args.database), vars(args), start, end) elif args.option == "discrete": - pfbudget.graph.discrete(DBManager(args.database), vars(args), start, end) + pfbudget.reporting.graph.discrete(DBManager(args.database), vars(args), start, end) elif args.option == "networth": - pfbudget.graph.networth(DBManager(args.database), vars(args), start, end) + pfbudget.reporting.graph.networth(DBManager(args.database), vars(args), start, end) def report(args): @@ -193,9 +246,9 @@ def report(args): """ start, end = pfbudget.utils.parse_args_period(args) if args.option == "net": - pfbudget.report.net(DBManager(args.database), start, end) + pfbudget.reporting.report.net(DBManager(args.database), start, end) elif args.option == "detailed": - pfbudget.report.detailed(DBManager(args.database), start, end) + pfbudget.reporting.report.detailed(DBManager(args.database), start, end) def run(): diff --git a/pfbudget/core/__init__.py b/pfbudget/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pfbudget/categories.py b/pfbudget/core/categories.py similarity index 98% rename from pfbudget/categories.py rename to pfbudget/core/categories.py index ce7f319..4af5abc 100644 --- a/pfbudget/categories.py +++ b/pfbudget/core/categories.py @@ -8,8 +8,8 @@ import yaml if TYPE_CHECKING: - from pfbudget.database import DBManager - from pfbudget.transactions import Transaction + from pfbudget.db.manager import DBManager + from pfbudget.core.transactions import Transaction Options = namedtuple( diff --git a/pfbudget/core/input/__init__.py b/pfbudget/core/input/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pfbudget/core/input/nordigen.py b/pfbudget/core/input/nordigen.py new file mode 100644 index 0000000..64107af --- /dev/null +++ b/pfbudget/core/input/nordigen.py @@ -0,0 +1,74 @@ +from dotenv import load_dotenv +from json import dump +from nordigen import NordigenClient +from uuid import uuid4 +import os +import webbrowser + +load_dotenv() + + +class Client: + def __init__(self): + self._client = NordigenClient( + secret_key=os.environ.get("SECRET_KEY"), + secret_id=os.environ.get("SECRET_ID"), + ) + + self._client.token = self.__token() + + def token(self): + token = self._client.generate_token() + print(f"New access token: {token}") + return token + + def requisition(self, institution: str, country: str = "PT"): + link, _ = self.__requisition_id(institution, country) + webbrowser.open(link) + + def download(self, id: str): + if len(id) > 0: + return self.transactions(id) + else: + print("you forgot the req id") + + def transactions(self, id): + requisition = self._client.requisition.get_requisition_by_id(id) + + # transactions_list = [] + for acc in requisition["accounts"]: + account = self._client.account_api(acc) + print(account.get_metadata()) + with open("cetelem.json", "w") as f: + dump(account.get_transactions(), f, indent=4) + # print(dumps(account.get_transactions(), indent=4)) + + # print(transactions_list) + + def banks(self, country: str): + print(self._client.institution.get_institutions(country)) + + @property + def client(self): + return self._client + + def __token(self): + if token := os.environ.get("TOKEN"): + return token + else: + token = self._client.generate_token() + print(f"New access token: {token}") + return token + + def __requisition_id(self, i: str, c: str): + id = self._client.institution.get_institution_id_by_name( + country=c, institution=i + ) + init = self._client.initialize_session( + redirect_uri="https://murta.dev", + institution_id=id, + reference_id=str(uuid4()), + ) + + print(f"{i}({c}) link: {init.link} and requisition ID: {init.requisition_id}") + return (init.link, init.requisition_id) diff --git a/pfbudget/parsers.py b/pfbudget/core/input/parsers.py similarity index 96% rename from pfbudget/parsers.py rename to pfbudget/core/input/parsers.py index 6fab891..f45d1c2 100644 --- a/pfbudget/parsers.py +++ b/pfbudget/core/input/parsers.py @@ -1,16 +1,11 @@ -from __future__ import annotations from collections import namedtuple from decimal import Decimal from importlib import import_module -from typing import TYPE_CHECKING import datetime as dt import yaml -from .transactions import Transaction -from . import utils - -if TYPE_CHECKING: - from .database import DBManager +from pfbudget.core.transactions import Transaction +from pfbudget.utils import utils Index = namedtuple( "Index", ["date", "text", "value", "negate"], defaults=[-1, -1, -1, False] diff --git a/pfbudget/transactions.py b/pfbudget/core/transactions.py similarity index 100% rename from pfbudget/transactions.py rename to pfbudget/core/transactions.py diff --git a/pfbudget/database.py b/pfbudget/db/manager.py similarity index 99% rename from pfbudget/database.py rename to pfbudget/db/manager.py index 2d5705b..ad6ad8a 100644 --- a/pfbudget/database.py +++ b/pfbudget/db/manager.py @@ -7,7 +7,7 @@ import logging.config import pathlib import sqlite3 -from .transactions import Transaction +from ..core.transactions import Transaction if not pathlib.Path("logs").is_dir(): diff --git a/pfbudget/reporting/__init__.py b/pfbudget/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pfbudget/graph.py b/pfbudget/reporting/graph.py similarity index 79% rename from pfbudget/graph.py rename to pfbudget/reporting/graph.py index a7ea9bc..9e9ca1f 100644 --- a/pfbudget/graph.py +++ b/pfbudget/reporting/graph.py @@ -5,14 +5,14 @@ from typing import TYPE_CHECKING import datetime as dt import matplotlib.pyplot as plt -import pfbudget.categories +import pfbudget.core.categories if TYPE_CHECKING: - from pfbudget.database import DBManager + from pfbudget.db.manager import DBManager -groups = pfbudget.categories.cfg["Groups"] +groups = pfbudget.core.categories.cfg["Groups"] def monthly( @@ -33,7 +33,7 @@ def monthly( <= month + dt.timedelta(days=monthrange(month.year, month.month)[1] - 1) ) - for group, categories in pfbudget.categories.groups.items() + for group, categories in pfbudget.core.categories.groups.items() }, ) for month in [ @@ -68,21 +68,21 @@ def monthly( list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), [ [-groups[group] for _, groups in monthly_transactions] - for group in pfbudget.categories.groups + for group in pfbudget.core.categories.groups if group != "income-fixed" and group != "income-extra" and group != "investment" ], labels=[ group - for group in pfbudget.categories.groups + for group in pfbudget.core.categories.groups if group != "income-fixed" and group != "income-extra" and group != "investment" ], colors=[ groups.get(group, {"color": "gray"})["color"] - for group in pfbudget.categories.groups + for group in pfbudget.core.categories.groups if group != "income-fixed" and group != "income-extra" and group != "investment" @@ -113,7 +113,7 @@ def discrete( <= month + dt.timedelta(days=monthrange(month.year, month.month)[1] - 1) ) - for category in pfbudget.categories.categories + for category in pfbudget.core.categories.categories }, ) for month in [ @@ -131,8 +131,8 @@ def discrete( sum( value for category, value in categories.items() - if category in pfbudget.categories.groups["income-fixed"] - or category in pfbudget.categories.groups["income-extra"] + if category in pfbudget.core.categories.groups["income-fixed"] + or category in pfbudget.core.categories.groups["income-extra"] ) for _, categories in monthly_transactions ], @@ -145,7 +145,7 @@ def discrete( sum( value for category, value in categories.items() - if category in pfbudget.categories.groups["income-fixed"] + if category in pfbudget.core.categories.groups["income-fixed"] ) for _, categories in monthly_transactions ], @@ -156,18 +156,18 @@ def discrete( list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), [ [-categories[category] for _, categories in monthly_transactions] - for category in pfbudget.categories.categories - if category not in pfbudget.categories.groups["income-fixed"] - and category not in pfbudget.categories.groups["income-extra"] - and category not in pfbudget.categories.groups["investment"] + for category in pfbudget.core.categories.categories + if category not in pfbudget.core.categories.groups["income-fixed"] + and category not in pfbudget.core.categories.groups["income-extra"] + and category not in pfbudget.core.categories.groups["investment"] and category != "Null" ], labels=[ category - for category in pfbudget.categories.categories - if category not in pfbudget.categories.groups["income-fixed"] - and category not in pfbudget.categories.groups["income-extra"] - and category not in pfbudget.categories.groups["investment"] + for category in pfbudget.core.categories.categories + if category not in pfbudget.core.categories.groups["income-fixed"] + and category not in pfbudget.core.categories.groups["income-extra"] + and category not in pfbudget.core.categories.groups["investment"] and category != "Null" ], ) @@ -193,11 +193,12 @@ def networth( transaction.value for transaction in transactions if transaction.original != "No" - and transaction.category not in pfbudget.categories.groups["investment"] + and transaction.category not in pfbudget.core.categories.groups["investment"] and month <= transaction.date <= month + dt.timedelta(days=monthrange(month.year, month.month)[1] - 1) - ) + accum + ) + + accum, ) for month in [ month.date() @@ -210,10 +211,8 @@ def networth( plt.figure(tight_layout=True) plt.plot( list(rrule(MONTHLY, dtstart=start.replace(day=1), until=end.replace(day=1))), - [ - value for _, value in monthly_networth - ], - label="Total networth" + [value for _, value in monthly_networth], + label="Total networth", ) plt.grid() plt.legend(loc="upper left") diff --git a/pfbudget/report.py b/pfbudget/reporting/report.py similarity index 85% rename from pfbudget/report.py rename to pfbudget/reporting/report.py index d58e605..b181d6e 100644 --- a/pfbudget/report.py +++ b/pfbudget/reporting/report.py @@ -3,10 +3,10 @@ from dateutil.rrule import rrule, YEARLY from typing import TYPE_CHECKING import datetime as dt -import pfbudget.categories +import pfbudget.core.categories if TYPE_CHECKING: - from pfbudget.database import DBManager + from pfbudget.db.manager import DBManager def net(db: DBManager, start: dt.date = dt.date.min, end: dt.date = dt.date.max): @@ -23,7 +23,7 @@ def net(db: DBManager, start: dt.date = dt.date.min, end: dt.date = dt.date.max) if transaction.category in categories and year <= transaction.date <= year.replace(month=12, day=31) ) - for group, categories in pfbudget.categories.groups.items() + for group, categories in pfbudget.core.categories.groups.items() }, ) for year in [ @@ -62,6 +62,7 @@ def net(db: DBManager, start: dt.date = dt.date.min, end: dt.date = dt.date.max) print(f"Invested: {investments:.2f}€\n") + def detailed(db: DBManager, start: dt.date = dt.date.min, end: dt.date = dt.date.max): transactions = db.get_daterange(start, end) start, end = transactions[0].date, transactions[-1].date @@ -76,7 +77,7 @@ def detailed(db: DBManager, start: dt.date = dt.date.min, end: dt.date = dt.date if transaction.category == category and year <= transaction.date <= year.replace(month=12, day=31) ) - for category in pfbudget.categories.categories + for category in pfbudget.core.categories.categories }, ) for year in [ @@ -93,23 +94,23 @@ def detailed(db: DBManager, start: dt.date = dt.date.min, end: dt.date = dt.date income = sum( sum for category, sum in categories.items() - if category in pfbudget.categories.groups["income-fixed"] - or category in pfbudget.categories.groups["income-extra"] + if category in pfbudget.core.categories.groups["income-fixed"] + or category in pfbudget.core.categories.groups["income-extra"] ) print(f"Income: {income:.2f}€\n") investments = -sum( sum for category, sum in categories.items() - if category in pfbudget.categories.groups["investment"] + if category in pfbudget.core.categories.groups["investment"] ) expenses = 0 for category, value in categories.items(): if ( - category not in pfbudget.categories.groups["income-fixed"] - and category not in pfbudget.categories.groups["income-extra"] - and category not in pfbudget.categories.groups["investment"] + category not in pfbudget.core.categories.groups["income-fixed"] + and category not in pfbudget.core.categories.groups["income-extra"] + and category not in pfbudget.core.categories.groups["investment"] ): if category == "Null": if value != 0: diff --git a/pfbudget/utils/__init__.py b/pfbudget/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pfbudget/utils.py b/pfbudget/utils/utils.py similarity index 97% rename from pfbudget/utils.py rename to pfbudget/utils/utils.py index 03fcdae..7e692f6 100644 --- a/pfbudget/utils.py +++ b/pfbudget/utils/utils.py @@ -53,7 +53,6 @@ def find_credit_institution(fn, banks, creditcards): if bank.lower() not in [bank.lower() for bank in banks]: raise BankNotAvailableError(f"{fn} -> {bank}: {banks}") if cc and cc.lower() not in [cc.lower() for cc in creditcards]: - # raise CreditCardNotAvailableError(f"{fn}: {banks}") print(f"{fn} -> {cc} not in {creditcards}, using {bank} parser") cc = None