diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e0c1a1e --- /dev/null +++ b/CLAUDE.md @@ -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.). diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..94ff045 --- /dev/null +++ b/src/main.py @@ -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() diff --git a/src/services/analysis.py b/src/services/analysis.py new file mode 100644 index 0000000..b413a2b --- /dev/null +++ b/src/services/analysis.py @@ -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()] diff --git a/src/services/csv_reader.py b/src/services/csv_reader.py new file mode 100644 index 0000000..4d50db6 --- /dev/null +++ b/src/services/csv_reader.py @@ -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 diff --git a/src/services/forecasting.py b/src/services/forecasting.py new file mode 100644 index 0000000..c74de10 --- /dev/null +++ b/src/services/forecasting.py @@ -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 + ] diff --git a/src/services/normalizer.py b/src/services/normalizer.py new file mode 100644 index 0000000..c9880bf --- /dev/null +++ b/src/services/normalizer.py @@ -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 diff --git a/src/services/recurring.py b/src/services/recurring.py new file mode 100644 index 0000000..02bd652 --- /dev/null +++ b/src/services/recurring.py @@ -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 diff --git a/src/ui/recurring_view.py b/src/ui/recurring_view.py new file mode 100644 index 0000000..b19cfb9 --- /dev/null +++ b/src/ui/recurring_view.py @@ -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}") diff --git a/src/ui/settings_view.py b/src/ui/settings_view.py new file mode 100644 index 0000000..92a83cc --- /dev/null +++ b/src/ui/settings_view.py @@ -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() diff --git a/src/ui/sidebar.py b/src/ui/sidebar.py new file mode 100644 index 0000000..842087b --- /dev/null +++ b/src/ui/sidebar.py @@ -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) diff --git a/src/ui/themes/__init__.py b/src/ui/themes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/themes/dark.qss b/src/ui/themes/dark.qss new file mode 100644 index 0000000..2014340 --- /dev/null +++ b/src/ui/themes/dark.qss @@ -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; +} diff --git a/src/ui/themes/light.qss b/src/ui/themes/light.qss new file mode 100644 index 0000000..11e3cd3 --- /dev/null +++ b/src/ui/themes/light.qss @@ -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; +} diff --git a/tests/services/test_analysis.py b/tests/services/test_analysis.py new file mode 100644 index 0000000..f00acf7 --- /dev/null +++ b/tests/services/test_analysis.py @@ -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 diff --git a/tests/services/test_csv_reader.py b/tests/services/test_csv_reader.py new file mode 100644 index 0000000..3537dab --- /dev/null +++ b/tests/services/test_csv_reader.py @@ -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 diff --git a/tests/services/test_forecasting.py b/tests/services/test_forecasting.py new file mode 100644 index 0000000..0ac881a --- /dev/null +++ b/tests/services/test_forecasting.py @@ -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 diff --git a/tests/services/test_import_categorize.py b/tests/services/test_import_categorize.py new file mode 100644 index 0000000..5d847fe --- /dev/null +++ b/tests/services/test_import_categorize.py @@ -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 diff --git a/tests/services/test_normalizer.py b/tests/services/test_normalizer.py new file mode 100644 index 0000000..6879e11 --- /dev/null +++ b/tests/services/test_normalizer.py @@ -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 diff --git a/tests/services/test_recurring.py b/tests/services/test_recurring.py new file mode 100644 index 0000000..d3795dc --- /dev/null +++ b/tests/services/test_recurring.py @@ -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 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1c47d4d --- /dev/null +++ b/tests/test_integration.py @@ -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"]