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