Skip to content
This repository was archived by the owner on Apr 24, 2025. It is now read-only.

Commit 803af58

Browse files
committed
feat: added initial implementation of ExpenseIncomeStats for calculating expense and income statistics
1 parent 09c4a69 commit 803af58

4 files changed

Lines changed: 156 additions & 5 deletions

File tree

projects/Expense-Tracker/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Expense Tracker App
22

33
## Tasks
4-
- [ ] Expense Categorization: This feature allows users to classify their expenses into categories like food, transportation, and entertainment, and provides a summary based on these categories.
5-
- [ ] Date Range Filtering: This feature enables users to filter and view their expenses within a specific date range.
4+
- [X] Expense Categorization: This feature allows users to classify their expenses into categories like food, transportation, and entertainment, and provides a summary based on these categories.
5+
- [X] Date Range Filtering: This feature enables users to filter and view their expenses within a specific date range.
66
- [ ] Expense Analysis: This feature offers statistical insights such as the average expense, highest expense, lowest expense, etc.
7-
- [ ] Data Saving and Loading: This feature lets users save their expense data to a file (like CSV or JSON) and load it back when needed.
7+
- [X] Data Saving and Loading: This feature lets users save their expense data to a file (like CSV or JSON) and load it back when needed.
88
- [ ] Data Export to PDF/Excel: This feature enables users to export their expense data to common formats like PDF or Excel for easy sharing or printing.
99
- [ ] Currency Converter: For users dealing with multiple currencies, this feature provides an option to convert expenses to a preferred currency.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from abc import ABC, abstractmethod
2+
from item import ItemsDB
3+
import statistics
4+
5+
6+
class ExpenseIncomeStats:
7+
def __init__(self, db_path: str, start: str = None, end: str = None):
8+
self._items_db = ItemsDB(db_path)
9+
self.start = start
10+
self.end = end
11+
12+
if start is None and end is None:
13+
# Get all Items
14+
self._items = self._items_db.get_all_items()
15+
pass
16+
elif start is None and end is not None:
17+
# Start from the Oldest Item(s) to the given end date string
18+
oldest = min(item.date for item in self._items_db.get_all_items())
19+
self._items = self._items_db.get_items_by_date_range(oldest.strftime("%Y-%m-%d"), end)
20+
elif start is not None and end is None:
21+
# Start from the given start date string to the latest date
22+
latest = max(item.date for item in self._items_db.get_all_items())
23+
self._items = self._items_db.get_items_by_date_range(start, latest.strftime("%Y-%m-%d"))
24+
else:
25+
# Get item from given date range
26+
self._items = self._items_db.get_items_by_date_range(start, end)
27+
28+
@staticmethod
29+
def _get_stats(items) -> dict:
30+
expense_items = [item.amount for item in items if item.amount < 0]
31+
if len(expense_items) > 0:
32+
expense_stats = {
33+
"average_expense": statistics.mean(expense_items),
34+
"min_expense": max(expense_items), # Warn: Negative Sign
35+
"max_expense": min(expense_items),
36+
}
37+
else:
38+
expense_stats = {}
39+
40+
income_items = [item.amount for item in items if item.amount > 0]
41+
if len(income_items) > 0:
42+
income_stats = {
43+
"average_income": statistics.mean(income_items),
44+
"min_income": min(income_items),
45+
"max_income": max(income_items),
46+
}
47+
else:
48+
income_stats = {}
49+
50+
return expense_stats | income_stats
51+
52+
def get_stats(self):
53+
return self._get_stats(self._items)
54+
55+
def get_stats_by_category(self) -> dict:
56+
# The 'root' of category (i.e. it aggregates the subcategories). We do not care about the subcategories.
57+
# Set of Root Categories
58+
# item.get_category_str() for an item without category would return 'Uncategorized'
59+
category_names = {item.category.name if item.category is not None else item.get_category_str() for item in
60+
self._items}
61+
62+
out_dict = {}
63+
for category_name in category_names:
64+
items = [item for item in self._items if item.category.name == category_name]
65+
out_dict[category_name] = self._get_stats(items)
66+
67+
return out_dict
68+
69+
def get_stats_by_category_with_subcategories(self) -> dict:
70+
category_names = self._items_db.get_category_names()
71+
stats_dict = {}
72+
for category_name in category_names:
73+
# Case: With Category and Without Subcategory
74+
stats_dict[f'{category_name}-NoSubcategory'] = self._get_stats(
75+
self._items_db.get_items_without_subcategory(category_name))
76+
# Case: With Category and With Subcategory
77+
for subcategory in self._items_db.get_subcategory_name(category_name):
78+
stats_dict[f'{category_name}-{subcategory}'] = \
79+
self._get_stats(self._items_db.get_items_by_category_and_subcategory(category_name, subcategory))
80+
81+
return stats_dict
82+
83+
@staticmethod
84+
def currency_converter(base_currency: str, quote_currency: str) -> float:
85+
# TODO: Implement currency_converter() method.
86+
pass
87+
88+
89+
class Report(ABC):
90+
@abstractmethod
91+
def generate_report(self, *args, **kwargs):
92+
pass
93+
94+
95+
class ExcelReport(Report):
96+
def __init__(self, file_path: str):
97+
self.file_path = file_path
98+
99+
def generate_report(self):
100+
pass
101+
102+
103+
class PdfReport(Report):
104+
def generate_report(self):
105+
pass

