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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# Budget Tracker

**Budget Tracker** is a Python-based tool for processing, categorizing, and summarizing monthly financial transactions from Excel files.
**Budget Tracker** is a Python-based tool for processing, categorizing, and summarizing monthly financial transactions from Excel files and text-based PDF credit card statements.
It supports Hebrew-language inputs and outputs a categorized summary into a pre-designed Excel dashboard.

## Features

### Core Features
- **Modern GUI Interface**: Easy-to-use graphical interface with Hebrew/English support
- **Automated Processing**: Load and normalize Excel transaction files (`.xlsx`, `.xls`)
- **Automated Processing**: Load and normalize transaction files (`.xlsx`, `.xls`, text-based `.pdf`)
- **Smart Categorization**: Map merchants to user-defined budget categories and subcategories
- **Dashboard Integration**: Output categorized monthly summaries into an existing formatted dashboard
- **Backups**: Automatic backups of your dashboard, archived transaction files
- **Hebrew Support**: Built-in RTL support for Hebrew text and date formats

### File Management
- **Drag & Drop**: Import files by dragging them into the window
- **Drag & Drop**: Import Excel and text-based PDF files by dragging them into the window
- **Duplicate Detection**: SHA256 hash-based detection prevents duplicate imports
- **File Preview**: See transaction count, date range, and total when selecting a file
- **Quick Delete**: Remove files with confirmation dialog
Expand Down Expand Up @@ -72,7 +72,7 @@ The Budget Tracker uses a two-tier category system:
*(GUI starts by default)*

2. **Import Files**:
- Drag & drop Excel files into the window, OR
- Drag & drop Excel or text-based PDF files into the window, OR
- Click "Import Files" button to browse

3. **Preview Files**: Click on a file to see transaction count, dates, and total
Expand Down
85 changes: 59 additions & 26 deletions gui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas # noqa: E402
from matplotlib.figure import Figure # noqa: E402

from src.config import TRANSACTIONS_DIR, CATEGORIES_FILE_PATH, DASHBOARD_FILE_PATH, APPDATA_DIR, LOG_FILE_NAME, ARCHIVE_DIR # noqa: E402
from src.config import TRANSACTIONS_DIR, CATEGORIES_FILE_PATH, DASHBOARD_FILE_PATH, APPDATA_DIR, LOG_FILE_NAME, ARCHIVE_DIR, SUPPORTED_EXTENSIONS # noqa: E402
from src.file_manager import ensure_dirs, load_transaction_files, _load_transaction_file # noqa: E402
from src.logger import setup_logging # noqa: E402
from src.normalizer import Normalizer # noqa: E402
Expand Down Expand Up @@ -665,29 +665,54 @@ def on_log_level_changed(self, level_name: str):
handler.setLevel(LOG_LEVEL_MAP[level_name])


class LogViewerHandler(logging.Handler, QObject):
"""
Logging handler that forwards log records to the GUI log viewer.
"""
class _LogSignalEmitter(QObject):
"""Qt signal bridge for forwarding log records to the viewer."""
log_signal = pyqtSignal(str, str)


class LogViewerHandler(logging.Handler):
"""Logging handler that forwards log records to the GUI log viewer."""

def __init__(self, viewer: LogViewerWidget):
QObject.__init__(self)
logging.Handler.__init__(self)
self.log_signal.connect(viewer.add_log)
super().__init__()
self._emitter = _LogSignalEmitter()
self._viewer = viewer
self._is_closed = False
self._emitter.log_signal.connect(viewer.add_log)

def emit(self, record: logging.LogRecord):
if self._is_closed or self._emitter is None:
return

try:
msg = self.format(record)
# Check if the widget still exists before emitting
if hasattr(self, 'log_signal'):
self.log_signal.emit(record.levelname, msg)
self._emitter.log_signal.emit(record.levelname, msg)
except RuntimeError:
# Widget has been deleted, ignore
pass
except Exception:
self.handleError(record)

