MyMidas/backend/app/services/account_service.py
megaproxy 8ef3bb2965 Fix audit findings: budget editing, dead code, logging, multi-currency
- Add budget editing: updateBudget() API, edit button on budget cards,
  BudgetFormModal adapted for create/update (category locked on edit)
- Remove permanently-broken POST /auth/totp/verify stub and its unused
  TOTPVerifyRequest schema
- Wire getHoldingTransactions() to AssetDetail page — transaction history
  table now shows above the candlestick chart, sorted newest-first
- Fix multi-currency net worth in account_service: account balances are
  now converted to base_currency via ExchangeRate table before summing
- Replace silent bare pass exception handlers with logger.warning() in
  transactions.py (OCR/AI pipeline) and price_feed_service.py (search)
  — ValueError in date/number regex parsing left silent (control flow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:54:32 +00:00

212 lines
6.6 KiB
Python

from __future__ import annotations
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import encrypt_field, decrypt_field
from app.db.models.account import Account
from app.db.models.currency import ExchangeRate
from app.db.models.transaction import Transaction
from app.schemas.account import AccountCreate, AccountUpdate
# Account types that are liabilities (balance is negative contribution to net worth)
LIABILITY_TYPES = {"credit_card", "loan", "mortgage"}
class AccountError(Exception):
def __init__(self, detail: str, status_code: int = 400):
self.detail = detail
self.status_code = status_code
def _encrypt(value: str | None) -> bytes | None:
if value is None:
return None
return encrypt_field(value)
def _decrypt(data: bytes | None) -> str | None:
if not data:
return None
return decrypt_field(data)
def _to_response(account: Account) -> dict:
return {
"id": account.id,
"name": _decrypt(account.name_enc) or "",
"institution": _decrypt(account.institution_enc),
"type": account.type,
"currency": account.currency,
"current_balance": account.current_balance,
"credit_limit": account.credit_limit,
"interest_rate": account.interest_rate,
"is_active": account.is_active,
"include_in_net_worth": account.include_in_net_worth,
"color": account.color,
"icon": account.icon,
"notes": _decrypt(account.notes_enc),
"created_at": account.created_at,
"updated_at": account.updated_at,
}
async def create_account(
db: AsyncSession,
user_id: uuid.UUID,
data: AccountCreate,
) -> dict:
now = datetime.now(timezone.utc)
account = Account(
user_id=user_id,
name_enc=encrypt_field(data.name),
institution_enc=_encrypt(data.institution),
type=data.type,
currency=data.currency,
current_balance=data.opening_balance,
credit_limit=data.credit_limit,
interest_rate=data.interest_rate,
include_in_net_worth=data.include_in_net_worth,
color=data.color,
icon=data.icon,
notes_enc=_encrypt(data.notes),
created_at=now,
updated_at=now,
)
db.add(account)
await db.flush()
return _to_response(account)
async def list_accounts(db: AsyncSession, user_id: uuid.UUID) -> list[dict]:
result = await db.execute(
select(Account).where(
Account.user_id == user_id,
Account.deleted_at.is_(None),
).order_by(Account.created_at)
)
return [_to_response(a) for a in result.scalars()]
async def get_account(db: AsyncSession, account_id: uuid.UUID, user_id: uuid.UUID) -> Account:
result = await db.execute(
select(Account).where(
Account.id == account_id,
Account.user_id == user_id,
Account.deleted_at.is_(None),
)
)
account = result.scalar_one_or_none()
if not account:
raise AccountError("Account not found", status_code=404)
return account
async def update_account(
db: AsyncSession,
account_id: uuid.UUID,
user_id: uuid.UUID,
data: AccountUpdate,
) -> dict:
account = await get_account(db, account_id, user_id)
now = datetime.now(timezone.utc)
if data.name is not None:
account.name_enc = encrypt_field(data.name)
if data.institution is not None:
account.institution_enc = _encrypt(data.institution)
if data.opening_balance is not None:
account.current_balance = data.opening_balance
if data.credit_limit is not None:
account.credit_limit = data.credit_limit
if data.interest_rate is not None:
account.interest_rate = data.interest_rate
if data.include_in_net_worth is not None:
account.include_in_net_worth = data.include_in_net_worth
if data.is_active is not None:
account.is_active = data.is_active
if data.color is not None:
account.color = data.color
if data.icon is not None:
account.icon = data.icon
if data.notes is not None:
account.notes_enc = _encrypt(data.notes)
account.updated_at = now
await db.flush()
return _to_response(account)
async def delete_account(
db: AsyncSession,
account_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
account = await get_account(db, account_id, user_id)
account.deleted_at = datetime.now(timezone.utc)
account.updated_at = datetime.now(timezone.utc)
await db.flush()
async def recalculate_balance(db: AsyncSession, account_id: uuid.UUID) -> None:
"""Recompute current_balance from all non-deleted transactions."""
result = await db.execute(
select(func.sum(Transaction.amount)).where(
Transaction.account_id == account_id,
Transaction.deleted_at.is_(None),
)
)
total = result.scalar_one_or_none() or Decimal("0")
account = await db.get(Account, account_id)
if account:
account.current_balance = total
account.updated_at = datetime.now(timezone.utc)
await db.flush()
async def _fx_rate(db: AsyncSession, from_currency: str, to_currency: str) -> Decimal:
if from_currency == to_currency:
return Decimal("1")
result = await db.execute(
select(ExchangeRate)
.where(ExchangeRate.base_currency == from_currency, ExchangeRate.quote_currency == to_currency)
.order_by(ExchangeRate.fetched_at.desc())
.limit(1)
)
er = result.scalar_one_or_none()
return er.rate if er else Decimal("1")
async def get_net_worth(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> dict:
accounts = await db.execute(
select(Account).where(
Account.user_id == user_id,
Account.include_in_net_worth == True,
Account.deleted_at.is_(None),
)
)
total_assets = Decimal("0")
total_liabilities = Decimal("0")
for account in accounts.scalars():
bal = account.current_balance or Decimal("0")
acct_currency = account.currency or base_currency
if acct_currency != base_currency:
rate = await _fx_rate(db, acct_currency, base_currency)
bal = bal * rate
if account.type in LIABILITY_TYPES:
total_liabilities += abs(bal)
else:
total_assets += bal
return {
"total_assets": total_assets,
"total_liabilities": total_liabilities,
"net_worth": total_assets - total_liabilities,
"base_currency": base_currency,
}