Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 6 additions & 45 deletions CLAUDE.md

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions backend/src/ledger_sync/api/categorization_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Categorization rule API endpoints.

CRUD for user-defined "note/account contains X -> set category Y" rules,
plus an explicit retroactive apply endpoint. Rules never auto-run on
save -- import-time application happens in the sync engine, and the
user triggers retroactive application via POST /apply. All business
logic lives in ``core/rules.py``; this router only maps models to
responses.
"""

from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.exc import OperationalError

from ledger_sync.api.deps import CurrentUser, DatabaseSession
from ledger_sync.core import rules as rules_engine
from ledger_sync.core.analytics_engine import AnalyticsEngine
from ledger_sync.db.models import CategorizationRule
from ledger_sync.schemas.categorization_rules import (
CategorizationRuleCreateRequest,
CategorizationRuleResponse,
CategorizationRuleUpdateRequest,
RulesApplyResponse,
)
from ledger_sync.utils.logging import logger

router = APIRouter(prefix="/api/categorization-rules", tags=["categorization-rules"])


def _to_rule_response(rule: CategorizationRule) -> CategorizationRuleResponse:
"""Convert a CategorizationRule model to a CategorizationRuleResponse."""
return CategorizationRuleResponse(
id=rule.id,
match_field=rule.match_field,
pattern=rule.pattern,
category=rule.category,
subcategory=rule.subcategory or "",
is_active=rule.is_active,
sort_order=rule.sort_order,
created_at=rule.created_at.isoformat(),
)


@router.get("")
async def list_rules(
current_user: CurrentUser,
db: DatabaseSession,
) -> list[CategorizationRuleResponse]:
"""List all of the user's rules (including inactive) in evaluation order."""
stmt = (
select(CategorizationRule)
.where(CategorizationRule.user_id == current_user.id)
.order_by(CategorizationRule.sort_order.asc(), CategorizationRule.id.asc())
)
rules = db.execute(stmt).scalars().all()
return [_to_rule_response(rule) for rule in rules]


@router.post("", status_code=201)
async def create_rule(
payload: CategorizationRuleCreateRequest,
current_user: CurrentUser,
db: DatabaseSession,
) -> CategorizationRuleResponse:
"""Create a rule. Does NOT apply it retroactively -- use POST /apply."""
rule = CategorizationRule(
user_id=current_user.id,
match_field=payload.match_field,
pattern=payload.pattern,
category=payload.category,
subcategory=payload.subcategory,
is_active=payload.is_active,
sort_order=payload.sort_order,
)
db.add(rule)
db.commit()
db.refresh(rule)
return _to_rule_response(rule)


@router.put("/{rule_id}", responses={404: {"description": "Rule not found"}})
async def update_rule(
rule_id: int,
payload: CategorizationRuleUpdateRequest,
current_user: CurrentUser,
db: DatabaseSession,
) -> CategorizationRuleResponse:
"""Fully replace a rule. Does NOT retro-apply -- use POST /apply."""
stmt = select(CategorizationRule).where(
CategorizationRule.id == rule_id,
CategorizationRule.user_id == current_user.id,
)
rule = db.execute(stmt).scalar_one_or_none()
if rule is None:
raise HTTPException(status_code=404, detail="Rule not found")

rule.match_field = payload.match_field
rule.pattern = payload.pattern
rule.category = payload.category
rule.subcategory = payload.subcategory
rule.is_active = payload.is_active
rule.sort_order = payload.sort_order

db.commit()
db.refresh(rule)
return _to_rule_response(rule)


@router.delete("/{rule_id}", status_code=204)
async def delete_rule(
rule_id: int,
current_user: CurrentUser,
db: DatabaseSession,
) -> None:
"""Delete a rule. Idempotent: a nonexistent id is also a 204."""
stmt = select(CategorizationRule).where(
CategorizationRule.id == rule_id,
CategorizationRule.user_id == current_user.id,
)
rule = db.execute(stmt).scalar_one_or_none()
if rule is not None:
db.delete(rule)
db.commit()


@router.post("/apply")
async def apply_rules(
current_user: CurrentUser,
db: DatabaseSession,
) -> RulesApplyResponse:
"""Apply all active rules to the user's live non-transfer transactions.

Updated rows get NEW transaction_id values (category feeds the dedup
hash) and their tags are migrated server-side. Analytics tables bake
in categories, so a full analytics rebuild runs afterwards --
non-fatally: the apply itself still succeeds if the rebuild fails.
"""
matched, updated = rules_engine.apply_rules_retroactively(db, current_user.id)

