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

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__
*.pyc
.venv
.git
.env
.env.*
!.env.example
.DS_Store

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \
CMD curl -f http://localhost:8000/api/health || exit 1
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

61
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,61 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import get_settings
from app.db.base import Base
from app.models import ( # noqa: F401 — registers metadata
Account,
AppSettings,
Asset,
AssetValuation,
Budget,
Category,
Goal,
GoalContribution,
RecurringTransaction,
Transaction,
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,42 @@
"""add capital role and monthly cash flow to assets
Revision ID: 1152a14e9d55
Revises: 6721ea138cdf
Create Date: 2026-08-14 17:29:53.284630
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1152a14e9d55'
down_revision: Union[str, None] = '6721ea138cdf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - adjusted: existing rows need a
# default or this NOT NULL column addition fails outright. ###
op.add_column(
'assets',
sa.Column(
'capital_role',
sa.Enum('INCOME', 'NEUTRAL', 'DRAIN', name='capital_role', native_enum=False, length=10),
nullable=False,
server_default='NEUTRAL',
),
)
op.alter_column('assets', 'capital_role', server_default=None)
op.add_column('assets', sa.Column('monthly_cash_flow', sa.Numeric(precision=14, scale=2), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('assets', 'monthly_cash_flow')
op.drop_column('assets', 'capital_role')
# ### end Alembic commands ###

View File

@@ -0,0 +1,49 @@
"""add assets and asset valuations
Revision ID: 6721ea138cdf
Revises: cfcd12cc849b
Create Date: 2026-08-14 15:43:19.548554
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6721ea138cdf'
down_revision: Union[str, None] = 'cfcd12cc849b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('assets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('asset_class', sa.Enum('INVESTMENTS', 'CRYPTO', 'REAL_ESTATE', 'VEHICLES', 'OTHER', name='asset_class', native_enum=False, length=20), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('asset_valuations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('asset_id', sa.Integer(), nullable=False),
sa.Column('value', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('as_of_date', sa.Date(), nullable=False),
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('asset_id', 'as_of_date', name='uq_asset_valuation_date')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('asset_valuations')
op.drop_table('assets')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""add app_settings
Revision ID: 9f3a2d7c5e11
Revises: 1152a14e9d55
Create Date: 2026-08-16 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9f3a2d7c5e11'
down_revision: Union[str, None] = '1152a14e9d55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'app_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False, server_default='USD'),
sa.PrimaryKeyConstraint('id'),
)
# Singleton row (id=1) — app boot's seed_default_app_settings() also
# get-or-creates it, but seeding it here means it exists immediately
# after migrating, even before the app has started once.
op.execute("INSERT INTO app_settings (id, currency) VALUES (1, 'USD')")
def downgrade() -> None:
op.drop_table('app_settings')

View File

@@ -0,0 +1,30 @@
"""add risky allocation threshold to app_settings
Revision ID: a71d9f0c3e58
Revises: f2c8e4a917b3
Create Date: 2026-08-16 23:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a71d9f0c3e58'
down_revision: Union[str, None] = 'f2c8e4a917b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'app_settings',
sa.Column('risky_allocation_threshold_percent', sa.Integer(), nullable=False, server_default='20'),
)
op.alter_column('app_settings', 'risky_allocation_threshold_percent', server_default=None)
def downgrade() -> None:
op.drop_column('app_settings', 'risky_allocation_threshold_percent')

View File

@@ -0,0 +1,36 @@
"""add alert thresholds to app_settings
Revision ID: b4e7c1a9f203
Revises: 9f3a2d7c5e11
Create Date: 2026-08-16 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b4e7c1a9f203'
down_revision: Union[str, None] = '9f3a2d7c5e11'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'app_settings',
sa.Column('negative_cash_flow_threshold_months', sa.Integer(), nullable=False, server_default='2'),
)
op.add_column(
'app_settings',
sa.Column('net_worth_decline_threshold_months', sa.Integer(), nullable=False, server_default='2'),
)
op.alter_column('app_settings', 'negative_cash_flow_threshold_months', server_default=None)
op.alter_column('app_settings', 'net_worth_decline_threshold_months', server_default=None)
def downgrade() -> None:
op.drop_column('app_settings', 'net_worth_decline_threshold_months')
op.drop_column('app_settings', 'negative_cash_flow_threshold_months')

View File

@@ -0,0 +1,36 @@
"""add budgets
Revision ID: c7a5f9e2b816
Revises: b4e7c1a9f203
Create Date: 2026-08-16 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c7a5f9e2b816'
down_revision: Union[str, None] = 'b4e7c1a9f203'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'budgets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('monthly_limit', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('category_id', name='uq_budgets_category_id'),
)
def downgrade() -> None:
op.drop_table('budgets')

View File

@@ -0,0 +1,70 @@
"""initial schema
Revision ID: cfcd12cc849b
Revises:
Create Date: 2026-08-14 15:23:43.618981
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cfcd12cc849b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('type', sa.Enum('CHECKING', 'SAVINGS', 'CREDIT_CARD', 'CASH', 'INVESTMENT', 'OTHER', name='account_type', native_enum=False, length=20), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('is_archived', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('kind', sa.Enum('INCOME', 'EXPENSE', name='category_kind', native_enum=False, length=10), nullable=False),
sa.Column('icon', sa.String(length=50), nullable=True),
sa.Column('color', sa.String(length=7), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('transfer_account_id', sa.Integer(), nullable=True),
sa.Column('type', sa.Enum('INCOME', 'EXPENSE', 'TRANSFER', name='transaction_type', native_enum=False, length=10), nullable=False),
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('description', sa.String(length=255), nullable=False),
sa.Column('merchant', sa.String(length=150), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['transfer_account_id'], ['accounts.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('transactions')
op.drop_table('categories')
op.drop_table('accounts')
# ### end Alembic commands ###

View File

@@ -0,0 +1,46 @@
"""add goals
Revision ID: d3f8a1c4b729
Revises: c7a5f9e2b816
Create Date: 2026-08-16 20:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd3f8a1c4b729'
down_revision: Union[str, None] = 'c7a5f9e2b816'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'goals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('target_amount', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('target_date', sa.Date(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_table(
'goal_contributions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('goal_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('note', sa.String(length=200), nullable=True),
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('goal_contributions')
op.drop_table('goals')

View File

@@ -0,0 +1,55 @@
"""add recurring transactions
Revision ID: e91b6d3c5a04
Revises: d3f8a1c4b729
Create Date: 2026-08-16 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e91b6d3c5a04'
down_revision: Union[str, None] = 'd3f8a1c4b729'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'recurring_transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('transfer_account_id', sa.Integer(), nullable=True),
sa.Column(
'type',
sa.Enum('INCOME', 'EXPENSE', 'TRANSFER', name='recurring_transaction_type', native_enum=False, length=10),
nullable=False,
),
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column('description', sa.String(length=255), nullable=False),
sa.Column('merchant', sa.String(length=150), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column(
'frequency',
sa.Enum('WEEKLY', 'MONTHLY', 'YEARLY', name='recurring_frequency', native_enum=False, length=10),
nullable=False,
),
sa.Column('anchor_date', sa.Date(), nullable=False),
sa.Column('last_posted_date', sa.Date(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['transfer_account_id'], ['accounts.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('recurring_transactions')

View File

@@ -0,0 +1,36 @@
"""add risk_level to assets
Revision ID: f2c8e4a917b3
Revises: e91b6d3c5a04
Create Date: 2026-08-16 23:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f2c8e4a917b3'
down_revision: Union[str, None] = 'e91b6d3c5a04'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### existing rows need a default or this NOT NULL column addition fails outright. ###
op.add_column(
'assets',
sa.Column(
'risk_level',
sa.Enum('LOW', 'MEDIUM', 'HIGH', name='risk_level', native_enum=False, length=10),
nullable=False,
server_default='MEDIUM',
),
)
op.alter_column('assets', 'risk_level', server_default=None)
def downgrade() -> None:
op.drop_column('assets', 'risk_level')

0
backend/app/__init__.py Normal file
View File

View File

12
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,12 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
DbSession = AsyncSession
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async for session in get_db():
yield session

View File

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
from app.services.account_service import create_account, delete_account, list_accounts, update_account
router = APIRouter(prefix="/accounts", tags=["accounts"])
@router.get("", response_model=list[AccountWithBalance])
async def read_accounts(
include_archived: bool = False, session: AsyncSession = Depends(get_session)
) -> list[AccountWithBalance]:
return await list_accounts(session, include_archived)
@router.post("", response_model=AccountWithBalance, status_code=201)
async def create_account_route(
payload: AccountCreate, session: AsyncSession = Depends(get_session)
) -> AccountWithBalance:
return await create_account(session, payload)
@router.patch("/{account_id}", response_model=AccountWithBalance)
async def update_account_route(
account_id: int, payload: AccountUpdate, session: AsyncSession = Depends(get_session)
) -> AccountWithBalance:
return await update_account(session, account_id, payload)
@router.delete("/{account_id}", status_code=204)
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_account(session, account_id)

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.advice import AdviceResponse
from app.services.advice_service import get_advice
router = APIRouter(prefix="/advice", tags=["advice"])
@router.get("", response_model=AdviceResponse)
async def read_advice(session: AsyncSession = Depends(get_session)) -> AdviceResponse:
return await get_advice(session)

View File

@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_session
from app.models.asset import Asset, AssetValuation
from app.schemas.asset import AssetCreate, AssetRead, AssetUpdate, AssetValuationCreate, AssetValuationRead
router = APIRouter(prefix="/assets", tags=["assets"])
_EAGER = (selectinload(Asset.valuations),)
def _to_read(asset: Asset) -> AssetRead:
latest = asset.valuations[-1] if asset.valuations else None
return AssetRead(
id=asset.id,
name=asset.name,
asset_class=asset.asset_class,
currency=asset.currency,
notes=asset.notes,
capital_role=asset.capital_role,
monthly_cash_flow=asset.monthly_cash_flow,
current_value=latest.value if latest else 0,
as_of_date=latest.as_of_date if latest else asset.created_at.date(),
)
@router.get("", response_model=list[AssetRead])
async def list_assets(session: AsyncSession = Depends(get_session)) -> list[AssetRead]:
result = await session.execute(select(Asset).options(*_EAGER).order_by(Asset.name))
return [_to_read(asset) for asset in result.scalars().all()]
@router.post("", response_model=AssetRead, status_code=201)
async def create_asset(payload: AssetCreate, session: AsyncSession = Depends(get_session)) -> AssetRead:
asset = Asset(
name=payload.name,
asset_class=payload.asset_class,
currency=payload.currency,
notes=payload.notes,
capital_role=payload.capital_role,
monthly_cash_flow=payload.monthly_cash_flow,
)
session.add(asset)
await session.flush()
session.add(AssetValuation(asset_id=asset.id, value=payload.value, as_of_date=payload.as_of_date))
await session.commit()
refreshed = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset.id))
return _to_read(refreshed.scalar_one())
@router.patch("/{asset_id}", response_model=AssetRead)
async def update_asset(asset_id: int, payload: AssetUpdate, session: AsyncSession = Depends(get_session)) -> AssetRead:
result = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset_id))
asset = result.scalar_one_or_none()
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(asset, field, value)
await session.commit()
await session.refresh(asset, attribute_names=["valuations"])
return _to_read(asset)
@router.post("/{asset_id}/valuations", response_model=AssetRead)
async def add_asset_valuation(
asset_id: int, payload: AssetValuationCreate, session: AsyncSession = Depends(get_session)
) -> AssetRead:
"""Records (or corrects) an asset's value as of a date. Re-submitting the
same date updates that day's value instead of erroring, so users can fix
a typo without needing a separate edit flow."""
asset = await session.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
upsert_stmt = (
pg_insert(AssetValuation)
.values(asset_id=asset_id, value=payload.value, as_of_date=payload.as_of_date)
.on_conflict_do_update(
index_elements=[AssetValuation.asset_id, AssetValuation.as_of_date],
set_={"value": payload.value},
)
)
await session.execute(upsert_stmt)
await session.commit()
refreshed = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset_id))
return _to_read(refreshed.scalar_one())
@router.get("/{asset_id}/valuations", response_model=list[AssetValuationRead])
async def list_asset_valuations(asset_id: int, session: AsyncSession = Depends(get_session)) -> list[AssetValuation]:
asset = await session.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
result = await session.execute(
select(AssetValuation).where(AssetValuation.asset_id == asset_id).order_by(AssetValuation.as_of_date)
)
return list(result.scalars().all())
@router.delete("/{asset_id}", status_code=204)
async def delete_asset(asset_id: int, session: AsyncSession = Depends(get_session)) -> None:
asset = await session.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
await session.delete(asset)
await session.commit()

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.backup import BackupPayload
from app.services.backup_service import build_backup, restore_backup
router = APIRouter(prefix="/backup", tags=["backup"])
@router.get("/export", response_model=BackupPayload)
async def export_backup(session: AsyncSession = Depends(get_session)) -> BackupPayload:
return await build_backup(session)
@router.post("/import")
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
await restore_backup(session, payload)
return {"status": "ok"}

View File

@@ -0,0 +1,53 @@
from datetime import date
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.models.budget import Budget
from app.schemas.budget import BudgetCreate, BudgetRead, BudgetStatusResponse, BudgetUpdate
from app.services.budget_service import create_budget, delete_budget, get_budget_status, list_budgets, update_budget
router = APIRouter(prefix="/budgets", tags=["budgets"])
def _to_read(budget: Budget) -> BudgetRead:
return BudgetRead(
id=budget.id,
category_id=budget.category_id,
category_name=budget.category.name,
category_color=budget.category.color,
category_icon=budget.category.icon,
monthly_limit=budget.monthly_limit,
)
@router.get("", response_model=list[BudgetRead])
async def read_budgets(session: AsyncSession = Depends(get_session)) -> list[BudgetRead]:
return [_to_read(budget) for budget in await list_budgets(session)]
@router.get("/status", response_model=BudgetStatusResponse)
async def read_budget_status(
year: int = Query(default_factory=lambda: date.today().year, ge=2000, le=2100),
month: int = Query(default_factory=lambda: date.today().month, ge=1, le=12),
session: AsyncSession = Depends(get_session),
) -> BudgetStatusResponse:
return await get_budget_status(session, year, month)
@router.post("", response_model=BudgetRead, status_code=201)
async def create_budget_route(payload: BudgetCreate, session: AsyncSession = Depends(get_session)) -> BudgetRead:
return _to_read(await create_budget(session, payload))
@router.patch("/{budget_id}", response_model=BudgetRead)
async def update_budget_route(
budget_id: int, payload: BudgetUpdate, session: AsyncSession = Depends(get_session)
) -> BudgetRead:
return _to_read(await update_budget(session, budget_id, payload))
@router.delete("/{budget_id}", status_code=204)
async def delete_budget_route(budget_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_budget(session, budget_id)

View File

@@ -0,0 +1,19 @@
from datetime import date as date_
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.cash_flow import CashFlowResponse
from app.services.cash_flow_service import get_cash_flow
router = APIRouter(prefix="/cash-flow", tags=["cash-flow"])
@router.get("", response_model=CashFlowResponse)
async def read_cash_flow(
start_date: date_ | None = Query(default=None),
end_date: date_ | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> CashFlowResponse:
return await get_cash_flow(session, start_date, end_date)

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.models.category import Category
from app.models.enums import CategoryKind
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
router = APIRouter(prefix="/categories", tags=["categories"])
@router.get("", response_model=list[CategoryRead])
async def list_categories(
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session)
) -> list[Category]:
stmt = select(Category).order_by(Category.sort_order)
if kind is not None:
stmt = stmt.where(Category.kind == kind)
result = await session.execute(stmt)
return list(result.scalars().all())
@router.post("", response_model=CategoryRead, status_code=201)
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category:
category = Category(**payload.model_dump(), is_default=False)
session.add(category)
await session.commit()
await session.refresh(category)
return category
@router.patch("/{category_id}", response_model=CategoryRead)
async def update_category(
category_id: int, payload: CategoryUpdate, session: AsyncSession = Depends(get_session)
) -> Category:
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(category, field, value)
await session.commit()
await session.refresh(category)
return category
@router.delete("/{category_id}", status_code=204)
async def delete_category(category_id: int, session: AsyncSession = Depends(get_session)) -> None:
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
if category.is_default:
raise HTTPException(status_code=400, detail="Default categories cannot be deleted")
await session.delete(category)
await session.commit()

View File

@@ -0,0 +1,19 @@
from datetime import date
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.dashboard import DashboardSummary
from app.services.dashboard_service import get_dashboard_summary
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/summary", response_model=DashboardSummary)
async def read_dashboard_summary(
year: int = Query(default_factory=lambda: date.today().year, ge=2000, le=2100),
month: int = Query(default_factory=lambda: date.today().month, ge=1, le=12),
session: AsyncSession = Depends(get_session),
) -> DashboardSummary:
return await get_dashboard_summary(session, year, month)

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.goal import GoalContributionCreate, GoalCreate, GoalRead, GoalUpdate
from app.services.goal_service import add_contribution, create_goal, delete_goal, list_goals, update_goal
router = APIRouter(prefix="/goals", tags=["goals"])
@router.get("", response_model=list[GoalRead])
async def read_goals(session: AsyncSession = Depends(get_session)) -> list[GoalRead]:
return await list_goals(session)
@router.post("", response_model=GoalRead, status_code=201)
async def create_goal_route(payload: GoalCreate, session: AsyncSession = Depends(get_session)) -> GoalRead:
return await create_goal(session, payload)
@router.patch("/{goal_id}", response_model=GoalRead)
async def update_goal_route(
goal_id: int, payload: GoalUpdate, session: AsyncSession = Depends(get_session)
) -> GoalRead:
return await update_goal(session, goal_id, payload)
@router.delete("/{goal_id}", status_code=204)
async def delete_goal_route(goal_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_goal(session, goal_id)
@router.post("/{goal_id}/contributions", response_model=GoalRead, status_code=201)
async def add_contribution_route(
goal_id: int, payload: GoalContributionCreate, session: AsyncSession = Depends(get_session)
) -> GoalRead:
return await add_contribution(session, goal_id, payload)

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.insights import AlertsResponse
from app.services.insights_service import get_financial_alerts
router = APIRouter(prefix="/insights", tags=["insights"])
@router.get("/alerts", response_model=AlertsResponse)
async def read_financial_alerts(session: AsyncSession = Depends(get_session)) -> AlertsResponse:
return await get_financial_alerts(session)

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.net_worth import NetWorthSummary
from app.services.net_worth_service import RANGE_DAYS, get_net_worth_summary
router = APIRouter(prefix="/net-worth", tags=["net-worth"])
_VALID_RANGES = sorted(set(RANGE_DAYS) | {"all"})
_RANGE_PATTERN = f"^({'|'.join(_VALID_RANGES)})$"
@router.get("/summary", response_model=NetWorthSummary)
async def read_net_worth_summary(
range: str = Query(default="30d", pattern=_RANGE_PATTERN),
session: AsyncSession = Depends(get_session),
) -> NetWorthSummary:
return await get_net_worth_summary(session, range)

View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.recurring import RecurringTransactionCreate, RecurringTransactionRead, RecurringTransactionUpdate
from app.services.recurring_service import (
create_recurring,
delete_recurring,
list_recurring,
post_recurring,
update_recurring,
)
router = APIRouter(prefix="/recurring", tags=["recurring"])
@router.get("", response_model=list[RecurringTransactionRead])
async def read_recurring(session: AsyncSession = Depends(get_session)) -> list[RecurringTransactionRead]:
return await list_recurring(session)
@router.post("", response_model=RecurringTransactionRead, status_code=201)
async def create_recurring_route(
payload: RecurringTransactionCreate, session: AsyncSession = Depends(get_session)
) -> RecurringTransactionRead:
return await create_recurring(session, payload)
@router.patch("/{recurring_id}", response_model=RecurringTransactionRead)
async def update_recurring_route(
recurring_id: int, payload: RecurringTransactionUpdate, session: AsyncSession = Depends(get_session)
) -> RecurringTransactionRead:
return await update_recurring(session, recurring_id, payload)
@router.delete("/{recurring_id}", status_code=204)
async def delete_recurring_route(recurring_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_recurring(session, recurring_id)
@router.post("/{recurring_id}/post", response_model=RecurringTransactionRead, status_code=201)
async def post_recurring_route(
recurring_id: int, session: AsyncSession = Depends(get_session)
) -> RecurringTransactionRead:
return await post_recurring(session, recurring_id)

View File

@@ -0,0 +1,31 @@
from datetime import date as date_
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.models.enums import CategoryKind
from app.schemas.reports import CategoryRankingReport, CategorySpendingReport
from app.services.reports_service import get_category_ranking_report, get_category_spending_report
router = APIRouter(prefix="/reports", tags=["reports"])
@router.get("/category-spending", response_model=CategorySpendingReport)
async def read_category_spending_report(
category_id: int,
start_date: date_ | None = Query(default=None),
end_date: date_ | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> CategorySpendingReport:
return await get_category_spending_report(session, category_id, start_date, end_date)
@router.get("/category-ranking", response_model=CategoryRankingReport)
async def read_category_ranking_report(
kind: CategoryKind = CategoryKind.EXPENSE,
start_date: date_ | None = Query(default=None),
end_date: date_ | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> CategoryRankingReport:
return await get_category_ranking_report(session, kind, start_date, end_date)

View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.models.settings import AppSettings
from app.schemas.settings import AppSettingsRead, AppSettingsUpdate
from app.services.settings_service import get_or_create_app_settings
router = APIRouter(prefix="/settings", tags=["settings"])
@router.get("", response_model=AppSettingsRead)
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
return await get_or_create_app_settings(session)
@router.patch("", response_model=AppSettingsRead)
async def update_settings(payload: AppSettingsUpdate, session: AsyncSession = Depends(get_session)) -> AppSettings:
settings = await get_or_create_app_settings(session)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(settings, field, value)
await session.commit()
await session.refresh(settings)
return settings

View File

@@ -0,0 +1,139 @@
from datetime import date as date_
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_session
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
router = APIRouter(prefix="/transactions", tags=["transactions"])
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category))
_TYPE_TO_CATEGORY_KIND = {
TransactionType.INCOME: CategoryKind.INCOME,
TransactionType.EXPENSE: CategoryKind.EXPENSE,
}
async def _ensure_category_matches_type(
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
) -> None:
"""A category picked for an income transaction must itself be an income
category (and likewise for expense) — otherwise the dashboard's spending
breakdown, which only joins EXPENSE-typed rows, would silently misclassify
the entry."""
if category_id is None:
return
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=400, detail="Category not found")
if expected_kind is not None and category.kind != expected_kind:
raise HTTPException(
status_code=400,
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction",
)
@router.get("", response_model=TransactionPage)
async def list_transactions(
year: int | None = Query(default=None, ge=2000, le=2100),
month: int | None = Query(default=None, ge=1, le=12),
start_date: date_ | None = Query(default=None),
end_date: date_ | None = Query(default=None),
account_id: int | None = None,
category_id: int | None = None,
type: TransactionType | None = None,
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> TransactionPage:
stmt = select(Transaction).options(*_EAGER)
count_stmt = select(func.count()).select_from(Transaction)
if year is not None:
stmt = stmt.where(func.extract("year", Transaction.date) == year)
count_stmt = count_stmt.where(func.extract("year", Transaction.date) == year)
if month is not None:
stmt = stmt.where(func.extract("month", Transaction.date) == month)
count_stmt = count_stmt.where(func.extract("month", Transaction.date) == month)
if start_date is not None:
stmt = stmt.where(Transaction.date >= start_date)
count_stmt = count_stmt.where(Transaction.date >= start_date)
if end_date is not None:
stmt = stmt.where(Transaction.date <= end_date)
count_stmt = count_stmt.where(Transaction.date <= end_date)
if account_id is not None:
stmt = stmt.where(Transaction.account_id == account_id)
count_stmt = count_stmt.where(Transaction.account_id == account_id)
if category_id is not None:
stmt = stmt.where(Transaction.category_id == category_id)
count_stmt = count_stmt.where(Transaction.category_id == category_id)
if type is not None:
stmt = stmt.where(Transaction.type == type)
count_stmt = count_stmt.where(Transaction.type == type)
total = (await session.execute(count_stmt)).scalar_one()
# id as a tiebreaker keeps pagination stable when many rows share a date/amount.
if sort == "amount_desc":
stmt = stmt.order_by(Transaction.amount.desc(), Transaction.id.desc())
elif sort == "amount_asc":
stmt = stmt.order_by(Transaction.amount.asc(), Transaction.id.desc())
else:
stmt = stmt.order_by(Transaction.date.desc(), Transaction.id.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await session.execute(stmt)
items = list(result.scalars().all())
return TransactionPage(items=items, total=total, page=page, page_size=page_size)
@router.post("", response_model=TransactionRead, status_code=201)
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
await _ensure_category_matches_type(session, payload.category_id, payload.type)
transaction = Transaction(**payload.model_dump())
session.add(transaction)
await session.commit()
refreshed = await session.execute(
select(Transaction).options(*_EAGER).where(Transaction.id == transaction.id)
)
return refreshed.scalar_one()
@router.patch("/{transaction_id}", response_model=TransactionRead)
async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
) -> Transaction:
transaction = await session.get(Transaction, transaction_id)
if transaction is None:
raise HTTPException(status_code=404, detail="Transaction not found")
updates = payload.model_dump(exclude_unset=True)
effective_type = updates.get("type", transaction.type)
effective_category_id = updates.get("category_id", transaction.category_id)
await _ensure_category_matches_type(session, effective_category_id, effective_type)
for field, value in updates.items():
setattr(transaction, field, value)
await session.commit()
refreshed = await session.execute(
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
)
return refreshed.scalar_one()
@router.delete("/{transaction_id}", status_code=204)
async def delete_transaction(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
transaction = await session.get(Transaction, transaction_id)
if transaction is None:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.delete(transaction)
await session.commit()

View File

View File

@@ -0,0 +1,44 @@
"""Application configuration, sourced from environment variables (.env)."""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
# Single source of truth for the running app's version — surfaced in the API
# title and embedded in exported backups so an old file can be told apart
# from a current one.
APP_VERSION = "0.15.2"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="AURUM_", extra="ignore")
# Postgres connection
postgres_user: str = "aurum"
postgres_password: str = "aurum"
postgres_db: str = "aurum"
postgres_host: str = "db"
postgres_port: int = 5432
# Default currency shown across the UI when an account doesn't override it
default_currency: str = "USD"
# Comma-separated list of origins allowed to call the API (frontend dev server, etc.)
cors_origins: str = "*"
@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
)
@property
def cors_origins_list(self) -> list[str]:
if self.cors_origins == "*":
return ["*"]
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

6
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,6 @@
"""Declarative base shared by every ORM model."""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

86
backend/app/db/seed.py Normal file
View File

@@ -0,0 +1,86 @@
"""Seeds the default category set on first boot.
The expense categories are assigned hues from the dataviz skill's validated
8-slot categorical palette, in the palette's fixed slot order (never
reordered/cycled) so the dashboard donut chart is colorblind-safe out of the
box. See CLAUDE.md-adjacent design notes in UPDATES.md for the source.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.models.account import Account
from app.models.category import Category
from app.models.enums import AccountType, CategoryKind
from app.models.settings import AppSettings
# (name, icon, color) — order doubles as sort_order / palette slot index.
DEFAULT_EXPENSE_CATEGORIES = [
("Housing & Utilities", "home", "#2a78d6"), # slot 1 blue
("Groceries", "shopping-basket", "#1baf7a"), # slot 3 aqua
("Dining Out", "utensils", "#eb6834"), # slot 2 orange
("Transportation", "car", "#4a3aa7"), # slot 7 violet
("Health & Fitness", "heart-pulse", "#e34948"), # slot 8 red
("Shopping", "shopping-bag", "#eda100"), # slot 4 yellow
("Entertainment", "clapperboard", "#e87ba4"), # slot 5 magenta
("Subscriptions", "repeat", "#008300"), # slot 6 green
]
DEFAULT_INCOME_CATEGORIES = [
("Salary", "banknote", "#2a78d6"),
("Freelance", "briefcase", "#1baf7a"),
("Investments", "trending-up", "#4a3aa7"),
("Gifts", "gift", "#e87ba4"),
("Other Income", "plus-circle", "#898781"),
]
async def seed_default_categories(session: AsyncSession) -> None:
existing = await session.execute(select(Category.id).limit(1))
if existing.first() is not None:
return
order = 0
for name, icon, color in DEFAULT_EXPENSE_CATEGORIES:
session.add(
Category(name=name, kind=CategoryKind.EXPENSE, icon=icon, color=color, sort_order=order, is_default=True)
)
order += 1
for name, icon, color in DEFAULT_INCOME_CATEGORIES:
session.add(
Category(name=name, kind=CategoryKind.INCOME, icon=icon, color=color, sort_order=order, is_default=True)
)
order += 1
await session.commit()
async def seed_default_account(session: AsyncSession) -> None:
"""Creates one starter account so the Transactions form always has a
destination to post to, even before the (future) accounts management UI
ships."""
existing = await session.execute(select(Account.id).limit(1))
if existing.first() is not None:
return
session.add(
Account(
name="Main Account",
type=AccountType.CHECKING,
currency=get_settings().default_currency,
color="#2a78d6",
)
)
await session.commit()
async def seed_default_app_settings(session: AsyncSession) -> None:
"""Ensures the singleton app_settings row (id=1) exists — the migration
already inserts it, but this makes app boot self-healing if that row is
ever missing (e.g. a DB restored from a pre-currency-setting backup)."""
existing = await session.get(AppSettings, 1)
if existing is not None:
return
session.add(AppSettings(id=1, currency=get_settings().default_currency))
await session.commit()

17
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,17 @@
"""Async SQLAlchemy engine/session wiring."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session

68
backend/app/main.py Normal file
View File

@@ -0,0 +1,68 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import (
accounts,
advice,
assets,
backup,
budgets,
cash_flow,
categories,
dashboard,
goals,
insights,
net_worth,
recurring,
reports,
settings as settings_routes,
transactions,
)
from app.core.config import APP_VERSION, get_settings
from app.db.seed import seed_default_account, seed_default_app_settings, seed_default_categories
from app.db.session import AsyncSessionLocal
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncSessionLocal() as session:
await seed_default_categories(session)
await seed_default_account(session)
await seed_default_app_settings(session)
yield
app = FastAPI(title="Aurum API", version=APP_VERSION, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(dashboard.router, prefix="/api")
app.include_router(accounts.router, prefix="/api")
app.include_router(categories.router, prefix="/api")
app.include_router(transactions.router, prefix="/api")
app.include_router(assets.router, prefix="/api")
app.include_router(net_worth.router, prefix="/api")
app.include_router(backup.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
app.include_router(insights.router, prefix="/api")
app.include_router(settings_routes.router, prefix="/api")
app.include_router(budgets.router, prefix="/api")
app.include_router(advice.router, prefix="/api")
app.include_router(goals.router, prefix="/api")
app.include_router(recurring.router, prefix="/api")
app.include_router(cash_flow.router, prefix="/api")
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,21 @@
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
__all__ = [
"Account",
"AppSettings",
"Asset",
"AssetValuation",
"Budget",
"Category",
"Goal",
"GoalContribution",
"RecurringTransaction",
"Transaction",
]

View File

@@ -0,0 +1,29 @@
"""An account is any place money lives: bank account, card, cash, wallet."""
from sqlalchemy import Boolean, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import AccountType
from app.models.mixins import TimestampMixin
class Account(Base, TimestampMixin):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
type: Mapped[AccountType] = mapped_column(
Enum(AccountType, name="account_type", native_enum=False, length=20),
nullable=False,
default=AccountType.CHECKING,
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
# Hex color used for account-scoped UI accents (e.g. transaction list avatars).
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
transactions: Mapped[list["Transaction"]] = relationship(
back_populates="account",
foreign_keys="Transaction.account_id",
cascade="all, delete-orphan",
)

View File

@@ -0,0 +1,65 @@
"""A manually-tracked net-worth asset (investments, crypto, property, ...).
Cash is intentionally NOT an Asset — it's derived live from Account/Transaction
balances so there's a single source of truth for money that already flows
through the Transactions feature. See services/net_worth_service.py.
"""
from datetime import date as date_
from sqlalchemy import Date, Enum, ForeignKey, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import AssetClass, CapitalRole, RiskLevel
from app.models.mixins import TimestampMixin
class Asset(Base, TimestampMixin):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150), nullable=False)
asset_class: Mapped[AssetClass] = mapped_column(
Enum(AssetClass, name="asset_class", native_enum=False, length=20), nullable=False
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# User-assigned, not inferred (see CapitalRole docstring).
capital_role: Mapped[CapitalRole] = mapped_column(
Enum(CapitalRole, name="capital_role", native_enum=False, length=10),
nullable=False,
default=CapitalRole.NEUTRAL,
)
# A rough, self-reported monthly net cash flow (rent collected, upkeep
# paid, ...). Informational only — real-world cash flow for an asset
# varies month to month, so this is never treated as ground truth the
# way Transaction amounts are.
monthly_cash_flow: Mapped[Numeric | None] = mapped_column(Numeric(14, 2), nullable=True)
# User-assigned, not inferred (see RiskLevel docstring) — how likely
# this asset is to lose value, independent of asset_class or capital_role.
risk_level: Mapped[RiskLevel] = mapped_column(
Enum(RiskLevel, name="risk_level", native_enum=False, length=10),
nullable=False,
default=RiskLevel.MEDIUM,
)
valuations: Mapped[list["AssetValuation"]] = relationship(
back_populates="asset", cascade="all, delete-orphan", order_by="AssetValuation.as_of_date"
)
class AssetValuation(Base):
"""One point-in-time value for an asset. The latest row (by as_of_date) is
the asset's current value; the full history drives the net-worth chart."""
__tablename__ = "asset_valuations"
__table_args__ = (UniqueConstraint("asset_id", "as_of_date", name="uq_asset_valuation_date"),)
id: Mapped[int] = mapped_column(primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), nullable=False)
value: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
as_of_date: Mapped[date_] = mapped_column(Date, nullable=False)
asset: Mapped["Asset"] = relationship(back_populates="valuations")

View File

@@ -0,0 +1,22 @@
"""A monthly spending limit for one expense category — compared against
actual spend for whichever month is being viewed (services/budget_service.py).
Not month-scoped itself: one limit per category, in effect until changed."""
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
class Budget(Base, TimestampMixin):
__tablename__ = "budgets"
id: Mapped[int] = mapped_column(primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, unique=True
)
monthly_limit: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
category: Mapped["Category"] = relationship()

View File

@@ -0,0 +1,26 @@
"""A spending/income category, colored so it maps 1:1 to a dashboard chart slot."""
from sqlalchemy import Boolean, Enum, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import CategoryKind
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
kind: Mapped[CategoryKind] = mapped_column(
Enum(CategoryKind, name="category_kind", native_enum=False, length=10), nullable=False
)
# Lucide icon name rendered in the frontend (kept as a plain string so new
# icons don't require a migration).
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Hex color drawn from the dataviz skill's validated categorical palette.
color: Mapped[str] = mapped_column(String(7), nullable=False)
# Fixed slot ordering keeps the donut chart's category order stable across renders.
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
transactions: Mapped[list["Transaction"]] = relationship(back_populates="category")

View File

@@ -0,0 +1,63 @@
"""Shared enum types used by the ORM models and API schemas."""
import enum
class AccountType(str, enum.Enum):
CHECKING = "checking"
SAVINGS = "savings"
CREDIT_CARD = "credit_card"
CASH = "cash"
INVESTMENT = "investment"
OTHER = "other"
class CategoryKind(str, enum.Enum):
INCOME = "income"
EXPENSE = "expense"
class TransactionType(str, enum.Enum):
INCOME = "income"
EXPENSE = "expense"
TRANSFER = "transfer"
class AssetClass(str, enum.Enum):
"""Net-worth categories tracked manually (Cash is derived from Account
balances instead — see services/net_worth_service.py)."""
INVESTMENTS = "investments"
CRYPTO = "crypto"
REAL_ESTATE = "real_estate"
VEHICLES = "vehicles"
PRECIOUS_METALS = "precious_metals"
OTHER = "other"
class CapitalRole(str, enum.Enum):
"""How an asset behaves month to month — set by the user, not inferred:
the same laptop can be a productive work tool (NEUTRAL) or dead weight
(DRAIN) depending on how it's actually used, which isn't derivable from
any stored data."""
INCOME = "income" # e.g. a rented-out apartment
NEUTRAL = "neutral" # e.g. a laptop used for work, furniture
DRAIN = "drain" # e.g. a personal car, an idle depreciating gadget
class RecurringFrequency(str, enum.Enum):
WEEKLY = "weekly"
MONTHLY = "monthly"
YEARLY = "yearly"
class RiskLevel(str, enum.Enum):
"""Risk of loss, not asset class — set by the user, not inferred: real
estate can be a paid-off primary home (LOW) or a leveraged rental
(HIGH), the same asset_class doesn't determine which. Cash is always
LOW (see services/net_worth_service.py) — it's the zero-risk anchor an
80/20-style allocation rule is measured against."""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"

View File

@@ -0,0 +1,38 @@
"""A savings goal, tracked by a manual contribution log (GoalContribution) —
same "you record it, we sum it" shape as Asset/AssetValuation, except
contributions are deltas added together, not point-in-time snapshots."""
from datetime import date as date_
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
class Goal(Base, TimestampMixin):
__tablename__ = "goals"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(150), nullable=False)
target_amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
target_date: Mapped[date_ | None] = mapped_column(Date, nullable=True)
contributions: Mapped[list["GoalContribution"]] = relationship(
back_populates="goal", cascade="all, delete-orphan", order_by="GoalContribution.date"
)
class GoalContribution(Base):
"""One deposit (or, with a negative amount, a withdrawal) toward a goal.
A goal's current amount is the sum of all its contributions."""
__tablename__ = "goal_contributions"
id: Mapped[int] = mapped_column(primary_key=True)
goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id", ondelete="CASCADE"), nullable=False)
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
date: Mapped[date_] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(String(200), nullable=True)
goal: Mapped["Goal"] = relationship(back_populates="contributions")

View File

@@ -0,0 +1,14 @@
"""Reusable model mixins."""
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class TimestampMixin:
"""Adds created_at / updated_at columns managed by the database itself."""
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,49 @@
"""A template for a transaction that repeats on a schedule (rent, salary,
subscriptions). Posting is manual, one-click, not a background job — see
services/recurring_service.py for how next_due_date/is_due are computed
from anchor_date + last_posted_date, and how "Post now" creates a real
Transaction row from the template.
"""
from datetime import date as date_
from sqlalchemy import Boolean, Date, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import RecurringFrequency, TransactionType
from app.models.mixins import TimestampMixin
class RecurringTransaction(Base, TimestampMixin):
__tablename__ = "recurring_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
# Destination account for TRANSFER-type rows only — same shape as Transaction.
transfer_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[TransactionType] = mapped_column(
Enum(TransactionType, name="recurring_transaction_type", native_enum=False, length=10), nullable=False
)
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False)
merchant: Mapped[str | None] = mapped_column(String(150), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
frequency: Mapped[RecurringFrequency] = mapped_column(
Enum(RecurringFrequency, name="recurring_frequency", native_enum=False, length=10), nullable=False
)
# First due date if never posted; also the day-of-month/weekday/month-day
# the schedule is anchored to (see services/recurring_service._advance).
anchor_date: Mapped[date_] = mapped_column(Date, nullable=False)
last_posted_date: Mapped[date_ | None] = mapped_column(Date, nullable=True)
# Pausing (e.g. a cancelled subscription you might resume) without
# losing the template and its posting history.
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
account: Mapped["Account"] = relationship(foreign_keys=[account_id])
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship()

View File

@@ -0,0 +1,23 @@
"""App-wide configuration that isn't tied to any single account/asset — a
single-row table (id is always 1). Primary display currency (see UPDATES.md
for why this doesn't do currency conversion) and the proactive-alert
thresholds consumed by services/insights_service.py."""
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[int] = mapped_column(primary_key=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
# Consecutive complete months of negative cash flow / declining net worth
# before insights_service.py raises the corresponding alert.
negative_cash_flow_threshold_months: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
net_worth_decline_threshold_months: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
# Max % of total capital (cash + assets) allowed in medium/high risk
# tiers before insights_service.py raises risky_allocation_exceeded —
# the classic "80% at zero risk, 20% at most exposed" rule.
risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20)

View File

@@ -0,0 +1,35 @@
"""A single money movement: income, expense, or a transfer between accounts."""
from datetime import date as date_
from sqlalchemy import Date, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.enums import TransactionType
from app.models.mixins import TimestampMixin
class Transaction(Base, TimestampMixin):
__tablename__ = "transactions"
id: Mapped[int] = mapped_column(primary_key=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
# Destination account for TRANSFER-type rows only.
transfer_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[TransactionType] = mapped_column(
Enum(TransactionType, name="transaction_type", native_enum=False, length=10), nullable=False
)
# Always stored positive; `type` carries the sign/direction.
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False)
merchant: Mapped[str | None] = mapped_column(String(150), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
date: Mapped[date_] = mapped_column(Date, nullable=False)
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship(back_populates="transactions")

View File

View File

@@ -0,0 +1,44 @@
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import AccountType
class AccountBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
type: AccountType = AccountType.CHECKING
currency: str = Field(default="USD", min_length=3, max_length=3)
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
class AccountCreate(AccountBase):
pass
class AccountUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
type: AccountType | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
is_archived: bool | None = None
class AccountRead(AccountBase):
"""Used wherever an account is embedded in another response (e.g.
TransactionRead.account) — deliberately balance-less so adding a field
here can never break an unrelated endpoint's serialization."""
model_config = ConfigDict(from_attributes=True)
id: int
is_archived: bool
class AccountWithBalance(AccountRead):
"""The Accounts page's shape — adds the live balance (see
services/account_service.py), summed from Transaction rows rather than
stored, the same "derive it" approach net_worth_service.py uses for
Cash. Used only by /api/accounts' own endpoints, never nested."""
balance: Decimal

View File

@@ -0,0 +1,15 @@
from typing import Literal
from pydantic import BaseModel
class AdviceItem(BaseModel):
key: str
tone: Literal["positive", "neutral", "warning"]
# Frontend-translated by key — same key+params pattern as FinancialAlert,
# extended to allow string params (category names) alongside numbers.
params: dict[str, str | int | float]
class AdviceResponse(BaseModel):
items: list[AdviceItem]

View File

@@ -0,0 +1,54 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import AssetClass, CapitalRole, RiskLevel
class AssetValuationCreate(BaseModel):
value: Decimal = Field(ge=0, max_digits=14, decimal_places=2)
as_of_date: date_ = Field(default_factory=date_.today)
class AssetValuationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
value: Decimal
as_of_date: date_
class AssetBase(BaseModel):
name: str = Field(min_length=1, max_length=150)
asset_class: AssetClass
currency: str = Field(default="USD", min_length=3, max_length=3)
notes: str | None = None
capital_role: CapitalRole = CapitalRole.NEUTRAL
# Rough self-reported monthly net cash flow — informational, not tracked
# transactions (see Asset.monthly_cash_flow).
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
risk_level: RiskLevel = RiskLevel.MEDIUM
class AssetCreate(AssetBase):
value: Decimal = Field(ge=0, max_digits=14, decimal_places=2)
as_of_date: date_ = Field(default_factory=date_.today)
class AssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
asset_class: AssetClass | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
notes: str | None = None
capital_role: CapitalRole | None = None
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
risk_level: RiskLevel | None = None
class AssetRead(AssetBase):
model_config = ConfigDict(from_attributes=True)
id: int
current_value: Decimal
as_of_date: date_

View File

@@ -0,0 +1,166 @@
"""Shape of a full-database backup file (see services/backup_service.py)."""
from datetime import date as date_
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import (
AccountType,
AssetClass,
CapitalRole,
CategoryKind,
RecurringFrequency,
RiskLevel,
TransactionType,
)
class AccountBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
type: AccountType
currency: str
color: str | None
is_archived: bool
class CategoryBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
kind: CategoryKind
icon: str | None
color: str
sort_order: int
is_default: bool
class TransactionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
category_id: int | None
transfer_account_id: int | None
type: TransactionType
amount: Decimal
description: str
merchant: str | None
notes: str | None
date: date_
class AssetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
asset_class: AssetClass
currency: str
notes: str | None
# Defaulted so a backup exported before capital_role/monthly_cash_flow
# existed still imports cleanly under the same format version.
capital_role: CapitalRole = CapitalRole.NEUTRAL
monthly_cash_flow: Decimal | None = None
# Defaulted so a backup exported before risk_level existed still
# imports cleanly under the same format version.
risk_level: RiskLevel = RiskLevel.MEDIUM
class AssetValuationBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
asset_id: int
value: Decimal
as_of_date: date_
class BudgetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
category_id: int
monthly_limit: Decimal
class GoalBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
target_amount: Decimal
target_date: date_ | None
class GoalContributionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
goal_id: int
amount: Decimal
date: date_
note: str | None
class RecurringTransactionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
category_id: int | None
transfer_account_id: int | None
type: TransactionType
amount: Decimal
description: str
merchant: str | None
notes: str | None
frequency: RecurringFrequency
anchor_date: date_
last_posted_date: date_ | None
is_active: bool
class AppSettingsBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
currency: str
# Defaulted so a backup exported before these thresholds existed still
# imports cleanly under the same format version.
negative_cash_flow_threshold_months: int = 2
net_worth_decline_threshold_months: int = 2
# Defaulted so a backup exported before this threshold existed still
# imports cleanly under the same format version.
risky_allocation_threshold_percent: int = 20
class BackupPayload(BaseModel):
"""A full, portable snapshot of every table. `aurum_backup_version` is
checked on import so an incompatible/future file is rejected cleanly
instead of half-applied."""
aurum_backup_version: int
exported_at: datetime
app_version: str
accounts: list[AccountBackup]
categories: list[CategoryBackup]
transactions: list[TransactionBackup]
assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before budgets existed still imports
# cleanly under the same format version.
budgets: list[BudgetBackup] = Field(default_factory=list)
# Defaulted so a backup exported before goals existed still imports
# cleanly under the same format version.
goals: list[GoalBackup] = Field(default_factory=list)
goal_contributions: list[GoalContributionBackup] = Field(default_factory=list)
# Defaulted so a backup exported before recurring transactions existed
# still imports cleanly under the same format version.
recurring_transactions: list[RecurringTransactionBackup] = Field(default_factory=list)
# Defaulted so a backup exported before the currency setting existed
# still imports cleanly under the same format version.
app_settings: AppSettingsBackup = Field(default_factory=lambda: AppSettingsBackup(currency="USD"))

View File

@@ -0,0 +1,45 @@
from decimal import Decimal
from pydantic import BaseModel, Field
class BudgetCreate(BaseModel):
category_id: int
monthly_limit: Decimal = Field(gt=0)
class BudgetUpdate(BaseModel):
monthly_limit: Decimal = Field(gt=0)
class BudgetRead(BaseModel):
id: int
category_id: int
category_name: str
category_color: str
category_icon: str | None
monthly_limit: Decimal
class BudgetStatus(BaseModel):
"""One budgeted category's actual spend for a given month, compared
against its limit."""
budget_id: int
category_id: int
category_name: str
category_color: str
category_icon: str | None
monthly_limit: Decimal
spent: Decimal
remaining: Decimal
# Can exceed 100 — the UI clamps the progress bar fill, not this number,
# so "spent 150% of budget" is still visible in the raw value.
percent: float
is_over_budget: bool
class BudgetStatusResponse(BaseModel):
year: int
month: int
items: list[BudgetStatus]

View File

@@ -0,0 +1,21 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
class CashFlowPoint(BaseModel):
year: int
month: int
income: Decimal
expense: Decimal
net: Decimal
class CashFlowResponse(BaseModel):
start_date: date_ | None
end_date: date_ | None
points: list[CashFlowPoint]
total_income: Decimal
total_expense: Decimal
total_net: Decimal

View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import CategoryKind
class CategoryBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: CategoryKind
icon: str | None = None
color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$")
sort_order: int = 0
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
icon: str | None = None
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
sort_order: int | None = None
class CategoryRead(CategoryBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_default: bool

View File

@@ -0,0 +1,22 @@
from decimal import Decimal
from pydantic import BaseModel
class CategoryBreakdownItem(BaseModel):
category_id: int | None
name: str
color: str
icon: str | None
amount: Decimal
percent: float
class DashboardSummary(BaseModel):
year: int
month: int
real_income: Decimal
spent: Decimal
net: Decimal
transferred_out: Decimal
spending_by_category: list[CategoryBreakdownItem]

View File

@@ -0,0 +1,35 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, Field
class GoalCreate(BaseModel):
name: str = Field(min_length=1, max_length=150)
target_amount: Decimal = Field(gt=0)
target_date: date_ | None = None
class GoalUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
target_amount: Decimal | None = Field(default=None, gt=0)
target_date: date_ | None = None
class GoalContributionCreate(BaseModel):
# Negative allowed — a withdrawal from the goal is still a contribution
# to its running total, just in the other direction. Zero is pointless.
amount: Decimal = Field(ne=0)
date: date_
note: str | None = Field(default=None, max_length=200)
class GoalRead(BaseModel):
id: int
name: str
target_amount: Decimal
target_date: date_ | None
current_amount: Decimal
remaining: Decimal
percent: float
is_reached: bool

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class FinancialAlert(BaseModel):
key: str
severity: str
# Machine-readable params for the frontend to interpolate into its own
# localized message template (keyed by `key`) — keeps alert copy out of
# the backend so it can be translated without an API change.
params: dict[str, int]
class AlertsResponse(BaseModel):
alerts: list[FinancialAlert]

View File

@@ -0,0 +1,66 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
class NetWorthPoint(BaseModel):
date: date_
value: Decimal
class NetWorthBreakdownItem(BaseModel):
key: str
name: str
color: str
icon: str
amount: Decimal
percent: float
class CapitalRoleSummary(BaseModel):
"""Assets grouped by how they behave month to month (see CapitalRole) —
a cross-cut of the asset-class breakdown, not a replacement for it."""
role: str
label: str
color: str
total_value: Decimal
monthly_cash_flow: Decimal
count: int
class RiskLevelItem(BaseModel):
"""One holding within a risk tier — Cash (key="cash") or a single asset
(key=f"asset:{id}"). This list IS the diversification view: a tier with
one item at 100% is concentrated, several items with even shares aren't,
no separate score needed."""
key: str
name: str
amount: Decimal
percent: float # share of this tier, not of total capital
class RiskLevelSummary(BaseModel):
"""Cash + assets grouped by user-tagged risk of loss (see RiskLevel) —
another cross-cut, like capital_roles, but Cash participates here since
it's the zero-risk anchor an 80/20-style allocation rule needs."""
risk_level: str
label: str
color: str
total_value: Decimal
percent: float # share of total capital (cash + assets)
items: list[RiskLevelItem]
class NetWorthSummary(BaseModel):
range: str
current: Decimal
change_amount: Decimal
change_percent: float | None
series: list[NetWorthPoint]
breakdown: list[NetWorthBreakdownItem]
capital_roles: list[CapitalRoleSummary]
risk_levels: list[RiskLevelSummary]

View File

@@ -0,0 +1,58 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, Field
from app.models.enums import RecurringFrequency, TransactionType
class RecurringTransactionCreate(BaseModel):
account_id: int
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType
amount: Decimal = Field(gt=0)
description: str = Field(min_length=1, max_length=255)
merchant: str | None = None
notes: str | None = None
frequency: RecurringFrequency
anchor_date: date_
class RecurringTransactionUpdate(BaseModel):
account_id: int | None = None
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType | None = None
amount: Decimal | None = Field(default=None, gt=0)
description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = None
notes: str | None = None
frequency: RecurringFrequency | None = None
anchor_date: date_ | None = None
is_active: bool | None = None
class RecurringTransactionRead(BaseModel):
id: int
account_id: int
account_name: str
category_id: int | None
category_name: str | None
category_color: str | None
category_icon: str | None
transfer_account_id: int | None
transfer_account_name: str | None
type: TransactionType
amount: Decimal
description: str
merchant: str | None
notes: str | None
frequency: RecurringFrequency
anchor_date: date_
last_posted_date: date_ | None
is_active: bool
# Computed, not stored — see services/recurring_service.py.
next_due_date: date_
is_due: bool
days_until_due: int

View File

@@ -0,0 +1,40 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel
class CategorySpendingPoint(BaseModel):
year: int
month: int
amount: Decimal
class CategorySpendingReport(BaseModel):
category_id: int
category_name: str
category_color: str
category_icon: str | None
start_date: date_ | None
end_date: date_ | None
total_amount: Decimal
transaction_count: int
average_per_month: Decimal
series: list[CategorySpendingPoint]
class CategoryRankingItem(BaseModel):
category_id: int
name: str
color: str
icon: str | None
amount: Decimal
percent: float
transaction_count: int
class CategoryRankingReport(BaseModel):
start_date: date_ | None
end_date: date_ | None
total_amount: Decimal
items: list[CategoryRankingItem]

View File

@@ -0,0 +1,26 @@
from pydantic import BaseModel, Field
class AppSettingsRead(BaseModel):
currency: str
negative_cash_flow_threshold_months: int
net_worth_decline_threshold_months: int
risky_allocation_threshold_percent: int
class AppSettingsUpdate(BaseModel):
"""All fields optional — the route applies a partial update
(`exclude_unset`), same as CategoryUpdate, so a single-field PATCH from
e.g. CurrencyCard doesn't need to resend the alert thresholds."""
# ISO 4217 code, e.g. "USD"/"UAH"/"EUR" — the frontend only ever sends
# values from its curated currency list, but validate the shape anyway
# since Intl.NumberFormat would otherwise silently accept garbage.
currency: str | None = Field(default=None, min_length=3, max_length=3, pattern=r"^[A-Z]{3}$")
# Consecutive complete months before the corresponding alert fires.
# Capped at 24 to match insights_service.py's MAX_LOOKBACK_MONTHS.
negative_cash_flow_threshold_months: int | None = Field(default=None, ge=1, le=24)
net_worth_decline_threshold_months: int | None = Field(default=None, ge=1, le=24)
# Max % of capital allowed in medium/high risk tiers (the 80/20 rule's
# "20% at most exposed" half) before risky_allocation_exceeded fires.
risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100)

View File

@@ -0,0 +1,63 @@
from datetime import date as date_
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.models.enums import TransactionType
from app.schemas.account import AccountRead
from app.schemas.category import CategoryRead
class TransactionBase(BaseModel):
account_id: int
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
description: str = Field(min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None
date: date_
@model_validator(mode="after")
def _validate_type_specific_fields(self) -> "TransactionBase":
if self.type == TransactionType.TRANSFER and not self.transfer_account_id:
raise ValueError("transfer_account_id is required for transfer transactions")
if self.type == TransactionType.TRANSFER and self.transfer_account_id == self.account_id:
raise ValueError("transfer_account_id must differ from account_id")
if self.type != TransactionType.TRANSFER and self.transfer_account_id:
raise ValueError("transfer_account_id is only valid for transfer transactions")
if self.type == TransactionType.TRANSFER and self.category_id:
raise ValueError("category_id is not valid for transfer transactions")
return self
class TransactionCreate(TransactionBase):
pass
class TransactionUpdate(BaseModel):
account_id: int | None = None
category_id: int | None = None
transfer_account_id: int | None = None
type: TransactionType | None = None
amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2)
description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None
date: date_ | None = None
class TransactionRead(TransactionBase):
model_config = ConfigDict(from_attributes=True)
id: int
account: AccountRead
category: CategoryRead | None = None
class TransactionPage(BaseModel):
items: list[TransactionRead]
total: int
page: int
page_size: int

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

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.141.1
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.4
pydantic-settings==2.7.0
python-dotenv==1.2.2

View File

View File

@@ -0,0 +1,331 @@
"""Fills the database with realistic mock data for local UI testing.
Run it against a running stack with:
docker compose exec backend python -m scripts.seed_mock_data
It replaces any existing transactions/assets/extra accounts with a fresh,
deterministic (random.seed'd) five-month dataset — the default "Main
Account" and the auto-seeded categories are kept. Safe to re-run any time
you want a clean demo dataset again.
"""
import asyncio
import random
from datetime import date
from decimal import Decimal
from sqlalchemy import delete, select
from app.db.session import AsyncSessionLocal
from app.models import Account, Asset, AssetValuation, Category, Transaction
from app.models.enums import AccountType, AssetClass, TransactionType
random.seed(42)
TODAY = date.today()
def _shift_month(d: date, months: int) -> date:
month_index = d.month - 1 + months
return date(d.year + month_index // 12, month_index % 12 + 1, 1)
# Five months of history, ending with the current (partial) month.
_current_month_start = date(TODAY.year, TODAY.month, 1)
MONTH_STARTS = [_shift_month(_current_month_start, -i) for i in range(4, -1, -1)]
def day_in_month(month_start: date, day: int) -> date | None:
try:
d = date(month_start.year, month_start.month, day)
except ValueError:
return None
return d if d <= TODAY else None
def amount(lo: float, hi: float) -> Decimal:
return Decimal(str(round(random.uniform(lo, hi), 2)))
async def wipe_mock_data(session) -> Account:
"""Clears prior transactions/assets/extra accounts, keeps the default
seeded account + categories."""
await session.execute(delete(Transaction))
await session.execute(delete(AssetValuation))
await session.execute(delete(Asset))
await session.execute(delete(Account).where(Account.name != "Main Account"))
await session.commit()
result = await session.execute(select(Account).where(Account.name == "Main Account"))
main_account = result.scalar_one_or_none()
if main_account is None:
main_account = Account(name="Main Account", type=AccountType.CHECKING, currency="USD", color="#2a78d6")
session.add(main_account)
await session.flush()
return main_account
async def seed_transactions(session, main_account: Account, savings: Account, credit_card: Account) -> None:
categories_result = await session.execute(select(Category))
categories = {c.name: c for c in categories_result.scalars().all()}
for month_start in MONTH_STARTS:
# --- income ---
salary_date = day_in_month(month_start, 1)
if salary_date:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Salary"].id,
type=TransactionType.INCOME,
amount=amount(8200, 8800),
description="Salary",
date=salary_date,
)
)
freelance_date = day_in_month(month_start, 15)
if freelance_date and month_start.month % 2 == 0:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Freelance"].id,
type=TransactionType.INCOME,
amount=amount(500, 1500),
description="Freelance project",
date=freelance_date,
)
)
# --- expenses on the main (cash) account ---
rent_date = day_in_month(month_start, 2)
if rent_date:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Housing & Utilities"].id,
type=TransactionType.EXPENSE,
amount=amount(2500, 2750),
description="Rent & utilities",
date=rent_date,
)
)
for day, desc in [(4, "Groceries"), (11, "Groceries"), (18, "Groceries"), (25, "Groceries")]:
d = day_in_month(month_start, day)
if d:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Groceries"].id,
type=TransactionType.EXPENSE,
amount=amount(45, 95),
description=desc,
date=d,
)
)
for day, desc in [(3, "Restaurant"), (9, "Coffee shop"), (16, "Restaurant"), (23, "Takeout")]:
d = day_in_month(month_start, day)
if d:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Dining Out"].id,
type=TransactionType.EXPENSE,
amount=amount(15, 70),
description=desc,
date=d,
)
)
for day in (5, 15, 25):
d = day_in_month(month_start, day)
if d:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Transportation"].id,
type=TransactionType.EXPENSE,
amount=amount(35, 90),
description="Gas",
date=d,
)
)
gym_date = day_in_month(month_start, 6)
if gym_date:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Health & Fitness"].id,
type=TransactionType.EXPENSE,
amount=amount(50, 90),
description="Gym membership",
date=gym_date,
)
)
movie_date = day_in_month(month_start, 22)
if movie_date:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Entertainment"].id,
type=TransactionType.EXPENSE,
amount=amount(15, 55),
description="Movies",
date=movie_date,
)
)
subs_date = day_in_month(month_start, 1)
if subs_date:
for desc, value in [("Netflix", "15.99"), ("Spotify", "10.99"), ("Cloud storage", "9.99")]:
session.add(
Transaction(
account_id=main_account.id,
category_id=categories["Subscriptions"].id,
type=TransactionType.EXPENSE,
amount=Decimal(value),
description=desc,
date=subs_date,
)
)
# --- expenses on the credit card (excluded from Net Worth's Cash total) ---
cc_shopping_date = day_in_month(month_start, 20)
if cc_shopping_date:
session.add(
Transaction(
account_id=credit_card.id,
category_id=categories["Shopping"].id,
type=TransactionType.EXPENSE,
amount=amount(60, 220),
description="Online shopping",
date=cc_shopping_date,
)
)
cc_dining_date = day_in_month(month_start, 10)
if cc_dining_date:
session.add(
Transaction(
account_id=credit_card.id,
category_id=categories["Dining Out"].id,
type=TransactionType.EXPENSE,
amount=amount(20, 60),
description="Dinner out",
date=cc_dining_date,
)
)
# --- monthly transfer to savings ---
transfer_date = day_in_month(month_start, 27)
if transfer_date:
session.add(
Transaction(
account_id=main_account.id,
transfer_account_id=savings.id,
type=TransactionType.TRANSFER,
amount=amount(800, 1500),
description="Move to savings",
date=transfer_date,
)
)
await session.commit()
async def seed_assets(session) -> None:
def d(year: int, month: int, day: int) -> date:
return min(date(year, month, day), TODAY)
assets_plan = [
(
"Brokerage account",
AssetClass.INVESTMENTS,
[
(d(2026, 4, 1), "380000.00"),
(d(2026, 5, 1), "392000.00"),
(d(2026, 6, 1), "405000.00"),
(d(2026, 7, 1), "415000.00"),
(d(2026, 8, 1), "422910.43"),
(d(2026, 8, 14), "428500.00"),
],
),
(
"Bitcoin",
AssetClass.CRYPTO,
[
(d(2026, 4, 1), "3000.00"),
(d(2026, 5, 1), "3800.00"),
(d(2026, 6, 1), "3200.00"),
(d(2026, 7, 1), "4100.00"),
(d(2026, 8, 1), "3500.00"),
(d(2026, 8, 14), "3900.00"),
],
),
(
"Apartment",
AssetClass.REAL_ESTATE,
[
(d(2026, 4, 1), "345000.00"),
(d(2026, 7, 1), "348000.00"),
(d(2026, 8, 1), "350000.00"),
],
),
(
"Car",
AssetClass.VEHICLES,
[
(d(2026, 4, 1), "24000.00"),
(d(2026, 6, 1), "23200.00"),
(d(2026, 8, 1), "22500.00"),
],
),
(
"Emergency fund (gold coins)",
AssetClass.OTHER,
[
(d(2026, 4, 1), "1500.00"),
(d(2026, 8, 1), "2000.00"),
],
),
]
for name, asset_class, valuations in assets_plan:
asset = Asset(name=name, asset_class=asset_class, currency="USD")
session.add(asset)
await session.flush()
seen_dates: set[date] = set()
for as_of_date, value in valuations:
if as_of_date in seen_dates:
continue
seen_dates.add(as_of_date)
session.add(AssetValuation(asset_id=asset.id, value=Decimal(value), as_of_date=as_of_date))
await session.commit()
async def main() -> None:
async with AsyncSessionLocal() as session:
print("Wiping prior mock data (transactions, assets, extra accounts)...")
main_account = await wipe_mock_data(session)
savings = Account(name="Savings", type=AccountType.SAVINGS, currency="USD", color="#1baf7a")
credit_card = Account(name="Credit Card", type=AccountType.CREDIT_CARD, currency="USD", color="#e34948")
session.add_all([savings, credit_card])
await session.commit()
await session.refresh(savings)
await session.refresh(credit_card)
print(f"Seeding transactions for {len(MONTH_STARTS)} months...")
await seed_transactions(session, main_account, savings, credit_card)
print("Seeding assets and valuation history...")
await seed_assets(session)
print("Done.")
if __name__ == "__main__":
asyncio.run(main())