mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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.
85 lines
3.3 KiB
Python
85 lines
3.3 KiB
Python
"""Account CRUD, plus each account's live balance — summed from its
|
|
Transaction rows (income adds, expense subtracts, a transfer moves the
|
|
amount from the source account to the destination account) rather than
|
|
stored, the same "derive it, don't duplicate it" approach
|
|
net_worth_service.py uses for Cash.
|
|
"""
|
|
from collections import defaultdict
|
|
from decimal import Decimal
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.account import Account
|
|
from app.models.enums import TransactionType
|
|
from app.models.transaction import Transaction
|
|
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
|
|
|
|
|
async def _account_balances(session: AsyncSession) -> dict[int, Decimal]:
|
|
result = await session.execute(
|
|
select(Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
|
|
)
|
|
balances: dict[int, Decimal] = defaultdict(Decimal)
|
|
for tx_type, amount, account_id, transfer_account_id in result.all():
|
|
if tx_type == TransactionType.INCOME:
|
|
balances[account_id] += amount
|
|
elif tx_type == TransactionType.EXPENSE:
|
|
balances[account_id] -= amount
|
|
elif tx_type == TransactionType.TRANSFER:
|
|
balances[account_id] -= amount
|
|
if transfer_account_id is not None:
|
|
balances[transfer_account_id] += amount
|
|
return balances
|
|
|
|
|
|
def _to_read(account: Account, balance: Decimal) -> AccountWithBalance:
|
|
return AccountWithBalance(
|
|
id=account.id,
|
|
name=account.name,
|
|
type=account.type,
|
|
currency=account.currency,
|
|
color=account.color,
|
|
is_archived=account.is_archived,
|
|
balance=balance,
|
|
)
|
|
|
|
|
|
async def list_accounts(session: AsyncSession, include_archived: bool) -> list[AccountWithBalance]:
|
|
stmt = select(Account).order_by(Account.name)
|
|
if not include_archived:
|
|
stmt = stmt.where(Account.is_archived.is_(False))
|
|
accounts = (await session.execute(stmt)).scalars().all()
|
|
balances = await _account_balances(session)
|
|
return [_to_read(account, balances.get(account.id, Decimal("0"))) for account in accounts]
|
|
|
|
|
|
async def create_account(session: AsyncSession, payload: AccountCreate) -> AccountWithBalance:
|
|
account = Account(**payload.model_dump())
|
|
session.add(account)
|
|
await session.commit()
|
|
await session.refresh(account)
|
|
# A brand-new account has no transactions yet — no need to query.
|
|
return _to_read(account, Decimal("0"))
|
|
|
|
|
|
async def update_account(session: AsyncSession, account_id: int, payload: AccountUpdate) -> AccountWithBalance:
|
|
account = await session.get(Account, account_id)
|
|
if account is None:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(account, field, value)
|
|
await session.commit()
|
|
await session.refresh(account)
|
|
balances = await _account_balances(session)
|
|
return _to_read(account, balances.get(account.id, Decimal("0")))
|
|
|
|
|
|
async def delete_account(session: AsyncSession, account_id: int) -> None:
|
|
account = await session.get(Account, account_id)
|
|
if account is None:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
await session.delete(account)
|
|
await session.commit()
|