Initial public release

Aurum — a self-hosted personal finance operating system: cash flow,
net worth tracking, budgets, goals, recurring payments, reports,
proactive alerts, and a compound-interest returns calculator, all
bilingual (RU/EN) and mobile-first. Ships as a three-container Docker
Compose stack (Postgres + FastAPI + nginx-served React frontend).

See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
ZProger
2026-08-16 18:39:52 +03:00
commit fe9b079c12
199 changed files with 14410 additions and 0 deletions

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Copy this file to .env and adjust values before running `docker compose up`.
# --- Postgres ---
AURUM_POSTGRES_USER=aurum
AURUM_POSTGRES_PASSWORD=change-me
AURUM_POSTGRES_DB=aurum
AURUM_POSTGRES_HOST=db
AURUM_POSTGRES_PORT=5432
# --- Backend ---
# Comma-separated list of allowed origins, or * for any (fine for local self-hosting).
AURUM_CORS_ORIGINS=*
AURUM_DEFAULT_CURRENCY=USD
# --- Basic auth (STRONGLY recommended if this instance is reachable from
# anywhere other than your own machine) ---
# Aurum has no login system of its own — it's built for one person
# self-hosting their own private instance, not a multi-tenant service.
# Leave both blank and there is NO password on this app at all: anyone who
# can reach it can read, edit, and delete every transaction, account, and
# asset. Set both to put an HTTP Basic Auth prompt in front of the whole
# app (UI + API). See frontend/docker-entrypoint.d/20-basic-auth.sh.
AURUM_BASIC_AUTH_USER=
AURUM_BASIC_AUTH_PASSWORD=
# --- Ports exposed on your host machine ---
AURUM_WEB_PORT=3000

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# Env — everything named .env* is ignored except the checked-in template.
# Covers .env, .env.local, .env.production, a stray .env.bak, etc.
.env
.env.*
!.env.example
# Local Docker Compose overrides commonly used to inject secrets/ports
# without touching the tracked docker-compose.yml.
docker-compose.override.yml
# TLS/SSH keys, in case a reverse-proxy cert ends up dropped in the repo dir.
*.pem
*.key
*.crt
id_rsa
id_ed25519
# Database dumps / exports — pg_dump output or a downloaded
# "aurum-backup-*.json" from Settings landing in the repo by accident.
# Both contain full financial data and must never be committed.
*.sql
*.dump
*.sqlite3
aurum-backup-*.json
# Python
__pycache__/
*.pyc
.venv/
*.egg-info/
# Node / frontend
node_modules/
dist/
.vite/
# Editors / OS / local tooling
.DS_Store
.idea/
.vscode/
.claude/
# Postgres local volume (if bind-mounted during development)
pgdata/
# Other
UPDATES.md

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
1. Никогда не удаляй комментарии, которые уже есть. Ты можешь изменить комментарии на более актуальные, но нельзя удалять их, чтобы код оставался без них.
2. Код делай атомарным, чтобы изменение в одном месте, не поломало другие функции. Код должен быть написан таким образом, чтобы он хорошо поддерживался, обновлялся и масштабировался.
3. Важные новые функции которые ты добавил, записывай в файл UPDATES.md указывая текущую дату и версию приложения, мне это нужно, чтобы потом добавить в changelog моих обновлений платформы. Также после этого делай коммит и перечисляй что ты добавил.
6. Во время разработки интерфейса сразу же адаптируй это под мобильную версию.
7. Всегда отвечай мне на русском.

