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:
92
tests/services/test_analysis.py
Normal file
92
tests/services/test_analysis.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# tests/services/test_analysis.py
|
||||
import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories
|
||||
from src.services.analysis import AnalysisService
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def make_test_data(session):
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
groceries = session.query(Category).filter_by(name="Groceries").one()
|
||||
dining = session.query(Category).filter_by(name="Dining Out").one()
|
||||
|
||||
txns = [
|
||||
Transaction(date=datetime.date(2026, 1, 5), amount=-50.0, description="PUBLIX", account_id=account.id, category_id=groceries.id, tag="needs"),
|
||||
Transaction(date=datetime.date(2026, 1, 12), amount=-30.0, description="ALDI", account_id=account.id, category_id=groceries.id, tag="needs"),
|
||||
Transaction(date=datetime.date(2026, 1, 20), amount=-15.0, description="CHICK-FIL-A", account_id=account.id, category_id=dining.id, tag="wants"),
|
||||
Transaction(date=datetime.date(2026, 2, 3), amount=-60.0, description="PUBLIX", account_id=account.id, category_id=groceries.id, tag="needs"),
|
||||
Transaction(date=datetime.date(2026, 2, 7), amount=-25.0, description="KFC", account_id=account.id, category_id=dining.id, tag="wants"),
|
||||
]
|
||||
session.add_all(txns)
|
||||
session.commit()
|
||||
return account, member
|
||||
|
||||
|
||||
def test_spending_by_month():
|
||||
session = make_session()
|
||||
make_test_data(session)
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_period("month")
|
||||
assert len(result) == 2
|
||||
jan = [r for r in result if r["period"] == "2026-01"][0]
|
||||
assert jan["total"] == -95.0
|
||||
|
||||
|
||||
def test_spending_by_category():
|
||||
session = make_session()
|
||||
make_test_data(session)
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_category(
|
||||
start=datetime.date(2026, 1, 1),
|
||||
end=datetime.date(2026, 2, 28),
|
||||
)
|
||||
groceries_row = [r for r in result if r["category"] == "Groceries"][0]
|
||||
assert groceries_row["total"] == -140.0
|
||||
|
||||
|
||||
def test_spending_by_tag():
|
||||
session = make_session()
|
||||
make_test_data(session)
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_tag(
|
||||
start=datetime.date(2026, 1, 1),
|
||||
end=datetime.date(2026, 2, 28),
|
||||
)
|
||||
needs = [r for r in result if r["tag"] == "needs"][0]
|
||||
wants = [r for r in result if r["tag"] == "wants"][0]
|
||||
assert needs["total"] == -140.0
|
||||
assert wants["total"] == -40.0
|
||||
|
||||
|
||||
def test_spending_filtered_by_person():
|
||||
session = make_session()
|
||||
account, member = make_test_data(session)
|
||||
txns = session.query(Transaction).all()
|
||||
for t in txns:
|
||||
t.attributed_to_id = member.id
|
||||
session.commit()
|
||||
|
||||
svc = AnalysisService(session)
|
||||
result = svc.spending_by_category(
|
||||
start=datetime.date(2026, 1, 1),
|
||||
end=datetime.date(2026, 2, 28),
|
||||
person_id=member.id,
|
||||
)
|
||||
total = sum(r["total"] for r in result)
|
||||
assert total == -180.0
|
||||
42
tests/services/test_csv_reader.py
Normal file
42
tests/services/test_csv_reader.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
from src.services.csv_reader import read_csv, detect_format
|
||||
|
||||
RAWDATA = Path(__file__).parent.parent.parent / "rawdata"
|
||||
|
||||
|
||||
def test_detect_chase_format():
|
||||
result = detect_format(RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV")
|
||||
assert result["has_headers"] is True
|
||||
assert "Transaction Date" in result["headers"]
|
||||
assert result["delimiter"] == ","
|
||||
assert result["row_count"] > 0
|
||||
|
||||
|
||||
def test_detect_checking_format():
|
||||
result = detect_format(RAWDATA / "Checking1.csv")
|
||||
assert result["has_headers"] is False
|
||||
assert result["column_count"] == 5
|
||||
assert result["row_count"] > 0
|
||||
|
||||
|
||||
def test_read_chase_csv():
|
||||
rows = read_csv(RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV")
|
||||
assert len(rows) > 100
|
||||
first = rows[0]
|
||||
assert "Transaction Date" in first
|
||||
assert "Amount" in first
|
||||
assert "Description" in first
|
||||
|
||||
|
||||
def test_read_headerless_csv():
|
||||
rows = read_csv(RAWDATA / "Checking1.csv")
|
||||
assert len(rows) > 50
|
||||
first = rows[0]
|
||||
# Headerless: keys should be column indices (integers)
|
||||
assert 0 in first or "0" in first
|
||||
|
||||
|
||||
def test_preview_rows():
|
||||
result = detect_format(RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV")
|
||||
assert "preview" in result
|
||||
assert len(result["preview"]) <= 5
|
||||
70
tests/services/test_forecasting.py
Normal file
70
tests/services/test_forecasting.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# tests/services/test_forecasting.py
|
||||
import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories
|
||||
from src.services.forecasting import ForecastingService
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def make_forecast_data(session):
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
groceries = session.query(Category).filter_by(name="Groceries").one()
|
||||
|
||||
# 3 months of grocery spending: $500, $600, $700 (trending up)
|
||||
for month, total in [(10, 500), (11, 600), (12, 700)]:
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2025, month, 15),
|
||||
amount=-total,
|
||||
description="GROCERIES",
|
||||
account_id=account.id,
|
||||
category_id=groceries.id,
|
||||
tag="needs",
|
||||
))
|
||||
session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def test_monthly_forecast():
|
||||
session = make_session()
|
||||
make_forecast_data(session)
|
||||
svc = ForecastingService(session)
|
||||
forecast = svc.forecast_month()
|
||||
groceries = [f for f in forecast if f["category"] == "Groceries"]
|
||||
assert len(groceries) == 1
|
||||
assert groceries[0]["projected"] < 0
|
||||
|
||||
|
||||
def test_annual_forecast():
|
||||
session = make_session()
|
||||
make_forecast_data(session)
|
||||
svc = ForecastingService(session)
|
||||
forecast = svc.forecast_year()
|
||||
assert len(forecast) > 0
|
||||
total = sum(f["projected"] for f in forecast)
|
||||
assert total < 0
|
||||
|
||||
|
||||
def test_what_if_removes_recurring():
|
||||
session = make_session()
|
||||
make_forecast_data(session)
|
||||
svc = ForecastingService(session)
|
||||
base = svc.forecast_month()
|
||||
adjusted = svc.forecast_month(exclude_descriptions=["GROCERIES"])
|
||||
base_total = sum(f["projected"] for f in base)
|
||||
adj_total = sum(f["projected"] for f in adjusted)
|
||||
assert adj_total > base_total # Less spending when we exclude
|
||||
71
tests/services/test_import_categorize.py
Normal file
71
tests/services/test_import_categorize.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# tests/services/test_import_categorize.py
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories
|
||||
from src.services.importer import ImportService
|
||||
|
||||
RAWDATA = Path(__file__).parent.parent.parent / "rawdata"
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_import_applies_categorization_rules():
|
||||
session = make_session()
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
groceries = session.query(Category).filter_by(name="Groceries").one()
|
||||
rule = CategorizationRule(pattern="PUBLIX", category_id=groceries.id, priority=10)
|
||||
session.add(rule)
|
||||
session.commit()
|
||||
|
||||
svc = ImportService(session)
|
||||
svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=account.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
|
||||
publix_txns = session.query(Transaction).filter(Transaction.description.contains("PUBLIX")).all()
|
||||
assert len(publix_txns) > 0
|
||||
for txn in publix_txns:
|
||||
assert txn.category_id == groceries.id
|
||||
assert txn.tag == "needs"
|
||||
|
||||
|
||||
def test_uncategorized_transactions_have_no_category():
|
||||
session = make_session()
|
||||
seed_categories(session)
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Chase", institution="Chase", account_type="credit", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
# No rules defined
|
||||
svc = ImportService(session)
|
||||
svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=account.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
|
||||
uncategorized = session.query(Transaction).filter(Transaction.category_id.is_(None)).count()
|
||||
total = session.query(Transaction).count()
|
||||
assert uncategorized == total
|
||||
58
tests/services/test_normalizer.py
Normal file
58
tests/services/test_normalizer.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from src.services.normalizer import normalize_description
|
||||
|
||||
|
||||
def test_strip_authorization_prefix():
|
||||
raw = "PURCHASE AUTHORIZED ON 02/06 WALMART.COM 8009256278 BENTONVILLE AR P000000089502338 CARD 5360"
|
||||
result = normalize_description(raw)
|
||||
assert "AUTHORIZED ON" not in result
|
||||
assert "CARD 5360" not in result
|
||||
assert "WALMART.COM" in result
|
||||
|
||||
|
||||
def test_strip_recurring_prefix():
|
||||
raw = "RECURRING PAYMENT AUTHORIZED ON 02/05 HELLOFRESH 646-846-3663 NY S356036316425851 CARD 5360"
|
||||
result = normalize_description(raw)
|
||||
assert "AUTHORIZED ON" not in result
|
||||
assert "HELLOFRESH" in result
|
||||
|
||||
|
||||
def test_strip_reference_ids():
|
||||
raw = "RECURRING TRANSFER TO CONLON A WAY2SAVE SAVINGS REF #OP0WS99NKQ XXXXXX6065"
|
||||
result = normalize_description(raw)
|
||||
assert "REF #" not in result
|
||||
assert "XXXXXX" not in result
|
||||
assert "WAY2SAVE SAVINGS" in result
|
||||
|
||||
|
||||
def test_strip_card_number():
|
||||
raw = "PURCHASE AUTHORIZED ON 01/08 MRS B COMPANY LLC PORT ROYAL SC S386008692282379 CARD 5360"
|
||||
result = normalize_description(raw)
|
||||
assert "CARD 5360" not in result
|
||||
assert "MRS B COMPANY LLC" in result
|
||||
|
||||
|
||||
def test_strip_transaction_codes():
|
||||
raw = "OASISBATCH PAYROLL 260109 MP027126352 DONNA CONLON"
|
||||
result = normalize_description(raw)
|
||||
assert "OASISBATCH PAYROLL" in result
|
||||
assert "DONNA CONLON" in result
|
||||
|
||||
|
||||
def test_clean_chase_description():
|
||||
"""Chase descriptions are already clean, should pass through mostly unchanged."""
|
||||
raw = "PUBLIX #1716"
|
||||
result = normalize_description(raw)
|
||||
assert result == "PUBLIX #1716"
|
||||
|
||||
|
||||
def test_check_number():
|
||||
raw = "CHECK # 104"
|
||||
result = normalize_description(raw)
|
||||
assert "CHECK" in result
|
||||
|
||||
|
||||
def test_strip_html_entities():
|
||||
raw = "TST*PONCHOS TACOS & BEE"
|
||||
result = normalize_description(raw)
|
||||
assert "&" not in result
|
||||
assert "&" in result
|
||||
85
tests/services/test_recurring.py
Normal file
85
tests/services/test_recurring.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# tests/services/test_recurring.py
|
||||
import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.services.recurring import RecurringDetector
|
||||
|
||||
|
||||
def make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def make_recurring_data(session):
|
||||
member = HouseholdMember(name="Andrew", relationship="self")
|
||||
session.add(member)
|
||||
session.flush()
|
||||
account = Account(name="Checking", institution="WF", account_type="checking", owner_id=member.id)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
|
||||
# Netflix monthly: ~$19.07 on 3rd/4th of month
|
||||
for month in [1, 2]:
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2026, month, 4),
|
||||
amount=-19.07, description="Netflix.com", account_id=account.id,
|
||||
))
|
||||
|
||||
# HelloFresh weekly: ~$142.87
|
||||
for day in [1, 8, 15, 22, 29]:
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2026, 1, day),
|
||||
amount=-142.87, description="HELLOFRESH", account_id=account.id,
|
||||
))
|
||||
|
||||
# Random one-off purchase
|
||||
session.add(Transaction(
|
||||
date=datetime.date(2026, 1, 10),
|
||||
amount=-95.38, description="CARL'S GOLFLAND INC", account_id=account.id,
|
||||
))
|
||||
|
||||
session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def test_detect_monthly_recurring():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
netflix = [r for r in results if "Netflix" in r["description"]]
|
||||
assert len(netflix) == 1
|
||||
assert netflix[0]["frequency"] == "monthly"
|
||||
assert abs(netflix[0]["typical_amount"] - 19.07) < 0.01
|
||||
|
||||
|
||||
def test_detect_weekly_recurring():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
hello = [r for r in results if "HELLOFRESH" in r["description"]]
|
||||
assert len(hello) == 1
|
||||
assert hello[0]["frequency"] == "weekly"
|
||||
|
||||
|
||||
def test_one_off_not_detected():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
golf = [r for r in results if "GOLFLAND" in r["description"]]
|
||||
assert len(golf) == 0
|
||||
|
||||
|
||||
def test_annual_cost_calculation():
|
||||
session = make_session()
|
||||
make_recurring_data(session)
|
||||
detector = RecurringDetector(session)
|
||||
results = detector.detect()
|
||||
netflix = [r for r in results if "Netflix" in r["description"]][0]
|
||||
assert abs(netflix["annual_cost"] - 19.07 * 12) < 1.0
|
||||
99
tests/test_integration.py
Normal file
99
tests/test_integration.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# tests/test_integration.py
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db import Base
|
||||
from src.models import *
|
||||
from src.seed import seed_categories, seed_household, seed_default_rules
|
||||
from src.services.importer import ImportService
|
||||
from src.services.analysis import AnalysisService
|
||||
from src.services.recurring import RecurringDetector
|
||||
|
||||
RAWDATA = Path(__file__).parent.parent / "rawdata"
|
||||
|
||||
|
||||
def test_full_pipeline():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
|
||||
# Setup
|
||||
seed_categories(session)
|
||||
andrew = seed_household(session, "Andrew", "self")
|
||||
donna = seed_household(session, "Donna", "wife")
|
||||
seed_default_rules(session)
|
||||
|
||||
# Create accounts
|
||||
chase = Account(name="Chase Freedom", institution="Chase", account_type="credit", owner_id=andrew.id)
|
||||
checking = Account(name="WF Checking", institution="Wells Fargo", account_type="checking", owner_id=andrew.id, is_shared=True)
|
||||
session.add_all([chase, checking])
|
||||
session.flush()
|
||||
|
||||
svc = ImportService(session)
|
||||
|
||||
# Import Chase CSV
|
||||
r1 = svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=chase.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description", "source_category": "Category"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
assert r1["imported"] > 100
|
||||
|
||||
# Import Checking CSV
|
||||
r2 = svc.import_csv(
|
||||
RAWDATA / "Checking1.csv",
|
||||
account_id=checking.id,
|
||||
column_map={"date": 0, "amount": 1, "description": 4},
|
||||
amount_logic="signed",
|
||||
)
|
||||
assert r2["imported"] > 50
|
||||
|
||||
# Verify some transactions got auto-categorized
|
||||
categorized = session.query(Transaction).filter(Transaction.category_id.isnot(None)).count()
|
||||
total = session.query(Transaction).count()
|
||||
assert categorized > 0, "Expected some transactions to be auto-categorized"
|
||||
print(f"Categorized {categorized}/{total} transactions")
|
||||
|
||||
# Verify income attributed to correct people
|
||||
income_cat = session.query(Category).filter_by(name="Income").first()
|
||||
if income_cat:
|
||||
andrew_income = session.query(Transaction).filter(
|
||||
Transaction.category_id == income_cat.id,
|
||||
Transaction.attributed_to_id == andrew.id,
|
||||
).count()
|
||||
donna_income = session.query(Transaction).filter(
|
||||
Transaction.category_id == income_cat.id,
|
||||
Transaction.attributed_to_id == donna.id,
|
||||
).count()
|
||||
print(f"Andrew income transactions: {andrew_income}")
|
||||
print(f"Donna income transactions: {donna_income}")
|
||||
|
||||
# Analysis works
|
||||
analysis = AnalysisService(session)
|
||||
monthly = analysis.spending_by_period("month")
|
||||
assert len(monthly) >= 1, "Expected at least one month of data"
|
||||
|
||||
by_cat = analysis.spending_by_category()
|
||||
assert len(by_cat) >= 1, "Expected at least one category"
|
||||
|
||||
by_tag = analysis.spending_by_tag()
|
||||
assert len(by_tag) >= 1, "Expected at least one tag"
|
||||
|
||||
# Recurring detection works
|
||||
detector = RecurringDetector(session)
|
||||
recurring = detector.detect()
|
||||
# Print what was found for debugging
|
||||
for r in recurring:
|
||||
print(f"Recurring: {r['description']} - ${r['typical_amount']:.2f} {r['frequency']} (${r['annual_cost']:.2f}/yr)")
|
||||
|
||||
# Verify no duplicate imports
|
||||
r3 = svc.import_csv(
|
||||
RAWDATA / "Chase0372_Activity20260101_20260210_20260210.CSV",
|
||||
account_id=chase.id,
|
||||
column_map={"date": "Transaction Date", "amount": "Amount", "description": "Description"},
|
||||
amount_logic="signed",
|
||||
)
|
||||
assert r3["imported"] == 0, "Re-import should detect all duplicates"
|
||||
assert r3["duplicates"] == r1["imported"]
|
||||
Reference in New Issue
Block a user