def close(self):
"""Disconnect Qt resources before logging shutdown runs."""
self._is_closed = True

if self._emitter is not None:
try:
self._emitter.log_signal.disconnect()
except (TypeError, RuntimeError):
pass

if QApplication.instance() is not None:
try:
self._emitter.deleteLater()
except RuntimeError:
pass

self._emitter = None
self._viewer = None
super().close()


class QuickStatsWidget(QWidget):
"""Widget displaying quick statistics cards."""
Expand Down Expand Up @@ -1086,15 +1111,15 @@ def dropEvent(self, event: QDropEvent):
"""
Handle file drop event.

Extracts Excel file paths from dropped URLs and emits files_dropped signal.
Extracts supported transaction file paths from dropped URLs and emits files_dropped signal.

Args:
event: Drop event
"""
files = []
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if file_path.endswith(('.xlsx', '.xls')):
if file_path.lower().endswith(tuple(SUPPORTED_EXTENSIONS)):
files.append(file_path)

if files:
Expand Down Expand Up @@ -1656,13 +1681,13 @@ def perform_startup_validation(self):

def import_dropped_files(self, files: List[str]):
"""Import files dropped via drag and drop with duplicate detection."""
from src.validators import validate_excel_file, ValidationError
from src.validators import validate_transaction_file, ValidationError
count, errors, duplicates = 0, [], []

for file_path in files:
try:
src = Path(file_path)
validate_excel_file(src)
validate_transaction_file(src)

# Calculate hash
src_hash = calculate_file_hash(src)
Expand Down Expand Up @@ -2020,7 +2045,7 @@ def refresh_files(self):
"""
Refresh the transaction files list.

Scans the transactions directory for Excel files and updates the file list
Scans the transactions directory for supported transaction files and updates the file list
widget. Displays file count and total size information.
"""
self.file_list.clear()
Expand All @@ -2029,7 +2054,10 @@ def refresh_files(self):
ensure_dirs([TRANSACTIONS_DIR])
return

files = list(TRANSACTIONS_DIR.glob('*.xls*'))
files = [
file_path for file_path in TRANSACTIONS_DIR.glob('*')
if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_EXTENSIONS
]
for file_path in files:
self.file_list.addItem(file_path.name)

Expand Down Expand Up @@ -2093,7 +2121,7 @@ def refresh_archive(self):
"""
Refresh the archive files list.

Scans the archive directory for Excel files and updates the archive list widget.
Scans the archive directory for supported transaction files and updates the archive list widget.
Displays archive file count.
"""
self.archive_list.clear()
Expand All @@ -2102,7 +2130,10 @@ def refresh_archive(self):
if not archive_path.exists():
return

files = list(archive_path.glob('*.xls*'))
files = [
file_path for file_path in archive_path.glob('*')
if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_EXTENSIONS
]
for file_path in files:
self.archive_list.addItem(file_path.name)

Expand All @@ -2125,9 +2156,9 @@ def refresh_all(self):

