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

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