Skip to content

Commit 7df546a

Browse files
authored
Merge pull request #203 from Sagargupta16/feat/rules-tags-saved-views
feat: rule-based auto-categorization, transaction tags, saved filter views
2 parents a293a1e + cfeb3c5 commit 7df546a

40 files changed

Lines changed: 2936 additions & 65 deletions

CLAUDE.md

Lines changed: 6 additions & 45 deletions
Large diffs are not rendered by default.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Categorization rule API endpoints.
2+
3+
CRUD for user-defined "note/account contains X -> set category Y" rules,
4+
plus an explicit retroactive apply endpoint. Rules never auto-run on
5+
save -- import-time application happens in the sync engine, and the
6+
user triggers retroactive application via POST /apply. All business
7+
logic lives in ``core/rules.py``; this router only maps models to
8+
responses.
9+
"""
10+
11+
from fastapi import APIRouter, HTTPException
12+
from sqlalchemy import select
13+
from sqlalchemy.exc import OperationalError
14+
15+
from ledger_sync.api.deps import CurrentUser, DatabaseSession
16+
from ledger_sync.core import rules as rules_engine
17+
from ledger_sync.core.analytics_engine import AnalyticsEngine
18+
from ledger_sync.db.models import CategorizationRule
19+
from ledger_sync.schemas.categorization_rules import (
20+
CategorizationRuleCreateRequest,
21+
CategorizationRuleResponse,
22+
CategorizationRuleUpdateRequest,
23+
RulesApplyResponse,
24+
)
25+
from ledger_sync.utils.logging import logger
26+
27+
router = APIRouter(prefix="/api/categorization-rules", tags=["categorization-rules"])
28+
29+
30+
def _to_rule_response(rule: CategorizationRule) -> CategorizationRuleResponse:
31+
"""Convert a CategorizationRule model to a CategorizationRuleResponse."""
32+
return CategorizationRuleResponse(
33+
id=rule.id,
34+
match_field=rule.match_field,
35+
pattern=rule.pattern,
36+
category=rule.category,
37+
subcategory=rule.subcategory or "",
38+
is_active=rule.is_active,
39+
sort_order=rule.sort_order,
40+
created_at=rule.created_at.isoformat(),
41+
)
42+
43+
44+
@router.get("")
45+
async def list_rules(
46+
current_user: CurrentUser,
47+
db: DatabaseSession,
48+
) -> list[CategorizationRuleResponse]:
49+
"""List all of the user's rules (including inactive) in evaluation order."""
50+
stmt = (
51+
select(CategorizationRule)
52+
.where(CategorizationRule.user_id == current_user.id)
53+
.order_by(CategorizationRule.sort_order.asc(), CategorizationRule.id.asc())
54+
)
55+
rules = db.execute(stmt).scalars().all()
56+
return [_to_rule_response(rule) for rule in rules]
57+
58+
59+
@router.post("", status_code=201)
60+
async def create_rule(
61+
payload: CategorizationRuleCreateRequest,
62+
current_user: CurrentUser,
63+
db: DatabaseSession,
64+
) -> CategorizationRuleResponse:
65+
"""Create a rule. Does NOT apply it retroactively -- use POST /apply."""
66+
rule = CategorizationRule(
67+
user_id=current_user.id,
68+
match_field=payload.match_field,
69+
pattern=payload.pattern,
70+
category=payload.category,
71+
subcategory=payload.subcategory,
72+
is_active=payload.is_active,
73+
sort_order=payload.sort_order,
74+
)
75+
db.add(rule)
76+
db.commit()
77+
db.refresh(rule)
78+
return _to_rule_response(rule)
79+
80+
81+
@router.put("/{rule_id}", responses={404: {"description": "Rule not found"}})
82+
async def update_rule(
83+
rule_id: int,
84+
payload: CategorizationRuleUpdateRequest,
85+
current_user: CurrentUser,
86+
db: DatabaseSession,
87+
) -> CategorizationRuleResponse:
88+
"""Fully replace a rule. Does NOT retro-apply -- use POST /apply."""
89+
stmt = select(CategorizationRule).where(
90+
CategorizationRule.id == rule_id,
91+
CategorizationRule.user_id == current_user.id,
92+
)
93+
rule = db.execute(stmt).scalar_one_or_none()
94+
if rule is None:
95+
raise HTTPException(status_code=404, detail="Rule not found")
96+
97+
rule.match_field = payload.match_field
98+
rule.pattern = payload.pattern
99+
rule.category = payload.category
100+
rule.subcategory = payload.subcategory
101+
rule.is_active = payload.is_active
102+
rule.sort_order = payload.sort_order
103+
104+
db.commit()
105+
db.refresh(rule)
106+
return _to_rule_response(rule)
107+
108+
109+
@router.delete("/{rule_id}", status_code=204)
110+
async def delete_rule(
111+
rule_id: int,
112+
current_user: CurrentUser,
113+
db: DatabaseSession,
114+
) -> None:
115+
"""Delete a rule. Idempotent: a nonexistent id is also a 204."""
116+
stmt = select(CategorizationRule).where(
117+
CategorizationRule.id == rule_id,
118+
CategorizationRule.user_id == current_user.id,
119+
)
120+
rule = db.execute(stmt).scalar_one_or_none()
121+
if rule is not None:
122+
db.delete(rule)
123+
db.commit()
124+
125+
126+
@router.post("/apply")
127+
async def apply_rules(
128+
current_user: CurrentUser,
129+
db: DatabaseSession,
130+
) -> RulesApplyResponse:
131+
"""Apply all active rules to the user's live non-transfer transactions.
132+
133+
Updated rows get NEW transaction_id values (category feeds the dedup
134+
hash) and their tags are migrated server-side. Analytics tables bake
135+
in categories, so a full analytics rebuild runs afterwards --
136+
non-fatally: the apply itself still succeeds if the rebuild fails.
137+
"""
138+
matched, updated = rules_engine.apply_rules_retroactively(db, current_user.id)
139+
140+
analytics_refreshed = True
141+
try:
142+
analytics = AnalyticsEngine(db, user_id=current_user.id)
143+
analytics.run_full_analytics(source_file="rules_apply")
144+
except (OSError, RuntimeError, ValueError, OperationalError) as exc:
145+
# Don't fail the apply if the analytics rebuild blows up -- the
146+
# category rewrites are already committed; the user can re-run
147+
# POST /api/analytics/v2/refresh.
148+
logger.warning(
149+
"Post-apply analytics refresh failed for user_id=%s: %s",
150+
current_user.id,
151+
exc,
152+
)
153+
db.rollback()
154+
analytics_refreshed = False
155+
156+
return RulesApplyResponse(
157+
matched=matched,
158+
updated=updated,
159+
analytics_refreshed=analytics_refreshed,
160+
)

