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:
2026-02-10 14:57:46 -05:00
parent f538cad6ae
commit db06108d2b
20 changed files with 2391 additions and 0 deletions

16
CLAUDE.md Normal file
View 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
View 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
View 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
View 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

View 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
]

View 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
View 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
View 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
View 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
View 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)

View File

402
src/ui/themes/dark.qss Normal file
View 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
View 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;
}

View 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

View 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

View 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

View 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

View 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 &amp; BEE"
result = normalize_description(raw)
assert "&amp;" not in result
assert "&" in result

View 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
View 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"]