Skip to content

Commit

Permalink
Add transaction analysis logic
Browse files Browse the repository at this point in the history
Given transactions and supported categories it is possible to analyze
transactions.

The result are both:
-  matched categories with summed transaction values
-  unmatched transactions that still need manual classification
  • Loading branch information
WojtekMs committed Dec 1, 2023
1 parent a2fbb7c commit 2ed50d9
Show file tree
Hide file tree
Showing 11 changed files with 425 additions and 11 deletions.
21 changes: 20 additions & 1 deletion src/banker/__main__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,21 @@
from banker.analyzer.analyze import analyze_transactions
from banker.data.category import Category, PaymentType
import argparse

from banker.parser.html_transactions_parser import HtmlTransactionsParser


def main():
print("Hello world from Banker!")
supported_categories = [
Category(name="Kaufland", payment_type=PaymentType.Household, matching_regexes=[r"KAUFLAND PL"])]
transactions_parser = HtmlTransactionsParser()

parser = argparse.ArgumentParser()
parser.add_argument("html_file")
args = parser.parse_args()

with open(args.html_file, "rb") as file:
all_transactions = transactions_parser.parse_transactions(file.read().decode('utf-8'))
analyze_result = analyze_transactions(all_transactions, supported_categories)
print(analyze_result)
# TODO: format output
Empty file added src/banker/analyzer/__init__.py
Empty file.
30 changes: 30 additions & 0 deletions src/banker/analyzer/analyze.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from dataclasses import dataclass

from banker.data.category import Category
from banker.data.transaction import Transaction
from logging import getLogger

analyze_logger = getLogger("Analyze")


@dataclass(frozen=True)
class AnalyzeResult:
unmatched_transactions: list[Transaction]
matched_categories: list[Category]


def analyze_transactions(transactions: list[Transaction], supported_categories: list[Category]) -> AnalyzeResult:
unmatched_transactions = []
matched_categories = {}
for transaction in transactions:
matching_categories = transaction.find_matching(supported_categories)
matching_categories_count = len(matching_categories)
if matching_categories_count == 1:
category_name = matching_categories[0].get_name()
matched_category = matched_categories.setdefault(category_name, matching_categories[0])
matched_category.value += transaction.value
else:
analyze_logger.info(f"Transaction: {transaction} matched to {matching_categories_count} categories")
unmatched_transactions.append(transaction)
return AnalyzeResult(unmatched_transactions=unmatched_transactions,
matched_categories=list(matched_categories.values()))
20 changes: 16 additions & 4 deletions src/banker/data/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@


class PaymentType(Enum):
household = auto()
recurring = auto()
optional = auto()
occasional = auto()
Household = auto()
Recurring = auto()
Optional = auto()
Occasional = auto()


