Compare commits
7 Commits
00bbceeba5
...
ef9ca1d1ea
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9ca1d1ea | |||
| b1de4d519a | |||
| 2c7c527ea9 | |||
| 2843c66453 | |||
| d3c36e30d5 | |||
| d6cbb10c47 | |||
| 8710722eb2 |
@ -1,154 +0,0 @@
|
||||
from codetiming import Timer
|
||||
from datetime import timedelta
|
||||
from typing import Sequence
|
||||
|
||||
import pfbudget.db.model as t
|
||||
|
||||
|
||||
class Categorizer:
|
||||
options = {}
|
||||
|
||||
def __init__(self):
|
||||
self.options["null_days"] = 3
|
||||
|
||||
def rules(
|
||||
self,
|
||||
transactions: Sequence[t.BankTransaction],
|
||||
categories: Sequence[t.Category],
|
||||
tags: Sequence[t.Tag],
|
||||
nullify: bool = True
|
||||
):
|
||||
"""Overarching categorization tool
|
||||
|
||||
Receives a list of transactions (by ref) and updates their category according
|
||||
to the rules defined for each category
|
||||
|
||||
Args:
|
||||
transactions (Sequence[BankTransaction]): uncategorized transactions
|
||||
categories (Sequence[Category]): available categories
|
||||
tags (Sequence[Tag]): currently available tags
|
||||
"""
|
||||
|
||||
if nullify:
|
||||
try:
|
||||
null = next(cat for cat in categories if cat.name == "null")
|
||||
print("Nullifying")
|
||||
self._nullify(transactions, null)
|
||||
|
||||
except StopIteration:
|
||||
print("Null category not defined")
|
||||
|
||||
categories = [cat for cat in categories if cat.name != "null"]
|
||||
|
||||
self._rule_based_categories(transactions, categories)
|
||||
self._rule_based_tags(transactions, tags)
|
||||
|
||||
@Timer(name="nullify")
|
||||
def _nullify(self, transactions: Sequence[t.BankTransaction], null: t.Category):
|
||||
count = 0
|
||||
matching = []
|
||||
for transaction in transactions:
|
||||
for cancel in (
|
||||
cancel
|
||||
for cancel in transactions
|
||||
if (
|
||||
transaction.date - timedelta(days=self.options["null_days"])
|
||||
<= cancel.date
|
||||
<= transaction.date + timedelta(days=self.options["null_days"])
|
||||
and cancel != transaction
|
||||
and cancel.bank != transaction.bank
|
||||
and cancel.amount == -transaction.amount
|
||||
and transaction not in matching
|
||||
and cancel not in matching
|
||||
and all(r.matches(transaction) for r in null.rules)
|
||||
and all(r.matches(cancel) for r in null.rules)
|
||||
)
|
||||
):
|
||||
transaction.category = t.TransactionCategory(
|
||||
name="null",
|
||||
selector=t.CategorySelector(t.Selector_T.nullifier),
|
||||
)
|
||||
cancel.category = t.TransactionCategory(
|
||||
name="null",
|
||||
selector=t.CategorySelector(t.Selector_T.nullifier),
|
||||
)
|
||||
matching.extend([transaction, cancel])
|
||||
count += 2
|
||||
break
|
||||
|
||||
print(f"Nullified {count} of {len(transactions)} transactions")
|
||||
|
||||
@Timer(name="categoryrules")
|
||||
def _rule_based_categories(
|
||||
self,
|
||||
transactions: Sequence[t.BankTransaction],
|
||||
categories: Sequence[t.Category],
|
||||
):
|
||||
print(f"Categorizing {len(transactions)} transactions")
|
||||
d = {}
|
||||
for category in [c for c in categories if c.rules]:
|
||||
for rule in category.rules:
|
||||
# for transaction in [t for t in transactions if not t.category]:
|
||||
for transaction in [
|
||||
t
|
||||
for t in transactions
|
||||
if not t.category or t.category.name != "null"
|
||||
]:
|
||||
if not rule.matches(transaction):
|
||||
continue
|
||||
|
||||
# passed all conditions, assign category
|
||||
if transaction.category:
|
||||
if transaction.category.name == category.name:
|
||||
continue
|
||||
|
||||
if (
|
||||
input(
|
||||
f"Overwrite {transaction} with {category.name}? (y/n)"
|
||||
)
|
||||
== "y"
|
||||
):
|
||||
transaction.category.name = category.name
|
||||
transaction.category.selector.selector = t.Selector_T.rules
|
||||
else:
|
||||
transaction.category = t.TransactionCategory(
|
||||
category.name, t.CategorySelector(t.Selector_T.rules)
|
||||
)
|
||||
|
||||
if rule in d:
|
||||
d[rule] += 1
|
||||
else:
|
||||
d[rule] = 1
|
||||
|
||||
for k, v in d.items():
|
||||
print(f"{v}: {k}")
|
||||
|
||||
@Timer(name="tagrules")
|
||||
def _rule_based_tags(
|
||||
self, transactions: Sequence[t.BankTransaction], tags: Sequence[t.Tag]
|
||||
):
|
||||
print(f"Tagging {len(transactions)} transactions")
|
||||
d = {}
|
||||
for tag in [t for t in tags if len(t.rules) > 0]:
|
||||
for rule in tag.rules:
|
||||
# for transaction in [t for t in transactions if not t.category]:
|
||||
for transaction in [
|
||||
t
|
||||
for t in transactions
|
||||
if tag.name not in [tag.tag for tag in t.tags]
|
||||
]:
|
||||
if not rule.matches(transaction):
|
||||
continue
|
||||
|
||||
if not transaction.tags:
|
||||
transaction.tags = {t.TransactionTag(tag.name)}
|
||||
else:
|
||||
transaction.tags.add(t.TransactionTag(tag.name))
|
||||
|
||||
if rule in d:
|
||||
d[rule] += 1
|
||||
else:
|
||||
d[rule] = 1
|
||||
|
||||
for k, v in d.items():
|
||||
print(f"{v}: {k}")
|
||||
@ -1,12 +1,9 @@
|
||||
import csv
|
||||
import dotenv
|
||||
from pathlib import Path
|
||||
import pickle
|
||||
import os
|
||||
import webbrowser
|
||||
|
||||
from pfbudget.common.types import Operation
|
||||
from pfbudget.core.categorizer import Categorizer
|
||||
from pfbudget.db.client import DbClient
|
||||
from pfbudget.db.model import (
|
||||
Bank,
|
||||
@ -27,12 +24,12 @@ from pfbudget.db.model import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
)
|
||||
from pfbudget.extract.credentials import Credentials
|
||||
from pfbudget.extract.extract import Extract
|
||||
from pfbudget.extract.psd2 import PSD2Client
|
||||
from pfbudget.extract.nordigen import NordigenClient, NordigenCredentialsManager
|
||||
from pfbudget.extract.parsers import parse_data
|
||||
|
||||
dotenv.load_dotenv()
|
||||
from pfbudget.extract.psd2 import PSD2Extractor
|
||||
from pfbudget.transform.categorizer import Categorizer
|
||||
from pfbudget.transform.nullifier import Nullifier
|
||||
from pfbudget.transform.tagger import Tagger
|
||||
|
||||
|
||||
class Manager:
|
||||
@ -85,16 +82,18 @@ class Manager:
|
||||
else:
|
||||
banks = session.get(Bank, Bank.name, params[3])
|
||||
session.expunge_all()
|
||||
client.start = params[0]
|
||||
client.end = params[1]
|
||||
transactions = client.extract(banks)
|
||||
|
||||
extractor = PSD2Extractor(client)
|
||||
transactions = []
|
||||
for bank in banks:
|
||||
transactions.extend(extractor.extract(bank, params[0], params[1]))
|
||||
|
||||
# dry-run
|
||||
if not params[2]:
|
||||
with self.db.session() as session:
|
||||
session.add(sorted(transactions))
|
||||
else:
|
||||
print(transactions)
|
||||
print(sorted(transactions))
|
||||
|
||||
case Operation.Categorize:
|
||||
with self.db.session() as session:
|
||||
@ -103,7 +102,15 @@ class Manager:
|
||||
)
|
||||
categories = session.get(Category)
|
||||
tags = session.get(Tag)
|
||||
Categorizer().rules(uncategorized, categories, tags, params[0])
|
||||
|
||||
rules = [cat.rules for cat in categories if cat.name == "null"]
|
||||
Nullifier(rules).transform_inplace(uncategorized)
|
||||
|
||||
rules = [rule for cat in categories for rule in cat.rules]
|
||||
Categorizer(rules).transform_inplace(uncategorized)
|
||||
|
||||
rules = [rule for tag in tags for rule in tag.rules]
|
||||
Tagger(rules).transform_inplace(uncategorized)
|
||||
|
||||
case Operation.BankMod:
|
||||
with self.db.session() as session:
|
||||
@ -419,10 +426,5 @@ class Manager:
|
||||
self._db = url
|
||||
|
||||
@staticmethod
|
||||
def nordigen_client() -> Extract:
|
||||
credentials = Credentials(
|
||||
os.environ.get("SECRET_ID"),
|
||||
os.environ.get("SECRET_KEY"),
|
||||
os.environ.get("TOKEN"),
|
||||
)
|
||||
return PSD2Client(credentials)
|
||||
def nordigen_client() -> NordigenClient:
|
||||
return NordigenClient(NordigenCredentialsManager.default)
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credentials:
|
||||
id: str
|
||||
key: str
|
||||
token: str = ""
|
||||
|
||||
def valid(self) -> bool:
|
||||
return self.id and self.key
|
||||
@ -6,5 +6,13 @@ class BankError(ExtractError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialsError(ExtractError):
|
||||
class PSD2ClientError(ExtractError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialsError(PSD2ClientError):
|
||||
pass
|
||||
|
||||
|
||||
class DownloadError(PSD2ClientError):
|
||||
pass
|
||||
|
||||
114
pfbudget/extract/nordigen.py
Normal file
114
pfbudget/extract/nordigen.py
Normal file
@ -0,0 +1,114 @@
|
||||
from dataclasses import dataclass
|
||||
import dotenv
|
||||
import json
|
||||
import nordigen
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .exceptions import CredentialsError, DownloadError
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class NordigenCredentials:
|
||||
id: str
|
||||
key: str
|
||||
token: str = ""
|
||||
|
||||
def valid(self) -> bool:
|
||||
return self.id and self.key
|
||||
|
||||
|
||||
class NordigenClient:
|
||||
redirect_url = "https://murta.dev"
|
||||
|
||||
def __init__(self, credentials: NordigenCredentials):
|
||||
super().__init__()
|
||||
|
||||
if not credentials.valid():
|
||||
raise CredentialsError
|
||||
|
||||
self._client = nordigen.NordigenClient(
|
||||
secret_key=credentials.key, secret_id=credentials.id, timeout=5
|
||||
)
|
||||
|
||||
if credentials.token:
|
||||
self._client.token = credentials.token
|
||||
|
||||
def download(self, requisition_id):
|
||||
try:
|
||||
requisition = self._client.requisition.get_requisition_by_id(requisition_id)
|
||||
print(requisition)
|
||||
except requests.HTTPError as e:
|
||||
raise DownloadError(e)
|
||||
|
||||
transactions = {}
|
||||
for acc in requisition["accounts"]:
|
||||
account = self._client.account_api(acc)
|
||||
|
||||
retries = 0
|
||||
while retries < 3:
|
||||
try:
|
||||
downloaded = account.get_transactions()
|
||||
break
|
||||
except requests.ReadTimeout:
|
||||
retries += 1
|
||||
print(f"Request #{retries} timed-out, retrying in 1s")
|
||||
time.sleep(1)
|
||||
|
||||
if not downloaded:
|
||||
print(f"Couldn't download transactions for {account}")
|
||||
continue
|
||||
|
||||
transactions.update(downloaded)
|
||||
|
||||
return transactions
|
||||
|
||||
def dump(self, bank, downloaded):
|
||||
with open("json/" + bank.name + ".json", "w") as f:
|
||||
json.dump(downloaded, f)
|
||||
|
||||
def generate_token(self):
|
||||
self.token = self._client.generate_token()
|
||||
print(f"New access token: {self.token}")
|
||||
return self.token
|
||||
|
||||
def requisition(self, id: str, country: str = "PT"):
|
||||
requisition = self._client.initialize_session(
|
||||
redirect_uri=self.redirect_url,
|
||||
institution_id=id,
|
||||
reference_id=str(uuid.uuid4()),
|
||||
)
|
||||
return requisition.link, requisition.requisition_id
|
||||
|
||||
def country_banks(self, country: str):
|
||||
return self._client.institution.get_institutions(country)
|
||||
|
||||
# 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["access"]
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self._token
|
||||
|
||||
@token.setter
|
||||
def token(self, value):
|
||||
if self._token:
|
||||
print("Replacing existing token with {value}")
|
||||
self._token = value
|
||||
|
||||
|
||||
class NordigenCredentialsManager:
|
||||
default = NordigenCredentials(
|
||||
os.environ.get("SECRET_ID"),
|
||||
os.environ.get("SECRET_KEY"),
|
||||
os.environ.get("TOKEN"),
|
||||
)
|
||||
@ -1,142 +1,39 @@
|
||||
import datetime as dt
|
||||
import json
|
||||
import nordigen
|
||||
import requests
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
import pfbudget.db.model as t
|
||||
from pfbudget.db.model import Bank, BankTransaction
|
||||
from pfbudget.utils.converters import convert
|
||||
|
||||
from .credentials import Credentials
|
||||
from .exceptions import BankError, CredentialsError, ExtractError
|
||||
from .exceptions import BankError, DownloadError, ExtractError
|
||||
from .extract import Extract
|
||||
from .nordigen import NordigenClient
|
||||
|
||||
|
||||
class PSD2Client(Extract):
|
||||
redirect_url = "https://murta.dev"
|
||||
class PSD2Extractor(Extract):
|
||||
def __init__(self, client: NordigenClient):
|
||||
self.__client = client
|
||||
|
||||
def __init__(self, credentials: Credentials):
|
||||
super().__init__()
|
||||
def extract(
|
||||
self, bank: Bank, start=dt.date.min, end=dt.date.max
|
||||
) -> Sequence[BankTransaction]:
|
||||
if not bank.nordigen:
|
||||
raise BankError("Bank doesn't have Nordigen info")
|
||||
|
||||
if not credentials.valid():
|
||||
raise CredentialsError
|
||||
try:
|
||||
print(f"Downloading from {bank}...")
|
||||
downloaded = self.__client.download(bank.nordigen.requisition_id)
|
||||
except DownloadError as e:
|
||||
print(f"There was an issue downloading from {bank.name}\n{e}")
|
||||
raise ExtractError(e)
|
||||
|
||||
self._client = nordigen.NordigenClient(
|
||||
secret_key=credentials.key, secret_id=credentials.id, timeout=5
|
||||
)
|
||||
self.__client.dump(bank, downloaded)
|
||||
|
||||
if credentials.token:
|
||||
self._client.token = credentials.token
|
||||
return [
|
||||
t
|
||||
for t in self.convert(bank, downloaded, start, end)
|
||||
if start <= t.date <= end
|
||||
]
|
||||
|
||||
self._start = dt.date.min
|
||||
self._end = dt.date.max
|
||||
|
||||
def extract(self, banks: Sequence[t.Bank]) -> list[t.BankTransaction]:
|
||||
transactions = []
|
||||
if not banks or any(not b.nordigen for b in banks):
|
||||
raise BankError
|
||||
|
||||
for bank in banks:
|
||||
downloaded = None
|
||||
try:
|
||||
print(f"Downloading from {bank}...")
|
||||
downloaded = self.download(bank.nordigen.requisition_id)
|
||||
except requests.HTTPError as e:
|
||||
print(f"There was an issue downloading from {bank.name} -> {e}")
|
||||
raise ExtractError(e)
|
||||
|
||||
if downloaded:
|
||||
self.dump(bank, downloaded)
|
||||
|
||||
converted = [
|
||||
convert(t, bank) for t in downloaded["transactions"]["booked"]
|
||||
]
|
||||
|
||||
transactions.extend(
|
||||
[t for t in converted if self._start <= t.date <= self._end]
|
||||
)
|
||||
|
||||
return sorted(transactions)
|
||||
|
||||
def download(self, requisition_id):
|
||||
requisition = self._client.requisition.get_requisition_by_id(requisition_id)
|
||||
print(requisition)
|
||||
|
||||
transactions = {}
|
||||
for acc in requisition["accounts"]:
|
||||
account = self._client.account_api(acc)
|
||||
|
||||
retries = 0
|
||||
while retries < 3:
|
||||
try:
|
||||
downloaded = account.get_transactions()
|
||||
break
|
||||
except requests.ReadTimeout:
|
||||
retries += 1
|
||||
print(f"Request #{retries} timed-out, retrying in 1s")
|
||||
time.sleep(1)
|
||||
|
||||
if not downloaded:
|
||||
print(f"Couldn't download transactions for {account}")
|
||||
continue
|
||||
|
||||
transactions.update(downloaded)
|
||||
|
||||
return transactions
|
||||
|
||||
def dump(self, bank, downloaded):
|
||||
with open("json/" + bank.name + ".json", "w") as f:
|
||||
json.dump(downloaded, f)
|
||||
|
||||
def generate_token(self):
|
||||
self.token = self._client.generate_token()
|
||||
print(f"New access token: {self.token}")
|
||||
return self.token
|
||||
|
||||
def requisition(self, id: str, country: str = "PT"):
|
||||
requisition = self._client.initialize_session(
|
||||
redirect_uri=self.redirect_url,
|
||||
institution_id=id,
|
||||
reference_id=str(uuid.uuid4()),
|
||||
)
|
||||
return requisition.link, requisition.requisition_id
|
||||
|
||||
def country_banks(self, country: str):
|
||||
return self._client.institution.get_institutions(country)
|
||||
|
||||
@property
|
||||
def start(self):
|
||||
return self._start
|
||||
|
||||
@start.setter
|
||||
def start(self, value):
|
||||
self._start = value
|
||||
|
||||
@property
|
||||
def end(self):
|
||||
return self._end
|
||||
|
||||
@end.setter
|
||||
def end(self, value):
|
||||
self._end = value
|
||||
|
||||
# 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["access"]
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self._token
|
||||
|
||||
@token.setter
|
||||
def token(self, value):
|
||||
if self._token:
|
||||
print("Replacing existing token with {value}")
|
||||
self._token = value
|
||||
def convert(self, bank, downloaded, start, end):
|
||||
return [convert(t, bank) for t in downloaded["transactions"]["booked"]]
|
||||
|
||||
0
pfbudget/transform/__init__.py
Normal file
0
pfbudget/transform/__init__.py
Normal file
36
pfbudget/transform/categorizer.py
Normal file
36
pfbudget/transform/categorizer.py
Normal file
@ -0,0 +1,36 @@
|
||||
from copy import deepcopy
|
||||
from typing import Sequence
|
||||
|
||||
from pfbudget.db.model import (
|
||||
CategoryRule,
|
||||
CategorySelector,
|
||||
Selector_T,
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
)
|
||||
from .exceptions import TransactionCategorizedError
|
||||
from .transform import Transformer
|
||||
|
||||
|
||||
class Categorizer(Transformer):
|
||||
def __init__(self, rules: Sequence[CategoryRule]):
|
||||
self.rules = rules
|
||||
|
||||
def transform(self, transactions: Sequence[Transaction]) -> Sequence[Transaction]:
|
||||
result = deepcopy(transactions)
|
||||
self.transform_inplace(result)
|
||||
|
||||
return result
|
||||
|
||||
def transform_inplace(self, transactions: Sequence[Transaction]) -> None:
|
||||
for rule in self.rules:
|
||||
for transaction in transactions:
|
||||
if transaction.category:
|
||||
raise TransactionCategorizedError(transaction)
|
||||
|
||||
if not rule.matches(transaction):
|
||||
continue
|
||||
|
||||
transaction.category = TransactionCategory(
|
||||
rule.name, CategorySelector(Selector_T.rules)
|
||||
)
|
||||
6
pfbudget/transform/exceptions.py
Normal file
6
pfbudget/transform/exceptions.py
Normal file
@ -0,0 +1,6 @@
|
||||
class MoreThanOneMatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TransactionCategorizedError(Exception):
|
||||
pass
|
||||
94
pfbudget/transform/nullifier.py
Normal file
94
pfbudget/transform/nullifier.py
Normal file
@ -0,0 +1,94 @@
|
||||
from copy import deepcopy
|
||||
import datetime as dt
|
||||
from typing import Sequence
|
||||
|
||||
from .exceptions import MoreThanOneMatchError
|
||||
from .transform import Transformer
|
||||
from pfbudget.db.model import (
|
||||
CategorySelector,
|
||||
Selector_T,
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
)
|
||||
|
||||
|
||||
class Nullifier(Transformer):
|
||||
NULL_DAYS = 4
|
||||
|
||||
def __init__(self, rules=None):
|
||||
self.rules = rules
|
||||
|
||||
def transform(self, transactions: Sequence[Transaction]) -> Sequence[Transaction]:
|
||||
"""transform
|
||||
|
||||
Find transactions that nullify each others, e.g. transfers between banks or
|
||||
between bank and credit cards.
|
||||
|
||||
Args:
|
||||
transactions (Sequence[Transaction]): ordered sequence of transactions
|
||||
|
||||
Raises:
|
||||
MoreThanOneMatchError: if there is more than a match for a single transation
|
||||
|
||||
Returns:
|
||||
Sequence[Transaction]: nullified sequence of transactions
|
||||
"""
|
||||
|
||||
result = deepcopy(transactions)
|
||||
|
||||
for i, transaction in enumerate(result[:-1]):
|
||||
if matches := [t for t in result[i + 1 :] if self._cancels(transaction, t)]:
|
||||
if len(matches) > 1:
|
||||
raise MoreThanOneMatchError(f"{transaction} -> {matches}")
|
||||
|
||||
match = matches[0]
|
||||
|
||||
transaction = self._nullify(transaction)
|
||||
match = self._nullify(match)
|
||||
|
||||
return result
|
||||
|
||||
def transform_inplace(self, transactions: Sequence[Transaction]) -> None:
|
||||
"""_summary_
|
||||
|
||||
Find transactions that nullify each others, e.g. transfers between banks or
|
||||
between bank and credit cards.
|
||||
|
||||
Args:
|
||||
transactions (Sequence[Transaction]): ordered sequence of transactions that
|
||||
will be modified inplace
|
||||
|
||||
Raises:
|
||||
MoreThanOneMatchError: if there is more than a match for a single transation
|
||||
"""
|
||||
|
||||
for transaction in transactions:
|
||||
if matches := [t for t in transactions if self._cancels(transaction, t)]:
|
||||
if len(matches) > 1:
|
||||
raise MoreThanOneMatchError(f"{transaction} -> {matches}")
|
||||
|
||||
match = matches[0]
|
||||
|
||||
transaction = self._nullify(transaction)
|
||||
match = self._nullify(match)
|
||||
|
||||
def _cancels(self, transaction: Transaction, cancel: Transaction):
|
||||
return (
|
||||
transaction.date
|
||||
<= cancel.date
|
||||
<= transaction.date + dt.timedelta(days=self.NULL_DAYS)
|
||||
and cancel != transaction
|
||||
and cancel.bank != transaction.bank
|
||||
and cancel.amount == -transaction.amount
|
||||
and (not cancel.category or cancel.category.name != "null")
|
||||
and (
|
||||
any(r.matches(transaction) for r in self.rules) if self.rules else True
|
||||
)
|
||||
and (any(r.matches(cancel) for r in self.rules) if self.rules else True)
|
||||
)
|
||||
|
||||
def _nullify(self, transaction: Transaction) -> Transaction:
|
||||
transaction.category = TransactionCategory(
|
||||
"null", selector=CategorySelector(Selector_T.nullifier)
|
||||
)
|
||||
return transaction
|
||||
30
pfbudget/transform/tagger.py
Normal file
30
pfbudget/transform/tagger.py
Normal file
@ -0,0 +1,30 @@
|
||||
from copy import deepcopy
|
||||
from typing import Sequence
|
||||
|
||||
from pfbudget.db.model import TagRule, Transaction, TransactionTag
|
||||
from .transform import Transformer
|
||||
|
||||
|
||||
class Tagger(Transformer):
|
||||
def __init__(self, rules: Sequence[TagRule]):
|
||||
self.rules = rules
|
||||
|
||||
def transform(self, transactions: Sequence[Transaction]) -> Sequence[Transaction]:
|
||||
result = deepcopy(transactions)
|
||||
self.transform_inplace(result)
|
||||
|
||||
return result
|
||||
|
||||
def transform_inplace(self, transactions: Sequence[Transaction]) -> None:
|
||||
for rule in self.rules:
|
||||
for transaction in transactions:
|
||||
if rule.tag in transaction.tags:
|
||||
continue
|
||||
|
||||
if not rule.matches(transaction):
|
||||
continue
|
||||
|
||||
if not transaction.tags:
|
||||
transaction.tags = {TransactionTag(rule.tag)}
|
||||
else:
|
||||
transaction.tags.add(TransactionTag(rule.tag))
|
||||
14
pfbudget/transform/transform.py
Normal file
14
pfbudget/transform/transform.py
Normal file
@ -0,0 +1,14 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Sequence
|
||||
|
||||
from pfbudget.db.model import Transaction
|
||||
|
||||
|
||||
class Transformer(ABC):
|
||||
@abstractmethod
|
||||
def transform(self, _: Sequence[Transaction]) -> Sequence[Transaction]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def transform_inplace(self, _: Sequence[Transaction]) -> None:
|
||||
raise NotImplementedError
|
||||
50
poetry.lock
generated
50
poetry.lock
generated
@ -651,6 +651,53 @@ files = [
|
||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.2.0"
|
||||
description = "Optional static typing for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "mypy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d"},
|
||||
{file = "mypy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"},
|
||||
{file = "mypy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e"},
|
||||
{file = "mypy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a"},
|
||||
{file = "mypy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128"},
|
||||
{file = "mypy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41"},
|
||||
{file = "mypy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb"},
|
||||
{file = "mypy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937"},
|
||||
{file = "mypy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9"},
|
||||
{file = "mypy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602"},
|
||||
{file = "mypy-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140"},
|
||||
{file = "mypy-1.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336"},
|
||||
{file = "mypy-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e"},
|
||||
{file = "mypy-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119"},
|
||||
{file = "mypy-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a"},
|
||||
{file = "mypy-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950"},
|
||||
{file = "mypy-1.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6"},
|
||||
{file = "mypy-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5"},
|
||||
{file = "mypy-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8"},
|
||||
{file = "mypy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed"},
|
||||
{file = "mypy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f"},
|
||||
{file = "mypy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521"},
|
||||
{file = "mypy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238"},
|
||||
{file = "mypy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48"},
|
||||
{file = "mypy-1.2.0-py3-none-any.whl", hash = "sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394"},
|
||||
{file = "mypy-1.2.0.tar.gz", hash = "sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
mypy-extensions = ">=1.0.0"
|
||||
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
|
||||
typing-extensions = ">=3.10"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
install-types = ["pip"]
|
||||
python2 = ["typed-ast (>=1.4.0,<2)"]
|
||||
reports = ["lxml"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.0.0"
|
||||
@ -1141,6 +1188,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
|
||||
mypy = {version = ">=0.910", optional = true, markers = "extra == \"mypy\""}
|
||||
typing-extensions = ">=4.2.0"
|
||||
|
||||
[package.extras]
|
||||
@ -1210,4 +1258,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "9d3d37a64fddc6654db1f2606001b81a2432e8f4f667f7146921942f59472614"
|
||||
content-hash = "cc31ed1ff1c9e446fcadda094494251b015a103df8024c45e442436856977bbb"
|
||||
|
||||
@ -22,9 +22,11 @@ sqlalchemy = "^2.0.9"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^23.3.0"
|
||||
flake8 = "^6.0.0"
|
||||
mypy = "^1.2.0"
|
||||
pytest = "^7.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-mock = "^3.10.0"
|
||||
sqlalchemy = {extras = ["mypy"], version = "^2.0.9"}
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
@ -32,3 +34,6 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[pytest]
|
||||
mock_use_standalone_module = true
|
||||
|
||||
[tool.mypy]
|
||||
plugins = "sqlalchemy.ext.mypy.plugin"
|
||||
|
||||
15
tests/mocks/categories.py
Normal file
15
tests/mocks/categories.py
Normal file
@ -0,0 +1,15 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from pfbudget.db.model import Category, CategoryRule, Tag, TagRule
|
||||
|
||||
category_null = Category("null", None, set())
|
||||
|
||||
category1 = Category(
|
||||
"cat#1",
|
||||
None,
|
||||
{CategoryRule(None, None, "desc#1", None, None, None, Decimal(0), "cat#1")},
|
||||
)
|
||||
|
||||
tag_1 = Tag(
|
||||
"tag#1", {TagRule(None, None, "desc#1", None, None, None, Decimal(0), "tag#1")}
|
||||
)
|
||||
@ -6,9 +6,9 @@ import requests
|
||||
import mocks.nordigen as mock
|
||||
|
||||
from pfbudget.db.model import Bank, BankTransaction, Nordigen
|
||||
from pfbudget.extract.credentials import Credentials
|
||||
from pfbudget.extract.exceptions import BankError, CredentialsError
|
||||
from pfbudget.extract.psd2 import PSD2Client
|
||||
from pfbudget.extract.nordigen import NordigenClient, NordigenCredentials
|
||||
from pfbudget.extract.psd2 import PSD2Extractor
|
||||
|
||||
|
||||
class MockGet:
|
||||
@ -55,41 +55,37 @@ def mock_requests(monkeypatch):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> PSD2Client:
|
||||
credentials = Credentials("ID", "KEY", "TOKEN")
|
||||
return PSD2Client(credentials)
|
||||
def extractor() -> NordigenClient:
|
||||
credentials = NordigenCredentials("ID", "KEY", "TOKEN")
|
||||
return PSD2Extractor(NordigenClient(credentials))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def banks() -> list[Bank]:
|
||||
def bank() -> list[Bank]:
|
||||
bank = Bank("Bank#1", "", "")
|
||||
bank.nordigen = Nordigen("", "", mock.id, False)
|
||||
return [bank]
|
||||
return bank
|
||||
|
||||
|
||||
class TestExtractPSD2:
|
||||
def test_empty_credentials(self):
|
||||
cred = Credentials("", "")
|
||||
cred = NordigenCredentials("", "")
|
||||
with pytest.raises(CredentialsError):
|
||||
PSD2Client(cred)
|
||||
NordigenClient(cred)
|
||||
|
||||
def test_empty_banks(self, client):
|
||||
def test_no_psd2_bank(self, extractor):
|
||||
with pytest.raises(BankError):
|
||||
client.extract([])
|
||||
extractor.extract(Bank("", "", ""))
|
||||
|
||||
def test_no_psd2_bank(self, client):
|
||||
with pytest.raises(BankError):
|
||||
client.extract([Bank("", "", "")])
|
||||
|
||||
def test_timeout(self, monkeypatch, client, banks):
|
||||
def test_timeout(self, monkeypatch, extractor, bank):
|
||||
monkeypatch.setattr(
|
||||
"requests.get", MockGet(mock_exception=requests.ReadTimeout)
|
||||
)
|
||||
with pytest.raises(requests.Timeout):
|
||||
client.extract(banks)
|
||||
extractor.extract(bank)
|
||||
|
||||
def test_extract(self, client, banks):
|
||||
assert client.extract(banks) == [
|
||||
def test_extract(self, extractor, bank):
|
||||
assert extractor.extract(bank) == [
|
||||
BankTransaction(
|
||||
dt.date(2023, 1, 14), "string", Decimal("328.18"), "Bank#1"
|
||||
),
|
||||
|
||||
110
tests/test_transform.py
Normal file
110
tests/test_transform.py
Normal file
@ -0,0 +1,110 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import mocks.categories as mock
|
||||
|
||||
from pfbudget.db.model import (
|
||||
Bank,
|
||||
BankTransaction,
|
||||
CategoryRule,
|
||||
CategorySelector,
|
||||
Selector_T,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
)
|
||||
from pfbudget.transform.categorizer import Categorizer
|
||||
from pfbudget.transform.nullifier import Nullifier
|
||||
from pfbudget.transform.tagger import Tagger
|
||||
from pfbudget.transform.transform import Transformer
|
||||
|
||||
|
||||
class TestTransform:
|
||||
def test_nullifier(self):
|
||||
transactions = [
|
||||
BankTransaction(date(2023, 1, 1), "", Decimal("-500"), "Bank#1"),
|
||||
BankTransaction(date(2023, 1, 2), "", Decimal("500"), "Bank#2"),
|
||||
]
|
||||
|
||||
for t in transactions:
|
||||
assert not t.category
|
||||
|
||||
categorizer: Transformer = Nullifier()
|
||||
transactions = categorizer.transform(transactions)
|
||||
|
||||
for t in transactions:
|
||||
assert t.category == TransactionCategory(
|
||||
"null", CategorySelector(Selector_T.nullifier)
|
||||
)
|
||||
|
||||
def test_nullifier_inplace(self):
|
||||
transactions = [
|
||||
BankTransaction(date(2023, 1, 1), "", Decimal("-500"), "Bank#1"),
|
||||
BankTransaction(date(2023, 1, 2), "", Decimal("500"), "Bank#2"),
|
||||
]
|
||||
|
||||
for t in transactions:
|
||||
assert not t.category
|
||||
|
||||
categorizer: Transformer = Nullifier()
|
||||
categorizer.transform_inplace(transactions)
|
||||
|
||||
for t in transactions:
|
||||
assert t.category == TransactionCategory(
|
||||
"null", CategorySelector(Selector_T.nullifier)
|
||||
)
|
||||
|
||||
def test_nullifier_with_rules(self):
|
||||
transactions = [
|
||||
BankTransaction(date(2023, 1, 1), "", Decimal("-500"), "Bank#1"),
|
||||
BankTransaction(date(2023, 1, 2), "", Decimal("500"), "Bank#2"),
|
||||
]
|
||||
|
||||
for t in transactions:
|
||||
assert not t.category
|
||||
|
||||
rules = [CategoryRule(None, None, None, None, "Bank#1", None, None, "null")]
|
||||
|
||||
categorizer: Transformer = Nullifier(rules)
|
||||
transactions = categorizer.transform(transactions)
|
||||
|
||||
for t in transactions:
|
||||
assert not t.category
|
||||
|
||||
rules.append(CategoryRule(None, None, None, None, "Bank#2", None, None, "null"))
|
||||
categorizer = Nullifier(rules)
|
||||
transactions = categorizer.transform(transactions)
|
||||
|
||||
for t in transactions:
|
||||
assert t.category == TransactionCategory(
|
||||
"null", CategorySelector(Selector_T.nullifier)
|
||||
)
|
||||
|
||||
def test_tagger(self):
|
||||
transactions = [
|
||||
BankTransaction(date(2023, 1, 1), "desc#1", Decimal("-10"), "Bank#1")
|
||||
]
|
||||
|
||||
for t in transactions:
|
||||
assert not t.category
|
||||
|
||||
categorizer: Transformer = Tagger(mock.tag_1.rules)
|
||||
transactions = categorizer.transform(transactions)
|
||||
|
||||
for t in transactions:
|
||||
assert TransactionTag("tag#1") in t.tags
|
||||
|
||||
def test_categorize(self):
|
||||
transactions = [
|
||||
BankTransaction(date(2023, 1, 1), "desc#1", Decimal("-10"), "Bank#1")
|
||||
]
|
||||
|
||||
for t in transactions:
|
||||
assert not t.category
|
||||
|
||||
categorizer: Transformer = Categorizer(mock.category1.rules)
|
||||
transactions: Transformer = categorizer.transform(transactions)
|
||||
|
||||
for t in transactions:
|
||||
assert t.category == TransactionCategory(
|
||||
"cat#1", CategorySelector(Selector_T.rules)
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user