backend/src/ledger_sync/api/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
from ledger_sync.api.analytics_v2 import router as analytics_v2_router
2626
from ledger_sync.api.auth import router as auth_router
2727
from ledger_sync.api.calculations import router as calculations_router
28+
from ledger_sync.api.categorization_rules import router as categorization_rules_router
2829
from ledger_sync.api.exchange_rates import router as exchange_rates_router
2930
from ledger_sync.api.meta import router as meta_router
3031
from ledger_sync.api.oauth import router as oauth_router
3132
from ledger_sync.api.preferences import router as preferences_router
3233
from ledger_sync.api.rate_limit import limiter
3334
from ledger_sync.api.rates import router as rates_router
3435
from ledger_sync.api.reports import router as reports_router
36+
from ledger_sync.api.saved_views import router as saved_views_router
3537
from ledger_sync.api.stock_price import router as stock_price_router
3638
from ledger_sync.api.transactions import router as transactions_router
3739
from ledger_sync.api.upload import router as upload_router
@@ -260,8 +262,10 @@ async def add_timing_header(
260262
app.include_router(calculations_router)
261263
app.include_router(meta_router)
262264
app.include_router(account_classifications_router)
265+
app.include_router(categorization_rules_router)
263266
app.include_router(preferences_router)
264267
app.include_router(reports_router)
268+
app.include_router(saved_views_router)
265269
app.include_router(transactions_router)
266270
app.include_router(upload_router)
267271
app.include_router(exchange_rates_router)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Saved filter view API endpoints.
2+
3+
Named snapshots of the Transactions page filter state. The ``filters``
4+
payload is an opaque JSON object echoed verbatim -- the backend never
5+
validates its keys, so new frontend filter fields need zero backend
6+
changes. POST upserts by (user, name).
7+
"""
8+
9+
import json
10+
11+
from fastapi import APIRouter
12+
from sqlalchemy import select
13+
14+
from ledger_sync.api.deps import CurrentUser, DatabaseSession
15+
from ledger_sync.db.models import SavedFilterView
16+
from ledger_sync.schemas.saved_views import SavedViewCreateRequest, SavedViewResponse
17+
18+
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
19+
20+
21+
def _to_view_response(view: SavedFilterView) -> SavedViewResponse:
22+
"""Convert a SavedFilterView model to a SavedViewResponse."""
23+
try:
24+
filters = json.loads(view.filters)
25+
if not isinstance(filters, dict):
26+
filters = {}
27+
except (TypeError, ValueError):
28+
filters = {}
29+
return SavedViewResponse(
30+
id=view.id,
31+
name=view.name,
32+
filters=filters,
33+
created_at=view.created_at.isoformat(),
34+
updated_at=view.updated_at.isoformat(),
35+
)
36+
37+
38+
@router.get("")
39+
async def list_saved_views(
40+
current_user: CurrentUser,
41+
db: DatabaseSession,
42+
) -> list[SavedViewResponse]:
43+
"""List the user's saved views ordered by name."""
44+
stmt = (
45+
select(SavedFilterView)
46+
.where(SavedFilterView.user_id == current_user.id)
47+
.order_by(SavedFilterView.name.asc())
48+
)
49+
views = db.execute(stmt).scalars().all()
50+
return [_to_view_response(view) for view in views]
51+
52+
53+
@router.post("")
54+
async def save_view(
55+
payload: SavedViewCreateRequest,
56+
current_user: CurrentUser,
57+
db: DatabaseSession,
58+
) -> SavedViewResponse:
59+
"""Create or update a saved view -- UPSERT by (user, name).
60+
61+
If a view with this name already exists for the user, its filters
62+
and updated_at are overwritten and the existing id is returned.
63+
Always 200, never 201/409, so the frontend "Save current view" flow
64+
can blindly POST without checking name collisions.
65+
"""
66+
stmt = select(SavedFilterView).where(
67+
SavedFilterView.user_id == current_user.id,
68+
SavedFilterView.name == payload.name,
69+
)
70+
view = db.execute(stmt).scalar_one_or_none()
71+
72+
if view is not None:
73+
view.filters = json.dumps(payload.filters)
74+
else:
75+
view = SavedFilterView(
76+
user_id=current_user.id,
77+
name=payload.name,
78+
filters=json.dumps(payload.filters),
79+
)
80+
db.add(view)
81+
82+
db.commit()
83+
db.refresh(view)
84+
return _to_view_response(view)
85+
86+
87+
@router.delete("/{view_id}", status_code=204)
88+
async def delete_saved_view(
89+
view_id: int,
90+
current_user: CurrentUser,
91+
db: DatabaseSession,
92+
) -> None:
93+
"""Delete a saved view. Idempotent: a nonexistent id is also a 204."""
94+
stmt = select(SavedFilterView).where(
95+
SavedFilterView.id == view_id,
96+
SavedFilterView.user_id == current_user.id,
97+
)
98+
view = db.execute(stmt).scalar_one_or_none()
99+
if view is not None:
100+
db.delete(view)
101+
db.commit()

0 commit comments

Comments
 (0)