mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
Initial public release
Aurum — a self-hosted personal finance operating system: cash flow, net worth tracking, budgets, goals, recurring payments, reports, proactive alerts, and a compound-interest returns calculator, all bilingual (RU/EN) and mobile-first. Ships as a three-container Docker Compose stack (Postgres + FastAPI + nginx-served React frontend). See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
12
backend/app/api/deps.py
Normal file
12
backend/app/api/deps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
|
||||
DbSession = AsyncSession
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async for session in get_db():
|
||||
yield session
|
||||
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
34
backend/app/api/routes/accounts.py
Normal file
34
backend/app/api/routes/accounts.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
||||
from app.services.account_service import create_account, delete_account, list_accounts, update_account
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountWithBalance])
|
||||
async def read_accounts(
|
||||
include_archived: bool = False, session: AsyncSession = Depends(get_session)
|
||||
) -> list[AccountWithBalance]:
|
||||
return await list_accounts(session, include_archived)
|
||||
|
||||
|
||||
@router.post("", response_model=AccountWithBalance, status_code=201)
|
||||
async def create_account_route(
|
||||
payload: AccountCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> AccountWithBalance:
|
||||
return await create_account(session, payload)
|
||||
|
||||
|
||||
@router.patch("/{account_id}", response_model=AccountWithBalance)
|
||||
async def update_account_route(
|
||||
account_id: int, payload: AccountUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> AccountWithBalance:
|
||||
return await update_account(session, account_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=204)
|
||||
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await delete_account(session, account_id)
|
||||
13
backend/app/api/routes/advice.py
Normal file
13
backend/app/api/routes/advice.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.advice import AdviceResponse
|
||||
from app.services.advice_service import get_advice
|
||||
|
||||
router = APIRouter(prefix="/advice", tags=["advice"])
|
||||
|
||||
|
||||
@router.get("", response_model=AdviceResponse)
|
||||
async def read_advice(session: AsyncSession = Depends(get_session)) -> AdviceResponse:
|
||||
return await get_advice(session)
|
||||
112
backend/app/api/routes/assets.py
Normal file
112
backend/app/api/routes/assets.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.schemas.asset import AssetCreate, AssetRead, AssetUpdate, AssetValuationCreate, AssetValuationRead
|
||||
|
||||
router = APIRouter(prefix="/assets", tags=["assets"])
|
||||
|
||||
_EAGER = (selectinload(Asset.valuations),)
|
||||
|
||||
|
||||
def _to_read(asset: Asset) -> AssetRead:
|
||||
latest = asset.valuations[-1] if asset.valuations else None
|
||||
return AssetRead(
|
||||
id=asset.id,
|
||||
name=asset.name,
|
||||
asset_class=asset.asset_class,
|
||||
currency=asset.currency,
|
||||
notes=asset.notes,
|
||||
capital_role=asset.capital_role,
|
||||
monthly_cash_flow=asset.monthly_cash_flow,
|
||||
current_value=latest.value if latest else 0,
|
||||
as_of_date=latest.as_of_date if latest else asset.created_at.date(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AssetRead])
|
||||
async def list_assets(session: AsyncSession = Depends(get_session)) -> list[AssetRead]:
|
||||
result = await session.execute(select(Asset).options(*_EAGER).order_by(Asset.name))
|
||||
return [_to_read(asset) for asset in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=AssetRead, status_code=201)
|
||||
async def create_asset(payload: AssetCreate, session: AsyncSession = Depends(get_session)) -> AssetRead:
|
||||
asset = Asset(
|
||||
name=payload.name,
|
||||
asset_class=payload.asset_class,
|
||||
currency=payload.currency,
|
||||
notes=payload.notes,
|
||||
capital_role=payload.capital_role,
|
||||
monthly_cash_flow=payload.monthly_cash_flow,
|
||||
)
|
||||
session.add(asset)
|
||||
await session.flush()
|
||||
session.add(AssetValuation(asset_id=asset.id, value=payload.value, as_of_date=payload.as_of_date))
|
||||
await session.commit()
|
||||
|
||||
refreshed = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset.id))
|
||||
return _to_read(refreshed.scalar_one())
|
||||
|
||||
|
||||
@router.patch("/{asset_id}", response_model=AssetRead)
|
||||
async def update_asset(asset_id: int, payload: AssetUpdate, session: AsyncSession = Depends(get_session)) -> AssetRead:
|
||||
result = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset_id))
|
||||
asset = result.scalar_one_or_none()
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(asset, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(asset, attribute_names=["valuations"])
|
||||
return _to_read(asset)
|
||||
|
||||
|
||||
@router.post("/{asset_id}/valuations", response_model=AssetRead)
|
||||
async def add_asset_valuation(
|
||||
asset_id: int, payload: AssetValuationCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> AssetRead:
|
||||
"""Records (or corrects) an asset's value as of a date. Re-submitting the
|
||||
same date updates that day's value instead of erroring, so users can fix
|
||||
a typo without needing a separate edit flow."""
|
||||
asset = await session.get(Asset, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
upsert_stmt = (
|
||||
pg_insert(AssetValuation)
|
||||
.values(asset_id=asset_id, value=payload.value, as_of_date=payload.as_of_date)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[AssetValuation.asset_id, AssetValuation.as_of_date],
|
||||
set_={"value": payload.value},
|
||||
)
|
||||
)
|
||||
await session.execute(upsert_stmt)
|
||||
await session.commit()
|
||||
|
||||
refreshed = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset_id))
|
||||
return _to_read(refreshed.scalar_one())
|
||||
|
||||
|
||||
@router.get("/{asset_id}/valuations", response_model=list[AssetValuationRead])
|
||||
async def list_asset_valuations(asset_id: int, session: AsyncSession = Depends(get_session)) -> list[AssetValuation]:
|
||||
asset = await session.get(Asset, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
result = await session.execute(
|
||||
select(AssetValuation).where(AssetValuation.asset_id == asset_id).order_by(AssetValuation.as_of_date)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204)
|
||||
async def delete_asset(asset_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
asset = await session.get(Asset, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
await session.delete(asset)
|
||||
await session.commit()
|
||||
19
backend/app/api/routes/backup.py
Normal file
19
backend/app/api/routes/backup.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.backup import BackupPayload
|
||||
from app.services.backup_service import build_backup, restore_backup
|
||||
|
||||
router = APIRouter(prefix="/backup", tags=["backup"])
|
||||
|
||||
|
||||
@router.get("/export", response_model=BackupPayload)
|
||||
async def export_backup(session: AsyncSession = Depends(get_session)) -> BackupPayload:
|
||||
return await build_backup(session)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
|
||||
await restore_backup(session, payload)
|
||||
return {"status": "ok"}
|
||||
53
backend/app/api/routes/budgets.py
Normal file
53
backend/app/api/routes/budgets.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.budget import Budget
|
||||
from app.schemas.budget import BudgetCreate, BudgetRead, BudgetStatusResponse, BudgetUpdate
|
||||
from app.services.budget_service import create_budget, delete_budget, get_budget_status, list_budgets, update_budget
|
||||
|
||||
router = APIRouter(prefix="/budgets", tags=["budgets"])
|
||||
|
||||
|
||||
def _to_read(budget: Budget) -> BudgetRead:
|
||||
return BudgetRead(
|
||||
id=budget.id,
|
||||
category_id=budget.category_id,
|
||||
category_name=budget.category.name,
|
||||
category_color=budget.category.color,
|
||||
category_icon=budget.category.icon,
|
||||
monthly_limit=budget.monthly_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BudgetRead])
|
||||
async def read_budgets(session: AsyncSession = Depends(get_session)) -> list[BudgetRead]:
|
||||
return [_to_read(budget) for budget in await list_budgets(session)]
|
||||
|
||||
|
||||
@router.get("/status", response_model=BudgetStatusResponse)
|
||||
async def read_budget_status(
|
||||
year: int = Query(default_factory=lambda: date.today().year, ge=2000, le=2100),
|
||||
month: int = Query(default_factory=lambda: date.today().month, ge=1, le=12),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BudgetStatusResponse:
|
||||
return await get_budget_status(session, year, month)
|
||||
|
||||
|
||||
@router.post("", response_model=BudgetRead, status_code=201)
|
||||
async def create_budget_route(payload: BudgetCreate, session: AsyncSession = Depends(get_session)) -> BudgetRead:
|
||||
return _to_read(await create_budget(session, payload))
|
||||
|
||||
|
||||
@router.patch("/{budget_id}", response_model=BudgetRead)
|
||||
async def update_budget_route(
|
||||
budget_id: int, payload: BudgetUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> BudgetRead:
|
||||
return _to_read(await update_budget(session, budget_id, payload))
|
||||
|
||||
|
||||
@router.delete("/{budget_id}", status_code=204)
|
||||
async def delete_budget_route(budget_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await delete_budget(session, budget_id)
|
||||
19
backend/app/api/routes/cash_flow.py
Normal file
19
backend/app/api/routes/cash_flow.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import date as date_
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.cash_flow import CashFlowResponse
|
||||
from app.services.cash_flow_service import get_cash_flow
|
||||
|
||||
router = APIRouter(prefix="/cash-flow", tags=["cash-flow"])
|
||||
|
||||
|
||||
@router.get("", response_model=CashFlowResponse)
|
||||
async def read_cash_flow(
|
||||
start_date: date_ | None = Query(default=None),
|
||||
end_date: date_ | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CashFlowResponse:
|
||||
return await get_cash_flow(session, start_date, end_date)
|
||||
55
backend/app/api/routes/categories.py
Normal file
55
backend/app/api/routes/categories.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.category import Category
|
||||
from app.models.enums import CategoryKind
|
||||
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryRead])
|
||||
async def list_categories(
|
||||
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session)
|
||||
) -> list[Category]:
|
||||
stmt = select(Category).order_by(Category.sort_order)
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Category.kind == kind)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryRead, status_code=201)
|
||||
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category:
|
||||
category = Category(**payload.model_dump(), is_default=False)
|
||||
session.add(category)
|
||||
await session.commit()
|
||||
await session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@router.patch("/{category_id}", response_model=CategoryRead)
|
||||
async def update_category(
|
||||
category_id: int, payload: CategoryUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> Category:
|
||||
category = await session.get(Category, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(category, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@router.delete("/{category_id}", status_code=204)
|
||||
async def delete_category(category_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
category = await session.get(Category, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
if category.is_default:
|
||||
raise HTTPException(status_code=400, detail="Default categories cannot be deleted")
|
||||
await session.delete(category)
|
||||
await session.commit()
|
||||
19
backend/app/api/routes/dashboard.py
Normal file
19
backend/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.dashboard import DashboardSummary
|
||||
from app.services.dashboard_service import get_dashboard_summary
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DashboardSummary)
|
||||
async def read_dashboard_summary(
|
||||
year: int = Query(default_factory=lambda: date.today().year, ge=2000, le=2100),
|
||||
month: int = Query(default_factory=lambda: date.today().month, ge=1, le=12),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DashboardSummary:
|
||||
return await get_dashboard_summary(session, year, month)
|
||||
37
backend/app/api/routes/goals.py
Normal file
37
backend/app/api/routes/goals.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.goal import GoalContributionCreate, GoalCreate, GoalRead, GoalUpdate
|
||||
from app.services.goal_service import add_contribution, create_goal, delete_goal, list_goals, update_goal
|
||||
|
||||
router = APIRouter(prefix="/goals", tags=["goals"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[GoalRead])
|
||||
async def read_goals(session: AsyncSession = Depends(get_session)) -> list[GoalRead]:
|
||||
return await list_goals(session)
|
||||
|
||||
|
||||
@router.post("", response_model=GoalRead, status_code=201)
|
||||
async def create_goal_route(payload: GoalCreate, session: AsyncSession = Depends(get_session)) -> GoalRead:
|
||||
return await create_goal(session, payload)
|
||||
|
||||
|
||||
@router.patch("/{goal_id}", response_model=GoalRead)
|
||||
async def update_goal_route(
|
||||
goal_id: int, payload: GoalUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> GoalRead:
|
||||
return await update_goal(session, goal_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{goal_id}", status_code=204)
|
||||
async def delete_goal_route(goal_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await delete_goal(session, goal_id)
|
||||
|
||||
|
||||
@router.post("/{goal_id}/contributions", response_model=GoalRead, status_code=201)
|
||||
async def add_contribution_route(
|
||||
goal_id: int, payload: GoalContributionCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> GoalRead:
|
||||
return await add_contribution(session, goal_id, payload)
|
||||
13
backend/app/api/routes/insights.py
Normal file
13
backend/app/api/routes/insights.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.insights import AlertsResponse
|
||||
from app.services.insights_service import get_financial_alerts
|
||||
|
||||
router = APIRouter(prefix="/insights", tags=["insights"])
|
||||
|
||||
|
||||
@router.get("/alerts", response_model=AlertsResponse)
|
||||
async def read_financial_alerts(session: AsyncSession = Depends(get_session)) -> AlertsResponse:
|
||||
return await get_financial_alerts(session)
|
||||
19
backend/app/api/routes/net_worth.py
Normal file
19
backend/app/api/routes/net_worth.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.net_worth import NetWorthSummary
|
||||
from app.services.net_worth_service import RANGE_DAYS, get_net_worth_summary
|
||||
|
||||
router = APIRouter(prefix="/net-worth", tags=["net-worth"])
|
||||
|
||||
_VALID_RANGES = sorted(set(RANGE_DAYS) | {"all"})
|
||||
_RANGE_PATTERN = f"^({'|'.join(_VALID_RANGES)})$"
|
||||
|
||||
|
||||
@router.get("/summary", response_model=NetWorthSummary)
|
||||
async def read_net_worth_summary(
|
||||
range: str = Query(default="30d", pattern=_RANGE_PATTERN),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> NetWorthSummary:
|
||||
return await get_net_worth_summary(session, range)
|
||||
45
backend/app/api/routes/recurring.py
Normal file
45
backend/app/api/routes/recurring.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.recurring import RecurringTransactionCreate, RecurringTransactionRead, RecurringTransactionUpdate
|
||||
from app.services.recurring_service import (
|
||||
create_recurring,
|
||||
delete_recurring,
|
||||
list_recurring,
|
||||
post_recurring,
|
||||
update_recurring,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/recurring", tags=["recurring"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[RecurringTransactionRead])
|
||||
async def read_recurring(session: AsyncSession = Depends(get_session)) -> list[RecurringTransactionRead]:
|
||||
return await list_recurring(session)
|
||||
|
||||
|
||||
@router.post("", response_model=RecurringTransactionRead, status_code=201)
|
||||
async def create_recurring_route(
|
||||
payload: RecurringTransactionCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> RecurringTransactionRead:
|
||||
return await create_recurring(session, payload)
|
||||
|
||||
|
||||
@router.patch("/{recurring_id}", response_model=RecurringTransactionRead)
|
||||
async def update_recurring_route(
|
||||
recurring_id: int, payload: RecurringTransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> RecurringTransactionRead:
|
||||
return await update_recurring(session, recurring_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{recurring_id}", status_code=204)
|
||||
async def delete_recurring_route(recurring_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await delete_recurring(session, recurring_id)
|
||||
|
||||
|
||||
@router.post("/{recurring_id}/post", response_model=RecurringTransactionRead, status_code=201)
|
||||
async def post_recurring_route(
|
||||
recurring_id: int, session: AsyncSession = Depends(get_session)
|
||||
) -> RecurringTransactionRead:
|
||||
return await post_recurring(session, recurring_id)
|
||||
31
backend/app/api/routes/reports.py
Normal file
31
backend/app/api/routes/reports.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import date as date_
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.enums import CategoryKind
|
||||
from app.schemas.reports import CategoryRankingReport, CategorySpendingReport
|
||||
from app.services.reports_service import get_category_ranking_report, get_category_spending_report
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
|
||||
@router.get("/category-spending", response_model=CategorySpendingReport)
|
||||
async def read_category_spending_report(
|
||||
category_id: int,
|
||||
start_date: date_ | None = Query(default=None),
|
||||
end_date: date_ | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CategorySpendingReport:
|
||||
return await get_category_spending_report(session, category_id, start_date, end_date)
|
||||
|
||||
|
||||
@router.get("/category-ranking", response_model=CategoryRankingReport)
|
||||
async def read_category_ranking_report(
|
||||
kind: CategoryKind = CategoryKind.EXPENSE,
|
||||
start_date: date_ | None = Query(default=None),
|
||||
end_date: date_ | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CategoryRankingReport:
|
||||
return await get_category_ranking_report(session, kind, start_date, end_date)
|
||||
24
backend/app/api/routes/settings.py
Normal file
24
backend/app/api/routes/settings.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.settings import AppSettings
|
||||
from app.schemas.settings import AppSettingsRead, AppSettingsUpdate
|
||||
from app.services.settings_service import get_or_create_app_settings
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettingsRead)
|
||||
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
|
||||
return await get_or_create_app_settings(session)
|
||||
|
||||
|
||||
@router.patch("", response_model=AppSettingsRead)
|
||||
async def update_settings(payload: AppSettingsUpdate, session: AsyncSession = Depends(get_session)) -> AppSettings:
|
||||
settings = await get_or_create_app_settings(session)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(settings, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
139
backend/app/api/routes/transactions.py
Normal file
139
backend/app/api/routes/transactions.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from datetime import date as date_
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.models.category import Category
|
||||
from app.models.enums import CategoryKind, TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category))
|
||||
|
||||
_TYPE_TO_CATEGORY_KIND = {
|
||||
TransactionType.INCOME: CategoryKind.INCOME,
|
||||
TransactionType.EXPENSE: CategoryKind.EXPENSE,
|
||||
}
|
||||
|
||||
|
||||
async def _ensure_category_matches_type(
|
||||
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
|
||||
) -> None:
|
||||
"""A category picked for an income transaction must itself be an income
|
||||
category (and likewise for expense) — otherwise the dashboard's spending
|
||||
breakdown, which only joins EXPENSE-typed rows, would silently misclassify
|
||||
the entry."""
|
||||
if category_id is None:
|
||||
return
|
||||
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
|
||||
category = await session.get(Category, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=400, detail="Category not found")
|
||||
if expected_kind is not None and category.kind != expected_kind:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TransactionPage)
|
||||
async def list_transactions(
|
||||
year: int | None = Query(default=None, ge=2000, le=2100),
|
||||
month: int | None = Query(default=None, ge=1, le=12),
|
||||
start_date: date_ | None = Query(default=None),
|
||||
end_date: date_ | None = Query(default=None),
|
||||
account_id: int | None = None,
|
||||
category_id: int | None = None,
|
||||
type: TransactionType | None = None,
|
||||
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TransactionPage:
|
||||
stmt = select(Transaction).options(*_EAGER)
|
||||
count_stmt = select(func.count()).select_from(Transaction)
|
||||
|
||||
if year is not None:
|
||||
stmt = stmt.where(func.extract("year", Transaction.date) == year)
|
||||
count_stmt = count_stmt.where(func.extract("year", Transaction.date) == year)
|
||||
if month is not None:
|
||||
stmt = stmt.where(func.extract("month", Transaction.date) == month)
|
||||
count_stmt = count_stmt.where(func.extract("month", Transaction.date) == month)
|
||||
if start_date is not None:
|
||||
stmt = stmt.where(Transaction.date >= start_date)
|
||||
count_stmt = count_stmt.where(Transaction.date >= start_date)
|
||||
if end_date is not None:
|
||||
stmt = stmt.where(Transaction.date <= end_date)
|
||||
count_stmt = count_stmt.where(Transaction.date <= end_date)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(Transaction.account_id == account_id)
|
||||
count_stmt = count_stmt.where(Transaction.account_id == account_id)
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Transaction.category_id == category_id)
|
||||
count_stmt = count_stmt.where(Transaction.category_id == category_id)
|
||||
if type is not None:
|
||||
stmt = stmt.where(Transaction.type == type)
|
||||
count_stmt = count_stmt.where(Transaction.type == type)
|
||||
|
||||
total = (await session.execute(count_stmt)).scalar_one()
|
||||
|
||||
# id as a tiebreaker keeps pagination stable when many rows share a date/amount.
|
||||
if sort == "amount_desc":
|
||||
stmt = stmt.order_by(Transaction.amount.desc(), Transaction.id.desc())
|
||||
elif sort == "amount_asc":
|
||||
stmt = stmt.order_by(Transaction.amount.asc(), Transaction.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return TransactionPage(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.post("", response_model=TransactionRead, status_code=201)
|
||||
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
||||
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||
transaction = Transaction(**payload.model_dump())
|
||||
session.add(transaction)
|
||||
await session.commit()
|
||||
refreshed = await session.execute(
|
||||
select(Transaction).options(*_EAGER).where(Transaction.id == transaction.id)
|
||||
)
|
||||
return refreshed.scalar_one()
|
||||
|
||||
|
||||
@router.patch("/{transaction_id}", response_model=TransactionRead)
|
||||
async def update_transaction(
|
||||
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> Transaction:
|
||||
transaction = await session.get(Transaction, transaction_id)
|
||||
if transaction is None:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
effective_type = updates.get("type", transaction.type)
|
||||
effective_category_id = updates.get("category_id", transaction.category_id)
|
||||
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||
for field, value in updates.items():
|
||||
setattr(transaction, field, value)
|
||||
await session.commit()
|
||||
refreshed = await session.execute(
|
||||
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
|
||||
)
|
||||
return refreshed.scalar_one()
|
||||
|
||||
|
||||
@router.delete("/{transaction_id}", status_code=204)
|
||||
async def delete_transaction(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
transaction = await session.get(Transaction, transaction_id)
|
||||
if transaction is None:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
await session.delete(transaction)
|
||||
await session.commit()
|
||||
Reference in New Issue
Block a user