class Category:
Expand All @@ -18,6 +18,18 @@ def __init__(self, name: str, payment_type: PaymentType, matching_regexes: list[
self.__matching_regexes: list[re.Pattern] = [re.compile(pattern) for pattern in matching_regexes]
self.value = Money(amount='0', currency=PLN)

def __eq__(self, other):
if type(other) is type(self):
return self.__dict__ == other.__dict__
return False

def __str__(self):
return f"Category(name={self.__name}, payment_type={self.__payment_type}, " \
f"matching_regexes={self.__matching_regexes}, value={self.value})"

def __repr__(self):
return self.__str__()

def get_name(self) -> str:
return self.__name

Expand Down
11 changes: 8 additions & 3 deletions src/banker/data/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
class Transaction:
date: str
value: Money
type: str
description: str

def __post_init__(self):
if self.value.currency != PLN:
raise ValueError("The only accepted transaction currency is PLN")

def count_matching(self, categories: list[Category]) -> int:
# TODO: implement
return 0
def find_matching(self, categories: list[Category]) -> list[Category]:
result = []
for category in categories:
if any([True for pattern in category.get_matching_regexes() if
pattern.search(self.description) is not None]):
result.append(category)
return result
7 changes: 6 additions & 1 deletion src/banker/parser/html_transactions_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ def parse_transactions(self, content: str) -> list[Transaction]:
if value is None:
self.logger.warning(f"Value not found in transaction {row_id}")
continue
transaction_type = transaction.get("Typ transakcji")
if transaction_type is None:
self.logger.warning(f"Transaction type not found in transaction {row_id}")
continue
result.append(
Transaction(date=date, description=description, value=Money(amount=str(value), currency=PLN)))
Transaction(date=date, description=description, value=Money(amount=str(value), currency=PLN),
type=transaction_type))
return result
Empty file.
199 changes: 199 additions & 0 deletions tests/banker/analyzer/test_analyze.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
from copy import deepcopy

import pytest

from moneyed import Money, PLN

from banker.data.category import Category, PaymentType
from banker.data.transaction import Transaction
from banker.analyzer.analyze import analyze_transactions, AnalyzeResult


def make_category_with_value(category: Category, value: Money) -> Category:
category_copy = deepcopy(category)
category_copy.value = value
return category_copy


@pytest.mark.parametrize(
'transactions, supported_categories, expected_result',
[
(
[
Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN), description="New shoes",
type="Card")
],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"])
],
AnalyzeResult(unmatched_transactions=[], matched_categories=[
make_category_with_value(
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Money(amount="-11.27", currency=PLN))
])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-13.30", currency=PLN),
description="Amazing trekking shoes", type="Card"),
Transaction(date="2023-01-02", value=Money(amount="-16.70", currency=PLN),
description="Casual shoes", type="Card"),
Transaction(date="2023-01-03", value=Money(amount="-20.00", currency=PLN),
description="Dancing shoes", type="Card")
],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"])
],
AnalyzeResult(unmatched_transactions=[], matched_categories=[
make_category_with_value(
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Money(amount="-50.00", currency=PLN))
])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-15.00", currency=PLN),
description="Amazing trekking shoes", type="Card"),
Transaction(date="2023-01-02", value=Money(amount="-33.00", currency=PLN),
description="Cheap shirts", type="Card"),
Transaction(date="2023-01-03", value=Money(amount="-50.00", currency=PLN),
description="Expensive sweets", type="Card")
],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Category(name="Sweets", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)sweets"]),
Category(name="Shirts", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shirts"])
],
AnalyzeResult(unmatched_transactions=[], matched_categories=[
make_category_with_value(
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Money(amount="-15.00", currency=PLN)),
make_category_with_value(
Category(name="Shirts", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shirts"]),
Money(amount="-33.00", currency=PLN)),
make_category_with_value(
Category(name="Sweets", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)sweets"]),
Money(amount="-50.00", currency=PLN))
])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN), description="New shoes",
type="Card"),
Transaction(date="2023-01-02", value=Money(amount="-500.00", currency=PLN), description="New game",
type="Card")
],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"])
],
AnalyzeResult(
unmatched_transactions=[
Transaction(date="2023-01-02", value=Money(amount="-500.00", currency=PLN),
description="New game", type="Card")],
matched_categories=[
make_category_with_value(
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Money(amount="-11.27", currency=PLN))
])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN), description="New shoes",
type="Card"),
],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Category(name="New stuff", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)new"])
],
AnalyzeResult(
unmatched_transactions=[Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN),
description="New shoes", type="Card")],
matched_categories=[])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN), description="New shoes",
type="Card"),
],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Category(name="Weirdo", payment_type=PaymentType.Optional, matching_regexes=[r"what?"])
],
AnalyzeResult(
unmatched_transactions=[],
matched_categories=[
make_category_with_value(
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
Money(amount="-11.27", currency=PLN))
])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN), description="New shoes",
type="Card"),
],
[
Category(name="Weirdo", payment_type=PaymentType.Optional, matching_regexes=[r"what?"])
],
AnalyzeResult(
unmatched_transactions=[Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN),
description="New shoes", type="Card")],
matched_categories=[])
),
(
[],
[
Category(name="Shoes", payment_type=PaymentType.Optional, matching_regexes=[r"(?i)shoes"]),
],
AnalyzeResult(
unmatched_transactions=[],
matched_categories=[])
),
(
[
Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN), description="New shoes",
type="Card"),
Transaction(date="2023-01-02", value=Money(amount="-500.00", currency=PLN), description="New game",
type="Card")
],
[],
AnalyzeResult(
unmatched_transactions=[Transaction(date="2023-01-01", value=Money(amount="-11.27", currency=PLN),
description="New shoes", type="Card"),
Transaction(date="2023-01-02", value=Money(amount="-500.00", currency=PLN),
description="New game", type="Card")],
matched_categories=[])
),
(
[],
[],
AnalyzeResult(
unmatched_transactions=[],
matched_categories=[])
),
],
ids=["given matching transaction to one category then return matched category with increased value",
"given many matching transactions to one category then return matched category with increased value",
"given many transactions and many categories then return all matched categories with increased values",
"given transaction that does not match then return unmatched transaction",
"given matching transaction to many categories then return unmatched transaction",
"given category that was not matched then exclude it from matched categories",
"given one transaction that does not match then return unmatched transaction and no categories",
"given no transactions then return empty list of both transactions and categories",
"given transactions and empty list of categories then return all transactions as unmatched",
"given empty transactions and empty categories then return empty lists"]
)
def test_given_transactions_and_supported_categories_when_analyze_then_return_result(
transactions, supported_categories, expected_result
):
actual_result = analyze_transactions(transactions, supported_categories)

assert actual_result == expected_result
11 changes: 9 additions & 2 deletions tests/banker/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def transaction1():
"Data i czas operacji : 2023-10-30 "
"Oryginalna kwota operacji : 37.35 "
"Numer karty : 516931******3943",
type="Płatność kartą",
value=Money(amount='-37.35', currency=PLN))


Expand All @@ -26,6 +27,7 @@ def transaction2():
"Data i czas operacji : 2023-10-30 "
"Oryginalna kwota operacji : 200.00 "
"Numer karty : 516931******3943",
type="Wypłata z bankomatu",
value=Money(amount='-200.00', currency=PLN))


Expand All @@ -37,6 +39,7 @@ def transaction3():
"Adres : intercity.pl "
"'Operacja : 00000076965444780 "
"Numer referencyjny : 00000076965444780",
type="Płatność web - kod mobilny",
value=Money(amount='-49.02', currency=PLN))


Expand All @@ -46,11 +49,15 @@ def transaction4():
"Adres nadawcy : "
"UL.GULASZOWA 0 "
"00-001 WROCŁAW POL "
"Tytuł : WPŁATA", value=Money(amount='800.00', currency=PLN))
"Tytuł : WPŁATA",
type="Wpłata gotówkowa w kasie",
value=Money(amount='800.00', currency=PLN))


@pytest.fixture
def transaction5():
return Transaction(date="2023-10-08", description="Rachunek odbiorcy : 000000000000000000000 "
"Nazwa odbiorcy : Alicja "
"Tytuł : Na korki", value=Money(amount='-50.00', currency=PLN))
"Tytuł : Na korki",
type="Zlecenie stałe",
value=Money(amount='-50.00', currency=PLN))
Empty file added tests/banker/data/__init__.py
Empty file.
Loading

0 comments on commit 2ed50d9

Please sign in to comment.