Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion expense_tracker/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
from expense_tracker.utils.migration import migrate_legacy_databases
from expense_tracker.core.merchant_repository import MerchantCategoryRepository
from expense_tracker.core.transaction_repository import TransactionRepository
from expense_tracker.services.merchant import MerchantCategoryService
from expense_tracker.services.transaction import TransactionService
from expense_tracker.services.statistics import StatisticsService
from expense_tracker.utils.merchant_normalizer import normalize_merchant

def main():
"""Start the Expense Tracker application."""
Expand All @@ -24,6 +27,10 @@ def main():
merchant_repo = MerchantCategoryRepository(
str(get_database_path("merchant_categories.db"))
)
merchant_service = MerchantCategoryService(
merchant_repo, transaction_repo, normalize_merchant
)
transaction_service = TransactionService(transaction_repo, merchant_service)
statistics_service = StatisticsService(transaction_repo)

root = Tk()
Expand All @@ -35,6 +42,6 @@ def main():
tb.Style("darkly")
except Exception:
ttk.Style()
MainWindow(root, transaction_repo, merchant_repo, statistics_service)
MainWindow(root, transaction_repo, transaction_service, statistics_service)
root.focus_force()
root.mainloop()
24 changes: 1 addition & 23 deletions expense_tracker/core/transaction_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,28 +234,6 @@ def get_monthly_net_income(self, start_date: date, end_date: date) -> float:
result = row.fetchone()
return result["net_income"] if result["net_income"] is not None else 0.0

def get_top_spending_category(self, start_date: date, end_date: date) -> tuple[str, float] | None:
"""
Returns the category with the highest spending (sum of negative amounts) for a specific month.
Returns tuple of (category_name, total_spending) or None if no expenses exist.
"""
rows = self.conn.execute(
"""
SELECT category, SUM(ABS(amount)) as total
FROM transactions
WHERE date >= ? AND date < ?
AND amount < 0
GROUP BY category
ORDER BY total DESC
LIMIT 1
""",
(start_date.isoformat(), end_date.isoformat()),
)
result = rows.fetchone()
if result is None:
return None
return (result["category"], result["total"])

def get_transactions_for_date(self, target_date: date) -> list[Transaction]:
"""
Query transactions matching exact date.
Expand Down Expand Up @@ -298,7 +276,7 @@ def get_latest_month_with_data(self) -> tuple[int, int]:
return (result["year"], result["month"])


def get_all_months_with_data(self) -> list[tuple[int, int]]:
def get_all_months_with_data(self) -> set[tuple[int, int]]:
"""
Returns a list of (year, month) tuples for all months that have transaction data.
Ordered by year and month descending (most recent first).
Expand Down
61 changes: 16 additions & 45 deletions expense_tracker/gui/dialogs/add_expense.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,37 @@
from datetime import date
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import messagebox

from expense_tracker.core.models import Transaction
from expense_tracker.core.transaction_repository import TransactionRepository
from expense_tracker.services.transaction import TransactionService
from expense_tracker.gui.dialogs.expense_form import build_expense_form, validate_amount


class AddExpenseDialog(tk.Toplevel):
def __init__(self, master, repo: TransactionRepository):
def __init__(self, master, transaction_service: TransactionService):
super().__init__(master)
self.repo = repo
self.transaction_service = transaction_service
self.title("Add Expense")
self.resizable(False, False)

self.amount_var = tk.StringVar()
self.category_var = tk.StringVar()
self.description_var = tk.StringVar()

self._build_form()

def _build_form(self):
frame = ttk.Frame(self)
frame.pack(fill="both", padx=10, pady=10)

# Amount
ttk.Label(frame, text="Amount (e.g. 12.50):").grid(row=0, column=0, sticky="w")
amount = ttk.Entry(frame, textvariable=self.amount_var, width=20)
amount.grid(row=1, column=0, sticky="w")

# Category
ttk.Label(frame, text="Category:").grid(row=2, column=0, sticky="w")
category = ttk.Entry(frame, textvariable=self.category_var, width=20)
category.grid(row=3, column=0, sticky="w")

# Description
ttk.Label(frame, text="Description:").grid(row=4, column=0, sticky="w")
description = ttk.Entry(frame, textvariable=self.description_var, width=20)
description.grid(row=5, column=0, sticky="w")

