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/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
84
backend/app/services/account_service.py
Normal file
84
backend/app/services/account_service.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""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()
|
||||
152
backend/app/services/advice_service.py
Normal file
152
backend/app/services/advice_service.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Personalized, read-only financial advice — a curated set of insights
|
||||
computed from data that already exists (transactions, budgets, category
|
||||
totals). Unlike insights_service.py's proactive alerts (urgent, shown as a
|
||||
banner on every page), these are advisory notes shown only on the dedicated
|
||||
Advice page — a mix of positive, neutral, and cautionary tone, not just
|
||||
warnings, and not something the user needs to act on immediately.
|
||||
"""
|
||||
import calendar
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.budget import Budget
|
||||
from app.models.category import Category
|
||||
from app.models.enums import TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.advice import AdviceItem, AdviceResponse
|
||||
from app.services.dashboard_service import get_dashboard_summary
|
||||
|
||||
TRAILING_MONTHS = 3
|
||||
RISING_CATEGORY_THRESHOLD_PERCENT = 25
|
||||
SAVINGS_RATE_TREND_THRESHOLD_POINTS = 5
|
||||
|
||||
|
||||
def _month_bounds(year: int, month: int) -> tuple[date, date]:
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, 1), date(year, month, last_day)
|
||||
|
||||
|
||||
def _previous_month(year: int, month: int) -> tuple[int, int]:
|
||||
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
|
||||
|
||||
async def _category_expense_totals(session: AsyncSession, year: int, month: int) -> dict[int, Decimal]:
|
||||
start, end = _month_bounds(year, month)
|
||||
stmt = (
|
||||
select(Transaction.category_id, func.sum(Transaction.amount))
|
||||
.where(
|
||||
Transaction.type == TransactionType.EXPENSE,
|
||||
Transaction.category_id.is_not(None),
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
)
|
||||
.group_by(Transaction.category_id)
|
||||
)
|
||||
return {row[0]: row[1] for row in (await session.execute(stmt)).all()}
|
||||
|
||||
|
||||
async def _rising_category_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
|
||||
"""The expense category furthest above its own trailing-3-month average,
|
||||
if that's at least RISING_CATEGORY_THRESHOLD_PERCENT higher."""
|
||||
current_totals = await _category_expense_totals(session, year, month)
|
||||
if not current_totals:
|
||||
return None
|
||||
|
||||
trailing_totals: dict[int, Decimal] = defaultdict(Decimal)
|
||||
y, m = year, month
|
||||
for _ in range(TRAILING_MONTHS):
|
||||
y, m = _previous_month(y, m)
|
||||
for cat_id, amount in (await _category_expense_totals(session, y, m)).items():
|
||||
trailing_totals[cat_id] += amount
|
||||
|
||||
best: tuple[float, int, Decimal, Decimal] | None = None
|
||||
for cat_id, current in current_totals.items():
|
||||
average = trailing_totals.get(cat_id, Decimal("0")) / TRAILING_MONTHS
|
||||
if average <= 0:
|
||||
continue
|
||||
increase_percent = float((current - average) / average * 100)
|
||||
if increase_percent >= RISING_CATEGORY_THRESHOLD_PERCENT and (best is None or increase_percent > best[0]):
|
||||
best = (increase_percent, cat_id, current, average)
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
|
||||
increase_percent, cat_id, current, average = best
|
||||
category = await session.get(Category, cat_id)
|
||||
if category is None:
|
||||
return None
|
||||
|
||||
return AdviceItem(
|
||||
key="rising_category",
|
||||
tone="warning",
|
||||
params={
|
||||
"category": category.name,
|
||||
"percent": round(increase_percent),
|
||||
"current": float(current),
|
||||
"average": float(average),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _unbudgeted_top_category_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
|
||||
"""This month's highest-spending expense category that has no budget."""
|
||||
current_totals = await _category_expense_totals(session, year, month)
|
||||
if not current_totals:
|
||||
return None
|
||||
|
||||
budgeted_ids = {row[0] for row in (await session.execute(select(Budget.category_id))).all()}
|
||||
|
||||
for cat_id, amount in sorted(current_totals.items(), key=lambda item: item[1], reverse=True):
|
||||
if cat_id in budgeted_ids:
|
||||
continue
|
||||
category = await session.get(Category, cat_id)
|
||||
if category is None:
|
||||
continue
|
||||
return AdviceItem(
|
||||
key="unbudgeted_top_category",
|
||||
tone="neutral",
|
||||
params={"category": category.name, "amount": float(amount)},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _savings_rate_trend_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
|
||||
"""This month's savings rate (net / real_income) vs. last month's, when
|
||||
the swing is large enough to be worth mentioning."""
|
||||
current = await get_dashboard_summary(session, year, month)
|
||||
prev_year, prev_month = _previous_month(year, month)
|
||||
previous = await get_dashboard_summary(session, prev_year, prev_month)
|
||||
|
||||
if current.real_income <= 0 or previous.real_income <= 0:
|
||||
return None
|
||||
|
||||
current_rate = float(current.net / current.real_income * 100)
|
||||
previous_rate = float(previous.net / previous.real_income * 100)
|
||||
diff = current_rate - previous_rate
|
||||
|
||||
if abs(diff) < SAVINGS_RATE_TREND_THRESHOLD_POINTS:
|
||||
return None
|
||||
|
||||
return AdviceItem(
|
||||
key="savings_rate_trend",
|
||||
tone="positive" if diff > 0 else "warning",
|
||||
params={"rate": round(current_rate), "diff": round(diff)},
|
||||
)
|
||||
|
||||
|
||||
async def get_advice(session: AsyncSession) -> AdviceResponse:
|
||||
today = date.today()
|
||||
generators = (_rising_category_advice, _unbudgeted_top_category_advice, _savings_rate_trend_advice)
|
||||
|
||||
items: list[AdviceItem] = []
|
||||
for generate in generators:
|
||||
item = await generate(session, today.year, today.month)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
|
||||
return AdviceResponse(items=items)
|
||||
185
backend/app/services/backup_service.py
Normal file
185
backend/app/services/backup_service.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Full-database JSON backup & restore.
|
||||
|
||||
Exports every row (accounts, categories, transactions, assets, asset
|
||||
valuations) as one portable JSON document a user can download from the
|
||||
browser and re-upload later. Restore fully REPLACES existing data — it's a
|
||||
snapshot restore, not a merge — so the whole operation runs in one DB
|
||||
transaction: a corrupt or incompatible file is rejected (referential checks
|
||||
run first, before any row is touched), and any failure during the swap rolls
|
||||
the database back to exactly where it was, so a bad file never leaves the
|
||||
app half-restored.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import APP_VERSION
|
||||
from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.budget import Budget
|
||||
from app.models.category import Category
|
||||
from app.models.goal import Goal, GoalContribution
|
||||
from app.models.recurring import RecurringTransaction
|
||||
from app.models.settings import AppSettings
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.backup import (
|
||||
AccountBackup,
|
||||
AppSettingsBackup,
|
||||
AssetBackup,
|
||||
AssetValuationBackup,
|
||||
BackupPayload,
|
||||
BudgetBackup,
|
||||
CategoryBackup,
|
||||
GoalBackup,
|
||||
GoalContributionBackup,
|
||||
RecurringTransactionBackup,
|
||||
TransactionBackup,
|
||||
)
|
||||
|
||||
BACKUP_FORMAT_VERSION = 1
|
||||
|
||||
|
||||
async def build_backup(session: AsyncSession) -> BackupPayload:
|
||||
accounts = (await session.execute(select(Account))).scalars().all()
|
||||
categories = (await session.execute(select(Category))).scalars().all()
|
||||
transactions = (await session.execute(select(Transaction))).scalars().all()
|
||||
assets = (await session.execute(select(Asset))).scalars().all()
|
||||
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
||||
budgets = (await session.execute(select(Budget))).scalars().all()
|
||||
goals = (await session.execute(select(Goal))).scalars().all()
|
||||
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
|
||||
recurring_transactions = (await session.execute(select(RecurringTransaction))).scalars().all()
|
||||
app_settings = await session.get(AppSettings, 1)
|
||||
|
||||
return BackupPayload(
|
||||
aurum_backup_version=BACKUP_FORMAT_VERSION,
|
||||
exported_at=datetime.now(timezone.utc),
|
||||
app_version=APP_VERSION,
|
||||
accounts=[AccountBackup.model_validate(row) for row in accounts],
|
||||
categories=[CategoryBackup.model_validate(row) for row in categories],
|
||||
transactions=[TransactionBackup.model_validate(row) for row in transactions],
|
||||
assets=[AssetBackup.model_validate(row) for row in assets],
|
||||
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
||||
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
||||
goals=[GoalBackup.model_validate(row) for row in goals],
|
||||
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
|
||||
recurring_transactions=[RecurringTransactionBackup.model_validate(row) for row in recurring_transactions],
|
||||
app_settings=AppSettingsBackup.model_validate(app_settings) if app_settings else AppSettingsBackup(currency="USD"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_references(payload: BackupPayload) -> None:
|
||||
account_ids = {row.id for row in payload.accounts}
|
||||
category_ids = {row.id for row in payload.categories}
|
||||
asset_ids = {row.id for row in payload.assets}
|
||||
|
||||
for t in payload.transactions:
|
||||
if t.account_id not in account_ids:
|
||||
raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}")
|
||||
if t.transfer_account_id is not None and t.transfer_account_id not in account_ids:
|
||||
raise HTTPException(
|
||||
400, f"Transaction {t.id} references unknown transfer_account_id {t.transfer_account_id}"
|
||||
)
|
||||
if t.category_id is not None and t.category_id not in category_ids:
|
||||
raise HTTPException(400, f"Transaction {t.id} references unknown category_id {t.category_id}")
|
||||
|
||||
for v in payload.asset_valuations:
|
||||
if v.asset_id not in asset_ids:
|
||||
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
|
||||
|
||||
for b in payload.budgets:
|
||||
if b.category_id not in category_ids:
|
||||
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
|
||||
|
||||
goal_ids = {row.id for row in payload.goals}
|
||||
for c in payload.goal_contributions:
|
||||
if c.goal_id not in goal_ids:
|
||||
raise HTTPException(400, f"Goal contribution {c.id} references unknown goal_id {c.goal_id}")
|
||||
|
||||
for r in payload.recurring_transactions:
|
||||
if r.account_id not in account_ids:
|
||||
raise HTTPException(400, f"Recurring transaction {r.id} references unknown account_id {r.account_id}")
|
||||
if r.transfer_account_id is not None and r.transfer_account_id not in account_ids:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Recurring transaction {r.id} references unknown transfer_account_id {r.transfer_account_id}",
|
||||
)
|
||||
if r.category_id is not None and r.category_id not in category_ids:
|
||||
raise HTTPException(400, f"Recurring transaction {r.id} references unknown category_id {r.category_id}")
|
||||
|
||||
|
||||
async def _reset_sequence(session: AsyncSession, table: str, rows: list) -> None:
|
||||
"""Bulk-inserting rows with explicit ids doesn't advance the table's
|
||||
identity sequence, so the next auto-generated id would collide — bump it
|
||||
to max(id) after a restore. `table` is always one of our five hardcoded
|
||||
table names, never user input."""
|
||||
if not rows:
|
||||
return
|
||||
max_id = max(row.id for row in rows)
|
||||
await session.execute(
|
||||
text(f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), :max_id)"), {"max_id": max_id}
|
||||
)
|
||||
|
||||
|
||||
async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
||||
if payload.aurum_backup_version != BACKUP_FORMAT_VERSION:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Unsupported backup version {payload.aurum_backup_version} "
|
||||
f"(this Aurum version supports {BACKUP_FORMAT_VERSION})",
|
||||
)
|
||||
|
||||
_validate_references(payload)
|
||||
|
||||
try:
|
||||
# Children before parents.
|
||||
await session.execute(delete(AssetValuation))
|
||||
await session.execute(delete(Budget))
|
||||
await session.execute(delete(GoalContribution))
|
||||
await session.execute(delete(Goal))
|
||||
await session.execute(delete(RecurringTransaction))
|
||||
await session.execute(delete(Transaction))
|
||||
await session.execute(delete(Asset))
|
||||
await session.execute(delete(Category))
|
||||
await session.execute(delete(Account))
|
||||
|
||||
# Parents before children.
|
||||
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
|
||||
session.add_all(Category(**row.model_dump()) for row in payload.categories)
|
||||
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
|
||||
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions)
|
||||
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
||||
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
|
||||
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
|
||||
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)
|
||||
session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions)
|
||||
await session.flush()
|
||||
|
||||
await _reset_sequence(session, "accounts", payload.accounts)
|
||||
await _reset_sequence(session, "categories", payload.categories)
|
||||
await _reset_sequence(session, "assets", payload.assets)
|
||||
await _reset_sequence(session, "transactions", payload.transactions)
|
||||
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
||||
await _reset_sequence(session, "budgets", payload.budgets)
|
||||
await _reset_sequence(session, "goals", payload.goals)
|
||||
await _reset_sequence(session, "goal_contributions", payload.goal_contributions)
|
||||
await _reset_sequence(session, "recurring_transactions", payload.recurring_transactions)
|
||||
|
||||
# Singleton row — updated in place, not deleted/recreated (no
|
||||
# sequence to reset, id is always 1).
|
||||
app_settings = await session.get(AppSettings, 1)
|
||||
if app_settings is None:
|
||||
session.add(AppSettings(id=1, **payload.app_settings.model_dump()))
|
||||
else:
|
||||
for field, value in payload.app_settings.model_dump().items():
|
||||
setattr(app_settings, field, value)
|
||||
|
||||
await session.commit()
|
||||
except HTTPException:
|
||||
await session.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(400, f"Restore failed, no changes were made: {exc}") from exc
|
||||
107
backend/app/services/budget_service.py
Normal file
107
backend/app/services/budget_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Monthly category budgets: CRUD for the limits themselves, plus
|
||||
get_budget_status(), which compares each limit against actual spend for a
|
||||
given month — the data behind the Budget page's progress bars and the
|
||||
budget_exceeded proactive alert (services/insights_service.py)."""
|
||||
import calendar
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.budget import Budget
|
||||
from app.models.category import Category
|
||||
from app.models.enums import CategoryKind, TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
|
||||
|
||||
_EAGER = (selectinload(Budget.category),)
|
||||
|
||||
|
||||
def _month_bounds(year: int, month: int) -> tuple[date, date]:
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, 1), date(year, month, last_day)
|
||||
|
||||
|
||||
async def list_budgets(session: AsyncSession) -> list[Budget]:
|
||||
result = await session.execute(select(Budget).options(*_EAGER).join(Category).order_by(Category.sort_order))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_budget(session: AsyncSession, payload: BudgetCreate) -> Budget:
|
||||
category = await session.get(Category, payload.category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=400, detail="Category not found")
|
||||
if category.kind != CategoryKind.EXPENSE:
|
||||
raise HTTPException(status_code=400, detail="Budgets can only be set on expense categories")
|
||||
|
||||
existing = await session.execute(select(Budget).where(Budget.category_id == payload.category_id))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=400, detail=f"'{category.name}' already has a budget")
|
||||
|
||||
budget = Budget(category_id=payload.category_id, monthly_limit=payload.monthly_limit)
|
||||
session.add(budget)
|
||||
await session.commit()
|
||||
refreshed = await session.execute(select(Budget).options(*_EAGER).where(Budget.id == budget.id))
|
||||
return refreshed.scalar_one()
|
||||
|
||||
|
||||
async def update_budget(session: AsyncSession, budget_id: int, payload: BudgetUpdate) -> Budget:
|
||||
budget = await session.get(Budget, budget_id)
|
||||
if budget is None:
|
||||
raise HTTPException(status_code=404, detail="Budget not found")
|
||||
budget.monthly_limit = payload.monthly_limit
|
||||
await session.commit()
|
||||
refreshed = await session.execute(select(Budget).options(*_EAGER).where(Budget.id == budget_id))
|
||||
return refreshed.scalar_one()
|
||||
|
||||
|
||||
async def delete_budget(session: AsyncSession, budget_id: int) -> None:
|
||||
budget = await session.get(Budget, budget_id)
|
||||
if budget is None:
|
||||
raise HTTPException(status_code=404, detail="Budget not found")
|
||||
await session.delete(budget)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_budget_status(session: AsyncSession, year: int, month: int) -> BudgetStatusResponse:
|
||||
start, end = _month_bounds(year, month)
|
||||
|
||||
budgets = await list_budgets(session)
|
||||
if not budgets:
|
||||
return BudgetStatusResponse(year=year, month=month, items=[])
|
||||
|
||||
spent_stmt = (
|
||||
select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0))
|
||||
.where(
|
||||
Transaction.category_id.in_([b.category_id for b in budgets]),
|
||||
Transaction.type == TransactionType.EXPENSE,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
)
|
||||
.group_by(Transaction.category_id)
|
||||
)
|
||||
spent_by_category: dict[int, Decimal] = {row[0]: row[1] for row in (await session.execute(spent_stmt)).all()}
|
||||
|
||||
items = []
|
||||
for budget in budgets:
|
||||
spent = spent_by_category.get(budget.category_id, Decimal("0"))
|
||||
percent = float(spent / budget.monthly_limit * 100) if budget.monthly_limit else 0.0
|
||||
items.append(
|
||||
BudgetStatus(
|
||||
budget_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,
|
||||
spent=spent,
|
||||
remaining=budget.monthly_limit - spent,
|
||||
percent=percent,
|
||||
is_over_budget=spent > budget.monthly_limit,
|
||||
)
|
||||
)
|
||||
|
||||
return BudgetStatusResponse(year=year, month=month, items=items)
|
||||
87
backend/app/services/cash_flow_service.py
Normal file
87
backend/app/services/cash_flow_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Month-by-month income vs. expense — the multi-month view neither the
|
||||
Dashboard (locked to one month) nor Reports (one category at a time, or a
|
||||
category ranking) answers on its own. Transfers between the user's own
|
||||
accounts are excluded from both totals, same as the Dashboard breakdown.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date as date_
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import extract, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.cash_flow import CashFlowPoint, CashFlowResponse
|
||||
|
||||
|
||||
def _next_month(year: int, month: int) -> tuple[int, int]:
|
||||
return (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
|
||||
async def get_cash_flow(
|
||||
session: AsyncSession, start_date: date_ | None, end_date: date_ | None
|
||||
) -> CashFlowResponse:
|
||||
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
|
||||
Transaction.type != TransactionType.TRANSFER
|
||||
)
|
||||
if start_date:
|
||||
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
|
||||
if end_date:
|
||||
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
|
||||
min_date, max_date = (await session.execute(bounds_stmt)).one()
|
||||
|
||||
effective_start = start_date or min_date
|
||||
effective_end = end_date or max_date
|
||||
|
||||
empty = CashFlowResponse(
|
||||
start_date=effective_start,
|
||||
end_date=effective_end,
|
||||
points=[],
|
||||
total_income=Decimal("0"),
|
||||
total_expense=Decimal("0"),
|
||||
total_net=Decimal("0"),
|
||||
)
|
||||
if effective_start is None or effective_end is None:
|
||||
return empty
|
||||
|
||||
rows_stmt = (
|
||||
select(
|
||||
extract("year", Transaction.date).label("year"),
|
||||
extract("month", Transaction.date).label("month"),
|
||||
Transaction.type,
|
||||
func.sum(Transaction.amount).label("amount"),
|
||||
)
|
||||
.where(
|
||||
Transaction.type != TransactionType.TRANSFER,
|
||||
Transaction.date >= effective_start,
|
||||
Transaction.date <= effective_end,
|
||||
)
|
||||
.group_by("year", "month", Transaction.type)
|
||||
)
|
||||
rows = (await session.execute(rows_stmt)).all()
|
||||
|
||||
by_month: dict[tuple[int, int], dict[TransactionType, Decimal]] = defaultdict(dict)
|
||||
for year, month, tx_type, amount in rows:
|
||||
by_month[(int(year), int(month))][tx_type] = amount
|
||||
|
||||
points: list[CashFlowPoint] = []
|
||||
year, month = effective_start.year, effective_start.month
|
||||
while (year, month) <= (effective_end.year, effective_end.month):
|
||||
totals = by_month.get((year, month), {})
|
||||
income = totals.get(TransactionType.INCOME, Decimal("0"))
|
||||
expense = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
||||
points.append(CashFlowPoint(year=year, month=month, income=income, expense=expense, net=income - expense))
|
||||
year, month = _next_month(year, month)
|
||||
|
||||
total_income = sum((p.income for p in points), Decimal("0"))
|
||||
total_expense = sum((p.expense for p in points), Decimal("0"))
|
||||
|
||||
return CashFlowResponse(
|
||||
start_date=effective_start,
|
||||
end_date=effective_end,
|
||||
points=points,
|
||||
total_income=total_income,
|
||||
total_expense=total_expense,
|
||||
total_net=total_income - total_expense,
|
||||
)
|
||||
89
backend/app/services/dashboard_service.py
Normal file
89
backend/app/services/dashboard_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Aggregation logic behind the Overview dashboard."""
|
||||
import calendar
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.category import Category
|
||||
from app.models.enums import TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary
|
||||
|
||||
# Categorical slots are capped at 8 (dataviz skill: a 9th series folds into "Other",
|
||||
# never a generated hue) — this is also the exact size of the default category set.
|
||||
MAX_CHART_SLICES = 8
|
||||
OTHER_SLICE_COLOR = "#898781" # muted ink, reserved for the non-categorical rollup
|
||||
|
||||
|
||||
def _month_bounds(year: int, month: int) -> tuple[date, date]:
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, 1), date(year, month, last_day)
|
||||
|
||||
|
||||
async def get_dashboard_summary(session: AsyncSession, year: int, month: int) -> DashboardSummary:
|
||||
start, end = _month_bounds(year, month)
|
||||
|
||||
totals_stmt = (
|
||||
select(Transaction.type, func.coalesce(func.sum(Transaction.amount), 0))
|
||||
.where(Transaction.date >= start, Transaction.date <= end)
|
||||
.group_by(Transaction.type)
|
||||
)
|
||||
totals_result = await session.execute(totals_stmt)
|
||||
totals: dict[TransactionType, Decimal] = {row[0]: row[1] for row in totals_result.all()}
|
||||
|
||||
real_income = totals.get(TransactionType.INCOME, Decimal("0"))
|
||||
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
||||
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
|
||||
|
||||
breakdown_stmt = (
|
||||
select(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.color,
|
||||
Category.icon,
|
||||
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
|
||||
)
|
||||
.join(Category, Category.id == Transaction.category_id)
|
||||
.where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
Transaction.type == TransactionType.EXPENSE,
|
||||
)
|
||||
.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
|
||||
.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
|
||||
)
|
||||
breakdown_result = await session.execute(breakdown_stmt)
|
||||
rows = breakdown_result.all()
|
||||
|
||||
top_rows, rest_rows = rows[:MAX_CHART_SLICES], rows[MAX_CHART_SLICES:]
|
||||
|
||||
def _percent(amount: Decimal) -> float:
|
||||
return float(amount / spent * 100) if spent else 0.0
|
||||
|
||||
spending_by_category = [
|
||||
CategoryBreakdownItem(
|
||||
category_id=cat_id, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount)
|
||||
)
|
||||
for cat_id, name, color, icon, amount in top_rows
|
||||
]
|
||||
|
||||
if rest_rows:
|
||||
other_amount = sum((row.amount for row in rest_rows), Decimal("0"))
|
||||
spending_by_category.append(
|
||||
CategoryBreakdownItem(
|
||||
category_id=None, name="Other", color=OTHER_SLICE_COLOR, icon="more-horizontal",
|
||||
amount=other_amount, percent=_percent(other_amount),
|
||||
)
|
||||
)
|
||||
|
||||
return DashboardSummary(
|
||||
year=year,
|
||||
month=month,
|
||||
real_income=real_income,
|
||||
spent=spent,
|
||||
net=real_income - spent,
|
||||
transferred_out=transferred_out,
|
||||
spending_by_category=spending_by_category,
|
||||
)
|
||||
94
backend/app/services/goal_service.py
Normal file
94
backend/app/services/goal_service.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Savings goals: CRUD for the goal itself, plus a running current_amount —
|
||||
the sum of all logged GoalContribution rows, computed on read rather than
|
||||
stored, so it's never out of sync with the log."""
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.engine import Row
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.goal import Goal, GoalContribution
|
||||
from app.schemas.goal import GoalContributionCreate, GoalCreate, GoalRead, GoalUpdate
|
||||
|
||||
_SELECT_WITH_TOTAL = (
|
||||
select(
|
||||
Goal.id,
|
||||
Goal.name,
|
||||
Goal.target_amount,
|
||||
Goal.target_date,
|
||||
func.coalesce(func.sum(GoalContribution.amount), 0).label("current_amount"),
|
||||
)
|
||||
.outerjoin(GoalContribution, GoalContribution.goal_id == Goal.id)
|
||||
.group_by(Goal.id, Goal.name, Goal.target_amount, Goal.target_date, Goal.created_at)
|
||||
.order_by(Goal.created_at)
|
||||
)
|
||||
|
||||
|
||||
def _to_read(row: Row) -> GoalRead:
|
||||
current = row.current_amount
|
||||
target = row.target_amount
|
||||
percent = float(current / target * 100) if target else 0.0
|
||||
return GoalRead(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
target_amount=target,
|
||||
target_date=row.target_date,
|
||||
current_amount=current,
|
||||
remaining=target - current,
|
||||
percent=percent,
|
||||
is_reached=current >= target,
|
||||
)
|
||||
|
||||
|
||||
async def _read_one(session: AsyncSession, goal_id: int) -> GoalRead:
|
||||
row = (await session.execute(_SELECT_WITH_TOTAL.where(Goal.id == goal_id))).one()
|
||||
return _to_read(row)
|
||||
|
||||
|
||||
async def list_goals(session: AsyncSession) -> list[GoalRead]:
|
||||
rows = (await session.execute(_SELECT_WITH_TOTAL)).all()
|
||||
return [_to_read(row) for row in rows]
|
||||
|
||||
|
||||
async def create_goal(session: AsyncSession, payload: GoalCreate) -> GoalRead:
|
||||
goal = Goal(name=payload.name, target_amount=payload.target_amount, target_date=payload.target_date)
|
||||
session.add(goal)
|
||||
await session.commit()
|
||||
return GoalRead(
|
||||
id=goal.id,
|
||||
name=goal.name,
|
||||
target_amount=goal.target_amount,
|
||||
target_date=goal.target_date,
|
||||
current_amount=Decimal("0"),
|
||||
remaining=goal.target_amount,
|
||||
percent=0.0,
|
||||
is_reached=False,
|
||||
)
|
||||
|
||||
|
||||
async def update_goal(session: AsyncSession, goal_id: int, payload: GoalUpdate) -> GoalRead:
|
||||
goal = await session.get(Goal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Goal not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(goal, field, value)
|
||||
await session.commit()
|
||||
return await _read_one(session, goal_id)
|
||||
|
||||
|
||||
async def delete_goal(session: AsyncSession, goal_id: int) -> None:
|
||||
goal = await session.get(Goal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Goal not found")
|
||||
await session.delete(goal)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def add_contribution(session: AsyncSession, goal_id: int, payload: GoalContributionCreate) -> GoalRead:
|
||||
goal = await session.get(Goal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Goal not found")
|
||||
session.add(GoalContribution(goal_id=goal_id, amount=payload.amount, date=payload.date, note=payload.note))
|
||||
await session.commit()
|
||||
return await _read_one(session, goal_id)
|
||||
119
backend/app/services/insights_service.py
Normal file
119
backend/app/services/insights_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Proactive early-warning checks, computed from data that already exists.
|
||||
Four signals: sustained negative monthly cash flow, a sustained net-worth
|
||||
decline, one or more over-budget categories, and too much capital in
|
||||
medium/high risk tiers (the "80% at zero risk, 20% at most exposed" rule).
|
||||
The first two only look at fully-elapsed calendar months, so a same-month
|
||||
false alarm (rent already paid, salary not landed yet) never fires — their
|
||||
"sustained for how long" thresholds, and the risk-allocation percentage, are
|
||||
all user-configurable (Settings page), stored on AppSettings, see
|
||||
get_or_create_app_settings. The budget check is the opposite: it deliberately
|
||||
looks at the CURRENT, still-in-progress month, since the whole point is to
|
||||
catch overspending while there's still time to react.
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.insights import AlertsResponse, FinancialAlert
|
||||
from app.schemas.net_worth import NetWorthSummary
|
||||
from app.services.budget_service import get_budget_status
|
||||
from app.services.dashboard_service import get_dashboard_summary
|
||||
from app.services.net_worth_service import get_net_worth_summary
|
||||
from app.services.settings_service import get_or_create_app_settings
|
||||
|
||||
MAX_LOOKBACK_MONTHS = 24
|
||||
|
||||
|
||||
def _previous_month(year: int, month: int) -> tuple[int, int]:
|
||||
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
|
||||
|
||||
async def _negative_cash_flow_streak(session: AsyncSession) -> int:
|
||||
today = date.today()
|
||||
year, month = _previous_month(today.year, today.month)
|
||||
|
||||
streak = 0
|
||||
for _ in range(MAX_LOOKBACK_MONTHS):
|
||||
summary = await get_dashboard_summary(session, year, month)
|
||||
if summary.net >= 0:
|
||||
break
|
||||
streak += 1
|
||||
year, month = _previous_month(year, month)
|
||||
return streak
|
||||
|
||||
|
||||
def _net_worth_decline_streak(summary: NetWorthSummary) -> int:
|
||||
today = date.today()
|
||||
|
||||
# Keep the last point seen for each (year, month) — `series` is ordered
|
||||
# ascending by date, so that's the month-end value — and drop the
|
||||
# current month, which is always partial.
|
||||
month_end: dict[tuple[int, int], Decimal] = {}
|
||||
for point in summary.series:
|
||||
if (point.date.year, point.date.month) == (today.year, today.month):
|
||||
continue
|
||||
month_end[(point.date.year, point.date.month)] = point.value
|
||||
|
||||
ordered_months = sorted(month_end)
|
||||
if len(ordered_months) < 2:
|
||||
return 0
|
||||
|
||||
streak = 0
|
||||
for i in range(len(ordered_months) - 1, 0, -1):
|
||||
if month_end[ordered_months[i]] < month_end[ordered_months[i - 1]]:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
return streak
|
||||
|
||||
|
||||
async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
|
||||
settings = await get_or_create_app_settings(session)
|
||||
alerts: list[FinancialAlert] = []
|
||||
|
||||
cash_flow_streak = await _negative_cash_flow_streak(session)
|
||||
if cash_flow_streak >= settings.negative_cash_flow_threshold_months:
|
||||
alerts.append(
|
||||
FinancialAlert(
|
||||
key="negative_cash_flow_streak",
|
||||
severity="warning",
|
||||
params={"months": cash_flow_streak},
|
||||
)
|
||||
)
|
||||
|
||||
net_worth_summary = await get_net_worth_summary(session, "all")
|
||||
|
||||
net_worth_streak = _net_worth_decline_streak(net_worth_summary)
|
||||
if net_worth_streak >= settings.net_worth_decline_threshold_months:
|
||||
alerts.append(
|
||||
FinancialAlert(
|
||||
key="net_worth_decline_streak",
|
||||
severity="warning",
|
||||
params={"months": net_worth_streak},
|
||||
)
|
||||
)
|
||||
|
||||
risky_percent = sum(tier.percent for tier in net_worth_summary.risk_levels if tier.risk_level != "low")
|
||||
if risky_percent > settings.risky_allocation_threshold_percent:
|
||||
alerts.append(
|
||||
FinancialAlert(
|
||||
key="risky_allocation_exceeded",
|
||||
severity="warning",
|
||||
params={"percent": round(risky_percent), "threshold": settings.risky_allocation_threshold_percent},
|
||||
)
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
budget_status = await get_budget_status(session, today.year, today.month)
|
||||
over_budget_count = sum(1 for item in budget_status.items if item.is_over_budget)
|
||||
if over_budget_count > 0:
|
||||
alerts.append(
|
||||
FinancialAlert(
|
||||
key="budget_exceeded",
|
||||
severity="warning",
|
||||
params={"count": over_budget_count},
|
||||
)
|
||||
)
|
||||
|
||||
return AlertsResponse(alerts=alerts)
|
||||
282
backend/app/services/net_worth_service.py
Normal file
282
backend/app/services/net_worth_service.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Net worth aggregation: a single trend line plus a percent breakdown by
|
||||
asset class.
|
||||
|
||||
Design note: "Cash" is not stored anywhere — it's derived live from
|
||||
Account/Transaction data (checking/savings/cash accounts only) so the
|
||||
Transactions feature stays the single source of truth for liquid money.
|
||||
Every other class (investments, crypto, real estate, vehicles, precious
|
||||
metals, other) is tracked manually via Asset/AssetValuation rows. Both are
|
||||
collapsed into one
|
||||
daily, forward-filled series so the chart reads as one continuous line even
|
||||
though the two halves are updated at very different cadences.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date as date_
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.enums import AccountType, AssetClass, CapitalRole, RiskLevel, TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.net_worth import (
|
||||
CapitalRoleSummary,
|
||||
NetWorthBreakdownItem,
|
||||
NetWorthPoint,
|
||||
NetWorthSummary,
|
||||
RiskLevelItem,
|
||||
RiskLevelSummary,
|
||||
)
|
||||
|
||||
CASH_ACCOUNT_TYPES = {AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH}
|
||||
|
||||
RANGE_DAYS = {"30d": 30, "90d": 90, "1y": 365, "5y": 365 * 5}
|
||||
|
||||
_CLASS_META: dict[str, tuple[str, str, str]] = {
|
||||
# key -> (display name, color, lucide icon). Colors follow the dataviz
|
||||
# skill's validated 8-slot order (blue, orange, aqua, yellow, magenta,
|
||||
# green, violet, red) in this exact sequence — an adjacent-pair-safe
|
||||
# order for a segmented bar/donut; do not reorder without re-validating.
|
||||
"cash": ("Наличные", "#2a78d6", "wallet"), # slot 1 blue
|
||||
AssetClass.INVESTMENTS.value: ("Инвестиции", "#eb6834", "trending-up"), # slot 2 orange
|
||||
AssetClass.CRYPTO.value: ("Криптовалюта", "#1baf7a", "bitcoin"), # slot 3 aqua
|
||||
AssetClass.REAL_ESTATE.value: ("Недвижимость", "#eda100", "building-2"), # slot 4 yellow
|
||||
AssetClass.VEHICLES.value: ("Транспорт", "#e87ba4", "car"), # slot 5 magenta
|
||||
AssetClass.PRECIOUS_METALS.value: ("Драгметаллы", "#008300", "gem"), # slot 6 green
|
||||
AssetClass.OTHER.value: ("Прочее", "#4a3aa7", "package"), # slot 7 violet
|
||||
}
|
||||
|
||||
_ROLE_META: dict[CapitalRole, tuple[str, str]] = {
|
||||
# role -> (label, color). Status colors, not categorical ones — "good"
|
||||
# and "critical" from the dataviz skill's fixed status palette, reused
|
||||
# verbatim from how StatCard/NetWorthChart already color income vs.
|
||||
# expense elsewhere in the app.
|
||||
CapitalRole.INCOME: ("Доходный", "var(--success)"),
|
||||
CapitalRole.NEUTRAL: ("Нейтральный", "var(--text-muted)"),
|
||||
CapitalRole.DRAIN: ("Убыточный", "var(--danger)"),
|
||||
}
|
||||
|
||||
_RISK_META: dict[RiskLevel, tuple[str, str]] = {
|
||||
# Same status-color reuse as _ROLE_META — low risk reads as "good",
|
||||
# high risk as "critical", not a categorical hue.
|
||||
RiskLevel.LOW: ("Низкий", "var(--success)"),
|
||||
RiskLevel.MEDIUM: ("Средний", "var(--text-muted)"),
|
||||
RiskLevel.HIGH: ("Высокий", "var(--danger)"),
|
||||
}
|
||||
|
||||
|
||||
def _daily_series(events: list[tuple[date_, Decimal]], start: date_, end: date_) -> list[NetWorthPoint]:
|
||||
"""Forward-fills a sparse, date-sorted list of cumulative totals into one
|
||||
point per calendar day. `events` before `start` still count — they just
|
||||
collapse into `start`'s opening value."""
|
||||
events = sorted(events, key=lambda e: e[0])
|
||||
points: list[NetWorthPoint] = []
|
||||
idx, n = 0, len(events)
|
||||
current = Decimal("0")
|
||||
day = start
|
||||
while day <= end:
|
||||
while idx < n and events[idx][0] <= day:
|
||||
current = events[idx][1]
|
||||
idx += 1
|
||||
points.append(NetWorthPoint(date=day, value=current))
|
||||
day += timedelta(days=1)
|
||||
return points
|
||||
|
||||
|
||||
async def _cash_cumulative_events(session: AsyncSession) -> list[tuple[date_, Decimal]]:
|
||||
accounts_result = await session.execute(select(Account.id, Account.type))
|
||||
cash_account_ids = {acc_id for acc_id, acc_type in accounts_result.all() if acc_type in CASH_ACCOUNT_TYPES}
|
||||
|
||||
txns_result = await session.execute(
|
||||
select(Transaction.date, Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
|
||||
)
|
||||
|
||||
delta_by_date: dict[date_, Decimal] = defaultdict(Decimal)
|
||||
for tx_date, tx_type, amount, account_id, transfer_account_id in txns_result.all():
|
||||
if tx_type == TransactionType.INCOME and account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] += amount
|
||||
elif tx_type == TransactionType.EXPENSE and account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] -= amount
|
||||
elif tx_type == TransactionType.TRANSFER:
|
||||
if account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] -= amount
|
||||
if transfer_account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] += amount
|
||||
|
||||
events: list[tuple[date_, Decimal]] = []
|
||||
running = Decimal("0")
|
||||
for day in sorted(delta_by_date):
|
||||
running += delta_by_date[day]
|
||||
events.append((day, running))
|
||||
return events
|
||||
|
||||
|
||||
async def _asset_events_and_class_totals(
|
||||
session: AsyncSession,
|
||||
) -> tuple[list[tuple[date_, Decimal]], dict[AssetClass, Decimal], dict[int, Decimal]]:
|
||||
asset_class_result = await session.execute(select(Asset.id, Asset.asset_class))
|
||||
asset_class_map = dict(asset_class_result.all())
|
||||
|
||||
valuations_result = await session.execute(
|
||||
select(AssetValuation.asset_id, AssetValuation.as_of_date, AssetValuation.value).order_by(
|
||||
AssetValuation.as_of_date
|
||||
)
|
||||
)
|
||||
rows = valuations_result.all()
|
||||
|
||||
current_by_asset: dict[int, Decimal] = {}
|
||||
events: list[tuple[date_, Decimal]] = []
|
||||
for day, group in groupby(rows, key=lambda row: row[1]):
|
||||
for asset_id, _, value in group:
|
||||
current_by_asset[asset_id] = value
|
||||
events.append((day, sum(current_by_asset.values(), Decimal("0"))))
|
||||
|
||||
class_totals: dict[AssetClass, Decimal] = defaultdict(Decimal)
|
||||
for asset_id, value in current_by_asset.items():
|
||||
asset_class = asset_class_map.get(asset_id)
|
||||
if asset_class is not None:
|
||||
class_totals[asset_class] += value
|
||||
|
||||
return events, class_totals, current_by_asset
|
||||
|
||||
|
||||
async def _capital_role_summary(session: AsyncSession, current_by_asset: dict[int, Decimal]) -> list[CapitalRoleSummary]:
|
||||
"""Cross-cuts the same assets by how the user tagged them (income /
|
||||
neutral / drain) instead of by asset class — always all three roles,
|
||||
even at zero, so the block reads as a fixed scale rather than a list
|
||||
that shuffles as assets are added."""
|
||||
roles_result = await session.execute(select(Asset.id, Asset.capital_role, Asset.monthly_cash_flow))
|
||||
|
||||
totals_value: dict[CapitalRole, Decimal] = defaultdict(Decimal)
|
||||
totals_flow: dict[CapitalRole, Decimal] = defaultdict(Decimal)
|
||||
counts: dict[CapitalRole, int] = defaultdict(int)
|
||||
for asset_id, role, cash_flow in roles_result.all():
|
||||
totals_value[role] += current_by_asset.get(asset_id, Decimal("0"))
|
||||
totals_flow[role] += cash_flow or Decimal("0")
|
||||
counts[role] += 1
|
||||
|
||||
return [
|
||||
CapitalRoleSummary(
|
||||
role=role.value,
|
||||
label=_ROLE_META[role][0],
|
||||
color=_ROLE_META[role][1],
|
||||
total_value=totals_value.get(role, Decimal("0")),
|
||||
monthly_cash_flow=totals_flow.get(role, Decimal("0")),
|
||||
count=counts.get(role, 0),
|
||||
)
|
||||
for role in CapitalRole
|
||||
]
|
||||
|
||||
|
||||
async def _risk_level_summary(
|
||||
session: AsyncSession, current_by_asset: dict[int, Decimal], cash_today: Decimal
|
||||
) -> list[RiskLevelSummary]:
|
||||
"""Cross-cuts Cash + assets by user-tagged risk of loss — unlike
|
||||
capital_roles, Cash participates here: it's the zero-risk anchor an
|
||||
80/20-style allocation rule ("80% of capital at zero risk, at most 20%
|
||||
exposed") is measured against. Always all three tiers, even at zero,
|
||||
same reasoning as capital_roles. Each tier's item list *is* its
|
||||
diversification view — a tier that's one holding at 100% is
|
||||
concentrated, several even-sized holdings aren't, no separate index."""
|
||||
assets_result = await session.execute(select(Asset.id, Asset.name, Asset.risk_level))
|
||||
asset_rows = assets_result.all()
|
||||
|
||||
totals: dict[RiskLevel, Decimal] = defaultdict(Decimal)
|
||||
items_by_level: dict[RiskLevel, list[tuple[str, str, Decimal]]] = defaultdict(list)
|
||||
|
||||
if cash_today:
|
||||
totals[RiskLevel.LOW] += cash_today
|
||||
items_by_level[RiskLevel.LOW].append(("cash", _CLASS_META["cash"][0], cash_today))
|
||||
|
||||
for asset_id, name, risk_level in asset_rows:
|
||||
value = current_by_asset.get(asset_id, Decimal("0"))
|
||||
if value == 0:
|
||||
continue
|
||||
totals[risk_level] += value
|
||||
items_by_level[risk_level].append((f"asset:{asset_id}", name, value))
|
||||
|
||||
grand_total = sum(totals.values(), Decimal("0"))
|
||||
|
||||
def _share(amount: Decimal, denominator: Decimal) -> float:
|
||||
return float(amount / denominator * 100) if denominator else 0.0
|
||||
|
||||
summaries = []
|
||||
for level in RiskLevel:
|
||||
tier_total = totals.get(level, Decimal("0"))
|
||||
items = [
|
||||
RiskLevelItem(key=key, name=name, amount=amount, percent=_share(amount, tier_total))
|
||||
for key, name, amount in sorted(items_by_level.get(level, []), key=lambda item: item[2], reverse=True)
|
||||
]
|
||||
summaries.append(
|
||||
RiskLevelSummary(
|
||||
risk_level=level.value,
|
||||
label=_RISK_META[level][0],
|
||||
color=_RISK_META[level][1],
|
||||
total_value=tier_total,
|
||||
percent=_share(tier_total, grand_total),
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _resolve_start_date(range_key: str, cash_events: list[tuple[date_, Decimal]], asset_events: list[tuple[date_, Decimal]], today: date_) -> date_:
|
||||
if range_key in RANGE_DAYS:
|
||||
return today - timedelta(days=RANGE_DAYS[range_key] - 1)
|
||||
|
||||
all_dates = [e[0] for e in cash_events] + [e[0] for e in asset_events]
|
||||
return min(all_dates) if all_dates else today
|
||||
|
||||
|
||||
async def get_net_worth_summary(session: AsyncSession, range_key: str) -> NetWorthSummary:
|
||||
today = date_.today()
|
||||
cash_events = await _cash_cumulative_events(session)
|
||||
asset_events, class_totals, current_by_asset = await _asset_events_and_class_totals(session)
|
||||
capital_roles = await _capital_role_summary(session, current_by_asset)
|
||||
|
||||
start = _resolve_start_date(range_key, cash_events, asset_events, today)
|
||||
|
||||
cash_series = _daily_series(cash_events, start, today)
|
||||
asset_series = _daily_series(asset_events, start, today)
|
||||
series = [
|
||||
NetWorthPoint(date=c.date, value=c.value + a.value) for c, a in zip(cash_series, asset_series, strict=True)
|
||||
]
|
||||
|
||||
current = series[-1].value if series else Decimal("0")
|
||||
start_value = series[0].value if series else Decimal("0")
|
||||
change_amount = current - start_value
|
||||
change_percent = float(change_amount / start_value * 100) if start_value else None
|
||||
|
||||
# cash_events entries are already cumulative — the last one *is* today's total.
|
||||
cash_today = cash_events[-1][1] if cash_events else Decimal("0")
|
||||
risk_levels = await _risk_level_summary(session, current_by_asset, cash_today)
|
||||
|
||||
total = cash_today + sum(class_totals.values(), Decimal("0"))
|
||||
|
||||
def _percent(amount: Decimal) -> float:
|
||||
return float(amount / total * 100) if total else 0.0
|
||||
|
||||
breakdown = []
|
||||
name, color, icon = _CLASS_META["cash"]
|
||||
breakdown.append(NetWorthBreakdownItem(key="cash", name=name, color=color, icon=icon, amount=cash_today, percent=_percent(cash_today)))
|
||||
for asset_class in AssetClass:
|
||||
name, color, icon = _CLASS_META[asset_class.value]
|
||||
amount = class_totals.get(asset_class, Decimal("0"))
|
||||
breakdown.append(
|
||||
NetWorthBreakdownItem(key=asset_class.value, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount))
|
||||
)
|
||||
|
||||
return NetWorthSummary(
|
||||
range=range_key,
|
||||
current=current,
|
||||
change_amount=change_amount,
|
||||
change_percent=change_percent,
|
||||
series=series,
|
||||
breakdown=breakdown,
|
||||
capital_roles=capital_roles,
|
||||
risk_levels=risk_levels,
|
||||
)
|
||||
167
backend/app/services/recurring_service.py
Normal file
167
backend/app/services/recurring_service.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Recurring transaction templates: CRUD, plus "post now" — a one-click
|
||||
action that creates a real Transaction from the template. Deliberately not a
|
||||
background job (see also insights_service.py's docstring on scope): the
|
||||
schedule only advances when the user actually clicks Post, so a missed
|
||||
week never silently back-fills a pile of transactions.
|
||||
"""
|
||||
import calendar
|
||||
from datetime import date as date_
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.category import Category
|
||||
from app.models.enums import CategoryKind, RecurringFrequency, TransactionType
|
||||
from app.models.recurring import RecurringTransaction
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.recurring import RecurringTransactionCreate, RecurringTransactionRead, RecurringTransactionUpdate
|
||||
|
||||
_EAGER = (
|
||||
selectinload(RecurringTransaction.account),
|
||||
selectinload(RecurringTransaction.category),
|
||||
selectinload(RecurringTransaction.transfer_account),
|
||||
)
|
||||
|
||||
_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:
|
||||
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} recurring transaction",
|
||||
)
|
||||
|
||||
|
||||
def _advance(day: date_, frequency: RecurringFrequency) -> date_:
|
||||
if frequency == RecurringFrequency.WEEKLY:
|
||||
return day + timedelta(days=7)
|
||||
if frequency == RecurringFrequency.MONTHLY:
|
||||
year = day.year + (day.month // 12)
|
||||
month = day.month % 12 + 1
|
||||
clamped_day = min(day.day, calendar.monthrange(year, month)[1])
|
||||
return date_(year, month, clamped_day)
|
||||
# YEARLY — Feb 29 anchors fall back to Feb 28 in non-leap years.
|
||||
try:
|
||||
return day.replace(year=day.year + 1)
|
||||
except ValueError:
|
||||
return day.replace(year=day.year + 1, day=28)
|
||||
|
||||
|
||||
def _next_due_date(recurring: RecurringTransaction) -> date_:
|
||||
if recurring.last_posted_date is None:
|
||||
return recurring.anchor_date
|
||||
return _advance(recurring.last_posted_date, recurring.frequency)
|
||||
|
||||
|
||||
def _to_read(recurring: RecurringTransaction) -> RecurringTransactionRead:
|
||||
next_due = _next_due_date(recurring)
|
||||
today = date_.today()
|
||||
return RecurringTransactionRead(
|
||||
id=recurring.id,
|
||||
account_id=recurring.account_id,
|
||||
account_name=recurring.account.name,
|
||||
category_id=recurring.category_id,
|
||||
category_name=recurring.category.name if recurring.category else None,
|
||||
category_color=recurring.category.color if recurring.category else None,
|
||||
category_icon=recurring.category.icon if recurring.category else None,
|
||||
transfer_account_id=recurring.transfer_account_id,
|
||||
transfer_account_name=recurring.transfer_account.name if recurring.transfer_account else None,
|
||||
type=recurring.type,
|
||||
amount=recurring.amount,
|
||||
description=recurring.description,
|
||||
merchant=recurring.merchant,
|
||||
notes=recurring.notes,
|
||||
frequency=recurring.frequency,
|
||||
anchor_date=recurring.anchor_date,
|
||||
last_posted_date=recurring.last_posted_date,
|
||||
is_active=recurring.is_active,
|
||||
next_due_date=next_due,
|
||||
is_due=recurring.is_active and next_due <= today,
|
||||
days_until_due=(next_due - today).days,
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_404(session: AsyncSession, recurring_id: int) -> RecurringTransaction:
|
||||
result = await session.execute(
|
||||
select(RecurringTransaction).options(*_EAGER).where(RecurringTransaction.id == recurring_id)
|
||||
)
|
||||
recurring = result.scalar_one_or_none()
|
||||
if recurring is None:
|
||||
raise HTTPException(status_code=404, detail="Recurring transaction not found")
|
||||
return recurring
|
||||
|
||||
|
||||
async def list_recurring(session: AsyncSession) -> list[RecurringTransactionRead]:
|
||||
result = await session.execute(
|
||||
select(RecurringTransaction).options(*_EAGER).order_by(RecurringTransaction.id)
|
||||
)
|
||||
return [_to_read(row) for row in result.scalars().all()]
|
||||
|
||||
|
||||
async def create_recurring(session: AsyncSession, payload: RecurringTransactionCreate) -> RecurringTransactionRead:
|
||||
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||
recurring = RecurringTransaction(**payload.model_dump())
|
||||
session.add(recurring)
|
||||
await session.commit()
|
||||
return _to_read(await _get_or_404(session, recurring.id))
|
||||
|
||||
|
||||
async def update_recurring(
|
||||
session: AsyncSession, recurring_id: int, payload: RecurringTransactionUpdate
|
||||
) -> RecurringTransactionRead:
|
||||
recurring = await _get_or_404(session, recurring_id)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
effective_type = updates.get("type", recurring.type)
|
||||
effective_category_id = updates.get("category_id", recurring.category_id)
|
||||
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||
for field, value in updates.items():
|
||||
setattr(recurring, field, value)
|
||||
await session.commit()
|
||||
return _to_read(await _get_or_404(session, recurring_id))
|
||||
|
||||
|
||||
async def delete_recurring(session: AsyncSession, recurring_id: int) -> None:
|
||||
recurring = await session.get(RecurringTransaction, recurring_id)
|
||||
if recurring is None:
|
||||
raise HTTPException(status_code=404, detail="Recurring transaction not found")
|
||||
await session.delete(recurring)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def post_recurring(session: AsyncSession, recurring_id: int) -> RecurringTransactionRead:
|
||||
"""Creates a real Transaction from the template, dated today, and moves
|
||||
last_posted_date forward — the only thing that advances the schedule."""
|
||||
recurring = await _get_or_404(session, recurring_id)
|
||||
today = date_.today()
|
||||
|
||||
session.add(
|
||||
Transaction(
|
||||
account_id=recurring.account_id,
|
||||
category_id=recurring.category_id,
|
||||
transfer_account_id=recurring.transfer_account_id,
|
||||
type=recurring.type,
|
||||
amount=recurring.amount,
|
||||
description=recurring.description,
|
||||
merchant=recurring.merchant,
|
||||
notes=recurring.notes,
|
||||
date=today,
|
||||
)
|
||||
)
|
||||
recurring.last_posted_date = today
|
||||
await session.commit()
|
||||
return _to_read(await _get_or_404(session, recurring_id))
|
||||
158
backend/app/services/reports_service.py
Normal file
158
backend/app/services/reports_service.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Long-range spending reports — the analysis the month-scoped Dashboard
|
||||
breakdown can't answer on its own: "how much did I spend on X this month,
|
||||
and how much in total over N years" (single-category detail), and "which
|
||||
categories cost the most over this whole period" (ranking, across all
|
||||
categories of one kind at once, not just the current month).
|
||||
"""
|
||||
from datetime import date as date_
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import extract, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.category import Category
|
||||
from app.models.enums import CategoryKind, TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.reports import (
|
||||
CategoryRankingItem,
|
||||
CategoryRankingReport,
|
||||
CategorySpendingPoint,
|
||||
CategorySpendingReport,
|
||||
)
|
||||
|
||||
|
||||
def _next_month(year: int, month: int) -> tuple[int, int]:
|
||||
return (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
|
||||
async def get_category_spending_report(
|
||||
session: AsyncSession, category_id: int, start_date: date_ | None, end_date: date_ | None
|
||||
) -> CategorySpendingReport:
|
||||
category = await session.get(Category, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
|
||||
Transaction.category_id == category_id
|
||||
)
|
||||
if start_date:
|
||||
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
|
||||
if end_date:
|
||||
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
|
||||
min_date, max_date = (await session.execute(bounds_stmt)).one()
|
||||
|
||||
effective_start = start_date or min_date
|
||||
effective_end = end_date or max_date
|
||||
|
||||
empty = CategorySpendingReport(
|
||||
category_id=category.id,
|
||||
category_name=category.name,
|
||||
category_color=category.color,
|
||||
category_icon=category.icon,
|
||||
start_date=effective_start,
|
||||
end_date=effective_end,
|
||||
total_amount=Decimal("0"),
|
||||
transaction_count=0,
|
||||
average_per_month=Decimal("0"),
|
||||
series=[],
|
||||
)
|
||||
if effective_start is None or effective_end is None:
|
||||
return empty
|
||||
|
||||
rows_stmt = (
|
||||
select(
|
||||
extract("year", Transaction.date).label("year"),
|
||||
extract("month", Transaction.date).label("month"),
|
||||
func.sum(Transaction.amount).label("amount"),
|
||||
func.count(Transaction.id).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.date >= effective_start,
|
||||
Transaction.date <= effective_end,
|
||||
)
|
||||
.group_by("year", "month")
|
||||
)
|
||||
rows = (await session.execute(rows_stmt)).all()
|
||||
by_month: dict[tuple[int, int], Decimal] = {(int(row.year), int(row.month)): row.amount for row in rows}
|
||||
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
||||
total_count = sum(row.cnt for row in rows)
|
||||
|
||||
series: list[CategorySpendingPoint] = []
|
||||
year, month = effective_start.year, effective_start.month
|
||||
while (year, month) <= (effective_end.year, effective_end.month):
|
||||
series.append(CategorySpendingPoint(year=year, month=month, amount=by_month.get((year, month), Decimal("0"))))
|
||||
year, month = _next_month(year, month)
|
||||
|
||||
months_count = len(series)
|
||||
average_per_month = (total_amount / months_count).quantize(Decimal("0.01")) if months_count else Decimal("0")
|
||||
|
||||
return CategorySpendingReport(
|
||||
category_id=category.id,
|
||||
category_name=category.name,
|
||||
category_color=category.color,
|
||||
category_icon=category.icon,
|
||||
start_date=effective_start,
|
||||
end_date=effective_end,
|
||||
total_amount=total_amount,
|
||||
transaction_count=total_count,
|
||||
average_per_month=average_per_month,
|
||||
series=series,
|
||||
)
|
||||
|
||||
|
||||
_KIND_TO_TRANSACTION_TYPE = {
|
||||
CategoryKind.EXPENSE: TransactionType.EXPENSE,
|
||||
CategoryKind.INCOME: TransactionType.INCOME,
|
||||
}
|
||||
|
||||
|
||||
async def get_category_ranking_report(
|
||||
session: AsyncSession, kind: CategoryKind, start_date: date_ | None, end_date: date_ | None
|
||||
) -> CategoryRankingReport:
|
||||
"""All categories of one kind, ranked by total spent/earned over an
|
||||
arbitrary period — "which category costs the most" across the whole
|
||||
range, unlike the month-scoped Dashboard breakdown or the
|
||||
single-category detail above."""
|
||||
stmt = (
|
||||
select(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.color,
|
||||
Category.icon,
|
||||
func.sum(Transaction.amount).label("amount"),
|
||||
func.count(Transaction.id).label("cnt"),
|
||||
)
|
||||
.join(Category, Category.id == Transaction.category_id)
|
||||
.where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
|
||||
)
|
||||
if start_date:
|
||||
stmt = stmt.where(Transaction.date >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.where(Transaction.date <= end_date)
|
||||
stmt = stmt.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
|
||||
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
|
||||
|
||||
rows = (await session.execute(stmt)).all()
|
||||
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
||||
|
||||
def _percent(amount: Decimal) -> float:
|
||||
return float(amount / total_amount * 100) if total_amount else 0.0
|
||||
|
||||
items = [
|
||||
CategoryRankingItem(
|
||||
category_id=cat_id,
|
||||
name=name,
|
||||
color=color,
|
||||
icon=icon,
|
||||
amount=amount,
|
||||
percent=_percent(amount),
|
||||
transaction_count=cnt,
|
||||
)
|
||||
for cat_id, name, color, icon, amount, cnt in rows
|
||||
]
|
||||
|
||||
return CategoryRankingReport(
|
||||
start_date=start_date, end_date=end_date, total_amount=total_amount, items=items
|
||||
)
|
||||
15
backend/app/services/settings_service.py
Normal file
15
backend/app/services/settings_service.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.settings import AppSettings
|
||||
|
||||
|
||||
async def get_or_create_app_settings(session: AsyncSession) -> AppSettings:
|
||||
"""Singleton row (id=1) — get-or-create so callers never hit a missing
|
||||
row even if app startup's seeding hasn't run yet (e.g. a fresh test DB)."""
|
||||
settings = await session.get(AppSettings, 1)
|
||||
if settings is None:
|
||||
settings = AppSettings(id=1)
|
||||
session.add(settings)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
Reference in New Issue
Block a user