feat: complete v1 implementation - all services, UI views, and tests
Adds remaining files from parallel development: - Services: analysis, csv_reader, forecasting, normalizer, recurring - UI: recurring_view, settings_view, sidebar, themes (dark/light) - Tests: analysis, csv_reader, forecasting, import_categorize, normalizer, recurring, integration - App entry point (main.py) and CLAUDE.md 52 tests passing across all modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
CLAUDE.md
Normal file
16
CLAUDE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SpendingAnalysis is a Python application that ingests data from various sources to analyze spending habits. The project is in early development.
|
||||
|
||||
## Repository
|
||||
|
||||
- Remote: https://gitea.conlon.fun/andy/SpendingAnalysis.git
|
||||
- Branch: main
|
||||
|
||||
## Development Setup
|
||||
|
||||
Python project — no build system, dependencies, or test framework configured yet. Standard Python .gitignore is in place covering common tooling (pytest, mypy, ruff, venv, etc.).
|
||||
19
src/main.py
Normal file
19
src/main.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import sys
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from src.db import get_session
|
||||
from src.seed import seed_categories
|
||||
from src.ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main():
|
||||
session = get_session()
|
||||
seed_categories(session)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow(session)
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
src/services/analysis.py
Normal file
83
src/services/analysis.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# src/services/analysis.py
|
||||
import datetime
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.transaction import Transaction
|
||||
from src.models.category import Category
|
||||
|
||||
|
||||
class AnalysisService:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def _base_query(self, start=None, end=None, person_id=None, account_id=None, category_id=None):
|
||||
q = self.session.query(Transaction).filter(Transaction.is_transfer == False)
|
||||
if start:
|
||||
q = q.filter(Transaction.date >= start)
|
||||
if end:
|
||||
q = q.filter(Transaction.date <= end)
|
||||
if person_id:
|
||||
q = q.filter(Transaction.attributed_to_id == person_id)
|
||||
if account_id:
|
||||
q = q.filter(Transaction.account_id == account_id)
|
||||
if category_id:
|
||||
q = q.filter(Transaction.category_id == category_id)
|
||||
return q
|
||||
|
||||
def spending_by_period(self, period: str = "month", **filters) -> list[dict]:
|
||||
q = self._base_query(**filters)
|
||||
if period == "month":
|
||||
q = (
|
||||
q.with_entities(
|
||||
func.strftime("%Y-%m", Transaction.date).label("period"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
)
|
||||
.group_by("period")
|
||||
.order_by("period")
|
||||
)
|
||||
elif period == "week":
|
||||
q = (
|
||||
q.with_entities(
|
||||
func.strftime("%Y-W%W", Transaction.date).label("period"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
)
|
||||
.group_by("period")
|
||||
.order_by("period")
|
||||
)
|
||||
elif period == "day":
|
||||
q = (
|
||||
q.with_entities(
|
||||
func.strftime("%Y-%m-%d", Transaction.date).label("period"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
)
|
||||
.group_by("period")
|
||||
.order_by("period")
|
||||
)
|
||||
return [{"period": r.period, "total": float(r.total)} for r in q.all()]
|
||||
|
||||
def spending_by_category(self, start=None, end=None, **filters) -> list[dict]:
|
||||
q = self._base_query(start=start, end=end, **filters)
|
||||
q = (
|
||||
q.join(Category, Transaction.category_id == Category.id, isouter=True)
|
||||
.with_entities(
|
||||
func.coalesce(Category.name, "Uncategorized").label("category"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.count(Transaction.id).label("count"),
|
||||
)
|
||||
.group_by("category")
|
||||
.order_by(func.sum(Transaction.amount))
|
||||
)
|
||||
return [{"category": r.category, "total": float(r.total), "count": r.count} for r in q.all()]
|
||||
|
||||
def spending_by_tag(self, start=None, end=None, **filters) -> list[dict]:
|
||||
q = self._base_query(start=start, end=end, **filters)
|
||||
q = (
|
||||
q.with_entities(
|
||||
func.coalesce(Transaction.tag, "untagged").label("tag"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
)
|
||||
.group_by("tag")
|
||||
.order_by(func.sum(Transaction.amount))
|
||||
)
|
||||
return [{"tag": r.tag, "total": float(r.total)} for r in q.all()]
|
||||
111
src/services/csv_reader.py
Normal file
111
src/services/csv_reader.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import csv
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _looks_like_data(row: list[str]) -> bool:
|
||||
"""Check if a row looks like data rather than headers.
|
||||
|
||||
Returns True if any cell in the row matches a date pattern or is a
|
||||
pure numeric value, which strongly suggests it's a data row.
|
||||
"""
|
||||
date_pattern = re.compile(r"^\d{1,2}/\d{1,2}/\d{2,4}$")
|
||||
for cell in row:
|
||||
cell = cell.strip()
|
||||
if date_pattern.match(cell):
|
||||
return True
|
||||
try:
|
||||
float(cell)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def detect_format(file_path: Path) -> dict:
|
||||
"""Detect the format of a CSV file.
|
||||
|
||||
Returns a dict with keys:
|
||||
- delimiter: the field delimiter character
|
||||
- has_headers: whether the first row is a header row
|
||||
- file_path: string path to the file
|
||||
- headers: list of header names (only if has_headers is True)
|
||||
- column_count: number of columns (only if has_headers is False)
|
||||
- row_count: number of data rows (excluding header if present)
|
||||
- preview: first 5 data rows as lists of strings
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
sample = f.read(8192)
|
||||
|
||||
dialect = csv.Sniffer().sniff(sample)
|
||||
has_headers = csv.Sniffer().has_header(sample)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f, dialect)
|
||||
all_rows = list(reader)
|
||||
|
||||
# Filter empty rows
|
||||
all_rows = [r for r in all_rows if any(cell.strip() for cell in r)]
|
||||
|
||||
# Guard against Sniffer incorrectly detecting headers when the first
|
||||
# row is actually data (e.g. Wells Fargo checking CSVs with no headers).
|
||||
if has_headers and all_rows and _looks_like_data(all_rows[0]):
|
||||
has_headers = False
|
||||
|
||||
result = {
|
||||
"delimiter": dialect.delimiter,
|
||||
"has_headers": has_headers,
|
||||
"file_path": str(file_path),
|
||||
}
|
||||
|
||||
if has_headers:
|
||||
result["headers"] = all_rows[0]
|
||||
data_rows = all_rows[1:]
|
||||
else:
|
||||
result["column_count"] = len(all_rows[0]) if all_rows else 0
|
||||
data_rows = all_rows
|
||||
|
||||
result["row_count"] = len(data_rows)
|
||||
result["preview"] = data_rows[:5]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def read_csv(file_path: Path) -> list[dict]:
|
||||
"""Read a CSV file and return a list of row dicts.
|
||||
|
||||
If the file has headers, each dict is keyed by header name.
|
||||
If the file has no headers, each dict is keyed by column index (int).
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
sample = f.read(8192)
|
||||
|
||||
dialect = csv.Sniffer().sniff(sample)
|
||||
has_headers = csv.Sniffer().has_header(sample)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||
if has_headers:
|
||||
# Peek at the first row to verify it really is a header
|
||||
reader = csv.reader(f, dialect)
|
||||
first_row = next(reader, None)
|
||||
|
||||
if first_row and _looks_like_data(first_row):
|
||||
# First row is data, not headers -- read as headerless
|
||||
rows = []
|
||||
rows.append({i: cell for i, cell in enumerate(first_row)})
|
||||
for row in reader:
|
||||
if any(cell.strip() for cell in row):
|
||||
rows.append({i: cell for i, cell in enumerate(row)})
|
||||
return rows
|
||||
|
||||
# Rewind and use DictReader
|
||||
f.seek(0)
|
||||
reader = csv.DictReader(f, dialect=dialect)
|
||||
return [dict(row) for row in reader if any(v.strip() for v in row.values())]
|
||||
else:
|
||||
reader = csv.reader(f, dialect)
|
||||
rows = []
|
||||
for row in reader:
|
||||
if any(cell.strip() for cell in row):
|
||||
rows.append({i: cell for i, cell in enumerate(row)})
|
||||
return rows
|
||||
66
src/services/forecasting.py
Normal file
66
src/services/forecasting.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# src/services/forecasting.py
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.transaction import Transaction
|
||||
from src.models.category import Category
|
||||
|
||||
|
||||
class ForecastingService:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def _get_monthly_by_category(self, months_back: int = 6, exclude_descriptions: list[str] | None = None) -> dict[str, list[float]]:
|
||||
cutoff = datetime.date.today() - datetime.timedelta(days=months_back * 31)
|
||||
q = (
|
||||
self.session.query(
|
||||
func.strftime("%Y-%m", Transaction.date).label("month"),
|
||||
func.coalesce(Category.name, "Uncategorized").label("category"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
)
|
||||
.join(Category, Transaction.category_id == Category.id, isouter=True)
|
||||
.filter(Transaction.date >= cutoff)
|
||||
.filter(Transaction.is_transfer == False)
|
||||
.filter(Transaction.amount < 0)
|
||||
)
|
||||
if exclude_descriptions:
|
||||
for desc in exclude_descriptions:
|
||||
q = q.filter(~Transaction.description.contains(desc))
|
||||
|
||||
rows = q.group_by("month", "category").all()
|
||||
|
||||
by_cat: dict[str, list[float]] = defaultdict(list)
|
||||
for row in rows:
|
||||
by_cat[row.category].append(float(row.total))
|
||||
return by_cat
|
||||
|
||||
def _weighted_average(self, values: list[float]) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
weights = list(range(1, len(values) + 1))
|
||||
total_weight = sum(weights)
|
||||
return sum(v * w for v, w in zip(values, weights)) / total_weight
|
||||
|
||||
def forecast_month(self, exclude_descriptions: list[str] | None = None) -> list[dict]:
|
||||
by_cat = self._get_monthly_by_category(exclude_descriptions=exclude_descriptions)
|
||||
|
||||
results = []
|
||||
for cat, monthly_totals in by_cat.items():
|
||||
projected = self._weighted_average(monthly_totals)
|
||||
results.append({
|
||||
"category": cat,
|
||||
"projected": round(projected, 2),
|
||||
"confidence": "high" if len(monthly_totals) >= 3 else "low",
|
||||
})
|
||||
|
||||
results.sort(key=lambda r: r["projected"])
|
||||
return results
|
||||
|
||||
def forecast_year(self, exclude_descriptions: list[str] | None = None) -> list[dict]:
|
||||
monthly = self.forecast_month(exclude_descriptions=exclude_descriptions)
|
||||
return [
|
||||
{**f, "projected": round(f["projected"] * 12, 2)}
|
||||
for f in monthly
|
||||
]
|
||||
32
src/services/normalizer.py
Normal file
32
src/services/normalizer.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import re
|
||||
import html
|
||||
|
||||
|
||||
def normalize_description(raw: str) -> str:
|
||||
desc = html.unescape(raw.strip())
|
||||
|
||||
# Strip "PURCHASE AUTHORIZED ON MM/DD" or "RECURRING PAYMENT AUTHORIZED ON MM/DD"
|
||||
desc = re.sub(r"^(PURCHASE|RECURRING PAYMENT|RECURRING TRANSFER)\s+AUTHORIZED ON \d{2}/\d{2}\s+", "", desc)
|
||||
|
||||
# Strip "CARD XXXX" at end
|
||||
desc = re.sub(r"\s+CARD\s+\d{4}\s*$", "", desc)
|
||||
|
||||
# Strip long alphanumeric transaction/reference codes (10+ chars, mixed letters/digits)
|
||||
desc = re.sub(r"\s+[A-Z0-9]{10,}\b", "", desc)
|
||||
|
||||
# Strip "REF #..." references
|
||||
desc = re.sub(r"\s+REF\s+#\S+", "", desc)
|
||||
|
||||
# Strip masked account numbers
|
||||
desc = re.sub(r"\s+X{4,}\d+", "", desc)
|
||||
|
||||
# Strip phone numbers
|
||||
desc = re.sub(r"\s+\d{3}-\d{3}-\d{4}", "", desc)
|
||||
|
||||
# Strip trailing state abbreviations + zip that follow addresses
|
||||
desc = re.sub(r"\s+[A-Z]{2}\s+\d{5}(?:-\d{4})?\s*$", "", desc)
|
||||
|
||||
# Clean up multiple spaces
|
||||
desc = re.sub(r"\s{2,}", " ", desc).strip()
|
||||
|
||||
return desc
|
||||
69
src/services/recurring.py
Normal file
69
src/services/recurring.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# src/services/recurring.py
|
||||
from collections import defaultdict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.transaction import Transaction
|
||||
|
||||
|
||||
class RecurringDetector:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def detect(self, amount_tolerance: float = 0.10) -> list[dict]:
|
||||
txns = (
|
||||
self.session.query(Transaction)
|
||||
.filter(Transaction.amount < 0)
|
||||
.filter(Transaction.is_transfer == False)
|
||||
.order_by(Transaction.date)
|
||||
.all()
|
||||
)
|
||||
|
||||
groups: dict[str, list[Transaction]] = defaultdict(list)
|
||||
for txn in txns:
|
||||
groups[txn.description].append(txn)
|
||||
|
||||
results = []
|
||||
for desc, group in groups.items():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
|
||||
amounts = [abs(float(t.amount)) for t in group]
|
||||
avg_amount = sum(amounts) / len(amounts)
|
||||
|
||||
if any(abs(a - avg_amount) / avg_amount > amount_tolerance for a in amounts):
|
||||
continue
|
||||
|
||||
dates = sorted(t.date for t in group)
|
||||
intervals = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)]
|
||||
avg_interval = sum(intervals) / len(intervals)
|
||||
|
||||
frequency = self._classify_frequency(avg_interval)
|
||||
if frequency is None:
|
||||
continue
|
||||
|
||||
annual_multiplier = {"weekly": 52, "biweekly": 26, "monthly": 12, "quarterly": 4, "annual": 1}
|
||||
|
||||
results.append({
|
||||
"description": desc,
|
||||
"typical_amount": round(avg_amount, 2),
|
||||
"frequency": frequency,
|
||||
"annual_cost": round(avg_amount * annual_multiplier[frequency], 2),
|
||||
"occurrences": len(group),
|
||||
"last_date": max(dates),
|
||||
})
|
||||
|
||||
results.sort(key=lambda r: r["annual_cost"], reverse=True)
|
||||
return results
|
||||
|
||||
def _classify_frequency(self, avg_days: float) -> str | None:
|
||||
if 5 <= avg_days <= 9:
|
||||
return "weekly"
|
||||
elif 12 <= avg_days <= 16:
|
||||
return "biweekly"
|
||||
elif 25 <= avg_days <= 35:
|
||||
return "monthly"
|
||||
elif 80 <= avg_days <= 100:
|
||||
return "quarterly"
|
||||
elif 350 <= avg_days <= 380:
|
||||
return "annual"
|
||||
return None
|
||||
144
src/ui/recurring_view.py
Normal file
144
src/ui/recurring_view.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget,
|
||||
QTableWidgetItem, QPushButton, QHeaderView, QFrame, QAbstractItemView,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.services.recurring import RecurringDetector
|
||||
|
||||
|
||||
class RecurringView(QWidget):
|
||||
"""View showing detected recurring charges with confirm/dismiss controls."""
|
||||
|
||||
STATUS_CONFIRMED = "Confirmed"
|
||||
STATUS_PENDING = "Pending"
|
||||
STATUS_DISMISSED = "Dismissed"
|
||||
|
||||
def __init__(self, session: Session, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.session = session
|
||||
self._statuses: dict[str, str] = {}
|
||||
self._items: list[dict] = []
|
||||
|
||||
self._build_ui()
|
||||
self._run_scan()
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(24, 24, 24, 24)
|
||||
root.setSpacing(16)
|
||||
|
||||
# -- summary cards --
|
||||
cards_layout = QHBoxLayout()
|
||||
cards_layout.setSpacing(16)
|
||||
|
||||
self._monthly_label = self._make_card("Total Monthly Cost", "$0.00")
|
||||
self._annual_label = self._make_card("Total Annual Cost", "$0.00")
|
||||
cards_layout.addWidget(self._monthly_label)
|
||||
cards_layout.addWidget(self._annual_label)
|
||||
cards_layout.addStretch()
|
||||
root.addLayout(cards_layout)
|
||||
|
||||
# -- toolbar --
|
||||
toolbar = QHBoxLayout()
|
||||
rescan_btn = QPushButton("Re-scan")
|
||||
rescan_btn.clicked.connect(self._run_scan)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(rescan_btn)
|
||||
root.addLayout(toolbar)
|
||||
|
||||
# -- table --
|
||||
self.table = QTableWidget(0, 8)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
"Description", "Amount", "Frequency",
|
||||
"Annual Cost", "Last Date", "Status",
|
||||
"", "",
|
||||
])
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
0, QHeaderView.ResizeMode.Stretch,
|
||||
)
|
||||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
root.addWidget(self.table, stretch=1)
|
||||
|
||||
def _make_card(self, title: str, value: str) -> QFrame:
|
||||
"""Return a small card widget with a title and a value label."""
|
||||
frame = QFrame()
|
||||
frame.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
frame.setMinimumWidth(220)
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet("font-size: 12px; color: #888;")
|
||||
layout.addWidget(title_lbl)
|
||||
|
||||
value_lbl = QLabel(value)
|
||||
value_lbl.setObjectName("card_value")
|
||||
value_lbl.setStyleSheet("font-size: 22px; font-weight: bold;")
|
||||
layout.addWidget(value_lbl)
|
||||
|
||||
return frame
|
||||
|
||||
# ── scanning / data ──────────────────────────────────────────────
|
||||
|
||||
def _run_scan(self):
|
||||
"""Run the recurring-charge detector and refresh the table."""
|
||||
detector = RecurringDetector(self.session)
|
||||
self._items = detector.detect()
|
||||
|
||||
# Assign default status for new descriptions; keep existing statuses
|
||||
for item in self._items:
|
||||
desc = item["description"]
|
||||
if desc not in self._statuses:
|
||||
self._statuses[desc] = self.STATUS_PENDING
|
||||
|
||||
self._populate_table()
|
||||
self._update_summary()
|
||||
|
||||
def _populate_table(self):
|
||||
self.table.setRowCount(0)
|
||||
self.table.setRowCount(len(self._items))
|
||||
|
||||
for row, item in enumerate(self._items):
|
||||
desc = item["description"]
|
||||
status = self._statuses.get(desc, self.STATUS_PENDING)
|
||||
|
||||
self.table.setItem(row, 0, QTableWidgetItem(desc))
|
||||
self.table.setItem(row, 1, QTableWidgetItem(f"${item['typical_amount']:.2f}"))
|
||||
self.table.setItem(row, 2, QTableWidgetItem(item["frequency"].title()))
|
||||
self.table.setItem(row, 3, QTableWidgetItem(f"${item['annual_cost']:.2f}"))
|
||||
self.table.setItem(row, 4, QTableWidgetItem(str(item["last_date"])))
|
||||
self.table.setItem(row, 5, QTableWidgetItem(status))
|
||||
|
||||
confirm_btn = QPushButton("Confirm")
|
||||
confirm_btn.clicked.connect(lambda _checked, d=desc: self._set_status(d, self.STATUS_CONFIRMED))
|
||||
self.table.setCellWidget(row, 6, confirm_btn)
|
||||
|
||||
dismiss_btn = QPushButton("Dismiss")
|
||||
dismiss_btn.clicked.connect(lambda _checked, d=desc: self._set_status(d, self.STATUS_DISMISSED))
|
||||
self.table.setCellWidget(row, 7, dismiss_btn)
|
||||
|
||||
def _set_status(self, description: str, status: str):
|
||||
self._statuses[description] = status
|
||||
self._populate_table()
|
||||
self._update_summary()
|
||||
|
||||
def _update_summary(self):
|
||||
total_annual = sum(
|
||||
item["annual_cost"]
|
||||
for item in self._items
|
||||
if self._statuses.get(item["description"]) != self.STATUS_DISMISSED
|
||||
)
|
||||
total_monthly = total_annual / 12.0
|
||||
|
||||
monthly_lbl: QLabel = self._monthly_label.findChild(QLabel, "card_value")
|
||||
annual_lbl: QLabel = self._annual_label.findChild(QLabel, "card_value")
|
||||
if monthly_lbl:
|
||||
monthly_lbl.setText(f"${total_monthly:,.2f}")
|
||||
if annual_lbl:
|
||||
annual_lbl.setText(f"${total_annual:,.2f}")
|
||||
475
src/ui/settings_view.py
Normal file
475
src/ui/settings_view.py
Normal file
@@ -0,0 +1,475 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTableWidget,
|
||||
QTableWidgetItem, QPushButton, QHeaderView, QDialog, QFormLayout,
|
||||
QLineEdit, QSpinBox, QComboBox, QCheckBox, QDialogButtonBox,
|
||||
QAbstractItemView, QMessageBox, QLabel,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models import (
|
||||
Category,
|
||||
CategorizationRule,
|
||||
HouseholdMember,
|
||||
Account,
|
||||
CsvMapping,
|
||||
)
|
||||
|
||||
|
||||
class SettingsView(QWidget):
|
||||
"""Tabbed settings panel for managing categories, rules, household, accounts, and CSV mappings."""
|
||||
|
||||
def __init__(self, session: Session, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.session = session
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(24, 24, 24, 24)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs)
|
||||
|
||||
# Build each tab
|
||||
self._build_categories_tab()
|
||||
self._build_rules_tab()
|
||||
self._build_household_tab()
|
||||
self._build_accounts_tab()
|
||||
self._build_csv_mappings_tab()
|
||||
|
||||
# Initial data load
|
||||
self._load_categories()
|
||||
self._load_rules()
|
||||
self._load_household()
|
||||
self._load_accounts()
|
||||
self._load_csv_mappings()
|
||||
|
||||
# ================================================================
|
||||
# Categories tab
|
||||
# ================================================================
|
||||
|
||||
def _build_categories_tab(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
add_btn = QPushButton("Add")
|
||||
add_btn.clicked.connect(self._add_category)
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.clicked.connect(self._delete_category)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(add_btn)
|
||||
toolbar.addWidget(delete_btn)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.cat_table = QTableWidget(0, 2)
|
||||
self.cat_table.setHorizontalHeaderLabels(["Name", "Default Tag"])
|
||||
self.cat_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.cat_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.cat_table.verticalHeader().setVisible(False)
|
||||
self.cat_table.cellChanged.connect(self._on_category_edited)
|
||||
layout.addWidget(self.cat_table)
|
||||
|
||||
self.tabs.addTab(page, "Categories")
|
||||
|
||||
def _load_categories(self):
|
||||
self.cat_table.blockSignals(True)
|
||||
cats = self.session.query(Category).order_by(Category.name).all()
|
||||
self.cat_table.setRowCount(len(cats))
|
||||
for row, cat in enumerate(cats):
|
||||
name_item = QTableWidgetItem(cat.name)
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, cat.id)
|
||||
self.cat_table.setItem(row, 0, name_item)
|
||||
self.cat_table.setItem(row, 1, QTableWidgetItem(cat.default_tag or ""))
|
||||
self.cat_table.blockSignals(False)
|
||||
|
||||
def _add_category(self):
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Add Category")
|
||||
form = QFormLayout(dlg)
|
||||
name_edit = QLineEdit()
|
||||
tag_edit = QLineEdit()
|
||||
form.addRow("Name:", name_edit)
|
||||
form.addRow("Default Tag:", tag_edit)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted and name_edit.text().strip():
|
||||
cat = Category(name=name_edit.text().strip(), default_tag=tag_edit.text().strip() or None)
|
||||
self.session.add(cat)
|
||||
self.session.commit()
|
||||
self._load_categories()
|
||||
|
||||
def _delete_category(self):
|
||||
row = self.cat_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
item = self.cat_table.item(row, 0)
|
||||
cat_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
cat = self.session.get(Category, cat_id)
|
||||
if cat:
|
||||
self.session.delete(cat)
|
||||
self.session.commit()
|
||||
self._load_categories()
|
||||
|
||||
def _on_category_edited(self, row: int, col: int):
|
||||
item = self.cat_table.item(row, 0)
|
||||
if item is None:
|
||||
return
|
||||
cat_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
cat = self.session.get(Category, cat_id)
|
||||
if cat is None:
|
||||
return
|
||||
if col == 0:
|
||||
cat.name = item.text()
|
||||
elif col == 1:
|
||||
tag_item = self.cat_table.item(row, 1)
|
||||
cat.default_tag = tag_item.text() if tag_item else None
|
||||
self.session.commit()
|
||||
|
||||
# ================================================================
|
||||
# Rules tab
|
||||
# ================================================================
|
||||
|
||||
def _build_rules_tab(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
add_btn = QPushButton("Add")
|
||||
add_btn.clicked.connect(self._add_rule)
|
||||
edit_btn = QPushButton("Edit")
|
||||
edit_btn.clicked.connect(self._edit_rule)
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.clicked.connect(self._delete_rule)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(add_btn)
|
||||
toolbar.addWidget(edit_btn)
|
||||
toolbar.addWidget(delete_btn)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.rule_table = QTableWidget(0, 5)
|
||||
self.rule_table.setHorizontalHeaderLabels(["Pattern", "Category", "Tag Override", "Person", "Priority"])
|
||||
self.rule_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.rule_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.rule_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.rule_table.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.rule_table)
|
||||
|
||||
self.tabs.addTab(page, "Rules")
|
||||
|
||||
def _load_rules(self):
|
||||
rules = (
|
||||
self.session.query(CategorizationRule)
|
||||
.order_by(CategorizationRule.priority.desc())
|
||||
.all()
|
||||
)
|
||||
self.rule_table.setRowCount(len(rules))
|
||||
for row, rule in enumerate(rules):
|
||||
pattern_item = QTableWidgetItem(rule.pattern)
|
||||
pattern_item.setData(Qt.ItemDataRole.UserRole, rule.id)
|
||||
self.rule_table.setItem(row, 0, pattern_item)
|
||||
self.rule_table.setItem(row, 1, QTableWidgetItem(rule.category.name if rule.category else ""))
|
||||
self.rule_table.setItem(row, 2, QTableWidgetItem(rule.tag_override or ""))
|
||||
self.rule_table.setItem(row, 3, QTableWidgetItem(
|
||||
rule.attributed_to.name if rule.attributed_to else "",
|
||||
))
|
||||
self.rule_table.setItem(row, 4, QTableWidgetItem(str(rule.priority)))
|
||||
|
||||
def _rule_dialog(self, title: str, rule: CategorizationRule | None = None) -> dict | None:
|
||||
"""Show a dialog for add/edit of a categorization rule. Returns field dict or None."""
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle(title)
|
||||
form = QFormLayout(dlg)
|
||||
|
||||
pattern_edit = QLineEdit(rule.pattern if rule else "")
|
||||
form.addRow("Pattern:", pattern_edit)
|
||||
|
||||
cat_combo = QComboBox()
|
||||
categories = self.session.query(Category).order_by(Category.name).all()
|
||||
cat_combo.addItem("", None)
|
||||
for cat in categories:
|
||||
cat_combo.addItem(cat.name, cat.id)
|
||||
if rule and rule.category_id:
|
||||
idx = cat_combo.findData(rule.category_id)
|
||||
if idx >= 0:
|
||||
cat_combo.setCurrentIndex(idx)
|
||||
form.addRow("Category:", cat_combo)
|
||||
|
||||
tag_edit = QLineEdit(rule.tag_override if rule else "")
|
||||
form.addRow("Tag Override:", tag_edit)
|
||||
|
||||
person_combo = QComboBox()
|
||||
members = self.session.query(HouseholdMember).order_by(HouseholdMember.name).all()
|
||||
person_combo.addItem("", None)
|
||||
for m in members:
|
||||
person_combo.addItem(m.name, m.id)
|
||||
if rule and rule.attributed_to_id:
|
||||
idx = person_combo.findData(rule.attributed_to_id)
|
||||
if idx >= 0:
|
||||
person_combo.setCurrentIndex(idx)
|
||||
form.addRow("Person:", person_combo)
|
||||
|
||||
priority_spin = QSpinBox()
|
||||
priority_spin.setRange(0, 9999)
|
||||
priority_spin.setValue(rule.priority if rule else 0)
|
||||
form.addRow("Priority:", priority_spin)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return None
|
||||
if not pattern_edit.text().strip():
|
||||
return None
|
||||
|
||||
return {
|
||||
"pattern": pattern_edit.text().strip(),
|
||||
"category_id": cat_combo.currentData(),
|
||||
"tag_override": tag_edit.text().strip() or None,
|
||||
"attributed_to_id": person_combo.currentData(),
|
||||
"priority": priority_spin.value(),
|
||||
}
|
||||
|
||||
def _add_rule(self):
|
||||
data = self._rule_dialog("Add Rule")
|
||||
if data is None or data["category_id"] is None:
|
||||
return
|
||||
rule = CategorizationRule(**data)
|
||||
self.session.add(rule)
|
||||
self.session.commit()
|
||||
self._load_rules()
|
||||
|
||||
def _edit_rule(self):
|
||||
row = self.rule_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
rule_id = self.rule_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
rule = self.session.get(CategorizationRule, rule_id)
|
||||
if rule is None:
|
||||
return
|
||||
data = self._rule_dialog("Edit Rule", rule)
|
||||
if data is None:
|
||||
return
|
||||
for key, value in data.items():
|
||||
setattr(rule, key, value)
|
||||
self.session.commit()
|
||||
self._load_rules()
|
||||
|
||||
def _delete_rule(self):
|
||||
row = self.rule_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
rule_id = self.rule_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
rule = self.session.get(CategorizationRule, rule_id)
|
||||
if rule:
|
||||
self.session.delete(rule)
|
||||
self.session.commit()
|
||||
self._load_rules()
|
||||
|
||||
# ================================================================
|
||||
# Household tab
|
||||
# ================================================================
|
||||
|
||||
def _build_household_tab(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
add_btn = QPushButton("Add")
|
||||
add_btn.clicked.connect(self._add_member)
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.clicked.connect(self._delete_member)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(add_btn)
|
||||
toolbar.addWidget(delete_btn)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.member_table = QTableWidget(0, 2)
|
||||
self.member_table.setHorizontalHeaderLabels(["Name", "Relationship"])
|
||||
self.member_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.member_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.member_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.member_table.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.member_table)
|
||||
|
||||
self.tabs.addTab(page, "Household")
|
||||
|
||||
def _load_household(self):
|
||||
members = self.session.query(HouseholdMember).order_by(HouseholdMember.name).all()
|
||||
self.member_table.setRowCount(len(members))
|
||||
for row, m in enumerate(members):
|
||||
name_item = QTableWidgetItem(m.name)
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, m.id)
|
||||
self.member_table.setItem(row, 0, name_item)
|
||||
self.member_table.setItem(row, 1, QTableWidgetItem(m.relationship))
|
||||
|
||||
def _add_member(self):
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Add Household Member")
|
||||
form = QFormLayout(dlg)
|
||||
name_edit = QLineEdit()
|
||||
rel_edit = QLineEdit()
|
||||
form.addRow("Name:", name_edit)
|
||||
form.addRow("Relationship:", rel_edit)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted and name_edit.text().strip():
|
||||
member = HouseholdMember(
|
||||
name=name_edit.text().strip(),
|
||||
relationship=rel_edit.text().strip(),
|
||||
)
|
||||
self.session.add(member)
|
||||
self.session.commit()
|
||||
self._load_household()
|
||||
|
||||
def _delete_member(self):
|
||||
row = self.member_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
member_id = self.member_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
member = self.session.get(HouseholdMember, member_id)
|
||||
if member:
|
||||
self.session.delete(member)
|
||||
self.session.commit()
|
||||
self._load_household()
|
||||
|
||||
# ================================================================
|
||||
# Accounts tab
|
||||
# ================================================================
|
||||
|
||||
def _build_accounts_tab(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
edit_btn = QPushButton("Edit")
|
||||
edit_btn.clicked.connect(self._edit_account)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(edit_btn)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.acct_table = QTableWidget(0, 5)
|
||||
self.acct_table.setHorizontalHeaderLabels(["Name", "Institution", "Type", "Owner", "Shared"])
|
||||
self.acct_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.acct_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.acct_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.acct_table.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.acct_table)
|
||||
|
||||
self.tabs.addTab(page, "Accounts")
|
||||
|
||||
def _load_accounts(self):
|
||||
accounts = self.session.query(Account).order_by(Account.name).all()
|
||||
self.acct_table.setRowCount(len(accounts))
|
||||
for row, acct in enumerate(accounts):
|
||||
name_item = QTableWidgetItem(acct.name)
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, acct.id)
|
||||
self.acct_table.setItem(row, 0, name_item)
|
||||
self.acct_table.setItem(row, 1, QTableWidgetItem(acct.institution))
|
||||
self.acct_table.setItem(row, 2, QTableWidgetItem(acct.account_type))
|
||||
self.acct_table.setItem(row, 3, QTableWidgetItem(acct.owner.name if acct.owner else ""))
|
||||
self.acct_table.setItem(row, 4, QTableWidgetItem("Yes" if acct.is_shared else "No"))
|
||||
|
||||
def _edit_account(self):
|
||||
row = self.acct_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
acct_id = self.acct_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
acct = self.session.get(Account, acct_id)
|
||||
if acct is None:
|
||||
return
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Edit Account")
|
||||
form = QFormLayout(dlg)
|
||||
|
||||
name_edit = QLineEdit(acct.name)
|
||||
inst_edit = QLineEdit(acct.institution)
|
||||
type_edit = QLineEdit(acct.account_type)
|
||||
form.addRow("Name:", name_edit)
|
||||
form.addRow("Institution:", inst_edit)
|
||||
form.addRow("Type:", type_edit)
|
||||
|
||||
owner_combo = QComboBox()
|
||||
members = self.session.query(HouseholdMember).order_by(HouseholdMember.name).all()
|
||||
owner_combo.addItem("", None)
|
||||
for m in members:
|
||||
owner_combo.addItem(m.name, m.id)
|
||||
if acct.owner_id:
|
||||
idx = owner_combo.findData(acct.owner_id)
|
||||
if idx >= 0:
|
||||
owner_combo.setCurrentIndex(idx)
|
||||
form.addRow("Owner:", owner_combo)
|
||||
|
||||
shared_check = QCheckBox()
|
||||
shared_check.setChecked(acct.is_shared)
|
||||
form.addRow("Shared:", shared_check)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(dlg.accept)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||||
acct.name = name_edit.text().strip() or acct.name
|
||||
acct.institution = inst_edit.text().strip() or acct.institution
|
||||
acct.account_type = type_edit.text().strip() or acct.account_type
|
||||
acct.owner_id = owner_combo.currentData()
|
||||
acct.is_shared = shared_check.isChecked()
|
||||
self.session.commit()
|
||||
self._load_accounts()
|
||||
|
||||
# ================================================================
|
||||
# CSV Mappings tab
|
||||
# ================================================================
|
||||
|
||||
def _build_csv_mappings_tab(self):
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.clicked.connect(self._delete_csv_mapping)
|
||||
toolbar.addStretch()
|
||||
toolbar.addWidget(delete_btn)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.csv_table = QTableWidget(0, 3)
|
||||
self.csv_table.setHorizontalHeaderLabels(["Name", "Account", "Fingerprint"])
|
||||
self.csv_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.csv_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.csv_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.csv_table.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.csv_table)
|
||||
|
||||
self.tabs.addTab(page, "CSV Mappings")
|
||||
|
||||
def _load_csv_mappings(self):
|
||||
mappings = self.session.query(CsvMapping).order_by(CsvMapping.name).all()
|
||||
self.csv_table.setRowCount(len(mappings))
|
||||
for row, m in enumerate(mappings):
|
||||
name_item = QTableWidgetItem(m.name)
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, m.id)
|
||||
self.csv_table.setItem(row, 0, name_item)
|
||||
self.csv_table.setItem(row, 1, QTableWidgetItem(m.account.name if m.account else ""))
|
||||
self.csv_table.setItem(row, 2, QTableWidgetItem(m.fingerprint))
|
||||
|
||||
def _delete_csv_mapping(self):
|
||||
row = self.csv_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
mapping_id = self.csv_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||||
mapping = self.session.get(CsvMapping, mapping_id)
|
||||
if mapping:
|
||||
self.session.delete(mapping)
|
||||
self.session.commit()
|
||||
self._load_csv_mappings()
|
||||
55
src/ui/sidebar.py
Normal file
55
src/ui/sidebar.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QFrame
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
|
||||
class Sidebar(QFrame):
|
||||
view_changed = Signal(str)
|
||||
theme_toggled = Signal(str)
|
||||
|
||||
VIEWS = [
|
||||
("Import", "import"),
|
||||
("Transactions", "transactions"),
|
||||
("Analysis", "analysis"),
|
||||
("Recurring", "recurring"),
|
||||
("Settings", "settings"),
|
||||
]
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("sidebar")
|
||||
self.setFixedWidth(200)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 10, 0, 10)
|
||||
|
||||
self._buttons: dict[str, QPushButton] = {}
|
||||
for label, key in self.VIEWS:
|
||||
btn = QPushButton(label)
|
||||
btn.setObjectName(f"sidebar-{key}")
|
||||
btn.setCheckable(True)
|
||||
btn.clicked.connect(lambda checked, k=key: self._on_click(k))
|
||||
layout.addWidget(btn)
|
||||
self._buttons[key] = btn
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
self._current_theme = "dark"
|
||||
self._theme_btn = QPushButton("Light Theme")
|
||||
self._theme_btn.setObjectName("theme-toggle")
|
||||
self._theme_btn.clicked.connect(self._on_theme_toggle)
|
||||
layout.addWidget(self._theme_btn)
|
||||
|
||||
self._buttons["import"].setChecked(True)
|
||||
|
||||
def _on_click(self, key: str):
|
||||
for k, btn in self._buttons.items():
|
||||
btn.setChecked(k == key)
|
||||
self.view_changed.emit(key)
|
||||
|
||||
def _on_theme_toggle(self):
|
||||
if self._current_theme == "dark":
|
||||
self._current_theme = "light"
|
||||
self._theme_btn.setText("Dark Theme")
|
||||
else:
|
||||
self._current_theme = "dark"
|
||||
self._theme_btn.setText("Light Theme")
|
||||
self.theme_toggled.emit(self._current_theme)
|
||||
0
src/ui/themes/__init__.py
Normal file
0
src/ui/themes/__init__.py
Normal file
402
src/ui/themes/dark.qss
Normal file
402
src/ui/themes/dark.qss
Normal file
@@ -0,0 +1,402 @@
|
||||
/* ===== Dark Theme ===== */
|
||||
/* Background: #1e1e2e Surface: #2a2a3c Text: #cdd6f4
|
||||
Accent: #89b4fa Border: #45475a
|
||||
Success/Income: #a6e3a1 Error/Expense: #f38ba8 */
|
||||
|
||||
/* ---------- Base Widgets ---------- */
|
||||
QMainWindow, QWidget {
|
||||
background-color: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
font-family: "Segoe UI", "Roboto", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #cdd6f4;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
#sidebar {
|
||||
background-color: #2a2a3c;
|
||||
border-right: 1px solid #45475a;
|
||||
}
|
||||
|
||||
#sidebar QPushButton {
|
||||
background-color: transparent;
|
||||
color: #cdd6f4;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
margin: 2px 8px;
|
||||
}
|
||||
|
||||
#sidebar QPushButton:hover {
|
||||
background-color: #45475a;
|
||||
}
|
||||
|
||||
#sidebar QPushButton:checked {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#sidebar QPushButton#theme-toggle {
|
||||
background-color: #45475a;
|
||||
color: #cdd6f4;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#sidebar QPushButton#theme-toggle:hover {
|
||||
background-color: #585b70;
|
||||
}
|
||||
|
||||
/* ---------- QPushButton (general) ---------- */
|
||||
QPushButton {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #74a8fc;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #5b96f7;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #45475a;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
/* ---------- QTableView / QTableWidget ---------- */
|
||||
QTableView, QTableWidget {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
gridline-color: #45475a;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 4px;
|
||||
selection-background-color: #89b4fa;
|
||||
selection-color: #1e1e2e;
|
||||
alternate-background-color: #313244;
|
||||
}
|
||||
|
||||
QTableView::item, QTableWidget::item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
QTableView::item:hover, QTableWidget::item:hover {
|
||||
background-color: #45475a;
|
||||
}
|
||||
|
||||
/* ---------- QHeaderView ---------- */
|
||||
QHeaderView::section {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-bottom: 2px solid #45475a;
|
||||
border-right: 1px solid #45475a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QHeaderView::section:hover {
|
||||
background-color: #45475a;
|
||||
}
|
||||
|
||||
/* ---------- QComboBox ---------- */
|
||||
QComboBox {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
QComboBox:hover {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: top right;
|
||||
width: 24px;
|
||||
border-left: 1px solid #45475a;
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
selection-background-color: #89b4fa;
|
||||
selection-color: #1e1e2e;
|
||||
}
|
||||
|
||||
/* ---------- QLineEdit ---------- */
|
||||
QLineEdit {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
QLineEdit:disabled {
|
||||
background-color: #313244;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
/* ---------- QDateEdit ---------- */
|
||||
QDateEdit {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
QDateEdit:focus {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
QDateEdit::drop-down {
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: top right;
|
||||
width: 24px;
|
||||
border-left: 1px solid #45475a;
|
||||
}
|
||||
|
||||
/* ---------- QTabWidget / QTabBar ---------- */
|
||||
QTabWidget::pane {
|
||||
background-color: #2a2a3c;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 4px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover {
|
||||
background-color: #45475a;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ---------- QScrollBar (vertical) ---------- */
|
||||
QScrollBar:vertical {
|
||||
background-color: #1e1e2e;
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #45475a;
|
||||
min-height: 30px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #585b70;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ---------- QScrollBar (horizontal) ---------- */
|
||||
QScrollBar:horizontal {
|
||||
background-color: #1e1e2e;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #45475a;
|
||||
min-width: 30px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #585b70;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ---------- QMessageBox ---------- */
|
||||
QMessageBox {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QMessageBox QLabel {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
QMessageBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* ---------- QToolTip ---------- */
|
||||
QToolTip {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- QGroupBox ---------- */
|
||||
QGroupBox {
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
padding: 0 6px;
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
/* ---------- QProgressBar ---------- */
|
||||
QProgressBar {
|
||||
background-color: #313244;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
color: #cdd6f4;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #89b4fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* ---------- QMenu ---------- */
|
||||
QMenu {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 6px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background-color: #45475a;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ---------- QStatusBar ---------- */
|
||||
QStatusBar {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border-top: 1px solid #45475a;
|
||||
}
|
||||
|
||||
/* ---------- QCheckBox / QRadioButton ---------- */
|
||||
QCheckBox, QRadioButton {
|
||||
color: #cdd6f4;
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #45475a;
|
||||
background-color: #2a2a3c;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QRadioButton::indicator {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
|
||||
background-color: #89b4fa;
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
/* ---------- QSpinBox / QDoubleSpinBox ---------- */
|
||||
QSpinBox, QDoubleSpinBox {
|
||||
background-color: #2a2a3c;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
/* ---------- Semantic Colors (via properties or classes) ---------- */
|
||||
QLabel[cssClass="income"] {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
QLabel[cssClass="expense"] {
|
||||
color: #f38ba8;
|
||||
}
|
||||
402
src/ui/themes/light.qss
Normal file
402
src/ui/themes/light.qss
Normal file
@@ -0,0 +1,402 @@
|
||||
/* ===== Light Theme ===== */
|
||||
/* Background: #eff1f5 Surface: #ffffff Text: #4c4f69
|
||||
Accent: #1e66f5 Border: #ccd0da
|
||||
Success/Income: #40a02b Error/Expense: #d20f39 */
|
||||
|
||||
/* ---------- Base Widgets ---------- */
|
||||
QMainWindow, QWidget {
|
||||
background-color: #eff1f5;
|
||||
color: #4c4f69;
|
||||
font-family: "Segoe UI", "Roboto", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #4c4f69;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
#sidebar {
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #ccd0da;
|
||||
}
|
||||
|
||||
#sidebar QPushButton {
|
||||
background-color: transparent;
|
||||
color: #4c4f69;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
margin: 2px 8px;
|
||||
}
|
||||
|
||||
#sidebar QPushButton:hover {
|
||||
background-color: #e6e9ef;
|
||||
}
|
||||
|
||||
#sidebar QPushButton:checked {
|
||||
background-color: #1e66f5;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#sidebar QPushButton#theme-toggle {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#sidebar QPushButton#theme-toggle:hover {
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
|
||||
/* ---------- QPushButton (general) ---------- */
|
||||
QPushButton {
|
||||
background-color: #1e66f5;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #1a5be0;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #1650c8;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #ccd0da;
|
||||
color: #9ca0b0;
|
||||
}
|
||||
|
||||
/* ---------- QTableView / QTableWidget ---------- */
|
||||
QTableView, QTableWidget {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
gridline-color: #ccd0da;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 4px;
|
||||
selection-background-color: #1e66f5;
|
||||
selection-color: #ffffff;
|
||||
alternate-background-color: #eff1f5;
|
||||
}
|
||||
|
||||
QTableView::item, QTableWidget::item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
QTableView::item:hover, QTableWidget::item:hover {
|
||||
background-color: #e6e9ef;
|
||||
}
|
||||
|
||||
/* ---------- QHeaderView ---------- */
|
||||
QHeaderView::section {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-bottom: 2px solid #ccd0da;
|
||||
border-right: 1px solid #ccd0da;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QHeaderView::section:hover {
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
|
||||
/* ---------- QComboBox ---------- */
|
||||
QComboBox {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
QComboBox:hover {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: top right;
|
||||
width: 24px;
|
||||
border-left: 1px solid #ccd0da;
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
selection-background-color: #1e66f5;
|
||||
selection-color: #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- QLineEdit ---------- */
|
||||
QLineEdit {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
QLineEdit:disabled {
|
||||
background-color: #eff1f5;
|
||||
color: #9ca0b0;
|
||||
}
|
||||
|
||||
/* ---------- QDateEdit ---------- */
|
||||
QDateEdit {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
QDateEdit:focus {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
QDateEdit::drop-down {
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: top right;
|
||||
width: 24px;
|
||||
border-left: 1px solid #ccd0da;
|
||||
}
|
||||
|
||||
/* ---------- QTabWidget / QTabBar ---------- */
|
||||
QTabWidget::pane {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 4px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #e6e9ef;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover {
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #1e66f5;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ---------- QScrollBar (vertical) ---------- */
|
||||
QScrollBar:vertical {
|
||||
background-color: #eff1f5;
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #ccd0da;
|
||||
min-height: 30px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #acb0be;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ---------- QScrollBar (horizontal) ---------- */
|
||||
QScrollBar:horizontal {
|
||||
background-color: #eff1f5;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #ccd0da;
|
||||
min-width: 30px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #acb0be;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ---------- QMessageBox ---------- */
|
||||
QMessageBox {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QMessageBox QLabel {
|
||||
color: #4c4f69;
|
||||
}
|
||||
|
||||
QMessageBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* ---------- QToolTip ---------- */
|
||||
QToolTip {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- QGroupBox ---------- */
|
||||
QGroupBox {
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
padding: 0 6px;
|
||||
color: #1e66f5;
|
||||
}
|
||||
|
||||
/* ---------- QProgressBar ---------- */
|
||||
QProgressBar {
|
||||
background-color: #e6e9ef;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
color: #4c4f69;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #1e66f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* ---------- QMenu ---------- */
|
||||
QMenu {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 6px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #1e66f5;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background-color: #ccd0da;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ---------- QStatusBar ---------- */
|
||||
QStatusBar {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border-top: 1px solid #ccd0da;
|
||||
}
|
||||
|
||||
/* ---------- QCheckBox / QRadioButton ---------- */
|
||||
QCheckBox, QRadioButton {
|
||||
color: #4c4f69;
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #ccd0da;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QRadioButton::indicator {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
|
||||
background-color: #1e66f5;
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
/* ---------- QSpinBox / QDoubleSpinBox ---------- */
|
||||
QSpinBox, QDoubleSpinBox {
|
||||
background-color: #ffffff;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border-color: #1e66f5;
|
||||
}
|
||||
|
||||
/* ---------- Semantic Colors (via properties or classes) ---------- */
|
||||
QLabel[cssClass="income"] {
|
||||
color: #40a02b;
|
||||
}
|
||||
|
||||
QLabel[cssClass="expense"] {
|
||||
color: #d20f39;
|
||||
}
|
||||
92
tests/services/test_analysis.py
Normal file
92
tests/services/test_analysis.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# tests/services/test_analysis.py
|
||||
import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories
|
||||
from src.services.analysis import AnalysisService
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def make_test_data(session):
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
groceries = session.query(Category).filter_by(name="Groceries").one()
|
||||
dining = session.query(Category).filter_by(name="Dining Out").one()
|
||||
|
||||
txns = [
|
||||
Transaction(date=datetime.date(2026, 1, 5), amount=-50.0, description="PUBLIX", account_id=account.id, category_id=groceries.id, tag="needs"),
|
||||
Transaction(date=datetime.date(2026, 1, 12), amount=-30.0, description="ALDI", account_id=account.id, category_id=groceries.id, tag="needs"),
|
||||
Transaction(date=datetime.date(2026, 1, 20), amount=-15.0, description="CHICK-FIL-A", account_id=account.id, category_id=dining.id, tag="wants"),
|
||||
Transaction(date=datetime.date(2026, 2, 3), amount=-60.0, description="PUBLIX", account_id=account.id, category_id=groceries.id, tag="needs"),
|
||||
Transaction(date=datetime.date(2026, 2, 7), amount=-25.0, description="KFC", account_id=account.id, category_id=dining.id, tag="wants"),
|
||||
]
|
||||
session.add_all(txns)
|
||||
session.commit()
|
||||
return account, member
|
||||
|
||||
|
||||
def test_spending_by_month():
|
||||
session = make_session()
|
||||
make_test_data(session)
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_period("month")
|
||||
assert len(result) == 2
|
||||
jan = [r for r in result if r["period"] == "2026-01"][0]
|
||||
assert jan["total"] == -95.0
|
||||
|
||||
|
||||
def test_spending_by_category():
|
||||
session = make_session()
|
||||
make_test_data(session)
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_category(
|
||||
start=datetime.date(2026, 1, 1),
|
||||
end=datetime.date(2026, 2, 28),
|
||||
)
|
||||
groceries_row = [r for r in result if r["category"] == "Groceries"][0]
|
||||
assert groceries_row["total"] == -140.0
|
||||
|
||||
|
||||
def test_spending_by_tag():
|
||||
session = make_session()
|
||||
make_test_data(session)
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_tag(
|
||||
start=datetime.date(2026, 1, 1),
|
||||
end=datetime.date(2026, 2, 28),
|
||||
)
|
||||
needs = [r for r in result if r["tag"] == "needs"][0]
|
||||
wants = [r for r in result if r["tag"] == "wants"][0]
|
||||
assert needs["total"] == -140.0
|
||||
assert wants["total"] == -40.0
|
||||
|
||||
|
||||
def test_spending_filtered_by_person():
|
||||
session = make_session()
|
||||
account, member = make_test_data(session)
|
||||
txns = session.query(Transaction).all()
|
||||
for t in txns:
|
||||
t.attributed_to_id = member.id
|
||||
session.commit()
|
||||
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_category(
|
||||
start=datetime.date(2026, 1, 1),
|
||||
end=datetime.date(2026, 2, 28),
|
||||
person_id=member.id,
|
||||
)
|
||||
total = sum(r["total"] for r in result)
|
||||
assert total == -180.0
|
||||
42
tests/services/test_csv_reader.py
Normal file
42
tests/services/test_csv_reader.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
from src.services.csv_reader import read_csv, detect_format
|
||||
|
||||
RAWDATA = Path(__file__).parent.parent.parent / "rawdata"
|
||||
|
||||
|
||||
def test_detect_chase_format():
|
||||
result = detect_format(RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV")
|
||||
assert result["has_headers"] is True
|
||||
assert "Transaction Date" in result["headers"]
|
||||
assert result["delimiter"] == ","
|
||||
assert result["row_count"] > 0
|
||||
|
||||
|
||||
def test_detect_checking_format():
|
||||
result = detect_format(RAWDATA / "Checking1.csv")
|
||||
assert result["has_headers"] is False
|
||||
assert result["column_count"] == 5
|
||||
assert result["row_count"] > 0
|
||||
|
||||
|
||||
def test_read_chase_csv():
|
||||
rows = read_csv(RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV")
|
||||
assert len(rows) > 100
|
||||
first = rows[0]
|
||||
assert "Transaction Date" in first
|
||||
assert "Amount" in first
|
||||
assert "Description" in first
|
||||
|
||||
|
||||
def test_read_headerless_csv():
|
||||
rows = read_csv(RAWDATA / "Checking1.csv")
|
||||
assert len(rows) > 50
|
||||
first = rows[0]
|
||||
# Headerless: keys should be column indices (integers)
|
||||
assert 0 in first or "0" in first
|
||||
|
||||
|
||||
def test_preview_rows():
|
||||
result = detect_format(RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV")
|
||||
assert "preview" in result
|
||||
assert len(result["preview"]) <= 5
|
||||
70
tests/services/test_forecasting.py
Normal file
70
tests/services/test_forecasting.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# tests/services/test_forecasting.py
|
||||
import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories
|
||||
from src.services.forecasting import ForecastingService
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def make_forecast_data(session):
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
groceries = session.query(Category).filter_by(name="Groceries").one()
|
||||
|
||||
# 3 months of grocery spending: $500, $600, $700 (trending up)
|
||||
for month, total in [(10, 500), (11, 600), (12, 700)]:
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2025, month, 15),
|
||||
amount=-total,
|
||||
description="GROCERIES",
|
||||
account_id=account.id,
|
||||
category_id=groceries.id,
|
||||
tag="needs",
|
||||
))
|
||||
session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def test_monthly_forecast():
|
||||
session = make_session()
|
||||
make_forecast_data(session)
|
||||
svc = ForecastingService(session)
|
||||
forecast = svc.forecast_month()
|
||||
groceries = [f for f in forecast if f["category"] == "Groceries"]
|
||||
assert len(groceries) == 1
|
||||
assert groceries[0]["projected"] < 0
|
||||
|
||||
|
||||
def test_annual_forecast():
|
||||
session = make_session()
|
||||
make_forecast_data(session)
|
||||
svc = ForecastingService(session)
|
||||
forecast = svc.forecast_year()
|
||||
assert len(forecast) > 0
|
||||
total = sum(f["projected"] for f in forecast)
|
||||
assert total < 0
|
||||
|
||||
|
||||
def test_what_if_removes_recurring():
|
||||
session = make_session()
|
||||
make_forecast_data(session)
|
||||
svc = ForecastingService(session)
|
||||
base = svc.forecast_month()
|
||||
adjusted = svc.forecast_month(exclude_descriptions=["GROCERIES"])
|
||||
base_total = sum(f["projected"] for f in base)
|
||||
adj_total = sum(f["projected"] for f in adjusted)
|
||||
assert adj_total > base_total # Less spending when we exclude
|
||||
71
tests/services/test_import_categorize.py
Normal file
71
tests/services/test_import_categorize.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# tests/services/test_import_categorize.py
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories
|
||||
from src.services.importer import ImportService
|
||||
|
||||
RAWDATA = Path(__file__).parent.parent.parent / "rawdata"
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_import_applies_categorization_rules():
|
||||
session = make_session()
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
groceries = session.query(Category).filter_by(name="Groceries").one()
|
||||
rule = CategorizationRule(pattern="PUBLIX", category_id=groceries.id, priority=10)
|
||||
session.add(rule)
|
||||
session.commit()
|
||||
|
||||
svc = ImportService(session)
|
||||
svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=account.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
|
||||
publix_txns = session.query(Transaction).filter(Transaction.description.contains("PUBLIX")).all()
|
||||
assert len(publix_txns) > 0
|
||||
for txn in publix_txns:
|
||||
assert txn.category_id == groceries.id
|
||||
assert txn.tag == "needs"
|
||||
|
||||
|
||||
def test_uncategorized_transactions_have_no_category():
|
||||
session = make_session()
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
# No rules defined
|
||||
svc = ImportService(session)
|
||||
svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=account.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
|
||||
uncategorized = session.query(Transaction).filter(Transaction.category_id.is_(None)).count()
|
||||
total = session.query(Transaction).count()
|
||||
assert uncategorized == total
|
||||
58
tests/services/test_normalizer.py
Normal file
58
tests/services/test_normalizer.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from src.services.normalizer import normalize_description
|
||||
|
||||
|
||||
def test_strip_authorization_prefix():
|
||||
raw = "PURCHASE AUTHORIZED ON 02/06 WALMART.COM 8009256278 BENTONVILLE AR P000000089502338 CARD 5360"
|
||||
result = normalize_description(raw)
|
||||
assert "AUTHORIZED ON" not in result
|
||||
assert "CARD 5360" not in result
|
||||
assert "WALMART.COM" in result
|
||||
|
||||
|
||||
def test_strip_recurring_prefix():
|
||||
raw = "RECURRING PAYMENT AUTHORIZED ON 02/05 HELLOFRESH 646-846-3663 NY S356036316425851 CARD 5360"
|
||||
result = normalize_description(raw)
|
||||
assert "AUTHORIZED ON" not in result
|
||||
assert "HELLOFRESH" in result
|
||||
|
||||
|
||||
def test_strip_reference_ids():
|
||||
raw = "RECURRING TRANSFER TO CONLON A WAY2SAVE SAVINGS REF #OP0WS99NKQ XXXXXX6065"
|
||||
result = normalize_description(raw)
|
||||
assert "REF #" not in result
|
||||
assert "XXXXXX" not in result
|
||||
assert "WAY2SAVE SAVINGS" in result
|
||||
|
||||
|
||||
def test_strip_card_number():
|
||||
raw = "PURCHASE AUTHORIZED ON 01/08 MRS B COMPANY LLC PORT ROYAL SC S386008692282379 CARD 5360"
|
||||
result = normalize_description(raw)
|
||||
assert "CARD 5360" not in result
|
||||
assert "MRS B COMPANY LLC" in result
|
||||
|
||||
|
||||
def test_strip_transaction_codes():
|
||||
raw = "OASISBATCH PAYROLL 260109 MP027126352 DONNA CONLON"
|
||||
result = normalize_description(raw)
|
||||
assert "OASISBATCH PAYROLL" in result
|
||||
assert "DONNA CONLON" in result
|
||||
|
||||
|
||||
def test_clean_chase_description():
|
||||
"""Chase descriptions are already clean, should pass through mostly unchanged."""
|
||||
raw = "PUBLIX #1716"
|
||||
result = normalize_description(raw)
|
||||
assert result == "PUBLIX #1716"
|
||||
|
||||
|
||||
def test_check_number():
|
||||
raw = "CHECK # 104"
|
||||
result = normalize_description(raw)
|
||||
assert "CHECK" in result
|
||||
|
||||
|
||||
def test_strip_html_entities():
|
||||
raw = "TST*PONCHOS TACOS & BEE"
|
||||
result = normalize_description(raw)
|
||||
assert "&" not in result
|
||||
assert "&" in result
|
||||
85
tests/services/test_recurring.py
Normal file
85
tests/services/test_recurring.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# tests/services/test_recurring.py
|
||||
import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.services.recurring import RecurringDetector
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def make_recurring_data(session):
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Checking", institution="WF", account_type="checking", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
# Netflix monthly: ~$19.07 on 3rd/4th of month
|
||||
for month in [1, 2]:
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2026, month, 4),
|
||||
amount=-19.07, description="Netflix.com", account_id=account.id,
|
||||
))
|
||||
|
||||
# HelloFresh weekly: ~$142.87
|
||||
for day in [1, 8, 15, 22, 29]:
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2026, 1, day),
|
||||
amount=-142.87, description="HELLOFRESH", account_id=account.id,
|
||||
))
|
||||
|
||||
# Random one-off purchase
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2026, 1, 10),
|
||||
amount=-95.38, description="CARL'S GOLFLAND INC", account_id=account.id,
|
||||
))
|
||||
|
||||
session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def test_detect_monthly_recurring():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
netflix = [r for r in results if "Netflix" in r["description"]]
|
||||
assert len(netflix) == 1
|
||||
assert netflix[0]["frequency"] == "monthly"
|
||||
assert abs(netflix[0]["typical_amount"] - 19.07) < 0.01
|
||||
|
||||
|
||||
def test_detect_weekly_recurring():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
hello = [r for r in results if "HELLOFRESH" in r["description"]]
|
||||
assert len(hello) == 1
|
||||
assert hello[0]["frequency"] == "weekly"
|
||||
|
||||
|
||||
def test_one_off_not_detected():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
golf = [r for r in results if "GOLFLAND" in r["description"]]
|
||||
assert len(golf) == 0
|
||||
|
||||
|
||||
def test_annual_cost_calculation():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
netflix = [r for r in results if "Netflix" in r["description"]][0]
|
||||
assert abs(netflix["annual_cost"] - 19.07 * 12) < 1.0
|
||||
99
tests/test_integration.py
Normal file
99
tests/test_integration.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# tests/test_integration.py
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories, seed_household, seed_default_rules
|
||||
from src.services.importer import ImportService
|
||||
from src.services.analysis import AnalysisService
|
||||
from src.services.recurring import RecurringDetector
|
||||
|
||||
RAWDATA = Path(__file__).parent.parent / "rawdata"
|
||||
|
||||
|
||||
def test_full_pipeline():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
|
||||
# Setup
|
||||
seed_categories(session)
|
||||
andrew = seed_household(session, "Andrew", "self")
|
||||
donna = seed_household(session, "Donna", "wife")
|
||||
seed_default_rules(session)
|
||||
|
||||
# Create accounts
|
||||
chase = Account(name="Chase Freedom", institution="Chase", account_type="credit", owner_id=andrew.id)
|
||||
checking = Account(name="WF Checking", institution="Wells Fargo", account_type="checking", owner_id=andrew.id, is_shared=True)
|
||||
session.add_all([chase, checking])
|
||||
session.flush()
|
||||
|
||||
svc = ImportService(session)
|
||||
|
||||
# Import Chase CSV
|
||||
r1 = svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=chase.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description", "source_category": "Category"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
assert r1["imported"] > 100
|
||||
|
||||
# Import Checking CSV
|
||||
r2 = svc.import_csv(
|
||||
RAWDATA / "Checking1.csv",
|
||||
account_id=checking.id,
|
||||
column_map={"date": 0, "amount": 1, "description": 4},
|
||||
amount_logic="signed",
|
||||
)
|
||||
assert r2["imported"] > 50
|
||||
|
||||
# Verify some transactions got auto-categorized
|
||||
categorized = session.query(Transaction).filter(Transaction.category_id.isnot(None)).count()
|
||||
total = session.query(Transaction).count()
|
||||
assert categorized > 0, "Expected some transactions to be auto-categorized"
|
||||
print(f"Categorized {categorized}/{total} transactions")
|
||||
|
||||
# Verify income attributed to correct people
|
||||
income_cat = session.query(Category).filter_by(name="Income").first()
|
||||
if income_cat:
|
||||
andrew_income = session.query(Transaction).filter(
|
||||
Transaction.category_id == income_cat.id,
|
||||
Transaction.attributed_to_id == andrew.id,
|
||||
).count()
|
||||
donna_income = session.query(Transaction).filter(
|
||||
Transaction.category_id == income_cat.id,
|
||||
Transaction.attributed_to_id == donna.id,
|
||||
).count()
|
||||
print(f"Andrew income transactions: {andrew_income}")
|
||||
print(f"Donna income transactions: {donna_income}")
|
||||
|
||||
# Analysis works
|
||||
analysis = AnalysisService(session)
|
||||
monthly = analysis.spending_by_period("month")
|
||||
assert len(monthly) >= 1, "Expected at least one month of data"
|
||||
|
||||
by_cat = analysis.spending_by_category()
|
||||
assert len(by_cat) >= 1, "Expected at least one category"
|
||||
|
||||
by_tag = analysis.spending_by_tag()
|
||||
assert len(by_tag) >= 1, "Expected at least one tag"
|
||||
|
||||
# Recurring detection works
|
||||
detector = RecurringDetector(session)
|
||||
recurring = detector.detect()
|
||||
# Print what was found for debugging
|
||||
for r in recurring:
|
||||
print(f"Recurring: {r['description']} - ${r['typical_amount']:.2f} {r['frequency']} (${r['annual_cost']:.2f}/yr)")
|
||||
|
||||
# Verify no duplicate imports
|
||||
r3 = svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=chase.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
assert r3["imported"] == 0, "Re-import should detect all duplicates"
|
||||
assert r3["duplicates"] == r1["imported"]
|
||||
Reference in New Issue
Block a user