# Buttons
button_frame = ttk.Frame(frame)
button_frame.grid(row=6, column=0, pady=10, sticky="e")
ttk.Button(button_frame, text="Add", command=self._on_add).pack(
side="right", padx=5
build_expense_form(
self,
self.amount_var,
self.category_var,
self.description_var,
submit_text="Add",
on_submit=self._on_add,
on_cancel=self._on_cancel,
)
ttk.Button(button_frame, text="Cancel", command=self._on_cancel).pack(
side="right"
)

# Keyboard bindings
self.bind("<Escape>", lambda e: self._on_cancel())

def _on_add(self):
raw = self.amount_var.get()
if not raw:
messagebox.showerror("Error", "Amount is required.")
return

try:
amount = float(raw)
except ValueError:
messagebox.showerror("Error", "Amount must be a valid number.")
amount = validate_amount(self.amount_var)
if amount is None:
return

try:
Expand All @@ -71,7 +42,7 @@ def _on_add(self):
category=self.category_var.get() or "Uncategorized",
description=self.description_var.get() or "",
)
saved_transaction = self.repo.add_transaction(transaction)
saved_transaction = self.transaction_service.add_transaction(transaction)
self.result = saved_transaction.id
self.destroy()
messagebox.showinfo(
Expand Down
114 changes: 33 additions & 81 deletions expense_tracker/gui/dialogs/edit_expense.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@
import tkinter as tk
from tkinter import ttk, messagebox

from expense_tracker.core.transaction_repository import TransactionRepository
from expense_tracker.core.merchant_repository import MerchantCategoryRepository
from expense_tracker.services.merchant import MerchantCategoryService
from expense_tracker.utils.merchant_normalizer import normalize_merchant
from expense_tracker.services.transaction import TransactionService
from expense_tracker.gui.dialogs.expense_form import build_expense_form, validate_amount

logger = logging.getLogger(__name__)

Expand All @@ -14,17 +12,12 @@ class EditExpenseDialog(tk.Toplevel):
def __init__(
self,
master,
repo: TransactionRepository,
merchant_repo: MerchantCategoryRepository,
transaction_service: TransactionService,
transaction_id: int,
):
super().__init__(master)
self.repo = repo
self.merchant_repo = merchant_repo
self.transaction_service = transaction_service
self.transaction_id = transaction_id
self.merchant_service = MerchantCategoryService(
merchant_repo, repo, normalize_merchant
)
self.title("Edit Expense")
self.resizable(False, False)

Expand All @@ -40,66 +33,37 @@ def __init__(

self.prev_data = None

self._build_form()
self._load_transaction_data()

def _build_form(self):
frame = ttk.Frame(self)
frame.pack(fill="both", padx=10, pady=10)

# Amount
ttk.Label(frame, text="Amount (e.g. 12.50):").grid(row=0, column=0, sticky="w")
amount = ttk.Entry(frame, textvariable=self.amount_var, width=20)
amount.grid(row=1, column=0, sticky="w")

# Category
ttk.Label(frame, text="Category:").grid(row=2, column=0, sticky="w")
category = ttk.Entry(frame, textvariable=self.category_var, width=20)
category.grid(row=3, column=0, sticky="w")

# Description
ttk.Label(frame, text="Description:").grid(row=4, column=0, sticky="w")
description = ttk.Entry(frame, textvariable=self.description_var, width=20)
description.grid(row=5, column=0, sticky="w")

# Buttons
button_frame = ttk.Frame(frame)
button_frame.grid(row=6, column=0, pady=10, sticky="e")
ttk.Button(button_frame, text="Save", command=self._on_save).pack(
side="right", padx=5
)
ttk.Button(button_frame, text="Cancel", command=self._on_cancel).pack(
side="right"
build_expense_form(
self,
self.amount_var,
self.category_var,
self.description_var,
submit_text="Save",
on_submit=self._on_save,
on_cancel=self._on_cancel,
)

# Keyboard bindings
self.bind("<Escape>", lambda e: self._on_cancel())

self._load_transaction_data()

def _load_transaction_data(self):
self.prev_data = self.repo.get_transaction(self.transaction_id)
self.prev_data = self.transaction_service.get_transaction(self.transaction_id)
if self.prev_data is not None:
self.amount_var.set(str(self.prev_data.amount))
self.category_var.set(self.prev_data.category)
self.description_var.set(self.prev_data.description)

# Only suggest category if the current category is "Uncategorized"
# Suggest a better category if currently uncategorized
if self.prev_data.category == "Uncategorized":
suggested_category = self.merchant_repo.get_category(
normalize_merchant(self.prev_data.description)
suggested = self.transaction_service.suggest_category(
self.prev_data.description, self.prev_data.amount
)
if suggested_category:
self.category_var.set(suggested_category.category)
if suggested != "Uncategorized":
self.category_var.set(suggested)

def _on_save(self):
raw = self.amount_var.get()
if not raw:
messagebox.showerror("Error", "Amount is required.")
return

try:
amount = float(raw)
except ValueError:
messagebox.showerror("Error", "Amount must be a valid number.")
amount = validate_amount(self.amount_var)
if amount is None:
return

try:
Expand All @@ -108,34 +72,22 @@ def _on_save(self):
"category": self.category_var.get() or "Uncategorized",
"description": self.description_var.get() or "",
}
self.repo.update_transaction(self.transaction_id, data)

# Check if we need to update merchant categories
if (
self.prev_data is not None
and self.prev_data.category != data["category"]
):
try:
self.merchant_service.update_category(
self.prev_data.description, data["category"]
)
self.merchant_service.update_uncategorized_transactions()
messagebox.showinfo(
"Success",
f"Transaction {self.transaction_id} updated and related transactions recategorized.",
)
except Exception as e:
messagebox.showerror(
"Error", f"Failed to update related transactions: {e}"
)
self.destroy()

categories_updated = self.transaction_service.update_transaction(
self.transaction_id, data
)

if categories_updated:
messagebox.showinfo(
"Success",
f"Transaction {self.transaction_id} updated and related transactions recategorized.",
)
else:
# No category change, just close the dialog
self.result = self.transaction_id
messagebox.showinfo(
"Success", f"Transaction {self.transaction_id} updated."
)
self.destroy()
self.destroy()

except Exception as e:
messagebox.showerror("Error", f"Failed to update transaction: {e}")
Expand Down
57 changes: 57 additions & 0 deletions expense_tracker/gui/dialogs/expense_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import tkinter as tk
from tkinter import ttk, messagebox


def build_expense_form(
parent: tk.Widget,
amount_var: tk.StringVar,
category_var: tk.StringVar,
description_var: tk.StringVar,
submit_text: str,
on_submit,
on_cancel,
) -> ttk.Frame:
"""Build the shared Amount/Category/Description form used by Add and Edit dialogs."""
frame = ttk.Frame(parent)
frame.pack(fill="both", padx=10, pady=10)

# Amount
ttk.Label(frame, text="Amount (e.g. 12.50):").grid(row=0, column=0, sticky="w")
ttk.Entry(frame, textvariable=amount_var, width=20).grid(
row=1, column=0, sticky="w"
)

# Category
ttk.Label(frame, text="Category:").grid(row=2, column=0, sticky="w")
ttk.Entry(frame, textvariable=category_var, width=20).grid(
row=3, column=0, sticky="w"
)

# Description
ttk.Label(frame, text="Description:").grid(row=4, column=0, sticky="w")
ttk.Entry(frame, textvariable=description_var, width=20).grid(
row=5, column=0, sticky="w"
)

# Buttons
button_frame = ttk.Frame(frame)
button_frame.grid(row=6, column=0, pady=10, sticky="e")
ttk.Button(button_frame, text=submit_text, command=on_submit).pack(
side="right", padx=5
)
ttk.Button(button_frame, text="Cancel", command=on_cancel).pack(side="right")

return frame


def validate_amount(amount_var: tk.StringVar) -> float | None:
"""Validate and parse the amount field. Shows error messagebox on failure."""
raw = amount_var.get()
if not raw:
messagebox.showerror("Error", "Amount is required.")
return None
try:
return float(raw)
except ValueError:
messagebox.showerror("Error", "Amount must be a valid number.")
return None
Loading