projects/Expense-Tracker/item.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, List
1+
from typing import Optional, List, Set
22
from dataclasses import dataclass, asdict, is_dataclass
33
from datetime import datetime
44
from copy import deepcopy
@@ -21,6 +21,18 @@ class Category:
2121
name: str
2222
subcategory: Optional[str] = None
2323

24+
def __str__(self):
25+
if self.subcategory is None:
26+
return f'{self.name}-NoSubcategory'
27+
else:
28+
return f'{self.name}-{self.subcategory}'
29+
30+
def __eq__(self, other):
31+
return self.name == other.name and self.subcategory == other.subcategory
32+
33+
def is_same_category_name(self, other):
34+
return self.name == other.name
35+
2436

2537
@dataclass
2638
class Item:
@@ -34,6 +46,9 @@ class Item:
3446
def __str__(self):
3547
return self.to_json_str(indent=4)
3648

49+
def get_category_str(self) -> str:
50+
return "Uncategorized" if self.category is None else str(self.category)
51+
3752
@classmethod
3853
def create(cls, name: str, amount: float, description: str, date_str: str, category: Optional[Category] = None):
3954
item_id = str(uuid.uuid4()) # Generate a unique ID
@@ -120,7 +135,38 @@ def delete_all_items(self):
120135
def get_all_items(self) -> List[Item]:
121136
return [Item.from_json_str(json.dumps(doc)) for doc in self._db.all()]
122137

123-
def get_items_by_date_range(self, start: str, end: str):
138+
def get_items_by_date_range(self, start: str, end: str) -> List[Item]:
124139
start_date = datetime.strptime(start, "%Y-%m-%d")
125140
end_date = datetime.strptime(end, "%Y-%m-%d")
126141
return [item for item in self.get_all_items() if start_date <= item.date <= end_date]
142+
143+
def get_items_by_category(self, category_name: str) -> List[Item]:
144+
return [item for item in self.get_all_items() if item.category.name == category_name]
145+
146+
def get_items_by_category_and_subcategory(self, category_name: str, subcategory_name: str) -> List[Item]:
147+
return [item for item in self.get_all_items() if
148+
item.category.name == category_name and item.category.subcategory == subcategory_name]
149+
150+
def get_items_uncategorized(self) -> List[Item]:
151+
return [item for item in self.get_all_items() if item.category is None]
152+
153+
def get_items_without_subcategory(self, category_name: str) -> List[Item]:
154+
# This excludes uncategorized items (i.e. item.category is None)
155+
return [item for item in self.get_all_items() if
156+
item.category is not None and item.category.name == category_name and item.category.subcategory is None]
157+
158+
def get_items_with_subcategory(self, category_name: str) -> List[Item]:
159+
# This excludes uncategorized items (i.e. item.category is None)
160+
return [item for item in self.get_all_items() if
161+
item.category is not None and item.category.name == category_name and item.category.subcategory is not
162+
None]
163+
164+
def get_category_names(self) -> Set[str]:
165+
# This excludes the Items with No Category (i.e. item.category is None)
166+
return {item.category.name for item in self.get_all_items() if item.category is not None}
167+
168+
def get_subcategory_name(self, category_name: str) -> Set[str]:
169+
# This excludes items without subcategories
170+
return {item.category.subcategory for item in self.get_all_items() if
171+
item.category is not None and item.category.name == category_name and item.category.subcategory is not
172+
None}
-32 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)