def import_files(self):
"""
Import Excel files via file dialog with duplicate detection.
Import transaction files via file dialog with duplicate detection.

Opens a file selection dialog allowing user to choose one or more Excel files.
Opens a file selection dialog allowing user to choose one or more supported transaction files.
Copies selected files to the transactions directory and refreshes the file list.
Shows success message with count of imported files.
Validates files before importing and provides user-friendly error messages.
Expand All @@ -2137,7 +2168,7 @@ def import_files(self):
self,
self.translations.get('select_files'),
"",
"Excel Files (*.xlsx *.xls)"
"Transaction Files (*.xlsx *.xls *.pdf)"
)

if not files:
Expand Down Expand Up @@ -2170,7 +2201,7 @@ def import_files(self):

# Check if source file is locked
if is_file_locked(src):
errors.append(f"{src.name}: File is locked (may be open in Excel)")
errors.append(f"{src.name}: File is locked (may be open in another application)")
continue

dst = TRANSACTIONS_DIR / src.name
Expand Down Expand Up @@ -2274,7 +2305,7 @@ def process_transactions(self):
from src.file_manager import _compute_file_hash, _load_processed_hashes
transaction_files = [
f for f in Path(TRANSACTIONS_DIR).glob('*')
if f.suffix in ('.xlsx', '.xls') and f.is_file()
if f.suffix.lower() in SUPPORTED_EXTENSIONS and f.is_file()
]

file_hashes = {}
Expand Down Expand Up @@ -2737,8 +2768,9 @@ def clear_archive(self):
if reply == QMessageBox.StandardButton.Yes:
archive_path = ARCHIVE_DIR
if archive_path.exists():
for file_path in archive_path.glob('*.xls*'):
file_path.unlink()
for file_path in archive_path.glob('*'):
if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_EXTENSIONS:
file_path.unlink()

self.refresh_archive()
QMessageBox.information(
Expand Down Expand Up @@ -2793,6 +2825,7 @@ def closeEvent(self, event):
try:
logging.getLogger().removeHandler(self._log_viewer_handler)
self._log_viewer_handler.close()
self._log_viewer_handler = None
except Exception:
pass # Ignore errors during cleanup

Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Dependencies
pandas # Data manipulation and transaction processing
openpyxl # Excel file reading and writing
pypdf # Text-based PDF extraction for transaction imports
pdfplumber # Layout-aware PDF parsing for credit card statements
PyQt5>=5.15.0 # Modern GUI framework
matplotlib>=3.7.0 # Charts and graphs generation

Expand Down
4 changes: 3 additions & 1 deletion roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
- [x] **File Preview**: Show transaction count, date range, and total when selecting file
- [x] **Interactive Chart Filtering**: Click category to show subcategory breakdown with RTL support
- [x] **Robust File Parsing**: Flexible header detection (handles quote variations, extra spaces, simplified names)
- [x] **PDF Transaction Import**: Import text-based credit card statement PDFs through the same GUI flow as Excel
- [x] **PDF Statement Parsing Hardening**: Added layout-aware parsing, merchant cleanup, foreign-currency support, and focused parser tests
- [x] **Pre-loaded Common Mappings**: Default merchant-to-category mappings for 211 common Israeli merchants

---
Expand Down Expand Up @@ -44,7 +46,7 @@ _(Currently none)_
- [ ] Column position hints (amount typically in rightmost columns)
- [ ] **File Format Support**
- [ ] CSV import support
- [ ] PDF bank statement parsing
- [x] PDF bank statement parsing
- [ ] Direct bank API integration

---
Expand Down
6 changes: 3 additions & 3 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
LOG_FILE_NAME = 'budget.log'
TEMPLATE_SHEET_NAME = "Template"

SUPPORTED_EXTENSIONS = ['.xlsx', '.xls']
SUPPORTED_EXTENSIONS = ['.xlsx', '.xls', '.pdf']

# Default log level
LOG_SEVERITY = logging.DEBUG
Expand Down Expand Up @@ -93,9 +93,9 @@ def get_log_level_name() -> str:
}

# Security and Validation Constants
MAX_FILE_SIZE_MB = 50 # Maximum file size for Excel files
MAX_FILE_SIZE_MB = 50 # Maximum file size for supported transaction files
MAX_CATEGORIES = 10000 # Maximum number of categories to prevent unbounded growth
MAX_MERCHANT_NAME_LENGTH = 200 # Maximum length for merchant names
ALLOWED_FILE_EXTENSIONS = ['.xlsx', '.xls']
ALLOWED_FILE_EXTENSIONS = ['.xlsx', '.xls', '.pdf']
BACKUP_SUFFIX = '.backup'
PROCESSING_TIMEOUT_SECONDS = 300 # 5 minutes max processing time
Loading
Loading