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:
ZProger
2026-08-16 18:39:52 +03:00
commit fe9b079c12
199 changed files with 14410 additions and 0 deletions

View File

12
backend/app/api/deps.py Normal file
View 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

View File

View 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)

View 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)

View 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()

View 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"}

View 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)

View 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)

View 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()

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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

View 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()