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

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

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

View 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

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

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

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

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

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

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

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

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

View 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