52
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,52 @@
# Contributing to Aurum
Thanks for considering a contribution — code, docs, bug reports, and ideas are all welcome.
## Getting a dev environment running
The fastest path to a working environment is the same Docker Compose setup end users run:
```bash
cp .env.example .env
docker compose up -d --build
```
This gives you Postgres, the FastAPI backend (with migrations applied automatically on boot), and the built frontend served through nginx at `http://localhost:3000`.
For active frontend development with hot reload:
```bash
cd frontend
npm install
npm run dev
```
The dev server proxies API calls to whatever backend is running — point it at the Dockerized backend above, or run the backend locally:
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload
```
## Before opening a PR
- **Frontend:** `npm run build` (runs `tsc -b` then `vite build`) must pass with no type errors.
- **Backend:** new tables or columns need an Alembic migration (`alembic revision --autogenerate -m "..."`) — check the generated migration by hand, autogenerate isn't always right.
- **New user-facing text** goes through the translation system in `frontend/src/lib/i18n.ts` (both `ru` and `en` — the `en` object is typed against `ru`'s keys, so a missing translation is a build error, not a runtime surprise) rather than being hardcoded in a component.
- **Mobile:** check your change at a narrow viewport — Aurum is designed mobile-first.
- Keep PRs focused. A bug fix doesn't need an accompanying refactor.
## Reporting bugs
Open an [Issue](../../issues) with steps to reproduce. For security issues, please use GitHub's private Security tab instead of a public issue.
## Proposing features
Open a [Discussion](../../discussions) first for anything that changes the data model or adds a new area of the app — it's a lot easier to align on the shape of a feature before code exists than to rework a finished PR.
## License
Aurum is licensed under [PolyForm Noncommercial 1.0.0](LICENSE), not a traditional OSI open source license — see the [README's License section](README.md#-license) for what that means in practice. By submitting a PR, you agree your contribution is licensed under the same terms as the rest of the project.

136
LICENSE Normal file
View File

@@ -0,0 +1,136 @@
Required Notice: Copyright ZProger (https://github.com/ZProger)
PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright ZProger (https://github.com/ZProger)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
**Trademark** means trademarks, service marks, and similar
rights.

186
README.md Normal file
View File

@@ -0,0 +1,186 @@
<div align="center">
# Aurum
**See where every dollar comes from. Know where every dollar goes.**
Aurum is a source-available personal finance operating system — a complete, granular, real-time picture of your money: cash flow, net worth, budgets, subscriptions, assets, and every income stream you own.
[![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-blue.svg)](LICENSE)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
[Features](#-core-features) • [Getting Started](#-getting-started) • [Security](#-security--self-hosting) • [Roadmap](#%EF%B8%8F-roadmap) • [Contributing](#-contributing) • [License](#-license)
</div>
---
## 📖 Overview
Most finance apps show you a pie chart of last month's spending and call it a day. **Aurum goes further.**
It's built for people who don't just want to *log* transactions — they want to understand the *mechanics* of their money: how capital grows or shrinks over time, which assets are pulling their weight, which subscriptions are quietly draining them, and which income streams are actually passive versus which just look that way on paper.
Aurum treats your financial life as a **system**, not a spreadsheet.
## 💡 Why Aurum
- **Fragmentation is the enemy.** Bank apps, brokerage apps, crypto wallets, spreadsheets, subscription trackers — your financial picture is scattered across a dozen tools that don't talk to each other.
- **Most trackers stop at "what happened."** Aurum also flags rising spending categories, unbudgeted expenses, and shifts in your savings rate — before they become a problem.
- **The 80/20 rule, enforced.** Aurum can group your capital by risk level and warn you the moment too much of it is exposed.
- **It should be free, forever — for you.** Financial clarity shouldn't sit behind a paywall. Aurum is source-available and self-hosted — your data never leaves your own server, and personal use is free forever. (See [License](#-license) — the code is open to read, run, and modify, but not to resell.)
## 🧩 Core Features
### 💰 Cash Flow & Transactions
Log income, expenses, and transfers across as many accounts as you want, categorized and searchable. A dedicated Cash Flow view charts income vs. expense month by month.
### 📈 Net Worth Engine
A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level.
### 🎯 Budgets, Goals & Recurring Payments
Set a monthly limit per category and watch progress bars fill up. Track savings goals with a running contribution log. Register recurring bills and post them with one click when they're due — nothing runs automatically in the background.
### 📊 Reports & Advice
Rank every category by total spend over any custom period to find what's actually eating your budget. A rules-based Advice tab surfaces rising spending categories, unbudgeted top expenses, and month-over-month savings rate trends in plain language.
### 🧮 Returns Calculator
A standalone ROI calculator: enter what you'd invest and what it would pay you monthly, and see the annual return, payback period, and a compound-interest projection — with a year-by-year comparison chart of compounding vs. just banking the cash — before you commit to a purchase.
### 🔔 Proactive Alerts
Aurum watches your numbers in the background and surfaces a warning the moment something crosses a threshold you configure: a sustained negative cash flow streak, a declining net worth trend, an over-budget category, or too much capital sitting at risk.
### 🌐 Bilingual, Mobile-First
Full Russian/English UI with a language switch in Settings, and every screen designed mobile-first from day one.
### 💾 Full Backup & Restore
Export your entire dataset — accounts, transactions, assets, budgets, goals — to a single JSON file at any time, and restore it later on a fresh install.
## 🚀 Getting Started
Aurum ships as three containers — Postgres, a FastAPI backend, and an nginx-served frontend — wired together with Docker Compose. No local Python, Node, or Postgres installation needed; Docker is the only requirement.
### 1. Install Docker
You need **Docker Engine** and the **Docker Compose plugin** (the `docker compose` command, not the older standalone `docker-compose`).
- **macOS / Windows:** install [Docker Desktop](https://www.docker.com/products/docker-desktop/) — it bundles both.
- **Linux:** follow the [official install guide](https://docs.docker.com/engine/install/) for your distribution, then install the [Compose plugin](https://docs.docker.com/compose/install/linux/) if it isn't already included.
Confirm both are available:
```bash
docker --version
docker compose version
```
### 2. Get the code
```bash
git clone <this repository's URL>
cd aurum
```
### 3. Configure your environment
Copy the template and open it in an editor:
```bash
cp .env.example .env
```
At minimum, change these two before going any further:
| Variable | What it does |
|---|---|
| `AURUM_POSTGRES_PASSWORD` | Password for Aurum's own Postgres container. The template ships with `change-me` on purpose — replace it with something real. |
| `AURUM_BASIC_AUTH_USER` / `AURUM_BASIC_AUTH_PASSWORD` | **Aurum has no login screen of its own.** Leave these blank and the app has no password at all — fine if it's only reachable from `localhost`, not fine anywhere else. Set both to put an HTTP Basic Auth prompt in front of the whole app. See [Security & Self-Hosting](#-security--self-hosting) below. |
Everything else in `.env` (currency, CORS, the port Aurum listens on) has a sensible default and can be left alone for a first run.
### 4. Start it
```bash
docker compose up -d --build
```
This builds the backend and frontend images, starts Postgres, waits for it to report healthy, then starts the backend (which runs every database migration automatically — nothing to do by hand) and finally the frontend. First run takes a minute or two; after that, images are cached and it's seconds.
### 5. Open it
Visit **http://localhost:3000** (or whatever port you set via `AURUM_WEB_PORT` in `.env`). A default account and the standard expense/income categories are seeded automatically — there's nothing to configure before you can add your first transaction.
### 6. Check it's healthy (optional)
```bash
docker compose ps
```
All three containers (`db`, `backend`, `web`) should show `healthy`. If `web` or `backend` doesn't, check its logs:
```bash
docker compose logs backend
docker compose logs web
```
### Everyday operations
```bash
docker compose down # stop everything, keep your data
docker compose up -d # start it again later
docker compose down -v # stop AND permanently delete your data — be sure
docker compose pull && docker compose up -d --build # update to newer code
```
Your data lives in a Docker named volume (`aurum_pgdata`), not in the repo folder — it survives `docker compose down`, image rebuilds, and `git pull`. It's only gone if you explicitly run `docker compose down -v` or delete the volume yourself. For anything short of that, use the in-app **Settings → Backup & Restore** to export a JSON snapshot of everything before making risky changes.
## 🔒 Security & Self-Hosting
**Aurum has no built-in login system.** It's built for one person to self-host one private instance of their own financial data — not as a multi-tenant service with per-user accounts. That's a deliberate trade-off, not an oversight, but it means:
- If you leave `AURUM_BASIC_AUTH_USER` / `AURUM_BASIC_AUTH_PASSWORD` unset in `.env`, **anyone who can reach the container can read, edit, and delete all of it — no password prompt at all.** This is fine if Aurum is only reachable from `localhost` or your own private network.
- Set both variables before exposing your instance beyond your own machine (a VPS, a subdomain, a Tailscale/VPN endpoint someone else might share). This turns on an HTTP Basic Auth prompt in front of the entire app, UI and API alike.
- For anything beyond that — a reverse proxy with TLS (Caddy, Traefik, nginx + Let's Encrypt) is on you; Aurum doesn't terminate HTTPS itself.
If you find a security issue, please open a private report via GitHub's Security tab rather than a public issue.
## 🗺️ Roadmap
Aurum's core (accounts, transactions, budgets, goals, recurring payments, net worth, reports, alerts, backup/restore) is built and in daily use. Planned next:
- [ ] Bank/broker sync integrations
- [ ] Native multi-currency support (live conversion, not just relabeling)
- [ ] AI-assisted insights (anomaly detection, natural-language Q&A over your own data)
- [ ] Automated test suite & CI
- [ ] Mobile app
> Want to help shape this list? Open a [Discussion](../../discussions) or an [Issue](../../issues).
## 🛠️ Tech Stack
- **Frontend:** React 19, TypeScript, Vite, Tailwind CSS, React Router, TanStack Query, Recharts
- **Backend:** FastAPI, SQLAlchemy 2.0 (async), Alembic, Pydantic v2
- **Database:** PostgreSQL
- **Deployment:** Docker Compose (Postgres + FastAPI + nginx-served SPA)
## 🤝 Contributing
Aurum is built in the open, for everyone. Contributions of all kinds are welcome — code, design, documentation, ideas, and bug reports. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get a dev environment running and what a good pull request looks like.
## 📄 License
Aurum is released under the [PolyForm Noncommercial License 1.0.0](LICENSE).
In plain terms: you can read the code, self-host it, modify it, and use it for any personal, educational, or noncommercial purpose, for free, forever. What you can't do is take it (or a modified version of it) and sell it, host it as a paid service for others, or otherwise build a commercial product on top of it. This is **not** an OSI-approved open source license — it's *source-available*. See the [LICENSE](LICENSE) file for the exact terms, and open a [Discussion](../../discussions) if you have a use case you're not sure is covered.
## 💬 Community
Have ideas, questions, or feedback? Open a [Discussion](../../discussions) or start an [Issue](../../issues). Every conversation helps shape where Aurum goes next.
---
<div align="center">
**If Aurum helps you understand your money better, consider giving it a ⭐ — it genuinely helps the project grow.**
</div>

8
backend/.dockerignore Normal file
View File

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

22
backend/Dockerfile Normal file
View File

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

38
backend/alembic.ini Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,21 @@
from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget
from app.models.category import Category
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
from app.models.transaction import Transaction
__all__ = [
"Account",
"AppSettings",
"Asset",
"AssetValuation",
"Budget",
"Category",
"Goal",
"GoalContribution",
"RecurringTransaction",
"Transaction",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@@ -0,0 +1,84 @@
"""Account CRUD, plus each account's live balance — summed from its
Transaction rows (income adds, expense subtracts, a transfer moves the
amount from the source account to the destination account) rather than
stored, the same "derive it, don't duplicate it" approach
net_worth_service.py uses for Cash.
"""
from collections import defaultdict
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
async def _account_balances(session: AsyncSession) -> dict[int, Decimal]:
result = await session.execute(
select(Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
)
balances: dict[int, Decimal] = defaultdict(Decimal)
for tx_type, amount, account_id, transfer_account_id in result.all():
if tx_type == TransactionType.INCOME:
balances[account_id] += amount
elif tx_type == TransactionType.EXPENSE:
balances[account_id] -= amount
elif tx_type == TransactionType.TRANSFER:
balances[account_id] -= amount
if transfer_account_id is not None:
balances[transfer_account_id] += amount
return balances
def _to_read(account: Account, balance: Decimal) -> AccountWithBalance:
return AccountWithBalance(
id=account.id,
name=account.name,
type=account.type,
currency=account.currency,
color=account.color,
is_archived=account.is_archived,
balance=balance,
)
async def list_accounts(session: AsyncSession, include_archived: bool) -> list[AccountWithBalance]:
stmt = select(Account).order_by(Account.name)
if not include_archived:
stmt = stmt.where(Account.is_archived.is_(False))
accounts = (await session.execute(stmt)).scalars().all()
balances = await _account_balances(session)
return [_to_read(account, balances.get(account.id, Decimal("0"))) for account in accounts]
async def create_account(session: AsyncSession, payload: AccountCreate) -> AccountWithBalance:
account = Account(**payload.model_dump())
session.add(account)
await session.commit()
await session.refresh(account)
# A brand-new account has no transactions yet — no need to query.
return _to_read(account, Decimal("0"))
async def update_account(session: AsyncSession, account_id: int, payload: AccountUpdate) -> AccountWithBalance:
account = await session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=404, detail="Account not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(account, field, value)
await session.commit()
await session.refresh(account)
balances = await _account_balances(session)
return _to_read(account, balances.get(account.id, Decimal("0")))
async def delete_account(session: AsyncSession, account_id: int) -> None:
account = await session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=404, detail="Account not found")
await session.delete(account)
await session.commit()

View File

@@ -0,0 +1,152 @@
"""Personalized, read-only financial advice — a curated set of insights
computed from data that already exists (transactions, budgets, category
totals). Unlike insights_service.py's proactive alerts (urgent, shown as a
banner on every page), these are advisory notes shown only on the dedicated
Advice page — a mix of positive, neutral, and cautionary tone, not just
warnings, and not something the user needs to act on immediately.
"""
import calendar
from collections import defaultdict
from datetime import date
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.budget import Budget
from app.models.category import Category
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.advice import AdviceItem, AdviceResponse
from app.services.dashboard_service import get_dashboard_summary
TRAILING_MONTHS = 3
RISING_CATEGORY_THRESHOLD_PERCENT = 25
SAVINGS_RATE_TREND_THRESHOLD_POINTS = 5
def _month_bounds(year: int, month: int) -> tuple[date, date]:
last_day = calendar.monthrange(year, month)[1]
return date(year, month, 1), date(year, month, last_day)
def _previous_month(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1)
async def _category_expense_totals(session: AsyncSession, year: int, month: int) -> dict[int, Decimal]:
start, end = _month_bounds(year, month)
stmt = (
select(Transaction.category_id, func.sum(Transaction.amount))
.where(
Transaction.type == TransactionType.EXPENSE,
Transaction.category_id.is_not(None),
Transaction.date >= start,
Transaction.date <= end,
)
.group_by(Transaction.category_id)
)
return {row[0]: row[1] for row in (await session.execute(stmt)).all()}
async def _rising_category_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
"""The expense category furthest above its own trailing-3-month average,
if that's at least RISING_CATEGORY_THRESHOLD_PERCENT higher."""
current_totals = await _category_expense_totals(session, year, month)
if not current_totals:
return None
trailing_totals: dict[int, Decimal] = defaultdict(Decimal)
y, m = year, month
for _ in range(TRAILING_MONTHS):
y, m = _previous_month(y, m)
for cat_id, amount in (await _category_expense_totals(session, y, m)).items():
trailing_totals[cat_id] += amount
best: tuple[float, int, Decimal, Decimal] | None = None
for cat_id, current in current_totals.items():
average = trailing_totals.get(cat_id, Decimal("0")) / TRAILING_MONTHS
if average <= 0:
continue
increase_percent = float((current - average) / average * 100)
if increase_percent >= RISING_CATEGORY_THRESHOLD_PERCENT and (best is None or increase_percent > best[0]):
best = (increase_percent, cat_id, current, average)
if best is None:
return None
increase_percent, cat_id, current, average = best
category = await session.get(Category, cat_id)
if category is None:
return None
return AdviceItem(
key="rising_category",
tone="warning",
params={
"category": category.name,
"percent": round(increase_percent),
"current": float(current),
"average": float(average),
},
)
async def _unbudgeted_top_category_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
"""This month's highest-spending expense category that has no budget."""
current_totals = await _category_expense_totals(session, year, month)
if not current_totals:
return None
budgeted_ids = {row[0] for row in (await session.execute(select(Budget.category_id))).all()}
for cat_id, amount in sorted(current_totals.items(), key=lambda item: item[1], reverse=True):
if cat_id in budgeted_ids:
continue
category = await session.get(Category, cat_id)
if category is None:
continue
return AdviceItem(
key="unbudgeted_top_category",
tone="neutral",
params={"category": category.name, "amount": float(amount)},
)
return None
async def _savings_rate_trend_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
"""This month's savings rate (net / real_income) vs. last month's, when
the swing is large enough to be worth mentioning."""
current = await get_dashboard_summary(session, year, month)
prev_year, prev_month = _previous_month(year, month)
previous = await get_dashboard_summary(session, prev_year, prev_month)
if current.real_income <= 0 or previous.real_income <= 0:
return None
current_rate = float(current.net / current.real_income * 100)
previous_rate = float(previous.net / previous.real_income * 100)
diff = current_rate - previous_rate
if abs(diff) < SAVINGS_RATE_TREND_THRESHOLD_POINTS:
return None
return AdviceItem(
key="savings_rate_trend",
tone="positive" if diff > 0 else "warning",
params={"rate": round(current_rate), "diff": round(diff)},
)
async def get_advice(session: AsyncSession) -> AdviceResponse:
today = date.today()
generators = (_rising_category_advice, _unbudgeted_top_category_advice, _savings_rate_trend_advice)
items: list[AdviceItem] = []
for generate in generators:
item = await generate(session, today.year, today.month)
if item is not None:
items.append(item)
return AdviceResponse(items=items)

View File

@@ -0,0 +1,185 @@
"""Full-database JSON backup & restore.
Exports every row (accounts, categories, transactions, assets, asset
valuations) as one portable JSON document a user can download from the
browser and re-upload later. Restore fully REPLACES existing data — it's a
snapshot restore, not a merge — so the whole operation runs in one DB
transaction: a corrupt or incompatible file is rejected (referential checks
run first, before any row is touched), and any failure during the swap rolls
the database back to exactly where it was, so a bad file never leaves the
app half-restored.
"""
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import APP_VERSION
from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget
from app.models.category import Category
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
from app.models.transaction import Transaction
from app.schemas.backup import (
AccountBackup,
AppSettingsBackup,
AssetBackup,
AssetValuationBackup,
BackupPayload,
BudgetBackup,
CategoryBackup,
GoalBackup,
GoalContributionBackup,
RecurringTransactionBackup,
TransactionBackup,
)
BACKUP_FORMAT_VERSION = 1
async def build_backup(session: AsyncSession) -> BackupPayload:
accounts = (await session.execute(select(Account))).scalars().all()
categories = (await session.execute(select(Category))).scalars().all()
transactions = (await session.execute(select(Transaction))).scalars().all()
assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all()
budgets = (await session.execute(select(Budget))).scalars().all()
goals = (await session.execute(select(Goal))).scalars().all()
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
recurring_transactions = (await session.execute(select(RecurringTransaction))).scalars().all()
app_settings = await session.get(AppSettings, 1)
return BackupPayload(
aurum_backup_version=BACKUP_FORMAT_VERSION,
exported_at=datetime.now(timezone.utc),
app_version=APP_VERSION,
accounts=[AccountBackup.model_validate(row) for row in accounts],
categories=[CategoryBackup.model_validate(row) for row in categories],
transactions=[TransactionBackup.model_validate(row) for row in transactions],
assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
budgets=[BudgetBackup.model_validate(row) for row in budgets],
goals=[GoalBackup.model_validate(row) for row in goals],
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
recurring_transactions=[RecurringTransactionBackup.model_validate(row) for row in recurring_transactions],
app_settings=AppSettingsBackup.model_validate(app_settings) if app_settings else AppSettingsBackup(currency="USD"),
)
def _validate_references(payload: BackupPayload) -> None:
account_ids = {row.id for row in payload.accounts}
category_ids = {row.id for row in payload.categories}
asset_ids = {row.id for row in payload.assets}
for t in payload.transactions:
if t.account_id not in account_ids:
raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}")
if t.transfer_account_id is not None and t.transfer_account_id not in account_ids:
raise HTTPException(
400, f"Transaction {t.id} references unknown transfer_account_id {t.transfer_account_id}"
)
if t.category_id is not None and t.category_id not in category_ids:
raise HTTPException(400, f"Transaction {t.id} references unknown category_id {t.category_id}")
for v in payload.asset_valuations:
if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
for b in payload.budgets:
if b.category_id not in category_ids:
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
goal_ids = {row.id for row in payload.goals}
for c in payload.goal_contributions:
if c.goal_id not in goal_ids:
raise HTTPException(400, f"Goal contribution {c.id} references unknown goal_id {c.goal_id}")
for r in payload.recurring_transactions:
if r.account_id not in account_ids:
raise HTTPException(400, f"Recurring transaction {r.id} references unknown account_id {r.account_id}")
if r.transfer_account_id is not None and r.transfer_account_id not in account_ids:
raise HTTPException(
400,
f"Recurring transaction {r.id} references unknown transfer_account_id {r.transfer_account_id}",
)
if r.category_id is not None and r.category_id not in category_ids:
raise HTTPException(400, f"Recurring transaction {r.id} references unknown category_id {r.category_id}")
async def _reset_sequence(session: AsyncSession, table: str, rows: list) -> None:
"""Bulk-inserting rows with explicit ids doesn't advance the table's
identity sequence, so the next auto-generated id would collide — bump it
to max(id) after a restore. `table` is always one of our five hardcoded
table names, never user input."""
if not rows:
return
max_id = max(row.id for row in rows)
await session.execute(
text(f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), :max_id)"), {"max_id": max_id}
)
async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
if payload.aurum_backup_version != BACKUP_FORMAT_VERSION:
raise HTTPException(
400,
f"Unsupported backup version {payload.aurum_backup_version} "
f"(this Aurum version supports {BACKUP_FORMAT_VERSION})",
)
_validate_references(payload)
try:
# Children before parents.
await session.execute(delete(AssetValuation))
await session.execute(delete(Budget))
await session.execute(delete(GoalContribution))
await session.execute(delete(Goal))
await session.execute(delete(RecurringTransaction))
await session.execute(delete(Transaction))
await session.execute(delete(Asset))
await session.execute(delete(Category))
await session.execute(delete(Account))
# Parents before children.
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
session.add_all(Category(**row.model_dump()) for row in payload.categories)
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions)
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)
session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions)
await session.flush()
await _reset_sequence(session, "accounts", payload.accounts)
await _reset_sequence(session, "categories", payload.categories)
await _reset_sequence(session, "assets", payload.assets)
await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
await _reset_sequence(session, "budgets", payload.budgets)
await _reset_sequence(session, "goals", payload.goals)
await _reset_sequence(session, "goal_contributions", payload.goal_contributions)
await _reset_sequence(session, "recurring_transactions", payload.recurring_transactions)
# Singleton row — updated in place, not deleted/recreated (no
# sequence to reset, id is always 1).
app_settings = await session.get(AppSettings, 1)
if app_settings is None:
session.add(AppSettings(id=1, **payload.app_settings.model_dump()))
else:
for field, value in payload.app_settings.model_dump().items():
setattr(app_settings, field, value)
await session.commit()
except HTTPException:
await session.rollback()
raise
except Exception as exc:
await session.rollback()
raise HTTPException(400, f"Restore failed, no changes were made: {exc}") from exc

View File

@@ -0,0 +1,107 @@
"""Monthly category budgets: CRUD for the limits themselves, plus
get_budget_status(), which compares each limit against actual spend for a
given month — the data behind the Budget page's progress bars and the
budget_exceeded proactive alert (services/insights_service.py)."""
import calendar
from datetime import date
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.budget import Budget
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
_EAGER = (selectinload(Budget.category),)
def _month_bounds(year: int, month: int) -> tuple[date, date]:
last_day = calendar.monthrange(year, month)[1]
return date(year, month, 1), date(year, month, last_day)
async def list_budgets(session: AsyncSession) -> list[Budget]:
result = await session.execute(select(Budget).options(*_EAGER).join(Category).order_by(Category.sort_order))
return list(result.scalars().all())
async def create_budget(session: AsyncSession, payload: BudgetCreate) -> Budget:
category = await session.get(Category, payload.category_id)
if category is None:
raise HTTPException(status_code=400, detail="Category not found")
if category.kind != CategoryKind.EXPENSE:
raise HTTPException(status_code=400, detail="Budgets can only be set on expense categories")
existing = await session.execute(select(Budget).where(Budget.category_id == payload.category_id))
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=400, detail=f"'{category.name}' already has a budget")
budget = Budget(category_id=payload.category_id, monthly_limit=payload.monthly_limit)
session.add(budget)
await session.commit()
refreshed = await session.execute(select(Budget).options(*_EAGER).where(Budget.id == budget.id))
return refreshed.scalar_one()
async def update_budget(session: AsyncSession, budget_id: int, payload: BudgetUpdate) -> Budget:
budget = await session.get(Budget, budget_id)
if budget is None:
raise HTTPException(status_code=404, detail="Budget not found")
budget.monthly_limit = payload.monthly_limit
await session.commit()
refreshed = await session.execute(select(Budget).options(*_EAGER).where(Budget.id == budget_id))
return refreshed.scalar_one()
async def delete_budget(session: AsyncSession, budget_id: int) -> None:
budget = await session.get(Budget, budget_id)
if budget is None:
raise HTTPException(status_code=404, detail="Budget not found")
await session.delete(budget)
await session.commit()
async def get_budget_status(session: AsyncSession, year: int, month: int) -> BudgetStatusResponse:
start, end = _month_bounds(year, month)
budgets = await list_budgets(session)
if not budgets:
return BudgetStatusResponse(year=year, month=month, items=[])
spent_stmt = (
select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0))
.where(
Transaction.category_id.in_([b.category_id for b in budgets]),
Transaction.type == TransactionType.EXPENSE,
Transaction.date >= start,
Transaction.date <= end,
)
.group_by(Transaction.category_id)
)
spent_by_category: dict[int, Decimal] = {row[0]: row[1] for row in (await session.execute(spent_stmt)).all()}
items = []
for budget in budgets:
spent = spent_by_category.get(budget.category_id, Decimal("0"))
percent = float(spent / budget.monthly_limit * 100) if budget.monthly_limit else 0.0
items.append(
BudgetStatus(
budget_id=budget.id,
category_id=budget.category_id,
category_name=budget.category.name,
category_color=budget.category.color,
category_icon=budget.category.icon,
monthly_limit=budget.monthly_limit,
spent=spent,
remaining=budget.monthly_limit - spent,
percent=percent,
is_over_budget=spent > budget.monthly_limit,
)
)
return BudgetStatusResponse(year=year, month=month, items=items)

View File

@@ -0,0 +1,87 @@
"""Month-by-month income vs. expense — the multi-month view neither the
Dashboard (locked to one month) nor Reports (one category at a time, or a
category ranking) answers on its own. Transfers between the user's own
accounts are excluded from both totals, same as the Dashboard breakdown.
"""
from collections import defaultdict
from datetime import date as date_
from decimal import Decimal
from sqlalchemy import extract, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.cash_flow import CashFlowPoint, CashFlowResponse
def _next_month(year: int, month: int) -> tuple[int, int]:
return (year + 1, 1) if month == 12 else (year, month + 1)
async def get_cash_flow(
session: AsyncSession, start_date: date_ | None, end_date: date_ | None
) -> CashFlowResponse:
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
Transaction.type != TransactionType.TRANSFER
)
if start_date:
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
if end_date:
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
min_date, max_date = (await session.execute(bounds_stmt)).one()
effective_start = start_date or min_date
effective_end = end_date or max_date
empty = CashFlowResponse(
start_date=effective_start,
end_date=effective_end,
points=[],
total_income=Decimal("0"),
total_expense=Decimal("0"),
total_net=Decimal("0"),
)
if effective_start is None or effective_end is None:
return empty
rows_stmt = (
select(
extract("year", Transaction.date).label("year"),
extract("month", Transaction.date).label("month"),
Transaction.type,
func.sum(Transaction.amount).label("amount"),
)
.where(
Transaction.type != TransactionType.TRANSFER,
Transaction.date >= effective_start,
Transaction.date <= effective_end,
)
.group_by("year", "month", Transaction.type)
)
rows = (await session.execute(rows_stmt)).all()
by_month: dict[tuple[int, int], dict[TransactionType, Decimal]] = defaultdict(dict)
for year, month, tx_type, amount in rows:
by_month[(int(year), int(month))][tx_type] = amount
points: list[CashFlowPoint] = []
year, month = effective_start.year, effective_start.month
while (year, month) <= (effective_end.year, effective_end.month):
totals = by_month.get((year, month), {})
income = totals.get(TransactionType.INCOME, Decimal("0"))
expense = totals.get(TransactionType.EXPENSE, Decimal("0"))
points.append(CashFlowPoint(year=year, month=month, income=income, expense=expense, net=income - expense))
year, month = _next_month(year, month)
total_income = sum((p.income for p in points), Decimal("0"))
total_expense = sum((p.expense for p in points), Decimal("0"))
return CashFlowResponse(
start_date=effective_start,
end_date=effective_end,
points=points,
total_income=total_income,
total_expense=total_expense,
total_net=total_income - total_expense,
)

View File

@@ -0,0 +1,89 @@
"""Aggregation logic behind the Overview dashboard."""
import calendar
from datetime import date
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category
from app.models.enums import TransactionType
from app.models.transaction import Transaction
from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary
# Categorical slots are capped at 8 (dataviz skill: a 9th series folds into "Other",
# never a generated hue) — this is also the exact size of the default category set.
MAX_CHART_SLICES = 8
OTHER_SLICE_COLOR = "#898781" # muted ink, reserved for the non-categorical rollup
def _month_bounds(year: int, month: int) -> tuple[date, date]:
last_day = calendar.monthrange(year, month)[1]
return date(year, month, 1), date(year, month, last_day)
async def get_dashboard_summary(session: AsyncSession, year: int, month: int) -> DashboardSummary:
start, end = _month_bounds(year, month)
totals_stmt = (
select(Transaction.type, func.coalesce(func.sum(Transaction.amount), 0))
.where(Transaction.date >= start, Transaction.date <= end)
.group_by(Transaction.type)
)
totals_result = await session.execute(totals_stmt)
totals: dict[TransactionType, Decimal] = {row[0]: row[1] for row in totals_result.all()}
real_income = totals.get(TransactionType.INCOME, Decimal("0"))
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
breakdown_stmt = (
select(
Category.id,
Category.name,
Category.color,
Category.icon,
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
)
.join(Category, Category.id == Transaction.category_id)
.where(
Transaction.date >= start,
Transaction.date <= end,
Transaction.type == TransactionType.EXPENSE,
)
.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
)
breakdown_result = await session.execute(breakdown_stmt)
rows = breakdown_result.all()
top_rows, rest_rows = rows[:MAX_CHART_SLICES], rows[MAX_CHART_SLICES:]
def _percent(amount: Decimal) -> float:
return float(amount / spent * 100) if spent else 0.0
spending_by_category = [
CategoryBreakdownItem(
category_id=cat_id, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount)
)
for cat_id, name, color, icon, amount in top_rows
]
if rest_rows:
other_amount = sum((row.amount for row in rest_rows), Decimal("0"))
spending_by_category.append(
CategoryBreakdownItem(
category_id=None, name="Other", color=OTHER_SLICE_COLOR, icon="more-horizontal",
amount=other_amount, percent=_percent(other_amount),
)
)
return DashboardSummary(
year=year,
month=month,
real_income=real_income,
spent=spent,
net=real_income - spent,
transferred_out=transferred_out,
spending_by_category=spending_by_category,
)

View File

@@ -0,0 +1,94 @@
"""Savings goals: CRUD for the goal itself, plus a running current_amount —
the sum of all logged GoalContribution rows, computed on read rather than
stored, so it's never out of sync with the log."""
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.engine import Row
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.goal import Goal, GoalContribution
from app.schemas.goal import GoalContributionCreate, GoalCreate, GoalRead, GoalUpdate
_SELECT_WITH_TOTAL = (
select(
Goal.id,
Goal.name,
Goal.target_amount,
Goal.target_date,
func.coalesce(func.sum(GoalContribution.amount), 0).label("current_amount"),
)
.outerjoin(GoalContribution, GoalContribution.goal_id == Goal.id)
.group_by(Goal.id, Goal.name, Goal.target_amount, Goal.target_date, Goal.created_at)
.order_by(Goal.created_at)
)
def _to_read(row: Row) -> GoalRead:
current = row.current_amount
target = row.target_amount
percent = float(current / target * 100) if target else 0.0
return GoalRead(
id=row.id,
name=row.name,
target_amount=target,
target_date=row.target_date,
current_amount=current,
remaining=target - current,
percent=percent,
is_reached=current >= target,
)
async def _read_one(session: AsyncSession, goal_id: int) -> GoalRead:
row = (await session.execute(_SELECT_WITH_TOTAL.where(Goal.id == goal_id))).one()
return _to_read(row)
async def list_goals(session: AsyncSession) -> list[GoalRead]:
rows = (await session.execute(_SELECT_WITH_TOTAL)).all()
return [_to_read(row) for row in rows]
async def create_goal(session: AsyncSession, payload: GoalCreate) -> GoalRead:
goal = Goal(name=payload.name, target_amount=payload.target_amount, target_date=payload.target_date)
session.add(goal)
await session.commit()
return GoalRead(
id=goal.id,
name=goal.name,
target_amount=goal.target_amount,
target_date=goal.target_date,
current_amount=Decimal("0"),
remaining=goal.target_amount,
percent=0.0,
is_reached=False,
)
async def update_goal(session: AsyncSession, goal_id: int, payload: GoalUpdate) -> GoalRead:
goal = await session.get(Goal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Goal not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(goal, field, value)
await session.commit()
return await _read_one(session, goal_id)
async def delete_goal(session: AsyncSession, goal_id: int) -> None:
goal = await session.get(Goal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Goal not found")
await session.delete(goal)
await session.commit()
async def add_contribution(session: AsyncSession, goal_id: int, payload: GoalContributionCreate) -> GoalRead:
goal = await session.get(Goal, goal_id)
if goal is None:
raise HTTPException(status_code=404, detail="Goal not found")
session.add(GoalContribution(goal_id=goal_id, amount=payload.amount, date=payload.date, note=payload.note))
await session.commit()
return await _read_one(session, goal_id)

View File

@@ -0,0 +1,119 @@
"""Proactive early-warning checks, computed from data that already exists.
Four signals: sustained negative monthly cash flow, a sustained net-worth
decline, one or more over-budget categories, and too much capital in
medium/high risk tiers (the "80% at zero risk, 20% at most exposed" rule).
The first two only look at fully-elapsed calendar months, so a same-month
false alarm (rent already paid, salary not landed yet) never fires — their
"sustained for how long" thresholds, and the risk-allocation percentage, are
all user-configurable (Settings page), stored on AppSettings, see
get_or_create_app_settings. The budget check is the opposite: it deliberately
looks at the CURRENT, still-in-progress month, since the whole point is to
catch overspending while there's still time to react.
"""
from datetime import date
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.insights import AlertsResponse, FinancialAlert
from app.schemas.net_worth import NetWorthSummary
from app.services.budget_service import get_budget_status
from app.services.dashboard_service import get_dashboard_summary
from app.services.net_worth_service import get_net_worth_summary
from app.services.settings_service import get_or_create_app_settings
MAX_LOOKBACK_MONTHS = 24
def _previous_month(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1)
async def _negative_cash_flow_streak(session: AsyncSession) -> int:
today = date.today()
year, month = _previous_month(today.year, today.month)
streak = 0
for _ in range(MAX_LOOKBACK_MONTHS):
summary = await get_dashboard_summary(session, year, month)
if summary.net >= 0:
break
streak += 1
year, month = _previous_month(year, month)
return streak
def _net_worth_decline_streak(summary: NetWorthSummary) -> int:
today = date.today()
# Keep the last point seen for each (year, month) — `series` is ordered
# ascending by date, so that's the month-end value — and drop the
# current month, which is always partial.
month_end: dict[tuple[int, int], Decimal] = {}
for point in summary.series:
if (point.date.year, point.date.month) == (today.year, today.month):
continue
month_end[(point.date.year, point.date.month)] = point.value
ordered_months = sorted(month_end)
if len(ordered_months) < 2:
return 0
streak = 0
for i in range(len(ordered_months) - 1, 0, -1):
if month_end[ordered_months[i]] < month_end[ordered_months[i - 1]]:
streak += 1
else:
break
return streak
async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
settings = await get_or_create_app_settings(session)
alerts: list[FinancialAlert] = []
cash_flow_streak = await _negative_cash_flow_streak(session)
if cash_flow_streak >= settings.negative_cash_flow_threshold_months:
alerts.append(
FinancialAlert(
key="negative_cash_flow_streak",
severity="warning",
params={"months": cash_flow_streak},
)
)
net_worth_summary = await get_net_worth_summary(session, "all")
net_worth_streak = _net_worth_decline_streak(net_worth_summary)
if net_worth_streak >= settings.net_worth_decline_threshold_months:
alerts.append(
FinancialAlert(
key="net_worth_decline_streak",
severity="warning",
params={"months": net_worth_streak},
)
)
risky_percent = sum(tier.percent for tier in net_worth_summary.risk_levels if tier.risk_level != "low")
if risky_percent > settings.risky_allocation_threshold_percent:
alerts.append(
FinancialAlert(
key="risky_allocation_exceeded",
severity="warning",
params={"percent": round(risky_percent), "threshold": settings.risky_allocation_threshold_percent},
)
)
today = date.today()
budget_status = await get_budget_status(session, today.year, today.month)
over_budget_count = sum(1 for item in budget_status.items if item.is_over_budget)
if over_budget_count > 0:
alerts.append(
FinancialAlert(
key="budget_exceeded",
severity="warning",
params={"count": over_budget_count},
)
)
return AlertsResponse(alerts=alerts)

View File

@@ -0,0 +1,282 @@
"""Net worth aggregation: a single trend line plus a percent breakdown by
asset class.
Design note: "Cash" is not stored anywhere — it's derived live from
Account/Transaction data (checking/savings/cash accounts only) so the
Transactions feature stays the single source of truth for liquid money.
Every other class (investments, crypto, real estate, vehicles, precious
metals, other) is tracked manually via Asset/AssetValuation rows. Both are
collapsed into one
daily, forward-filled series so the chart reads as one continuous line even
though the two halves are updated at very different cadences.
"""
from collections import defaultdict
from datetime import date as date_
from datetime import timedelta
from decimal import Decimal
from itertools import groupby
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.enums import AccountType, AssetClass, CapitalRole, RiskLevel, TransactionType
from app.models.transaction import Transaction
from app.schemas.net_worth import (
CapitalRoleSummary,
NetWorthBreakdownItem,
NetWorthPoint,
NetWorthSummary,
RiskLevelItem,
RiskLevelSummary,
)
CASH_ACCOUNT_TYPES = {AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH}
RANGE_DAYS = {"30d": 30, "90d": 90, "1y": 365, "5y": 365 * 5}
_CLASS_META: dict[str, tuple[str, str, str]] = {
# key -> (display name, color, lucide icon). Colors follow the dataviz
# skill's validated 8-slot order (blue, orange, aqua, yellow, magenta,
# green, violet, red) in this exact sequence — an adjacent-pair-safe
# order for a segmented bar/donut; do not reorder without re-validating.
"cash": ("Наличные", "#2a78d6", "wallet"), # slot 1 blue
AssetClass.INVESTMENTS.value: ("Инвестиции", "#eb6834", "trending-up"), # slot 2 orange
AssetClass.CRYPTO.value: ("Криптовалюта", "#1baf7a", "bitcoin"), # slot 3 aqua
AssetClass.REAL_ESTATE.value: ("Недвижимость", "#eda100", "building-2"), # slot 4 yellow
AssetClass.VEHICLES.value: ("Транспорт", "#e87ba4", "car"), # slot 5 magenta
AssetClass.PRECIOUS_METALS.value: ("Драгметаллы", "#008300", "gem"), # slot 6 green
AssetClass.OTHER.value: ("Прочее", "#4a3aa7", "package"), # slot 7 violet
}
_ROLE_META: dict[CapitalRole, tuple[str, str]] = {
# role -> (label, color). Status colors, not categorical ones — "good"
# and "critical" from the dataviz skill's fixed status palette, reused
# verbatim from how StatCard/NetWorthChart already color income vs.
# expense elsewhere in the app.
CapitalRole.INCOME: ("Доходный", "var(--success)"),
CapitalRole.NEUTRAL: ("Нейтральный", "var(--text-muted)"),
CapitalRole.DRAIN: ("Убыточный", "var(--danger)"),
}
_RISK_META: dict[RiskLevel, tuple[str, str]] = {
# Same status-color reuse as _ROLE_META — low risk reads as "good",
# high risk as "critical", not a categorical hue.
RiskLevel.LOW: ("Низкий", "var(--success)"),
RiskLevel.MEDIUM: ("Средний", "var(--text-muted)"),
RiskLevel.HIGH: ("Высокий", "var(--danger)"),
}
def _daily_series(events: list[tuple[date_, Decimal]], start: date_, end: date_) -> list[NetWorthPoint]:
"""Forward-fills a sparse, date-sorted list of cumulative totals into one
point per calendar day. `events` before `start` still count — they just
collapse into `start`'s opening value."""
events = sorted(events, key=lambda e: e[0])
points: list[NetWorthPoint] = []
idx, n = 0, len(events)
current = Decimal("0")
day = start
while day <= end:
while idx < n and events[idx][0] <= day:
current = events[idx][1]
idx += 1
points.append(NetWorthPoint(date=day, value=current))
day += timedelta(days=1)
return points
async def _cash_cumulative_events(session: AsyncSession) -> list[tuple[date_, Decimal]]:
accounts_result = await session.execute(select(Account.id, Account.type))
cash_account_ids = {acc_id for acc_id, acc_type in accounts_result.all() if acc_type in CASH_ACCOUNT_TYPES}
txns_result = await session.execute(
select(Transaction.date, Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
)
delta_by_date: dict[date_, Decimal] = defaultdict(Decimal)
for tx_date, tx_type, amount, account_id, transfer_account_id in txns_result.all():
if tx_type == TransactionType.INCOME and account_id in cash_account_ids:
delta_by_date[tx_date] += amount
elif tx_type == TransactionType.EXPENSE and account_id in cash_account_ids:
delta_by_date[tx_date] -= amount
elif tx_type == TransactionType.TRANSFER:
if account_id in cash_account_ids:
delta_by_date[tx_date] -= amount
if transfer_account_id in cash_account_ids:
delta_by_date[tx_date] += amount
events: list[tuple[date_, Decimal]] = []
running = Decimal("0")
for day in sorted(delta_by_date):
running += delta_by_date[day]
events.append((day, running))
return events
async def _asset_events_and_class_totals(
session: AsyncSession,
) -> tuple[list[tuple[date_, Decimal]], dict[AssetClass, Decimal], dict[int, Decimal]]:
asset_class_result = await session.execute(select(Asset.id, Asset.asset_class))
asset_class_map = dict(asset_class_result.all())
valuations_result = await session.execute(
select(AssetValuation.asset_id, AssetValuation.as_of_date, AssetValuation.value).order_by(
AssetValuation.as_of_date
)
)
rows = valuations_result.all()
current_by_asset: dict[int, Decimal] = {}
events: list[tuple[date_, Decimal]] = []
for day, group in groupby(rows, key=lambda row: row[1]):
for asset_id, _, value in group:
current_by_asset[asset_id] = value
events.append((day, sum(current_by_asset.values(), Decimal("0"))))
class_totals: dict[AssetClass, Decimal] = defaultdict(Decimal)
for asset_id, value in current_by_asset.items():
asset_class = asset_class_map.get(asset_id)
if asset_class is not None:
class_totals[asset_class] += value
return events, class_totals, current_by_asset
async def _capital_role_summary(session: AsyncSession, current_by_asset: dict[int, Decimal]) -> list[CapitalRoleSummary]:
"""Cross-cuts the same assets by how the user tagged them (income /
neutral / drain) instead of by asset class — always all three roles,
even at zero, so the block reads as a fixed scale rather than a list
that shuffles as assets are added."""
roles_result = await session.execute(select(Asset.id, Asset.capital_role, Asset.monthly_cash_flow))
totals_value: dict[CapitalRole, Decimal] = defaultdict(Decimal)
totals_flow: dict[CapitalRole, Decimal] = defaultdict(Decimal)
counts: dict[CapitalRole, int] = defaultdict(int)
for asset_id, role, cash_flow in roles_result.all():
totals_value[role] += current_by_asset.get(asset_id, Decimal("0"))
totals_flow[role] += cash_flow or Decimal("0")
counts[role] += 1
return [
CapitalRoleSummary(
role=role.value,
label=_ROLE_META[role][0],
color=_ROLE_META[role][1],
total_value=totals_value.get(role, Decimal("0")),
monthly_cash_flow=totals_flow.get(role, Decimal("0")),
count=counts.get(role, 0),
)
for role in CapitalRole
]
async def _risk_level_summary(
session: AsyncSession, current_by_asset: dict[int, Decimal], cash_today: Decimal
) -> list[RiskLevelSummary]:
"""Cross-cuts Cash + assets by user-tagged risk of loss — unlike
capital_roles, Cash participates here: it's the zero-risk anchor an
80/20-style allocation rule ("80% of capital at zero risk, at most 20%
exposed") is measured against. Always all three tiers, even at zero,
same reasoning as capital_roles. Each tier's item list *is* its
diversification view — a tier that's one holding at 100% is
concentrated, several even-sized holdings aren't, no separate index."""
assets_result = await session.execute(select(Asset.id, Asset.name, Asset.risk_level))
asset_rows = assets_result.all()
totals: dict[RiskLevel, Decimal] = defaultdict(Decimal)
items_by_level: dict[RiskLevel, list[tuple[str, str, Decimal]]] = defaultdict(list)
if cash_today:
totals[RiskLevel.LOW] += cash_today
items_by_level[RiskLevel.LOW].append(("cash", _CLASS_META["cash"][0], cash_today))
for asset_id, name, risk_level in asset_rows:
value = current_by_asset.get(asset_id, Decimal("0"))
if value == 0:
continue
totals[risk_level] += value
items_by_level[risk_level].append((f"asset:{asset_id}", name, value))
grand_total = sum(totals.values(), Decimal("0"))
def _share(amount: Decimal, denominator: Decimal) -> float:
return float(amount / denominator * 100) if denominator else 0.0
summaries = []
for level in RiskLevel:
tier_total = totals.get(level, Decimal("0"))
items = [
RiskLevelItem(key=key, name=name, amount=amount, percent=_share(amount, tier_total))
for key, name, amount in sorted(items_by_level.get(level, []), key=lambda item: item[2], reverse=True)
]
summaries.append(
RiskLevelSummary(
risk_level=level.value,
label=_RISK_META[level][0],
color=_RISK_META[level][1],
total_value=tier_total,
percent=_share(tier_total, grand_total),
items=items,
)
)
return summaries
def _resolve_start_date(range_key: str, cash_events: list[tuple[date_, Decimal]], asset_events: list[tuple[date_, Decimal]], today: date_) -> date_:
if range_key in RANGE_DAYS:
return today - timedelta(days=RANGE_DAYS[range_key] - 1)
all_dates = [e[0] for e in cash_events] + [e[0] for e in asset_events]
return min(all_dates) if all_dates else today
async def get_net_worth_summary(session: AsyncSession, range_key: str) -> NetWorthSummary:
today = date_.today()
cash_events = await _cash_cumulative_events(session)
asset_events, class_totals, current_by_asset = await _asset_events_and_class_totals(session)
capital_roles = await _capital_role_summary(session, current_by_asset)
start = _resolve_start_date(range_key, cash_events, asset_events, today)
cash_series = _daily_series(cash_events, start, today)
asset_series = _daily_series(asset_events, start, today)
series = [
NetWorthPoint(date=c.date, value=c.value + a.value) for c, a in zip(cash_series, asset_series, strict=True)
]
current = series[-1].value if series else Decimal("0")
start_value = series[0].value if series else Decimal("0")
change_amount = current - start_value
change_percent = float(change_amount / start_value * 100) if start_value else None
# cash_events entries are already cumulative — the last one *is* today's total.
cash_today = cash_events[-1][1] if cash_events else Decimal("0")
risk_levels = await _risk_level_summary(session, current_by_asset, cash_today)
total = cash_today + sum(class_totals.values(), Decimal("0"))
def _percent(amount: Decimal) -> float:
return float(amount / total * 100) if total else 0.0
breakdown = []
name, color, icon = _CLASS_META["cash"]
breakdown.append(NetWorthBreakdownItem(key="cash", name=name, color=color, icon=icon, amount=cash_today, percent=_percent(cash_today)))
for asset_class in AssetClass:
name, color, icon = _CLASS_META[asset_class.value]
amount = class_totals.get(asset_class, Decimal("0"))
breakdown.append(
NetWorthBreakdownItem(key=asset_class.value, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount))
)
return NetWorthSummary(
range=range_key,
current=current,
change_amount=change_amount,
change_percent=change_percent,
series=series,
breakdown=breakdown,
capital_roles=capital_roles,
risk_levels=risk_levels,
)

View File

@@ -0,0 +1,167 @@
"""Recurring transaction templates: CRUD, plus "post now" — a one-click
action that creates a real Transaction from the template. Deliberately not a
background job (see also insights_service.py's docstring on scope): the
schedule only advances when the user actually clicks Post, so a missed
week never silently back-fills a pile of transactions.
"""
import calendar
from datetime import date as date_
from datetime import timedelta
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.category import Category
from app.models.enums import CategoryKind, RecurringFrequency, TransactionType
from app.models.recurring import RecurringTransaction
from app.models.transaction import Transaction
from app.schemas.recurring import RecurringTransactionCreate, RecurringTransactionRead, RecurringTransactionUpdate
_EAGER = (
selectinload(RecurringTransaction.account),
selectinload(RecurringTransaction.category),
selectinload(RecurringTransaction.transfer_account),
)
_TYPE_TO_CATEGORY_KIND = {
TransactionType.INCOME: CategoryKind.INCOME,
TransactionType.EXPENSE: CategoryKind.EXPENSE,
}
async def _ensure_category_matches_type(
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
) -> None:
if category_id is None:
return
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=400, detail="Category not found")
if expected_kind is not None and category.kind != expected_kind:
raise HTTPException(
status_code=400,
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} recurring transaction",
)
def _advance(day: date_, frequency: RecurringFrequency) -> date_:
if frequency == RecurringFrequency.WEEKLY:
return day + timedelta(days=7)
if frequency == RecurringFrequency.MONTHLY:
year = day.year + (day.month // 12)
month = day.month % 12 + 1
clamped_day = min(day.day, calendar.monthrange(year, month)[1])
return date_(year, month, clamped_day)
# YEARLY — Feb 29 anchors fall back to Feb 28 in non-leap years.
try:
return day.replace(year=day.year + 1)
except ValueError:
return day.replace(year=day.year + 1, day=28)
def _next_due_date(recurring: RecurringTransaction) -> date_:
if recurring.last_posted_date is None:
return recurring.anchor_date
return _advance(recurring.last_posted_date, recurring.frequency)
def _to_read(recurring: RecurringTransaction) -> RecurringTransactionRead:
next_due = _next_due_date(recurring)
today = date_.today()
return RecurringTransactionRead(
id=recurring.id,
account_id=recurring.account_id,
account_name=recurring.account.name,
category_id=recurring.category_id,
category_name=recurring.category.name if recurring.category else None,
category_color=recurring.category.color if recurring.category else None,
category_icon=recurring.category.icon if recurring.category else None,
transfer_account_id=recurring.transfer_account_id,
transfer_account_name=recurring.transfer_account.name if recurring.transfer_account else None,
type=recurring.type,
amount=recurring.amount,
description=recurring.description,
merchant=recurring.merchant,
notes=recurring.notes,
frequency=recurring.frequency,
anchor_date=recurring.anchor_date,
last_posted_date=recurring.last_posted_date,
is_active=recurring.is_active,
next_due_date=next_due,
is_due=recurring.is_active and next_due <= today,
days_until_due=(next_due - today).days,
)
async def _get_or_404(session: AsyncSession, recurring_id: int) -> RecurringTransaction:
result = await session.execute(
select(RecurringTransaction).options(*_EAGER).where(RecurringTransaction.id == recurring_id)
)
recurring = result.scalar_one_or_none()
if recurring is None:
raise HTTPException(status_code=404, detail="Recurring transaction not found")
return recurring
async def list_recurring(session: AsyncSession) -> list[RecurringTransactionRead]:
result = await session.execute(
select(RecurringTransaction).options(*_EAGER).order_by(RecurringTransaction.id)
)
return [_to_read(row) for row in result.scalars().all()]
async def create_recurring(session: AsyncSession, payload: RecurringTransactionCreate) -> RecurringTransactionRead:
await _ensure_category_matches_type(session, payload.category_id, payload.type)
recurring = RecurringTransaction(**payload.model_dump())
session.add(recurring)
await session.commit()
return _to_read(await _get_or_404(session, recurring.id))
async def update_recurring(
session: AsyncSession, recurring_id: int, payload: RecurringTransactionUpdate
) -> RecurringTransactionRead:
recurring = await _get_or_404(session, recurring_id)
updates = payload.model_dump(exclude_unset=True)
effective_type = updates.get("type", recurring.type)
effective_category_id = updates.get("category_id", recurring.category_id)
await _ensure_category_matches_type(session, effective_category_id, effective_type)
for field, value in updates.items():
setattr(recurring, field, value)
await session.commit()
return _to_read(await _get_or_404(session, recurring_id))
async def delete_recurring(session: AsyncSession, recurring_id: int) -> None:
recurring = await session.get(RecurringTransaction, recurring_id)
if recurring is None:
raise HTTPException(status_code=404, detail="Recurring transaction not found")
await session.delete(recurring)
await session.commit()
async def post_recurring(session: AsyncSession, recurring_id: int) -> RecurringTransactionRead:
"""Creates a real Transaction from the template, dated today, and moves
last_posted_date forward — the only thing that advances the schedule."""
recurring = await _get_or_404(session, recurring_id)
today = date_.today()
session.add(
Transaction(
account_id=recurring.account_id,
category_id=recurring.category_id,
transfer_account_id=recurring.transfer_account_id,
type=recurring.type,
amount=recurring.amount,
description=recurring.description,
merchant=recurring.merchant,
notes=recurring.notes,
date=today,
)
)
recurring.last_posted_date = today
await session.commit()
return _to_read(await _get_or_404(session, recurring_id))

View File

@@ -0,0 +1,158 @@
"""Long-range spending reports — the analysis the month-scoped Dashboard
breakdown can't answer on its own: "how much did I spend on X this month,
and how much in total over N years" (single-category detail), and "which
categories cost the most over this whole period" (ranking, across all
categories of one kind at once, not just the current month).
"""
from datetime import date as date_
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import extract, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.transaction import Transaction
from app.schemas.reports import (
CategoryRankingItem,
CategoryRankingReport,
CategorySpendingPoint,
CategorySpendingReport,
)
def _next_month(year: int, month: int) -> tuple[int, int]:
return (year + 1, 1) if month == 12 else (year, month + 1)
async def get_category_spending_report(
session: AsyncSession, category_id: int, start_date: date_ | None, end_date: date_ | None
) -> CategorySpendingReport:
category = await session.get(Category, category_id)
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
Transaction.category_id == category_id
)
if start_date:
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
if end_date:
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
min_date, max_date = (await session.execute(bounds_stmt)).one()
effective_start = start_date or min_date
effective_end = end_date or max_date
empty = CategorySpendingReport(
category_id=category.id,
category_name=category.name,
category_color=category.color,
category_icon=category.icon,
start_date=effective_start,
end_date=effective_end,
total_amount=Decimal("0"),
transaction_count=0,
average_per_month=Decimal("0"),
series=[],
)
if effective_start is None or effective_end is None:
return empty
rows_stmt = (
select(
extract("year", Transaction.date).label("year"),
extract("month", Transaction.date).label("month"),
func.sum(Transaction.amount).label("amount"),
func.count(Transaction.id).label("cnt"),
)
.where(
Transaction.category_id == category_id,
Transaction.date >= effective_start,
Transaction.date <= effective_end,
)
.group_by("year", "month")
)
rows = (await session.execute(rows_stmt)).all()
by_month: dict[tuple[int, int], Decimal] = {(int(row.year), int(row.month)): row.amount for row in rows}
total_amount = sum((row.amount for row in rows), Decimal("0"))
total_count = sum(row.cnt for row in rows)
series: list[CategorySpendingPoint] = []
year, month = effective_start.year, effective_start.month
while (year, month) <= (effective_end.year, effective_end.month):
series.append(CategorySpendingPoint(year=year, month=month, amount=by_month.get((year, month), Decimal("0"))))
year, month = _next_month(year, month)
months_count = len(series)
average_per_month = (total_amount / months_count).quantize(Decimal("0.01")) if months_count else Decimal("0")
return CategorySpendingReport(
category_id=category.id,
category_name=category.name,
category_color=category.color,
category_icon=category.icon,
start_date=effective_start,
end_date=effective_end,
total_amount=total_amount,
transaction_count=total_count,
average_per_month=average_per_month,
series=series,
)
_KIND_TO_TRANSACTION_TYPE = {
CategoryKind.EXPENSE: TransactionType.EXPENSE,
CategoryKind.INCOME: TransactionType.INCOME,
}
async def get_category_ranking_report(
session: AsyncSession, kind: CategoryKind, start_date: date_ | None, end_date: date_ | None
) -> CategoryRankingReport:
"""All categories of one kind, ranked by total spent/earned over an
arbitrary period — "which category costs the most" across the whole
range, unlike the month-scoped Dashboard breakdown or the
single-category detail above."""
stmt = (
select(
Category.id,
Category.name,
Category.color,
Category.icon,
func.sum(Transaction.amount).label("amount"),
func.count(Transaction.id).label("cnt"),
)
.join(Category, Category.id == Transaction.category_id)
.where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
)
if start_date:
stmt = stmt.where(Transaction.date >= start_date)
if end_date:
stmt = stmt.where(Transaction.date <= end_date)
stmt = stmt.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
rows = (await session.execute(stmt)).all()
total_amount = sum((row.amount for row in rows), Decimal("0"))
def _percent(amount: Decimal) -> float:
return float(amount / total_amount * 100) if total_amount else 0.0
items = [
CategoryRankingItem(
category_id=cat_id,
name=name,
color=color,
icon=icon,
amount=amount,
percent=_percent(amount),
transaction_count=cnt,
)
for cat_id, name, color, icon, amount, cnt in rows
]
return CategoryRankingReport(
start_date=start_date, end_date=end_date, total_amount=total_amount, items=items
)

View File

@@ -0,0 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.settings import AppSettings
async def get_or_create_app_settings(session: AsyncSession) -> AppSettings:
"""Singleton row (id=1) — get-or-create so callers never hit a missing
row even if app startup's seeding hasn't run yet (e.g. a fresh test DB)."""
settings = await session.get(AppSettings, 1)
if settings is None:
settings = AppSettings(id=1)
session.add(settings)
await session.commit()
await session.refresh(settings)
return settings

8
backend/requirements.txt Normal file
View File

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

View File

View File

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

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-aurum}
POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
volumes:
- aurum_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${AURUM_POSTGRES_USER:-aurum}"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
restart: unless-stopped
environment:
AURUM_POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
AURUM_POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-aurum}
AURUM_POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
AURUM_POSTGRES_HOST: db
AURUM_POSTGRES_PORT: 5432
AURUM_CORS_ORIGINS: ${AURUM_CORS_ORIGINS:-*}
AURUM_DEFAULT_CURRENCY: ${AURUM_DEFAULT_CURRENCY:-USD}
depends_on:
db:
condition: service_healthy
web:
build: ./frontend
restart: unless-stopped
ports:
- "${AURUM_WEB_PORT:-3000}:80"
environment:
# Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh.
AURUM_BASIC_AUTH_USER: ${AURUM_BASIC_AUTH_USER:-}
AURUM_BASIC_AUTH_PASSWORD: ${AURUM_BASIC_AUTH_PASSWORD:-}
depends_on:
- backend
volumes:
aurum_pgdata:

14
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
# Without this file, `docker build` sends the whole frontend/ directory
# (including node_modules, which can be 100+MB of host-platform-specific
# native binaries) to the daemon, and the final `COPY . .` in the Dockerfile
# copies it straight over the fresh, correct-platform `npm install` output
# from the layer before it. Keep this in sync with .gitignore's intent even
# though the two files serve different tools.
node_modules
dist
.vite
.git
.env
.env.*
!.env.example
.DS_Store

21
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
# openssl generates the htpasswd hash for optional basic auth — see
# docker-entrypoint.d/20-basic-auth.sh.
RUN apk add --no-cache openssl
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/20-basic-auth.sh
RUN chmod +x /docker-entrypoint.d/20-basic-auth.sh \
&& touch /etc/nginx/basic-auth.conf
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=5 \
CMD wget -qO- http://127.0.0.1:80/api/health || exit 1

View File

@@ -0,0 +1,32 @@
#!/bin/sh
# Runs automatically before nginx starts (official nginx image convention:
# every executable script in /docker-entrypoint.d/ is sourced on boot).
#
# Aurum's backend has no login system by design (see backend/app/main.py) —
# it's built for one person self-hosting their own instance, not a
# multi-tenant service. That means whoever can reach this container can
# read, edit, and delete all financial data with no password at all. This
# script is the only gate in front of that: if AURUM_BASIC_AUTH_USER and
# AURUM_BASIC_AUTH_PASSWORD are set, it turns on HTTP Basic Auth for the
# whole app (UI + API) at the nginx layer, in front of everything except
# the health check endpoint (which must stay reachable for Docker's own
# HEALTHCHECK and external uptime monitors).
set -eu
AUTH_FRAGMENT=/etc/nginx/basic-auth.conf
if [ -n "${AURUM_BASIC_AUTH_USER:-}" ] && [ -n "${AURUM_BASIC_AUTH_PASSWORD:-}" ]; then
HASH="$(openssl passwd -apr1 "$AURUM_BASIC_AUTH_PASSWORD")"
echo "${AURUM_BASIC_AUTH_USER}:${HASH}" > /etc/nginx/.htpasswd
cat > "$AUTH_FRAGMENT" <<EOF
auth_basic "Aurum";
auth_basic_user_file /etc/nginx/.htpasswd;
EOF
echo "[aurum] Basic auth enabled for user '${AURUM_BASIC_AUTH_USER}'."
else
: > "$AUTH_FRAGMENT"
echo "[aurum] WARNING: AURUM_BASIC_AUTH_USER / AURUM_BASIC_AUTH_PASSWORD are not set." >&2
echo "[aurum] This instance has NO authentication — anyone who can reach it can read," >&2
echo "[aurum] edit, and delete all financial data. Fine for 'localhost only'. Before" >&2
echo "[aurum] exposing this beyond your own machine, set both variables in .env." >&2
fi

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0b0b" />
<title>Aurum — Личные финансы</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

37
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Always open, no auth — this is what Docker's own HEALTHCHECK and any
# external uptime monitor hit, and it returns nothing but {"status":"ok"}.
# docker-entrypoint.d/20-basic-auth.sh writes basic-auth.conf; without an
# explicit "auth_basic off" here this location would inherit whatever it
# sets below and healthchecks would start failing with 401.
location = /api/health {
auth_basic off;
proxy_pass http://backend:8000/api/health;
proxy_set_header Host $host;
}
# Reverse proxy to the FastAPI backend container. basic-auth.conf is
# generated at container start (empty if AURUM_BASIC_AUTH_USER/PASSWORD
# aren't set, so this is a no-op by default) — see docker-entrypoint.d/.
location /api/ {
include /etc/nginx/basic-auth.conf;
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SPA fallback — client-side routing handles the rest.
location / {
include /etc/nginx/basic-auth.conf;
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}

2467
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "aurum-frontend",
"private": true,
"version": "0.15.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc -b --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"clsx": "^2.1.1",
"lucide-react": "^1.31.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.1"
}
}

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="aurumCoin" x1="5" y1="4" x2="27" y2="28" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f7d488"/>
<stop offset="0.55" stop-color="#eda100"/>
<stop offset="1" stop-color="#a8690a"/>
</linearGradient>
</defs>
<circle cx="16" cy="16" r="14.5" fill="url(#aurumCoin)" stroke="#7a4e08" stroke-width="1.2"/>
<circle cx="16" cy="16" r="11.5" fill="none" stroke="#7a4e08" stroke-width="0.6" opacity="0.45"/>
<text x="16" y="21.5" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="700" text-anchor="middle" fill="#3a2405">A</text>
</svg>

After

Width:  |  Height:  |  Size: 693 B

62
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { Route, Routes } from "react-router-dom";
import { Sidebar } from "@/components/layout/Sidebar";
import { Topbar } from "@/components/layout/Topbar";
import { useLocalStorageState } from "@/hooks/useLocalStorageState";
import { useAppSettings } from "@/hooks/useSettings";
import { setCurrency } from "@/lib/i18n";
import { AccountsPage } from "@/pages/AccountsPage";
import { AdvicePage } from "@/pages/AdvicePage";
import { BudgetPage } from "@/pages/BudgetPage";
import { CashFlowPage } from "@/pages/CashFlowPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { NetWorthPage } from "@/pages/NetWorthPage";
import { RecurringPage } from "@/pages/RecurringPage";
import { ReportsPage } from "@/pages/ReportsPage";
import { RoiPage } from "@/pages/RoiPage";
import { SettingsPage } from "@/pages/SettingsPage";
import { TransactionsPage } from "@/pages/TransactionsPage";
export default function App() {
const [collapsed, setCollapsed] = useLocalStorageState("aurum:sidebar-collapsed", false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
// Primary currency is server-persisted, unlike language — sync the
// client-side reactive mirror (lib/i18n's getCurrency/formatCurrency)
// once the setting loads, so it's not stuck on the "USD" fallback default.
const { data: settings } = useAppSettings();
useEffect(() => {
if (settings) setCurrency(settings.currency);
}, [settings]);
return (
<div className="flex min-h-screen bg-surface-0">
<Sidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed(!collapsed)}
mobileOpen={mobileNavOpen}
onCloseMobile={() => setMobileNavOpen(false)}
/>
<div className="flex min-w-0 flex-1 flex-col">
<Topbar onOpenMobileNav={() => setMobileNavOpen(true)} />
<main className="mx-auto w-full max-w-6xl px-4 py-5 sm:px-6 sm:py-6 lg:px-8">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/net-worth" element={<NetWorthPage />} />
<Route path="/roi" element={<RoiPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/cash-flow" element={<CashFlowPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/budget" element={<BudgetPage />} />
<Route path="/advice" element={<AdvicePage />} />
<Route path="/goals" element={<GoalsPage />} />
<Route path="/recurring" element={<RecurringPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More