analytics_refreshed = True
try:
analytics = AnalyticsEngine(db, user_id=current_user.id)
analytics.run_full_analytics(source_file="rules_apply")
except (OSError, RuntimeError, ValueError, OperationalError) as exc:
# Don't fail the apply if the analytics rebuild blows up -- the
# category rewrites are already committed; the user can re-run
# POST /api/analytics/v2/refresh.
logger.warning(
"Post-apply analytics refresh failed for user_id=%s: %s",
current_user.id,
exc,
)
db.rollback()
analytics_refreshed = False

return RulesApplyResponse(
matched=matched,
updated=updated,
analytics_refreshed=analytics_refreshed,
)
4 changes: 4 additions & 0 deletions backend/src/ledger_sync/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
from ledger_sync.api.analytics_v2 import router as analytics_v2_router
from ledger_sync.api.auth import router as auth_router
from ledger_sync.api.calculations import router as calculations_router
from ledger_sync.api.categorization_rules import router as categorization_rules_router
from ledger_sync.api.exchange_rates import router as exchange_rates_router
from ledger_sync.api.meta import router as meta_router
from ledger_sync.api.oauth import router as oauth_router
from ledger_sync.api.preferences import router as preferences_router
from ledger_sync.api.rate_limit import limiter
from ledger_sync.api.rates import router as rates_router
from ledger_sync.api.reports import router as reports_router
from ledger_sync.api.saved_views import router as saved_views_router
from ledger_sync.api.stock_price import router as stock_price_router
from ledger_sync.api.transactions import router as transactions_router
from ledger_sync.api.upload import router as upload_router
Expand Down Expand Up @@ -260,8 +262,10 @@ async def add_timing_header(
app.include_router(calculations_router)
app.include_router(meta_router)
app.include_router(account_classifications_router)
app.include_router(categorization_rules_router)
app.include_router(preferences_router)
app.include_router(reports_router)
app.include_router(saved_views_router)
app.include_router(transactions_router)
app.include_router(upload_router)
app.include_router(exchange_rates_router)
Expand Down
101 changes: 101 additions & 0 deletions backend/src/ledger_sync/api/saved_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Saved filter view API endpoints.

Named snapshots of the Transactions page filter state. The ``filters``
payload is an opaque JSON object echoed verbatim -- the backend never
validates its keys, so new frontend filter fields need zero backend
changes. POST upserts by (user, name).
"""

import json

from fastapi import APIRouter
from sqlalchemy import select

from ledger_sync.api.deps import CurrentUser, DatabaseSession
from ledger_sync.db.models import SavedFilterView
from ledger_sync.schemas.saved_views import SavedViewCreateRequest, SavedViewResponse

router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])


def _to_view_response(view: SavedFilterView) -> SavedViewResponse:
"""Convert a SavedFilterView model to a SavedViewResponse."""
try:
filters = json.loads(view.filters)
if not isinstance(filters, dict):
filters = {}
except (TypeError, ValueError):
filters = {}
return SavedViewResponse(
id=view.id,
name=view.name,
filters=filters,
created_at=view.created_at.isoformat(),
updated_at=view.updated_at.isoformat(),
)


@router.get("")
async def list_saved_views(
current_user: CurrentUser,
db: DatabaseSession,
) -> list[SavedViewResponse]:
"""List the user's saved views ordered by name."""
stmt = (
select(SavedFilterView)
.where(SavedFilterView.user_id == current_user.id)
.order_by(SavedFilterView.name.asc())
)
views = db.execute(stmt).scalars().all()
return [_to_view_response(view) for view in views]


@router.post("")
async def save_view(
payload: SavedViewCreateRequest,
current_user: CurrentUser,
db: DatabaseSession,
) -> SavedViewResponse:
"""Create or update a saved view -- UPSERT by (user, name).

If a view with this name already exists for the user, its filters
and updated_at are overwritten and the existing id is returned.
Always 200, never 201/409, so the frontend "Save current view" flow
can blindly POST without checking name collisions.
"""
stmt = select(SavedFilterView).where(
SavedFilterView.user_id == current_user.id,
SavedFilterView.name == payload.name,
)
view = db.execute(stmt).scalar_one_or_none()

if view is not None:
view.filters = json.dumps(payload.filters)
else:
view = SavedFilterView(
user_id=current_user.id,
name=payload.name,
filters=json.dumps(payload.filters),
)
db.add(view)

db.commit()
db.refresh(view)
return _to_view_response(view)


@router.delete("/{view_id}", status_code=204)
async def delete_saved_view(
view_id: int,
current_user: CurrentUser,
db: DatabaseSession,
) -> None:
"""Delete a saved view. Idempotent: a nonexistent id is also a 204."""
stmt = select(SavedFilterView).where(
SavedFilterView.id == view_id,
SavedFilterView.user_id == current_user.id,
)
view = db.execute(stmt).scalar_one_or_none()
if view is not None:
db.delete(view)
db.commit()
Loading
Loading