mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
Initial public release
Aurum — a self-hosted personal finance operating system: cash flow, net worth tracking, budgets, goals, recurring payments, reports, proactive alerts, and a compound-interest returns calculator, all bilingual (RU/EN) and mobile-first. Ships as a three-container Docker Compose stack (Postgres + FastAPI + nginx-served React frontend). See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal 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
47
.gitignore
vendored
Normal 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
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
1. Никогда не удаляй комментарии, которые уже есть. Ты можешь изменить комментарии на более актуальные, но нельзя удалять их, чтобы код оставался без них.
|
||||||
|
2. Код делай атомарным, чтобы изменение в одном месте, не поломало другие функции. Код должен быть написан таким образом, чтобы он хорошо поддерживался, обновлялся и масштабировался.
|
||||||
|
3. Важные новые функции которые ты добавил, записывай в файл UPDATES.md указывая текущую дату и версию приложения, мне это нужно, чтобы потом добавить в changelog моих обновлений платформы. Также после этого делай коммит и перечисляй что ты добавил.
|
||||||
|
6. Во время разработки интерфейса сразу же адаптируй это под мобильную версию.
|
||||||
|
7. Всегда отвечай мне на русском.
|
||||||
52
CONTRIBUTING.md
Normal file
52
CONTRIBUTING.md
Normal 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
136
LICENSE
Normal 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
186
README.md
Normal 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)
|
||||||
|
[](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
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \
|
||||||
|
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
61
backend/alembic/env.py
Normal file
61
backend/alembic/env.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models import ( # noqa: F401 — registers metadata
|
||||||
|
Account,
|
||||||
|
AppSettings,
|
||||||
|
Asset,
|
||||||
|
AssetValuation,
|
||||||
|
Budget,
|
||||||
|
Category,
|
||||||
|
Goal,
|
||||||
|
GoalContribution,
|
||||||
|
RecurringTransaction,
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""add capital role and monthly cash flow to assets
|
||||||
|
|
||||||
|
Revision ID: 1152a14e9d55
|
||||||
|
Revises: 6721ea138cdf
|
||||||
|
Create Date: 2026-08-14 17:29:53.284630
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '1152a14e9d55'
|
||||||
|
down_revision: Union[str, None] = '6721ea138cdf'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - adjusted: existing rows need a
|
||||||
|
# default or this NOT NULL column addition fails outright. ###
|
||||||
|
op.add_column(
|
||||||
|
'assets',
|
||||||
|
sa.Column(
|
||||||
|
'capital_role',
|
||||||
|
sa.Enum('INCOME', 'NEUTRAL', 'DRAIN', name='capital_role', native_enum=False, length=10),
|
||||||
|
nullable=False,
|
||||||
|
server_default='NEUTRAL',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column('assets', 'capital_role', server_default=None)
|
||||||
|
op.add_column('assets', sa.Column('monthly_cash_flow', sa.Numeric(precision=14, scale=2), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('assets', 'monthly_cash_flow')
|
||||||
|
op.drop_column('assets', 'capital_role')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""add assets and asset valuations
|
||||||
|
|
||||||
|
Revision ID: 6721ea138cdf
|
||||||
|
Revises: cfcd12cc849b
|
||||||
|
Create Date: 2026-08-14 15:43:19.548554
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6721ea138cdf'
|
||||||
|
down_revision: Union[str, None] = 'cfcd12cc849b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('assets',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('asset_class', sa.Enum('INVESTMENTS', 'CRYPTO', 'REAL_ESTATE', 'VEHICLES', 'OTHER', name='asset_class', native_enum=False, length=20), nullable=False),
|
||||||
|
sa.Column('currency', sa.String(length=3), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('asset_valuations',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('asset_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('value', sa.Numeric(precision=14, scale=2), nullable=False),
|
||||||
|
sa.Column('as_of_date', sa.Date(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('asset_id', 'as_of_date', name='uq_asset_valuation_date')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('asset_valuations')
|
||||||
|
op.drop_table('assets')
|
||||||
|
# ### end Alembic commands ###
|
||||||
35
backend/alembic/versions/9f3a2d7c5e11_add_app_settings.py
Normal file
35
backend/alembic/versions/9f3a2d7c5e11_add_app_settings.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""add app_settings
|
||||||
|
|
||||||
|
Revision ID: 9f3a2d7c5e11
|
||||||
|
Revises: 1152a14e9d55
|
||||||
|
Create Date: 2026-08-16 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '9f3a2d7c5e11'
|
||||||
|
down_revision: Union[str, None] = '1152a14e9d55'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('currency', sa.String(length=3), nullable=False, server_default='USD'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
# Singleton row (id=1) — app boot's seed_default_app_settings() also
|
||||||
|
# get-or-creates it, but seeding it here means it exists immediately
|
||||||
|
# after migrating, even before the app has started once.
|
||||||
|
op.execute("INSERT INTO app_settings (id, currency) VALUES (1, 'USD')")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('app_settings')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add risky allocation threshold to app_settings
|
||||||
|
|
||||||
|
Revision ID: a71d9f0c3e58
|
||||||
|
Revises: f2c8e4a917b3
|
||||||
|
Create Date: 2026-08-16 23:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a71d9f0c3e58'
|
||||||
|
down_revision: Union[str, None] = 'f2c8e4a917b3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('risky_allocation_threshold_percent', sa.Integer(), nullable=False, server_default='20'),
|
||||||
|
)
|
||||||
|
op.alter_column('app_settings', 'risky_allocation_threshold_percent', server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('app_settings', 'risky_allocation_threshold_percent')
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add alert thresholds to app_settings
|
||||||
|
|
||||||
|
Revision ID: b4e7c1a9f203
|
||||||
|
Revises: 9f3a2d7c5e11
|
||||||
|
Create Date: 2026-08-16 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b4e7c1a9f203'
|
||||||
|
down_revision: Union[str, None] = '9f3a2d7c5e11'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('negative_cash_flow_threshold_months', sa.Integer(), nullable=False, server_default='2'),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('net_worth_decline_threshold_months', sa.Integer(), nullable=False, server_default='2'),
|
||||||
|
)
|
||||||
|
op.alter_column('app_settings', 'negative_cash_flow_threshold_months', server_default=None)
|
||||||
|
op.alter_column('app_settings', 'net_worth_decline_threshold_months', server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('app_settings', 'net_worth_decline_threshold_months')
|
||||||
|
op.drop_column('app_settings', 'negative_cash_flow_threshold_months')
|
||||||
36
backend/alembic/versions/c7a5f9e2b816_add_budgets.py
Normal file
36
backend/alembic/versions/c7a5f9e2b816_add_budgets.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""add budgets
|
||||||
|
|
||||||
|
Revision ID: c7a5f9e2b816
|
||||||
|
Revises: b4e7c1a9f203
|
||||||
|
Create Date: 2026-08-16 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c7a5f9e2b816'
|
||||||
|
down_revision: Union[str, None] = 'b4e7c1a9f203'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'budgets',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('monthly_limit', sa.Numeric(precision=14, scale=2), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('category_id', name='uq_budgets_category_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('budgets')
|
||||||
70
backend/alembic/versions/cfcd12cc849b_initial_schema.py
Normal file
70
backend/alembic/versions/cfcd12cc849b_initial_schema.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: cfcd12cc849b
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-14 15:23:43.618981
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'cfcd12cc849b'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('accounts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('type', sa.Enum('CHECKING', 'SAVINGS', 'CREDIT_CARD', 'CASH', 'INVESTMENT', 'OTHER', name='account_type', native_enum=False, length=20), nullable=False),
|
||||||
|
sa.Column('currency', sa.String(length=3), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=7), nullable=True),
|
||||||
|
sa.Column('is_archived', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('categories',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('kind', sa.Enum('INCOME', 'EXPENSE', name='category_kind', native_enum=False, length=10), nullable=False),
|
||||||
|
sa.Column('icon', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('color', sa.String(length=7), nullable=False),
|
||||||
|
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('transactions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('transfer_account_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('type', sa.Enum('INCOME', 'EXPENSE', 'TRANSFER', name='transaction_type', native_enum=False, length=10), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('merchant', sa.String(length=150), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['transfer_account_id'], ['accounts.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('transactions')
|
||||||
|
op.drop_table('categories')
|
||||||
|
op.drop_table('accounts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
46
backend/alembic/versions/d3f8a1c4b729_add_goals.py
Normal file
46
backend/alembic/versions/d3f8a1c4b729_add_goals.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""add goals
|
||||||
|
|
||||||
|
Revision ID: d3f8a1c4b729
|
||||||
|
Revises: c7a5f9e2b816
|
||||||
|
Create Date: 2026-08-16 20:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd3f8a1c4b729'
|
||||||
|
down_revision: Union[str, None] = 'c7a5f9e2b816'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'goals',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('target_amount', sa.Numeric(precision=14, scale=2), nullable=False),
|
||||||
|
sa.Column('target_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
'goal_contributions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('goal_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
|
||||||
|
sa.Column('date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('note', sa.String(length=200), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('goal_contributions')
|
||||||
|
op.drop_table('goals')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add recurring transactions
|
||||||
|
|
||||||
|
Revision ID: e91b6d3c5a04
|
||||||
|
Revises: d3f8a1c4b729
|
||||||
|
Create Date: 2026-08-16 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e91b6d3c5a04'
|
||||||
|
down_revision: Union[str, None] = 'd3f8a1c4b729'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'recurring_transactions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('transfer_account_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
'type',
|
||||||
|
sa.Enum('INCOME', 'EXPENSE', 'TRANSFER', name='recurring_transaction_type', native_enum=False, length=10),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=14, scale=2), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('merchant', sa.String(length=150), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
'frequency',
|
||||||
|
sa.Enum('WEEKLY', 'MONTHLY', 'YEARLY', name='recurring_frequency', native_enum=False, length=10),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('anchor_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('last_posted_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['transfer_account_id'], ['accounts.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('recurring_transactions')
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add risk_level to assets
|
||||||
|
|
||||||
|
Revision ID: f2c8e4a917b3
|
||||||
|
Revises: e91b6d3c5a04
|
||||||
|
Create Date: 2026-08-16 23:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f2c8e4a917b3'
|
||||||
|
down_revision: Union[str, None] = 'e91b6d3c5a04'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### existing rows need a default or this NOT NULL column addition fails outright. ###
|
||||||
|
op.add_column(
|
||||||
|
'assets',
|
||||||
|
sa.Column(
|
||||||
|
'risk_level',
|
||||||
|
sa.Enum('LOW', 'MEDIUM', 'HIGH', name='risk_level', native_enum=False, length=10),
|
||||||
|
nullable=False,
|
||||||
|
server_default='MEDIUM',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column('assets', 'risk_level', server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('assets', 'risk_level')
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
12
backend/app/api/deps.py
Normal file
12
backend/app/api/deps.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
|
||||||
|
DbSession = AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async for session in get_db():
|
||||||
|
yield session
|
||||||
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
34
backend/app/api/routes/accounts.py
Normal file
34
backend/app/api/routes/accounts.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
||||||
|
from app.services.account_service import create_account, delete_account, list_accounts, update_account
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AccountWithBalance])
|
||||||
|
async def read_accounts(
|
||||||
|
include_archived: bool = False, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> list[AccountWithBalance]:
|
||||||
|
return await list_accounts(session, include_archived)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AccountWithBalance, status_code=201)
|
||||||
|
async def create_account_route(
|
||||||
|
payload: AccountCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> AccountWithBalance:
|
||||||
|
return await create_account(session, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{account_id}", response_model=AccountWithBalance)
|
||||||
|
async def update_account_route(
|
||||||
|
account_id: int, payload: AccountUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> AccountWithBalance:
|
||||||
|
return await update_account(session, account_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{account_id}", status_code=204)
|
||||||
|
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
await delete_account(session, account_id)
|
||||||
13
backend/app/api/routes/advice.py
Normal file
13
backend/app/api/routes/advice.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.advice import AdviceResponse
|
||||||
|
from app.services.advice_service import get_advice
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/advice", tags=["advice"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AdviceResponse)
|
||||||
|
async def read_advice(session: AsyncSession = Depends(get_session)) -> AdviceResponse:
|
||||||
|
return await get_advice(session)
|
||||||
112
backend/app/api/routes/assets.py
Normal file
112
backend/app/api/routes/assets.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.asset import Asset, AssetValuation
|
||||||
|
from app.schemas.asset import AssetCreate, AssetRead, AssetUpdate, AssetValuationCreate, AssetValuationRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/assets", tags=["assets"])
|
||||||
|
|
||||||
|
_EAGER = (selectinload(Asset.valuations),)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(asset: Asset) -> AssetRead:
|
||||||
|
latest = asset.valuations[-1] if asset.valuations else None
|
||||||
|
return AssetRead(
|
||||||
|
id=asset.id,
|
||||||
|
name=asset.name,
|
||||||
|
asset_class=asset.asset_class,
|
||||||
|
currency=asset.currency,
|
||||||
|
notes=asset.notes,
|
||||||
|
capital_role=asset.capital_role,
|
||||||
|
monthly_cash_flow=asset.monthly_cash_flow,
|
||||||
|
current_value=latest.value if latest else 0,
|
||||||
|
as_of_date=latest.as_of_date if latest else asset.created_at.date(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AssetRead])
|
||||||
|
async def list_assets(session: AsyncSession = Depends(get_session)) -> list[AssetRead]:
|
||||||
|
result = await session.execute(select(Asset).options(*_EAGER).order_by(Asset.name))
|
||||||
|
return [_to_read(asset) for asset in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AssetRead, status_code=201)
|
||||||
|
async def create_asset(payload: AssetCreate, session: AsyncSession = Depends(get_session)) -> AssetRead:
|
||||||
|
asset = Asset(
|
||||||
|
name=payload.name,
|
||||||
|
asset_class=payload.asset_class,
|
||||||
|
currency=payload.currency,
|
||||||
|
notes=payload.notes,
|
||||||
|
capital_role=payload.capital_role,
|
||||||
|
monthly_cash_flow=payload.monthly_cash_flow,
|
||||||
|
)
|
||||||
|
session.add(asset)
|
||||||
|
await session.flush()
|
||||||
|
session.add(AssetValuation(asset_id=asset.id, value=payload.value, as_of_date=payload.as_of_date))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
refreshed = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset.id))
|
||||||
|
return _to_read(refreshed.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{asset_id}", response_model=AssetRead)
|
||||||
|
async def update_asset(asset_id: int, payload: AssetUpdate, session: AsyncSession = Depends(get_session)) -> AssetRead:
|
||||||
|
result = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset_id))
|
||||||
|
asset = result.scalar_one_or_none()
|
||||||
|
if asset is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(asset, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(asset, attribute_names=["valuations"])
|
||||||
|
return _to_read(asset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{asset_id}/valuations", response_model=AssetRead)
|
||||||
|
async def add_asset_valuation(
|
||||||
|
asset_id: int, payload: AssetValuationCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> AssetRead:
|
||||||
|
"""Records (or corrects) an asset's value as of a date. Re-submitting the
|
||||||
|
same date updates that day's value instead of erroring, so users can fix
|
||||||
|
a typo without needing a separate edit flow."""
|
||||||
|
asset = await session.get(Asset, asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
|
||||||
|
upsert_stmt = (
|
||||||
|
pg_insert(AssetValuation)
|
||||||
|
.values(asset_id=asset_id, value=payload.value, as_of_date=payload.as_of_date)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[AssetValuation.asset_id, AssetValuation.as_of_date],
|
||||||
|
set_={"value": payload.value},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(upsert_stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
refreshed = await session.execute(select(Asset).options(*_EAGER).where(Asset.id == asset_id))
|
||||||
|
return _to_read(refreshed.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{asset_id}/valuations", response_model=list[AssetValuationRead])
|
||||||
|
async def list_asset_valuations(asset_id: int, session: AsyncSession = Depends(get_session)) -> list[AssetValuation]:
|
||||||
|
asset = await session.get(Asset, asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
result = await session.execute(
|
||||||
|
select(AssetValuation).where(AssetValuation.asset_id == asset_id).order_by(AssetValuation.as_of_date)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{asset_id}", status_code=204)
|
||||||
|
async def delete_asset(asset_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
asset = await session.get(Asset, asset_id)
|
||||||
|
if asset is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
await session.delete(asset)
|
||||||
|
await session.commit()
|
||||||
19
backend/app/api/routes/backup.py
Normal file
19
backend/app/api/routes/backup.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.backup import BackupPayload
|
||||||
|
from app.services.backup_service import build_backup, restore_backup
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/backup", tags=["backup"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export", response_model=BackupPayload)
|
||||||
|
async def export_backup(session: AsyncSession = Depends(get_session)) -> BackupPayload:
|
||||||
|
return await build_backup(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
|
||||||
|
await restore_backup(session, payload)
|
||||||
|
return {"status": "ok"}
|
||||||
53
backend/app/api/routes/budgets.py
Normal file
53
backend/app/api/routes/budgets.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.schemas.budget import BudgetCreate, BudgetRead, BudgetStatusResponse, BudgetUpdate
|
||||||
|
from app.services.budget_service import create_budget, delete_budget, get_budget_status, list_budgets, update_budget
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/budgets", tags=["budgets"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(budget: Budget) -> BudgetRead:
|
||||||
|
return BudgetRead(
|
||||||
|
id=budget.id,
|
||||||
|
category_id=budget.category_id,
|
||||||
|
category_name=budget.category.name,
|
||||||
|
category_color=budget.category.color,
|
||||||
|
category_icon=budget.category.icon,
|
||||||
|
monthly_limit=budget.monthly_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BudgetRead])
|
||||||
|
async def read_budgets(session: AsyncSession = Depends(get_session)) -> list[BudgetRead]:
|
||||||
|
return [_to_read(budget) for budget in await list_budgets(session)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=BudgetStatusResponse)
|
||||||
|
async def read_budget_status(
|
||||||
|
year: int = Query(default_factory=lambda: date.today().year, ge=2000, le=2100),
|
||||||
|
month: int = Query(default_factory=lambda: date.today().month, ge=1, le=12),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> BudgetStatusResponse:
|
||||||
|
return await get_budget_status(session, year, month)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BudgetRead, status_code=201)
|
||||||
|
async def create_budget_route(payload: BudgetCreate, session: AsyncSession = Depends(get_session)) -> BudgetRead:
|
||||||
|
return _to_read(await create_budget(session, payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{budget_id}", response_model=BudgetRead)
|
||||||
|
async def update_budget_route(
|
||||||
|
budget_id: int, payload: BudgetUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> BudgetRead:
|
||||||
|
return _to_read(await update_budget(session, budget_id, payload))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{budget_id}", status_code=204)
|
||||||
|
async def delete_budget_route(budget_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
await delete_budget(session, budget_id)
|
||||||
19
backend/app/api/routes/cash_flow.py
Normal file
19
backend/app/api/routes/cash_flow.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.cash_flow import CashFlowResponse
|
||||||
|
from app.services.cash_flow_service import get_cash_flow
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cash-flow", tags=["cash-flow"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CashFlowResponse)
|
||||||
|
async def read_cash_flow(
|
||||||
|
start_date: date_ | None = Query(default=None),
|
||||||
|
end_date: date_ | None = Query(default=None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CashFlowResponse:
|
||||||
|
return await get_cash_flow(session, start_date, end_date)
|
||||||
55
backend/app/api/routes/categories.py
Normal file
55
backend/app/api/routes/categories.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import CategoryKind
|
||||||
|
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CategoryRead])
|
||||||
|
async def list_categories(
|
||||||
|
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> list[Category]:
|
||||||
|
stmt = select(Category).order_by(Category.sort_order)
|
||||||
|
if kind is not None:
|
||||||
|
stmt = stmt.where(Category.kind == kind)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CategoryRead, status_code=201)
|
||||||
|
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category:
|
||||||
|
category = Category(**payload.model_dump(), is_default=False)
|
||||||
|
session.add(category)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(category)
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{category_id}", response_model=CategoryRead)
|
||||||
|
async def update_category(
|
||||||
|
category_id: int, payload: CategoryUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> Category:
|
||||||
|
category = await session.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(category, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(category)
|
||||||
|
return category
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{category_id}", status_code=204)
|
||||||
|
async def delete_category(category_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
category = await session.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
if category.is_default:
|
||||||
|
raise HTTPException(status_code=400, detail="Default categories cannot be deleted")
|
||||||
|
await session.delete(category)
|
||||||
|
await session.commit()
|
||||||
19
backend/app/api/routes/dashboard.py
Normal file
19
backend/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.dashboard import DashboardSummary
|
||||||
|
from app.services.dashboard_service import get_dashboard_summary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=DashboardSummary)
|
||||||
|
async def read_dashboard_summary(
|
||||||
|
year: int = Query(default_factory=lambda: date.today().year, ge=2000, le=2100),
|
||||||
|
month: int = Query(default_factory=lambda: date.today().month, ge=1, le=12),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> DashboardSummary:
|
||||||
|
return await get_dashboard_summary(session, year, month)
|
||||||
37
backend/app/api/routes/goals.py
Normal file
37
backend/app/api/routes/goals.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.goal import GoalContributionCreate, GoalCreate, GoalRead, GoalUpdate
|
||||||
|
from app.services.goal_service import add_contribution, create_goal, delete_goal, list_goals, update_goal
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/goals", tags=["goals"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[GoalRead])
|
||||||
|
async def read_goals(session: AsyncSession = Depends(get_session)) -> list[GoalRead]:
|
||||||
|
return await list_goals(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=GoalRead, status_code=201)
|
||||||
|
async def create_goal_route(payload: GoalCreate, session: AsyncSession = Depends(get_session)) -> GoalRead:
|
||||||
|
return await create_goal(session, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{goal_id}", response_model=GoalRead)
|
||||||
|
async def update_goal_route(
|
||||||
|
goal_id: int, payload: GoalUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> GoalRead:
|
||||||
|
return await update_goal(session, goal_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{goal_id}", status_code=204)
|
||||||
|
async def delete_goal_route(goal_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
await delete_goal(session, goal_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{goal_id}/contributions", response_model=GoalRead, status_code=201)
|
||||||
|
async def add_contribution_route(
|
||||||
|
goal_id: int, payload: GoalContributionCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> GoalRead:
|
||||||
|
return await add_contribution(session, goal_id, payload)
|
||||||
13
backend/app/api/routes/insights.py
Normal file
13
backend/app/api/routes/insights.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.insights import AlertsResponse
|
||||||
|
from app.services.insights_service import get_financial_alerts
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/insights", tags=["insights"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts", response_model=AlertsResponse)
|
||||||
|
async def read_financial_alerts(session: AsyncSession = Depends(get_session)) -> AlertsResponse:
|
||||||
|
return await get_financial_alerts(session)
|
||||||
19
backend/app/api/routes/net_worth.py
Normal file
19
backend/app/api/routes/net_worth.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.net_worth import NetWorthSummary
|
||||||
|
from app.services.net_worth_service import RANGE_DAYS, get_net_worth_summary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/net-worth", tags=["net-worth"])
|
||||||
|
|
||||||
|
_VALID_RANGES = sorted(set(RANGE_DAYS) | {"all"})
|
||||||
|
_RANGE_PATTERN = f"^({'|'.join(_VALID_RANGES)})$"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=NetWorthSummary)
|
||||||
|
async def read_net_worth_summary(
|
||||||
|
range: str = Query(default="30d", pattern=_RANGE_PATTERN),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> NetWorthSummary:
|
||||||
|
return await get_net_worth_summary(session, range)
|
||||||
45
backend/app/api/routes/recurring.py
Normal file
45
backend/app/api/routes/recurring.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.schemas.recurring import RecurringTransactionCreate, RecurringTransactionRead, RecurringTransactionUpdate
|
||||||
|
from app.services.recurring_service import (
|
||||||
|
create_recurring,
|
||||||
|
delete_recurring,
|
||||||
|
list_recurring,
|
||||||
|
post_recurring,
|
||||||
|
update_recurring,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/recurring", tags=["recurring"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RecurringTransactionRead])
|
||||||
|
async def read_recurring(session: AsyncSession = Depends(get_session)) -> list[RecurringTransactionRead]:
|
||||||
|
return await list_recurring(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RecurringTransactionRead, status_code=201)
|
||||||
|
async def create_recurring_route(
|
||||||
|
payload: RecurringTransactionCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> RecurringTransactionRead:
|
||||||
|
return await create_recurring(session, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{recurring_id}", response_model=RecurringTransactionRead)
|
||||||
|
async def update_recurring_route(
|
||||||
|
recurring_id: int, payload: RecurringTransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> RecurringTransactionRead:
|
||||||
|
return await update_recurring(session, recurring_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recurring_id}", status_code=204)
|
||||||
|
async def delete_recurring_route(recurring_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
await delete_recurring(session, recurring_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recurring_id}/post", response_model=RecurringTransactionRead, status_code=201)
|
||||||
|
async def post_recurring_route(
|
||||||
|
recurring_id: int, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> RecurringTransactionRead:
|
||||||
|
return await post_recurring(session, recurring_id)
|
||||||
31
backend/app/api/routes/reports.py
Normal file
31
backend/app/api/routes/reports.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.enums import CategoryKind
|
||||||
|
from app.schemas.reports import CategoryRankingReport, CategorySpendingReport
|
||||||
|
from app.services.reports_service import get_category_ranking_report, get_category_spending_report
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/category-spending", response_model=CategorySpendingReport)
|
||||||
|
async def read_category_spending_report(
|
||||||
|
category_id: int,
|
||||||
|
start_date: date_ | None = Query(default=None),
|
||||||
|
end_date: date_ | None = Query(default=None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CategorySpendingReport:
|
||||||
|
return await get_category_spending_report(session, category_id, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/category-ranking", response_model=CategoryRankingReport)
|
||||||
|
async def read_category_ranking_report(
|
||||||
|
kind: CategoryKind = CategoryKind.EXPENSE,
|
||||||
|
start_date: date_ | None = Query(default=None),
|
||||||
|
end_date: date_ | None = Query(default=None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CategoryRankingReport:
|
||||||
|
return await get_category_ranking_report(session, kind, start_date, end_date)
|
||||||
24
backend/app/api/routes/settings.py
Normal file
24
backend/app/api/routes/settings.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.settings import AppSettings
|
||||||
|
from app.schemas.settings import AppSettingsRead, AppSettingsUpdate
|
||||||
|
from app.services.settings_service import get_or_create_app_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AppSettingsRead)
|
||||||
|
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
|
||||||
|
return await get_or_create_app_settings(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("", response_model=AppSettingsRead)
|
||||||
|
async def update_settings(payload: AppSettingsUpdate, session: AsyncSession = Depends(get_session)) -> AppSettings:
|
||||||
|
settings = await get_or_create_app_settings(session)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(settings, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(settings)
|
||||||
|
return settings
|
||||||
139
backend/app/api/routes/transactions.py
Normal file
139
backend/app/api/routes/transactions.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
|
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category))
|
||||||
|
|
||||||
|
_TYPE_TO_CATEGORY_KIND = {
|
||||||
|
TransactionType.INCOME: CategoryKind.INCOME,
|
||||||
|
TransactionType.EXPENSE: CategoryKind.EXPENSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_category_matches_type(
|
||||||
|
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
|
||||||
|
) -> None:
|
||||||
|
"""A category picked for an income transaction must itself be an income
|
||||||
|
category (and likewise for expense) — otherwise the dashboard's spending
|
||||||
|
breakdown, which only joins EXPENSE-typed rows, would silently misclassify
|
||||||
|
the entry."""
|
||||||
|
if category_id is None:
|
||||||
|
return
|
||||||
|
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
|
||||||
|
category = await session.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Category not found")
|
||||||
|
if expected_kind is not None and category.kind != expected_kind:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} transaction",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TransactionPage)
|
||||||
|
async def list_transactions(
|
||||||
|
year: int | None = Query(default=None, ge=2000, le=2100),
|
||||||
|
month: int | None = Query(default=None, ge=1, le=12),
|
||||||
|
start_date: date_ | None = Query(default=None),
|
||||||
|
end_date: date_ | None = Query(default=None),
|
||||||
|
account_id: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
type: TransactionType | None = None,
|
||||||
|
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> TransactionPage:
|
||||||
|
stmt = select(Transaction).options(*_EAGER)
|
||||||
|
count_stmt = select(func.count()).select_from(Transaction)
|
||||||
|
|
||||||
|
if year is not None:
|
||||||
|
stmt = stmt.where(func.extract("year", Transaction.date) == year)
|
||||||
|
count_stmt = count_stmt.where(func.extract("year", Transaction.date) == year)
|
||||||
|
if month is not None:
|
||||||
|
stmt = stmt.where(func.extract("month", Transaction.date) == month)
|
||||||
|
count_stmt = count_stmt.where(func.extract("month", Transaction.date) == month)
|
||||||
|
if start_date is not None:
|
||||||
|
stmt = stmt.where(Transaction.date >= start_date)
|
||||||
|
count_stmt = count_stmt.where(Transaction.date >= start_date)
|
||||||
|
if end_date is not None:
|
||||||
|
stmt = stmt.where(Transaction.date <= end_date)
|
||||||
|
count_stmt = count_stmt.where(Transaction.date <= end_date)
|
||||||
|
if account_id is not None:
|
||||||
|
stmt = stmt.where(Transaction.account_id == account_id)
|
||||||
|
count_stmt = count_stmt.where(Transaction.account_id == account_id)
|
||||||
|
if category_id is not None:
|
||||||
|
stmt = stmt.where(Transaction.category_id == category_id)
|
||||||
|
count_stmt = count_stmt.where(Transaction.category_id == category_id)
|
||||||
|
if type is not None:
|
||||||
|
stmt = stmt.where(Transaction.type == type)
|
||||||
|
count_stmt = count_stmt.where(Transaction.type == type)
|
||||||
|
|
||||||
|
total = (await session.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
# id as a tiebreaker keeps pagination stable when many rows share a date/amount.
|
||||||
|
if sort == "amount_desc":
|
||||||
|
stmt = stmt.order_by(Transaction.amount.desc(), Transaction.id.desc())
|
||||||
|
elif sort == "amount_asc":
|
||||||
|
stmt = stmt.order_by(Transaction.amount.asc(), Transaction.id.desc())
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||||
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
items = list(result.scalars().all())
|
||||||
|
|
||||||
|
return TransactionPage(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TransactionRead, status_code=201)
|
||||||
|
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
||||||
|
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||||
|
transaction = Transaction(**payload.model_dump())
|
||||||
|
session.add(transaction)
|
||||||
|
await session.commit()
|
||||||
|
refreshed = await session.execute(
|
||||||
|
select(Transaction).options(*_EAGER).where(Transaction.id == transaction.id)
|
||||||
|
)
|
||||||
|
return refreshed.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{transaction_id}", response_model=TransactionRead)
|
||||||
|
async def update_transaction(
|
||||||
|
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> Transaction:
|
||||||
|
transaction = await session.get(Transaction, transaction_id)
|
||||||
|
if transaction is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
effective_type = updates.get("type", transaction.type)
|
||||||
|
effective_category_id = updates.get("category_id", transaction.category_id)
|
||||||
|
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(transaction, field, value)
|
||||||
|
await session.commit()
|
||||||
|
refreshed = await session.execute(
|
||||||
|
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
|
||||||
|
)
|
||||||
|
return refreshed.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{transaction_id}", status_code=204)
|
||||||
|
async def delete_transaction(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
transaction = await session.get(Transaction, transaction_id)
|
||||||
|
if transaction is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
|
await session.delete(transaction)
|
||||||
|
await session.commit()
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
44
backend/app/core/config.py
Normal file
44
backend/app/core/config.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Application configuration, sourced from environment variables (.env)."""
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# Single source of truth for the running app's version — surfaced in the API
|
||||||
|
# title and embedded in exported backups so an old file can be told apart
|
||||||
|
# from a current one.
|
||||||
|
APP_VERSION = "0.15.2"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_prefix="AURUM_", extra="ignore")
|
||||||
|
|
||||||
|
# Postgres connection
|
||||||
|
postgres_user: str = "aurum"
|
||||||
|
postgres_password: str = "aurum"
|
||||||
|
postgres_db: str = "aurum"
|
||||||
|
postgres_host: str = "db"
|
||||||
|
postgres_port: int = 5432
|
||||||
|
|
||||||
|
# Default currency shown across the UI when an account doesn't override it
|
||||||
|
default_currency: str = "USD"
|
||||||
|
|
||||||
|
# Comma-separated list of origins allowed to call the API (frontend dev server, etc.)
|
||||||
|
cors_origins: str = "*"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
return (
|
||||||
|
f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}"
|
||||||
|
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
if self.cors_origins == "*":
|
||||||
|
return ["*"]
|
||||||
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
6
backend/app/db/base.py
Normal file
6
backend/app/db/base.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Declarative base shared by every ORM model."""
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
86
backend/app/db/seed.py
Normal file
86
backend/app/db/seed.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Seeds the default category set on first boot.
|
||||||
|
|
||||||
|
The expense categories are assigned hues from the dataviz skill's validated
|
||||||
|
8-slot categorical palette, in the palette's fixed slot order (never
|
||||||
|
reordered/cycled) so the dashboard donut chart is colorblind-safe out of the
|
||||||
|
box. See CLAUDE.md-adjacent design notes in UPDATES.md for the source.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import AccountType, CategoryKind
|
||||||
|
from app.models.settings import AppSettings
|
||||||
|
|
||||||
|
# (name, icon, color) — order doubles as sort_order / palette slot index.
|
||||||
|
DEFAULT_EXPENSE_CATEGORIES = [
|
||||||
|
("Housing & Utilities", "home", "#2a78d6"), # slot 1 blue
|
||||||
|
("Groceries", "shopping-basket", "#1baf7a"), # slot 3 aqua
|
||||||
|
("Dining Out", "utensils", "#eb6834"), # slot 2 orange
|
||||||
|
("Transportation", "car", "#4a3aa7"), # slot 7 violet
|
||||||
|
("Health & Fitness", "heart-pulse", "#e34948"), # slot 8 red
|
||||||
|
("Shopping", "shopping-bag", "#eda100"), # slot 4 yellow
|
||||||
|
("Entertainment", "clapperboard", "#e87ba4"), # slot 5 magenta
|
||||||
|
("Subscriptions", "repeat", "#008300"), # slot 6 green
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_INCOME_CATEGORIES = [
|
||||||
|
("Salary", "banknote", "#2a78d6"),
|
||||||
|
("Freelance", "briefcase", "#1baf7a"),
|
||||||
|
("Investments", "trending-up", "#4a3aa7"),
|
||||||
|
("Gifts", "gift", "#e87ba4"),
|
||||||
|
("Other Income", "plus-circle", "#898781"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_default_categories(session: AsyncSession) -> None:
|
||||||
|
existing = await session.execute(select(Category.id).limit(1))
|
||||||
|
if existing.first() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
order = 0
|
||||||
|
for name, icon, color in DEFAULT_EXPENSE_CATEGORIES:
|
||||||
|
session.add(
|
||||||
|
Category(name=name, kind=CategoryKind.EXPENSE, icon=icon, color=color, sort_order=order, is_default=True)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
for name, icon, color in DEFAULT_INCOME_CATEGORIES:
|
||||||
|
session.add(
|
||||||
|
Category(name=name, kind=CategoryKind.INCOME, icon=icon, color=color, sort_order=order, is_default=True)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_default_account(session: AsyncSession) -> None:
|
||||||
|
"""Creates one starter account so the Transactions form always has a
|
||||||
|
destination to post to, even before the (future) accounts management UI
|
||||||
|
ships."""
|
||||||
|
existing = await session.execute(select(Account.id).limit(1))
|
||||||
|
if existing.first() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Account(
|
||||||
|
name="Main Account",
|
||||||
|
type=AccountType.CHECKING,
|
||||||
|
currency=get_settings().default_currency,
|
||||||
|
color="#2a78d6",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_default_app_settings(session: AsyncSession) -> None:
|
||||||
|
"""Ensures the singleton app_settings row (id=1) exists — the migration
|
||||||
|
already inserts it, but this makes app boot self-healing if that row is
|
||||||
|
ever missing (e.g. a DB restored from a pre-currency-setting backup)."""
|
||||||
|
existing = await session.get(AppSettings, 1)
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
session.add(AppSettings(id=1, currency=get_settings().default_currency))
|
||||||
|
await session.commit()
|
||||||
17
backend/app/db/session.py
Normal file
17
backend/app/db/session.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Async SQLAlchemy engine/session wiring."""
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
68
backend/app/main.py
Normal file
68
backend/app/main.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.routes import (
|
||||||
|
accounts,
|
||||||
|
advice,
|
||||||
|
assets,
|
||||||
|
backup,
|
||||||
|
budgets,
|
||||||
|
cash_flow,
|
||||||
|
categories,
|
||||||
|
dashboard,
|
||||||
|
goals,
|
||||||
|
insights,
|
||||||
|
net_worth,
|
||||||
|
recurring,
|
||||||
|
reports,
|
||||||
|
settings as settings_routes,
|
||||||
|
transactions,
|
||||||
|
)
|
||||||
|
from app.core.config import APP_VERSION, get_settings
|
||||||
|
from app.db.seed import seed_default_account, seed_default_app_settings, seed_default_categories
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
await seed_default_categories(session)
|
||||||
|
await seed_default_account(session)
|
||||||
|
await seed_default_app_settings(session)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Aurum API", version=APP_VERSION, lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(dashboard.router, prefix="/api")
|
||||||
|
app.include_router(accounts.router, prefix="/api")
|
||||||
|
app.include_router(categories.router, prefix="/api")
|
||||||
|
app.include_router(transactions.router, prefix="/api")
|
||||||
|
app.include_router(assets.router, prefix="/api")
|
||||||
|
app.include_router(net_worth.router, prefix="/api")
|
||||||
|
app.include_router(backup.router, prefix="/api")
|
||||||
|
app.include_router(reports.router, prefix="/api")
|
||||||
|
app.include_router(insights.router, prefix="/api")
|
||||||
|
app.include_router(settings_routes.router, prefix="/api")
|
||||||
|
app.include_router(budgets.router, prefix="/api")
|
||||||
|
app.include_router(advice.router, prefix="/api")
|
||||||
|
app.include_router(goals.router, prefix="/api")
|
||||||
|
app.include_router(recurring.router, prefix="/api")
|
||||||
|
app.include_router(cash_flow.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
21
backend/app/models/__init__.py
Normal file
21
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from app.models.account import Account
|
||||||
|
from app.models.asset import Asset, AssetValuation
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.goal import Goal, GoalContribution
|
||||||
|
from app.models.recurring import RecurringTransaction
|
||||||
|
from app.models.settings import AppSettings
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Account",
|
||||||
|
"AppSettings",
|
||||||
|
"Asset",
|
||||||
|
"AssetValuation",
|
||||||
|
"Budget",
|
||||||
|
"Category",
|
||||||
|
"Goal",
|
||||||
|
"GoalContribution",
|
||||||
|
"RecurringTransaction",
|
||||||
|
"Transaction",
|
||||||
|
]
|
||||||
29
backend/app/models/account.py
Normal file
29
backend/app/models/account.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""An account is any place money lives: bank account, card, cash, wallet."""
|
||||||
|
from sqlalchemy import Boolean, Enum, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.enums import AccountType
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base, TimestampMixin):
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
type: Mapped[AccountType] = mapped_column(
|
||||||
|
Enum(AccountType, name="account_type", native_enum=False, length=20),
|
||||||
|
nullable=False,
|
||||||
|
default=AccountType.CHECKING,
|
||||||
|
)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
|
||||||
|
# Hex color used for account-scoped UI accents (e.g. transaction list avatars).
|
||||||
|
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
transactions: Mapped[list["Transaction"]] = relationship(
|
||||||
|
back_populates="account",
|
||||||
|
foreign_keys="Transaction.account_id",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
65
backend/app/models/asset.py
Normal file
65
backend/app/models/asset.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""A manually-tracked net-worth asset (investments, crypto, property, ...).
|
||||||
|
|
||||||
|
Cash is intentionally NOT an Asset — it's derived live from Account/Transaction
|
||||||
|
balances so there's a single source of truth for money that already flows
|
||||||
|
through the Transactions feature. See services/net_worth_service.py.
|
||||||
|
"""
|
||||||
|
from datetime import date as date_
|
||||||
|
|
||||||
|
from sqlalchemy import Date, Enum, ForeignKey, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.enums import AssetClass, CapitalRole, RiskLevel
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(Base, TimestampMixin):
|
||||||
|
__tablename__ = "assets"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||||
|
asset_class: Mapped[AssetClass] = mapped_column(
|
||||||
|
Enum(AssetClass, name="asset_class", native_enum=False, length=20), nullable=False
|
||||||
|
)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# User-assigned, not inferred (see CapitalRole docstring).
|
||||||
|
capital_role: Mapped[CapitalRole] = mapped_column(
|
||||||
|
Enum(CapitalRole, name="capital_role", native_enum=False, length=10),
|
||||||
|
nullable=False,
|
||||||
|
default=CapitalRole.NEUTRAL,
|
||||||
|
)
|
||||||
|
# A rough, self-reported monthly net cash flow (rent collected, upkeep
|
||||||
|
# paid, ...). Informational only — real-world cash flow for an asset
|
||||||
|
# varies month to month, so this is never treated as ground truth the
|
||||||
|
# way Transaction amounts are.
|
||||||
|
monthly_cash_flow: Mapped[Numeric | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||||
|
|
||||||
|
# User-assigned, not inferred (see RiskLevel docstring) — how likely
|
||||||
|
# this asset is to lose value, independent of asset_class or capital_role.
|
||||||
|
risk_level: Mapped[RiskLevel] = mapped_column(
|
||||||
|
Enum(RiskLevel, name="risk_level", native_enum=False, length=10),
|
||||||
|
nullable=False,
|
||||||
|
default=RiskLevel.MEDIUM,
|
||||||
|
)
|
||||||
|
|
||||||
|
valuations: Mapped[list["AssetValuation"]] = relationship(
|
||||||
|
back_populates="asset", cascade="all, delete-orphan", order_by="AssetValuation.as_of_date"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetValuation(Base):
|
||||||
|
"""One point-in-time value for an asset. The latest row (by as_of_date) is
|
||||||
|
the asset's current value; the full history drives the net-worth chart."""
|
||||||
|
|
||||||
|
__tablename__ = "asset_valuations"
|
||||||
|
__table_args__ = (UniqueConstraint("asset_id", "as_of_date", name="uq_asset_valuation_date"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
value: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
as_of_date: Mapped[date_] = mapped_column(Date, nullable=False)
|
||||||
|
|
||||||
|
asset: Mapped["Asset"] = relationship(back_populates="valuations")
|
||||||
22
backend/app/models/budget.py
Normal file
22
backend/app/models/budget.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""A monthly spending limit for one expense category — compared against
|
||||||
|
actual spend for whichever month is being viewed (services/budget_service.py).
|
||||||
|
Not month-scoped itself: one limit per category, in effect until changed."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Numeric
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Budget(Base, TimestampMixin):
|
||||||
|
__tablename__ = "budgets"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
category_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
monthly_limit: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
|
||||||
|
category: Mapped["Category"] = relationship()
|
||||||
26
backend/app/models/category.py
Normal file
26
backend/app/models/category.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""A spending/income category, colored so it maps 1:1 to a dashboard chart slot."""
|
||||||
|
from sqlalchemy import Boolean, Enum, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.enums import CategoryKind
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
kind: Mapped[CategoryKind] = mapped_column(
|
||||||
|
Enum(CategoryKind, name="category_kind", native_enum=False, length=10), nullable=False
|
||||||
|
)
|
||||||
|
# Lucide icon name rendered in the frontend (kept as a plain string so new
|
||||||
|
# icons don't require a migration).
|
||||||
|
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
# Hex color drawn from the dataviz skill's validated categorical palette.
|
||||||
|
color: Mapped[str] = mapped_column(String(7), nullable=False)
|
||||||
|
# Fixed slot ordering keeps the donut chart's category order stable across renders.
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
transactions: Mapped[list["Transaction"]] = relationship(back_populates="category")
|
||||||
63
backend/app/models/enums.py
Normal file
63
backend/app/models/enums.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Shared enum types used by the ORM models and API schemas."""
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class AccountType(str, enum.Enum):
|
||||||
|
CHECKING = "checking"
|
||||||
|
SAVINGS = "savings"
|
||||||
|
CREDIT_CARD = "credit_card"
|
||||||
|
CASH = "cash"
|
||||||
|
INVESTMENT = "investment"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryKind(str, enum.Enum):
|
||||||
|
INCOME = "income"
|
||||||
|
EXPENSE = "expense"
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionType(str, enum.Enum):
|
||||||
|
INCOME = "income"
|
||||||
|
EXPENSE = "expense"
|
||||||
|
TRANSFER = "transfer"
|
||||||
|
|
||||||
|
|
||||||
|
class AssetClass(str, enum.Enum):
|
||||||
|
"""Net-worth categories tracked manually (Cash is derived from Account
|
||||||
|
balances instead — see services/net_worth_service.py)."""
|
||||||
|
|
||||||
|
INVESTMENTS = "investments"
|
||||||
|
CRYPTO = "crypto"
|
||||||
|
REAL_ESTATE = "real_estate"
|
||||||
|
VEHICLES = "vehicles"
|
||||||
|
PRECIOUS_METALS = "precious_metals"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalRole(str, enum.Enum):
|
||||||
|
"""How an asset behaves month to month — set by the user, not inferred:
|
||||||
|
the same laptop can be a productive work tool (NEUTRAL) or dead weight
|
||||||
|
(DRAIN) depending on how it's actually used, which isn't derivable from
|
||||||
|
any stored data."""
|
||||||
|
|
||||||
|
INCOME = "income" # e.g. a rented-out apartment
|
||||||
|
NEUTRAL = "neutral" # e.g. a laptop used for work, furniture
|
||||||
|
DRAIN = "drain" # e.g. a personal car, an idle depreciating gadget
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringFrequency(str, enum.Enum):
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
YEARLY = "yearly"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(str, enum.Enum):
|
||||||
|
"""Risk of loss, not asset class — set by the user, not inferred: real
|
||||||
|
estate can be a paid-off primary home (LOW) or a leveraged rental
|
||||||
|
(HIGH), the same asset_class doesn't determine which. Cash is always
|
||||||
|
LOW (see services/net_worth_service.py) — it's the zero-risk anchor an
|
||||||
|
80/20-style allocation rule is measured against."""
|
||||||
|
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
38
backend/app/models/goal.py
Normal file
38
backend/app/models/goal.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""A savings goal, tracked by a manual contribution log (GoalContribution) —
|
||||||
|
same "you record it, we sum it" shape as Asset/AssetValuation, except
|
||||||
|
contributions are deltas added together, not point-in-time snapshots."""
|
||||||
|
from datetime import date as date_
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(Base, TimestampMixin):
|
||||||
|
__tablename__ = "goals"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||||
|
target_amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
target_date: Mapped[date_ | None] = mapped_column(Date, nullable=True)
|
||||||
|
|
||||||
|
contributions: Mapped[list["GoalContribution"]] = relationship(
|
||||||
|
back_populates="goal", cascade="all, delete-orphan", order_by="GoalContribution.date"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GoalContribution(Base):
|
||||||
|
"""One deposit (or, with a negative amount, a withdrawal) toward a goal.
|
||||||
|
A goal's current amount is the sum of all its contributions."""
|
||||||
|
|
||||||
|
__tablename__ = "goal_contributions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
date: Mapped[date_] = mapped_column(Date, nullable=False)
|
||||||
|
note: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
|
goal: Mapped["Goal"] = relationship(back_populates="contributions")
|
||||||
14
backend/app/models/mixins.py
Normal file
14
backend/app/models/mixins.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Reusable model mixins."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""Adds created_at / updated_at columns managed by the database itself."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
49
backend/app/models/recurring.py
Normal file
49
backend/app/models/recurring.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""A template for a transaction that repeats on a schedule (rent, salary,
|
||||||
|
subscriptions). Posting is manual, one-click, not a background job — see
|
||||||
|
services/recurring_service.py for how next_due_date/is_due are computed
|
||||||
|
from anchor_date + last_posted_date, and how "Post now" creates a real
|
||||||
|
Transaction row from the template.
|
||||||
|
"""
|
||||||
|
from datetime import date as date_
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, Enum, ForeignKey, Numeric, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.enums import RecurringFrequency, TransactionType
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransaction(Base, TimestampMixin):
|
||||||
|
__tablename__ = "recurring_transactions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
# Destination account for TRANSFER-type rows only — same shape as Transaction.
|
||||||
|
transfer_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
type: Mapped[TransactionType] = mapped_column(
|
||||||
|
Enum(TransactionType, name="recurring_transaction_type", native_enum=False, length=10), nullable=False
|
||||||
|
)
|
||||||
|
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
merchant: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
frequency: Mapped[RecurringFrequency] = mapped_column(
|
||||||
|
Enum(RecurringFrequency, name="recurring_frequency", native_enum=False, length=10), nullable=False
|
||||||
|
)
|
||||||
|
# First due date if never posted; also the day-of-month/weekday/month-day
|
||||||
|
# the schedule is anchored to (see services/recurring_service._advance).
|
||||||
|
anchor_date: Mapped[date_] = mapped_column(Date, nullable=False)
|
||||||
|
last_posted_date: Mapped[date_ | None] = mapped_column(Date, nullable=True)
|
||||||
|
# Pausing (e.g. a cancelled subscription you might resume) without
|
||||||
|
# losing the template and its posting history.
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
account: Mapped["Account"] = relationship(foreign_keys=[account_id])
|
||||||
|
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
|
||||||
|
category: Mapped["Category | None"] = relationship()
|
||||||
23
backend/app/models/settings.py
Normal file
23
backend/app/models/settings.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""App-wide configuration that isn't tied to any single account/asset — a
|
||||||
|
single-row table (id is always 1). Primary display currency (see UPDATES.md
|
||||||
|
for why this doesn't do currency conversion) and the proactive-alert
|
||||||
|
thresholds consumed by services/insights_service.py."""
|
||||||
|
from sqlalchemy import Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(Base):
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
|
||||||
|
# Consecutive complete months of negative cash flow / declining net worth
|
||||||
|
# before insights_service.py raises the corresponding alert.
|
||||||
|
negative_cash_flow_threshold_months: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
|
||||||
|
net_worth_decline_threshold_months: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
|
||||||
|
# Max % of total capital (cash + assets) allowed in medium/high risk
|
||||||
|
# tiers before insights_service.py raises risky_allocation_exceeded —
|
||||||
|
# the classic "80% at zero risk, 20% at most exposed" rule.
|
||||||
|
risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
|
||||||
35
backend/app/models/transaction.py
Normal file
35
backend/app/models/transaction.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""A single money movement: income, expense, or a transfer between accounts."""
|
||||||
|
from datetime import date as date_
|
||||||
|
|
||||||
|
from sqlalchemy import Date, Enum, ForeignKey, Numeric, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.enums import TransactionType
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base, TimestampMixin):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
# Destination account for TRANSFER-type rows only.
|
||||||
|
transfer_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
type: Mapped[TransactionType] = mapped_column(
|
||||||
|
Enum(TransactionType, name="transaction_type", native_enum=False, length=10), nullable=False
|
||||||
|
)
|
||||||
|
# Always stored positive; `type` carries the sign/direction.
|
||||||
|
amount: Mapped[Numeric] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
merchant: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
date: Mapped[date_] = mapped_column(Date, nullable=False)
|
||||||
|
|
||||||
|
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
|
||||||
|
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
|
||||||
|
category: Mapped["Category | None"] = relationship(back_populates="transactions")
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
44
backend/app/schemas/account.py
Normal file
44
backend/app/schemas/account.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.enums import AccountType
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
type: AccountType = AccountType.CHECKING
|
||||||
|
currency: str = Field(default="USD", min_length=3, max_length=3)
|
||||||
|
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreate(AccountBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
type: AccountType | None = None
|
||||||
|
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||||
|
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
is_archived: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRead(AccountBase):
|
||||||
|
"""Used wherever an account is embedded in another response (e.g.
|
||||||
|
TransactionRead.account) — deliberately balance-less so adding a field
|
||||||
|
here can never break an unrelated endpoint's serialization."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
is_archived: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AccountWithBalance(AccountRead):
|
||||||
|
"""The Accounts page's shape — adds the live balance (see
|
||||||
|
services/account_service.py), summed from Transaction rows rather than
|
||||||
|
stored, the same "derive it" approach net_worth_service.py uses for
|
||||||
|
Cash. Used only by /api/accounts' own endpoints, never nested."""
|
||||||
|
|
||||||
|
balance: Decimal
|
||||||
15
backend/app/schemas/advice.py
Normal file
15
backend/app/schemas/advice.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AdviceItem(BaseModel):
|
||||||
|
key: str
|
||||||
|
tone: Literal["positive", "neutral", "warning"]
|
||||||
|
# Frontend-translated by key — same key+params pattern as FinancialAlert,
|
||||||
|
# extended to allow string params (category names) alongside numbers.
|
||||||
|
params: dict[str, str | int | float]
|
||||||
|
|
||||||
|
|
||||||
|
class AdviceResponse(BaseModel):
|
||||||
|
items: list[AdviceItem]
|
||||||
54
backend/app/schemas/asset.py
Normal file
54
backend/app/schemas/asset.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.enums import AssetClass, CapitalRole, RiskLevel
|
||||||
|
|
||||||
|
|
||||||
|
class AssetValuationCreate(BaseModel):
|
||||||
|
value: Decimal = Field(ge=0, max_digits=14, decimal_places=2)
|
||||||
|
as_of_date: date_ = Field(default_factory=date_.today)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetValuationRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
value: Decimal
|
||||||
|
as_of_date: date_
|
||||||
|
|
||||||
|
|
||||||
|
class AssetBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=150)
|
||||||
|
asset_class: AssetClass
|
||||||
|
currency: str = Field(default="USD", min_length=3, max_length=3)
|
||||||
|
notes: str | None = None
|
||||||
|
capital_role: CapitalRole = CapitalRole.NEUTRAL
|
||||||
|
# Rough self-reported monthly net cash flow — informational, not tracked
|
||||||
|
# transactions (see Asset.monthly_cash_flow).
|
||||||
|
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
|
||||||
|
risk_level: RiskLevel = RiskLevel.MEDIUM
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCreate(AssetBase):
|
||||||
|
value: Decimal = Field(ge=0, max_digits=14, decimal_places=2)
|
||||||
|
as_of_date: date_ = Field(default_factory=date_.today)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=150)
|
||||||
|
asset_class: AssetClass | None = None
|
||||||
|
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||||
|
notes: str | None = None
|
||||||
|
capital_role: CapitalRole | None = None
|
||||||
|
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
|
||||||
|
risk_level: RiskLevel | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssetRead(AssetBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
current_value: Decimal
|
||||||
|
as_of_date: date_
|
||||||
166
backend/app/schemas/backup.py
Normal file
166
backend/app/schemas/backup.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""Shape of a full-database backup file (see services/backup_service.py)."""
|
||||||
|
from datetime import date as date_
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.enums import (
|
||||||
|
AccountType,
|
||||||
|
AssetClass,
|
||||||
|
CapitalRole,
|
||||||
|
CategoryKind,
|
||||||
|
RecurringFrequency,
|
||||||
|
RiskLevel,
|
||||||
|
TransactionType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: AccountType
|
||||||
|
currency: str
|
||||||
|
color: str | None
|
||||||
|
is_archived: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
kind: CategoryKind
|
||||||
|
icon: str | None
|
||||||
|
color: str
|
||||||
|
sort_order: int
|
||||||
|
is_default: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
account_id: int
|
||||||
|
category_id: int | None
|
||||||
|
transfer_account_id: int | None
|
||||||
|
type: TransactionType
|
||||||
|
amount: Decimal
|
||||||
|
description: str
|
||||||
|
merchant: str | None
|
||||||
|
notes: str | None
|
||||||
|
date: date_
|
||||||
|
|
||||||
|
|
||||||
|
class AssetBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
asset_class: AssetClass
|
||||||
|
currency: str
|
||||||
|
notes: str | None
|
||||||
|
# Defaulted so a backup exported before capital_role/monthly_cash_flow
|
||||||
|
# existed still imports cleanly under the same format version.
|
||||||
|
capital_role: CapitalRole = CapitalRole.NEUTRAL
|
||||||
|
monthly_cash_flow: Decimal | None = None
|
||||||
|
# Defaulted so a backup exported before risk_level existed still
|
||||||
|
# imports cleanly under the same format version.
|
||||||
|
risk_level: RiskLevel = RiskLevel.MEDIUM
|
||||||
|
|
||||||
|
|
||||||
|
class AssetValuationBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
asset_id: int
|
||||||
|
value: Decimal
|
||||||
|
as_of_date: date_
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
monthly_limit: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class GoalBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
target_amount: Decimal
|
||||||
|
target_date: date_ | None
|
||||||
|
|
||||||
|
|
||||||
|
class GoalContributionBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
goal_id: int
|
||||||
|
amount: Decimal
|
||||||
|
date: date_
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransactionBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
account_id: int
|
||||||
|
category_id: int | None
|
||||||
|
transfer_account_id: int | None
|
||||||
|
type: TransactionType
|
||||||
|
amount: Decimal
|
||||||
|
description: str
|
||||||
|
merchant: str | None
|
||||||
|
notes: str | None
|
||||||
|
frequency: RecurringFrequency
|
||||||
|
anchor_date: date_
|
||||||
|
last_posted_date: date_ | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettingsBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
currency: str
|
||||||
|
# Defaulted so a backup exported before these thresholds existed still
|
||||||
|
# imports cleanly under the same format version.
|
||||||
|
negative_cash_flow_threshold_months: int = 2
|
||||||
|
net_worth_decline_threshold_months: int = 2
|
||||||
|
# Defaulted so a backup exported before this threshold existed still
|
||||||
|
# imports cleanly under the same format version.
|
||||||
|
risky_allocation_threshold_percent: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPayload(BaseModel):
|
||||||
|
"""A full, portable snapshot of every table. `aurum_backup_version` is
|
||||||
|
checked on import so an incompatible/future file is rejected cleanly
|
||||||
|
instead of half-applied."""
|
||||||
|
|
||||||
|
aurum_backup_version: int
|
||||||
|
exported_at: datetime
|
||||||
|
app_version: str
|
||||||
|
accounts: list[AccountBackup]
|
||||||
|
categories: list[CategoryBackup]
|
||||||
|
transactions: list[TransactionBackup]
|
||||||
|
assets: list[AssetBackup]
|
||||||
|
asset_valuations: list[AssetValuationBackup]
|
||||||
|
# Defaulted so a backup exported before budgets existed still imports
|
||||||
|
# cleanly under the same format version.
|
||||||
|
budgets: list[BudgetBackup] = Field(default_factory=list)
|
||||||
|
# Defaulted so a backup exported before goals existed still imports
|
||||||
|
# cleanly under the same format version.
|
||||||
|
goals: list[GoalBackup] = Field(default_factory=list)
|
||||||
|
goal_contributions: list[GoalContributionBackup] = Field(default_factory=list)
|
||||||
|
# Defaulted so a backup exported before recurring transactions existed
|
||||||
|
# still imports cleanly under the same format version.
|
||||||
|
recurring_transactions: list[RecurringTransactionBackup] = Field(default_factory=list)
|
||||||
|
# Defaulted so a backup exported before the currency setting existed
|
||||||
|
# still imports cleanly under the same format version.
|
||||||
|
app_settings: AppSettingsBackup = Field(default_factory=lambda: AppSettingsBackup(currency="USD"))
|
||||||
45
backend/app/schemas/budget.py
Normal file
45
backend/app/schemas/budget.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetCreate(BaseModel):
|
||||||
|
category_id: int
|
||||||
|
monthly_limit: Decimal = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetUpdate(BaseModel):
|
||||||
|
monthly_limit: Decimal = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
category_name: str
|
||||||
|
category_color: str
|
||||||
|
category_icon: str | None
|
||||||
|
monthly_limit: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetStatus(BaseModel):
|
||||||
|
"""One budgeted category's actual spend for a given month, compared
|
||||||
|
against its limit."""
|
||||||
|
|
||||||
|
budget_id: int
|
||||||
|
category_id: int
|
||||||
|
category_name: str
|
||||||
|
category_color: str
|
||||||
|
category_icon: str | None
|
||||||
|
monthly_limit: Decimal
|
||||||
|
spent: Decimal
|
||||||
|
remaining: Decimal
|
||||||
|
# Can exceed 100 — the UI clamps the progress bar fill, not this number,
|
||||||
|
# so "spent 150% of budget" is still visible in the raw value.
|
||||||
|
percent: float
|
||||||
|
is_over_budget: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetStatusResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
items: list[BudgetStatus]
|
||||||
21
backend/app/schemas/cash_flow.py
Normal file
21
backend/app/schemas/cash_flow.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CashFlowPoint(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
income: Decimal
|
||||||
|
expense: Decimal
|
||||||
|
net: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class CashFlowResponse(BaseModel):
|
||||||
|
start_date: date_ | None
|
||||||
|
end_date: date_ | None
|
||||||
|
points: list[CashFlowPoint]
|
||||||
|
total_income: Decimal
|
||||||
|
total_expense: Decimal
|
||||||
|
total_net: Decimal
|
||||||
29
backend/app/schemas/category.py
Normal file
29
backend/app/schemas/category.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.enums import CategoryKind
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
kind: CategoryKind
|
||||||
|
icon: str | None = None
|
||||||
|
color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(CategoryBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
icon: str | None = None
|
||||||
|
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryRead(CategoryBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
is_default: bool
|
||||||
22
backend/app/schemas/dashboard.py
Normal file
22
backend/app/schemas/dashboard.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBreakdownItem(BaseModel):
|
||||||
|
category_id: int | None
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
icon: str | None
|
||||||
|
amount: Decimal
|
||||||
|
percent: float
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSummary(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
real_income: Decimal
|
||||||
|
spent: Decimal
|
||||||
|
net: Decimal
|
||||||
|
transferred_out: Decimal
|
||||||
|
spending_by_category: list[CategoryBreakdownItem]
|
||||||
35
backend/app/schemas/goal.py
Normal file
35
backend/app/schemas/goal.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class GoalCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=150)
|
||||||
|
target_amount: Decimal = Field(gt=0)
|
||||||
|
target_date: date_ | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GoalUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=150)
|
||||||
|
target_amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
target_date: date_ | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GoalContributionCreate(BaseModel):
|
||||||
|
# Negative allowed — a withdrawal from the goal is still a contribution
|
||||||
|
# to its running total, just in the other direction. Zero is pointless.
|
||||||
|
amount: Decimal = Field(ne=0)
|
||||||
|
date: date_
|
||||||
|
note: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class GoalRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
target_amount: Decimal
|
||||||
|
target_date: date_ | None
|
||||||
|
current_amount: Decimal
|
||||||
|
remaining: Decimal
|
||||||
|
percent: float
|
||||||
|
is_reached: bool
|
||||||
14
backend/app/schemas/insights.py
Normal file
14
backend/app/schemas/insights.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialAlert(BaseModel):
|
||||||
|
key: str
|
||||||
|
severity: str
|
||||||
|
# Machine-readable params for the frontend to interpolate into its own
|
||||||
|
# localized message template (keyed by `key`) — keeps alert copy out of
|
||||||
|
# the backend so it can be translated without an API change.
|
||||||
|
params: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class AlertsResponse(BaseModel):
|
||||||
|
alerts: list[FinancialAlert]
|
||||||
66
backend/app/schemas/net_worth.py
Normal file
66
backend/app/schemas/net_worth.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class NetWorthPoint(BaseModel):
|
||||||
|
date: date_
|
||||||
|
value: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class NetWorthBreakdownItem(BaseModel):
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
icon: str
|
||||||
|
amount: Decimal
|
||||||
|
percent: float
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalRoleSummary(BaseModel):
|
||||||
|
"""Assets grouped by how they behave month to month (see CapitalRole) —
|
||||||
|
a cross-cut of the asset-class breakdown, not a replacement for it."""
|
||||||
|
|
||||||
|
role: str
|
||||||
|
label: str
|
||||||
|
color: str
|
||||||
|
total_value: Decimal
|
||||||
|
monthly_cash_flow: Decimal
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevelItem(BaseModel):
|
||||||
|
"""One holding within a risk tier — Cash (key="cash") or a single asset
|
||||||
|
(key=f"asset:{id}"). This list IS the diversification view: a tier with
|
||||||
|
one item at 100% is concentrated, several items with even shares aren't,
|
||||||
|
no separate score needed."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
amount: Decimal
|
||||||
|
percent: float # share of this tier, not of total capital
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevelSummary(BaseModel):
|
||||||
|
"""Cash + assets grouped by user-tagged risk of loss (see RiskLevel) —
|
||||||
|
another cross-cut, like capital_roles, but Cash participates here since
|
||||||
|
it's the zero-risk anchor an 80/20-style allocation rule needs."""
|
||||||
|
|
||||||
|
risk_level: str
|
||||||
|
label: str
|
||||||
|
color: str
|
||||||
|
total_value: Decimal
|
||||||
|
percent: float # share of total capital (cash + assets)
|
||||||
|
items: list[RiskLevelItem]
|
||||||
|
|
||||||
|
|
||||||
|
class NetWorthSummary(BaseModel):
|
||||||
|
range: str
|
||||||
|
current: Decimal
|
||||||
|
change_amount: Decimal
|
||||||
|
change_percent: float | None
|
||||||
|
series: list[NetWorthPoint]
|
||||||
|
breakdown: list[NetWorthBreakdownItem]
|
||||||
|
capital_roles: list[CapitalRoleSummary]
|
||||||
|
risk_levels: list[RiskLevelSummary]
|
||||||
58
backend/app/schemas/recurring.py
Normal file
58
backend/app/schemas/recurring.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.enums import RecurringFrequency, TransactionType
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransactionCreate(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
category_id: int | None = None
|
||||||
|
transfer_account_id: int | None = None
|
||||||
|
type: TransactionType
|
||||||
|
amount: Decimal = Field(gt=0)
|
||||||
|
description: str = Field(min_length=1, max_length=255)
|
||||||
|
merchant: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
frequency: RecurringFrequency
|
||||||
|
anchor_date: date_
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransactionUpdate(BaseModel):
|
||||||
|
account_id: int | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
transfer_account_id: int | None = None
|
||||||
|
type: TransactionType | None = None
|
||||||
|
amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
description: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
merchant: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
frequency: RecurringFrequency | None = None
|
||||||
|
anchor_date: date_ | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringTransactionRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
account_id: int
|
||||||
|
account_name: str
|
||||||
|
category_id: int | None
|
||||||
|
category_name: str | None
|
||||||
|
category_color: str | None
|
||||||
|
category_icon: str | None
|
||||||
|
transfer_account_id: int | None
|
||||||
|
transfer_account_name: str | None
|
||||||
|
type: TransactionType
|
||||||
|
amount: Decimal
|
||||||
|
description: str
|
||||||
|
merchant: str | None
|
||||||
|
notes: str | None
|
||||||
|
frequency: RecurringFrequency
|
||||||
|
anchor_date: date_
|
||||||
|
last_posted_date: date_ | None
|
||||||
|
is_active: bool
|
||||||
|
# Computed, not stored — see services/recurring_service.py.
|
||||||
|
next_due_date: date_
|
||||||
|
is_due: bool
|
||||||
|
days_until_due: int
|
||||||
40
backend/app/schemas/reports.py
Normal file
40
backend/app/schemas/reports.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySpendingPoint(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
amount: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySpendingReport(BaseModel):
|
||||||
|
category_id: int
|
||||||
|
category_name: str
|
||||||
|
category_color: str
|
||||||
|
category_icon: str | None
|
||||||
|
start_date: date_ | None
|
||||||
|
end_date: date_ | None
|
||||||
|
total_amount: Decimal
|
||||||
|
transaction_count: int
|
||||||
|
average_per_month: Decimal
|
||||||
|
series: list[CategorySpendingPoint]
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryRankingItem(BaseModel):
|
||||||
|
category_id: int
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
icon: str | None
|
||||||
|
amount: Decimal
|
||||||
|
percent: float
|
||||||
|
transaction_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryRankingReport(BaseModel):
|
||||||
|
start_date: date_ | None
|
||||||
|
end_date: date_ | None
|
||||||
|
total_amount: Decimal
|
||||||
|
items: list[CategoryRankingItem]
|
||||||
26
backend/app/schemas/settings.py
Normal file
26
backend/app/schemas/settings.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettingsRead(BaseModel):
|
||||||
|
currency: str
|
||||||
|
negative_cash_flow_threshold_months: int
|
||||||
|
net_worth_decline_threshold_months: int
|
||||||
|
risky_allocation_threshold_percent: int
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettingsUpdate(BaseModel):
|
||||||
|
"""All fields optional — the route applies a partial update
|
||||||
|
(`exclude_unset`), same as CategoryUpdate, so a single-field PATCH from
|
||||||
|
e.g. CurrencyCard doesn't need to resend the alert thresholds."""
|
||||||
|
|
||||||
|
# ISO 4217 code, e.g. "USD"/"UAH"/"EUR" — the frontend only ever sends
|
||||||
|
# values from its curated currency list, but validate the shape anyway
|
||||||
|
# since Intl.NumberFormat would otherwise silently accept garbage.
|
||||||
|
currency: str | None = Field(default=None, min_length=3, max_length=3, pattern=r"^[A-Z]{3}$")
|
||||||
|
# Consecutive complete months before the corresponding alert fires.
|
||||||
|
# Capped at 24 to match insights_service.py's MAX_LOOKBACK_MONTHS.
|
||||||
|
negative_cash_flow_threshold_months: int | None = Field(default=None, ge=1, le=24)
|
||||||
|
net_worth_decline_threshold_months: int | None = Field(default=None, ge=1, le=24)
|
||||||
|
# Max % of capital allowed in medium/high risk tiers (the 80/20 rule's
|
||||||
|
# "20% at most exposed" half) before risky_allocation_exceeded fires.
|
||||||
|
risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100)
|
||||||
63
backend/app/schemas/transaction.py
Normal file
63
backend/app/schemas/transaction.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from app.models.enums import TransactionType
|
||||||
|
from app.schemas.account import AccountRead
|
||||||
|
from app.schemas.category import CategoryRead
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionBase(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
category_id: int | None = None
|
||||||
|
transfer_account_id: int | None = None
|
||||||
|
type: TransactionType
|
||||||
|
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
|
||||||
|
description: str = Field(min_length=1, max_length=255)
|
||||||
|
merchant: str | None = Field(default=None, max_length=150)
|
||||||
|
notes: str | None = None
|
||||||
|
date: date_
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_type_specific_fields(self) -> "TransactionBase":
|
||||||
|
if self.type == TransactionType.TRANSFER and not self.transfer_account_id:
|
||||||
|
raise ValueError("transfer_account_id is required for transfer transactions")
|
||||||
|
if self.type == TransactionType.TRANSFER and self.transfer_account_id == self.account_id:
|
||||||
|
raise ValueError("transfer_account_id must differ from account_id")
|
||||||
|
if self.type != TransactionType.TRANSFER and self.transfer_account_id:
|
||||||
|
raise ValueError("transfer_account_id is only valid for transfer transactions")
|
||||||
|
if self.type == TransactionType.TRANSFER and self.category_id:
|
||||||
|
raise ValueError("category_id is not valid for transfer transactions")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCreate(TransactionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionUpdate(BaseModel):
|
||||||
|
account_id: int | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
transfer_account_id: int | None = None
|
||||||
|
type: TransactionType | None = None
|
||||||
|
amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2)
|
||||||
|
description: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
merchant: str | None = Field(default=None, max_length=150)
|
||||||
|
notes: str | None = None
|
||||||
|
date: date_ | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionRead(TransactionBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
account: AccountRead
|
||||||
|
category: CategoryRead | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionPage(BaseModel):
|
||||||
|
items: list[TransactionRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
84
backend/app/services/account_service.py
Normal file
84
backend/app/services/account_service.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Account CRUD, plus each account's live balance — summed from its
|
||||||
|
Transaction rows (income adds, expense subtracts, a transfer moves the
|
||||||
|
amount from the source account to the destination account) rather than
|
||||||
|
stored, the same "derive it, don't duplicate it" approach
|
||||||
|
net_worth_service.py uses for Cash.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.enums import TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
||||||
|
|
||||||
|
|
||||||
|
async def _account_balances(session: AsyncSession) -> dict[int, Decimal]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
|
||||||
|
)
|
||||||
|
balances: dict[int, Decimal] = defaultdict(Decimal)
|
||||||
|
for tx_type, amount, account_id, transfer_account_id in result.all():
|
||||||
|
if tx_type == TransactionType.INCOME:
|
||||||
|
balances[account_id] += amount
|
||||||
|
elif tx_type == TransactionType.EXPENSE:
|
||||||
|
balances[account_id] -= amount
|
||||||
|
elif tx_type == TransactionType.TRANSFER:
|
||||||
|
balances[account_id] -= amount
|
||||||
|
if transfer_account_id is not None:
|
||||||
|
balances[transfer_account_id] += amount
|
||||||
|
return balances
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(account: Account, balance: Decimal) -> AccountWithBalance:
|
||||||
|
return AccountWithBalance(
|
||||||
|
id=account.id,
|
||||||
|
name=account.name,
|
||||||
|
type=account.type,
|
||||||
|
currency=account.currency,
|
||||||
|
color=account.color,
|
||||||
|
is_archived=account.is_archived,
|
||||||
|
balance=balance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_accounts(session: AsyncSession, include_archived: bool) -> list[AccountWithBalance]:
|
||||||
|
stmt = select(Account).order_by(Account.name)
|
||||||
|
if not include_archived:
|
||||||
|
stmt = stmt.where(Account.is_archived.is_(False))
|
||||||
|
accounts = (await session.execute(stmt)).scalars().all()
|
||||||
|
balances = await _account_balances(session)
|
||||||
|
return [_to_read(account, balances.get(account.id, Decimal("0"))) for account in accounts]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_account(session: AsyncSession, payload: AccountCreate) -> AccountWithBalance:
|
||||||
|
account = Account(**payload.model_dump())
|
||||||
|
session.add(account)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(account)
|
||||||
|
# A brand-new account has no transactions yet — no need to query.
|
||||||
|
return _to_read(account, Decimal("0"))
|
||||||
|
|
||||||
|
|
||||||
|
async def update_account(session: AsyncSession, account_id: int, payload: AccountUpdate) -> AccountWithBalance:
|
||||||
|
account = await session.get(Account, account_id)
|
||||||
|
if account is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(account, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(account)
|
||||||
|
balances = await _account_balances(session)
|
||||||
|
return _to_read(account, balances.get(account.id, Decimal("0")))
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_account(session: AsyncSession, account_id: int) -> None:
|
||||||
|
account = await session.get(Account, account_id)
|
||||||
|
if account is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
|
await session.delete(account)
|
||||||
|
await session.commit()
|
||||||
152
backend/app/services/advice_service.py
Normal file
152
backend/app/services/advice_service.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Personalized, read-only financial advice — a curated set of insights
|
||||||
|
computed from data that already exists (transactions, budgets, category
|
||||||
|
totals). Unlike insights_service.py's proactive alerts (urgent, shown as a
|
||||||
|
banner on every page), these are advisory notes shown only on the dedicated
|
||||||
|
Advice page — a mix of positive, neutral, and cautionary tone, not just
|
||||||
|
warnings, and not something the user needs to act on immediately.
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.advice import AdviceItem, AdviceResponse
|
||||||
|
from app.services.dashboard_service import get_dashboard_summary
|
||||||
|
|
||||||
|
TRAILING_MONTHS = 3
|
||||||
|
RISING_CATEGORY_THRESHOLD_PERCENT = 25
|
||||||
|
SAVINGS_RATE_TREND_THRESHOLD_POINTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(year: int, month: int) -> tuple[date, date]:
|
||||||
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
|
return date(year, month, 1), date(year, month, last_day)
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _category_expense_totals(session: AsyncSession, year: int, month: int) -> dict[int, Decimal]:
|
||||||
|
start, end = _month_bounds(year, month)
|
||||||
|
stmt = (
|
||||||
|
select(Transaction.category_id, func.sum(Transaction.amount))
|
||||||
|
.where(
|
||||||
|
Transaction.type == TransactionType.EXPENSE,
|
||||||
|
Transaction.category_id.is_not(None),
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date <= end,
|
||||||
|
)
|
||||||
|
.group_by(Transaction.category_id)
|
||||||
|
)
|
||||||
|
return {row[0]: row[1] for row in (await session.execute(stmt)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _rising_category_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
|
||||||
|
"""The expense category furthest above its own trailing-3-month average,
|
||||||
|
if that's at least RISING_CATEGORY_THRESHOLD_PERCENT higher."""
|
||||||
|
current_totals = await _category_expense_totals(session, year, month)
|
||||||
|
if not current_totals:
|
||||||
|
return None
|
||||||
|
|
||||||
|
trailing_totals: dict[int, Decimal] = defaultdict(Decimal)
|
||||||
|
y, m = year, month
|
||||||
|
for _ in range(TRAILING_MONTHS):
|
||||||
|
y, m = _previous_month(y, m)
|
||||||
|
for cat_id, amount in (await _category_expense_totals(session, y, m)).items():
|
||||||
|
trailing_totals[cat_id] += amount
|
||||||
|
|
||||||
|
best: tuple[float, int, Decimal, Decimal] | None = None
|
||||||
|
for cat_id, current in current_totals.items():
|
||||||
|
average = trailing_totals.get(cat_id, Decimal("0")) / TRAILING_MONTHS
|
||||||
|
if average <= 0:
|
||||||
|
continue
|
||||||
|
increase_percent = float((current - average) / average * 100)
|
||||||
|
if increase_percent >= RISING_CATEGORY_THRESHOLD_PERCENT and (best is None or increase_percent > best[0]):
|
||||||
|
best = (increase_percent, cat_id, current, average)
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
increase_percent, cat_id, current, average = best
|
||||||
|
category = await session.get(Category, cat_id)
|
||||||
|
if category is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AdviceItem(
|
||||||
|
key="rising_category",
|
||||||
|
tone="warning",
|
||||||
|
params={
|
||||||
|
"category": category.name,
|
||||||
|
"percent": round(increase_percent),
|
||||||
|
"current": float(current),
|
||||||
|
"average": float(average),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _unbudgeted_top_category_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
|
||||||
|
"""This month's highest-spending expense category that has no budget."""
|
||||||
|
current_totals = await _category_expense_totals(session, year, month)
|
||||||
|
if not current_totals:
|
||||||
|
return None
|
||||||
|
|
||||||
|
budgeted_ids = {row[0] for row in (await session.execute(select(Budget.category_id))).all()}
|
||||||
|
|
||||||
|
for cat_id, amount in sorted(current_totals.items(), key=lambda item: item[1], reverse=True):
|
||||||
|
if cat_id in budgeted_ids:
|
||||||
|
continue
|
||||||
|
category = await session.get(Category, cat_id)
|
||||||
|
if category is None:
|
||||||
|
continue
|
||||||
|
return AdviceItem(
|
||||||
|
key="unbudgeted_top_category",
|
||||||
|
tone="neutral",
|
||||||
|
params={"category": category.name, "amount": float(amount)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _savings_rate_trend_advice(session: AsyncSession, year: int, month: int) -> AdviceItem | None:
|
||||||
|
"""This month's savings rate (net / real_income) vs. last month's, when
|
||||||
|
the swing is large enough to be worth mentioning."""
|
||||||
|
current = await get_dashboard_summary(session, year, month)
|
||||||
|
prev_year, prev_month = _previous_month(year, month)
|
||||||
|
previous = await get_dashboard_summary(session, prev_year, prev_month)
|
||||||
|
|
||||||
|
if current.real_income <= 0 or previous.real_income <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_rate = float(current.net / current.real_income * 100)
|
||||||
|
previous_rate = float(previous.net / previous.real_income * 100)
|
||||||
|
diff = current_rate - previous_rate
|
||||||
|
|
||||||
|
if abs(diff) < SAVINGS_RATE_TREND_THRESHOLD_POINTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AdviceItem(
|
||||||
|
key="savings_rate_trend",
|
||||||
|
tone="positive" if diff > 0 else "warning",
|
||||||
|
params={"rate": round(current_rate), "diff": round(diff)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_advice(session: AsyncSession) -> AdviceResponse:
|
||||||
|
today = date.today()
|
||||||
|
generators = (_rising_category_advice, _unbudgeted_top_category_advice, _savings_rate_trend_advice)
|
||||||
|
|
||||||
|
items: list[AdviceItem] = []
|
||||||
|
for generate in generators:
|
||||||
|
item = await generate(session, today.year, today.month)
|
||||||
|
if item is not None:
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return AdviceResponse(items=items)
|
||||||
185
backend/app/services/backup_service.py
Normal file
185
backend/app/services/backup_service.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Full-database JSON backup & restore.
|
||||||
|
|
||||||
|
Exports every row (accounts, categories, transactions, assets, asset
|
||||||
|
valuations) as one portable JSON document a user can download from the
|
||||||
|
browser and re-upload later. Restore fully REPLACES existing data — it's a
|
||||||
|
snapshot restore, not a merge — so the whole operation runs in one DB
|
||||||
|
transaction: a corrupt or incompatible file is rejected (referential checks
|
||||||
|
run first, before any row is touched), and any failure during the swap rolls
|
||||||
|
the database back to exactly where it was, so a bad file never leaves the
|
||||||
|
app half-restored.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import delete, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import APP_VERSION
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.asset import Asset, AssetValuation
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.goal import Goal, GoalContribution
|
||||||
|
from app.models.recurring import RecurringTransaction
|
||||||
|
from app.models.settings import AppSettings
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.backup import (
|
||||||
|
AccountBackup,
|
||||||
|
AppSettingsBackup,
|
||||||
|
AssetBackup,
|
||||||
|
AssetValuationBackup,
|
||||||
|
BackupPayload,
|
||||||
|
BudgetBackup,
|
||||||
|
CategoryBackup,
|
||||||
|
GoalBackup,
|
||||||
|
GoalContributionBackup,
|
||||||
|
RecurringTransactionBackup,
|
||||||
|
TransactionBackup,
|
||||||
|
)
|
||||||
|
|
||||||
|
BACKUP_FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def build_backup(session: AsyncSession) -> BackupPayload:
|
||||||
|
accounts = (await session.execute(select(Account))).scalars().all()
|
||||||
|
categories = (await session.execute(select(Category))).scalars().all()
|
||||||
|
transactions = (await session.execute(select(Transaction))).scalars().all()
|
||||||
|
assets = (await session.execute(select(Asset))).scalars().all()
|
||||||
|
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
||||||
|
budgets = (await session.execute(select(Budget))).scalars().all()
|
||||||
|
goals = (await session.execute(select(Goal))).scalars().all()
|
||||||
|
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
|
||||||
|
recurring_transactions = (await session.execute(select(RecurringTransaction))).scalars().all()
|
||||||
|
app_settings = await session.get(AppSettings, 1)
|
||||||
|
|
||||||
|
return BackupPayload(
|
||||||
|
aurum_backup_version=BACKUP_FORMAT_VERSION,
|
||||||
|
exported_at=datetime.now(timezone.utc),
|
||||||
|
app_version=APP_VERSION,
|
||||||
|
accounts=[AccountBackup.model_validate(row) for row in accounts],
|
||||||
|
categories=[CategoryBackup.model_validate(row) for row in categories],
|
||||||
|
transactions=[TransactionBackup.model_validate(row) for row in transactions],
|
||||||
|
assets=[AssetBackup.model_validate(row) for row in assets],
|
||||||
|
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
||||||
|
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
||||||
|
goals=[GoalBackup.model_validate(row) for row in goals],
|
||||||
|
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
|
||||||
|
recurring_transactions=[RecurringTransactionBackup.model_validate(row) for row in recurring_transactions],
|
||||||
|
app_settings=AppSettingsBackup.model_validate(app_settings) if app_settings else AppSettingsBackup(currency="USD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_references(payload: BackupPayload) -> None:
|
||||||
|
account_ids = {row.id for row in payload.accounts}
|
||||||
|
category_ids = {row.id for row in payload.categories}
|
||||||
|
asset_ids = {row.id for row in payload.assets}
|
||||||
|
|
||||||
|
for t in payload.transactions:
|
||||||
|
if t.account_id not in account_ids:
|
||||||
|
raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}")
|
||||||
|
if t.transfer_account_id is not None and t.transfer_account_id not in account_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
400, f"Transaction {t.id} references unknown transfer_account_id {t.transfer_account_id}"
|
||||||
|
)
|
||||||
|
if t.category_id is not None and t.category_id not in category_ids:
|
||||||
|
raise HTTPException(400, f"Transaction {t.id} references unknown category_id {t.category_id}")
|
||||||
|
|
||||||
|
for v in payload.asset_valuations:
|
||||||
|
if v.asset_id not in asset_ids:
|
||||||
|
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
|
||||||
|
|
||||||
|
for b in payload.budgets:
|
||||||
|
if b.category_id not in category_ids:
|
||||||
|
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
|
||||||
|
|
||||||
|
goal_ids = {row.id for row in payload.goals}
|
||||||
|
for c in payload.goal_contributions:
|
||||||
|
if c.goal_id not in goal_ids:
|
||||||
|
raise HTTPException(400, f"Goal contribution {c.id} references unknown goal_id {c.goal_id}")
|
||||||
|
|
||||||
|
for r in payload.recurring_transactions:
|
||||||
|
if r.account_id not in account_ids:
|
||||||
|
raise HTTPException(400, f"Recurring transaction {r.id} references unknown account_id {r.account_id}")
|
||||||
|
if r.transfer_account_id is not None and r.transfer_account_id not in account_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Recurring transaction {r.id} references unknown transfer_account_id {r.transfer_account_id}",
|
||||||
|
)
|
||||||
|
if r.category_id is not None and r.category_id not in category_ids:
|
||||||
|
raise HTTPException(400, f"Recurring transaction {r.id} references unknown category_id {r.category_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _reset_sequence(session: AsyncSession, table: str, rows: list) -> None:
|
||||||
|
"""Bulk-inserting rows with explicit ids doesn't advance the table's
|
||||||
|
identity sequence, so the next auto-generated id would collide — bump it
|
||||||
|
to max(id) after a restore. `table` is always one of our five hardcoded
|
||||||
|
table names, never user input."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
max_id = max(row.id for row in rows)
|
||||||
|
await session.execute(
|
||||||
|
text(f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), :max_id)"), {"max_id": max_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
||||||
|
if payload.aurum_backup_version != BACKUP_FORMAT_VERSION:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Unsupported backup version {payload.aurum_backup_version} "
|
||||||
|
f"(this Aurum version supports {BACKUP_FORMAT_VERSION})",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_references(payload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Children before parents.
|
||||||
|
await session.execute(delete(AssetValuation))
|
||||||
|
await session.execute(delete(Budget))
|
||||||
|
await session.execute(delete(GoalContribution))
|
||||||
|
await session.execute(delete(Goal))
|
||||||
|
await session.execute(delete(RecurringTransaction))
|
||||||
|
await session.execute(delete(Transaction))
|
||||||
|
await session.execute(delete(Asset))
|
||||||
|
await session.execute(delete(Category))
|
||||||
|
await session.execute(delete(Account))
|
||||||
|
|
||||||
|
# Parents before children.
|
||||||
|
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
|
||||||
|
session.add_all(Category(**row.model_dump()) for row in payload.categories)
|
||||||
|
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
|
||||||
|
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions)
|
||||||
|
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
||||||
|
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
|
||||||
|
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
|
||||||
|
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)
|
||||||
|
session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await _reset_sequence(session, "accounts", payload.accounts)
|
||||||
|
await _reset_sequence(session, "categories", payload.categories)
|
||||||
|
await _reset_sequence(session, "assets", payload.assets)
|
||||||
|
await _reset_sequence(session, "transactions", payload.transactions)
|
||||||
|
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
||||||
|
await _reset_sequence(session, "budgets", payload.budgets)
|
||||||
|
await _reset_sequence(session, "goals", payload.goals)
|
||||||
|
await _reset_sequence(session, "goal_contributions", payload.goal_contributions)
|
||||||
|
await _reset_sequence(session, "recurring_transactions", payload.recurring_transactions)
|
||||||
|
|
||||||
|
# Singleton row — updated in place, not deleted/recreated (no
|
||||||
|
# sequence to reset, id is always 1).
|
||||||
|
app_settings = await session.get(AppSettings, 1)
|
||||||
|
if app_settings is None:
|
||||||
|
session.add(AppSettings(id=1, **payload.app_settings.model_dump()))
|
||||||
|
else:
|
||||||
|
for field, value in payload.app_settings.model_dump().items():
|
||||||
|
setattr(app_settings, field, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(400, f"Restore failed, no changes were made: {exc}") from exc
|
||||||
107
backend/app/services/budget_service.py
Normal file
107
backend/app/services/budget_service.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Monthly category budgets: CRUD for the limits themselves, plus
|
||||||
|
get_budget_status(), which compares each limit against actual spend for a
|
||||||
|
given month — the data behind the Budget page's progress bars and the
|
||||||
|
budget_exceeded proactive alert (services/insights_service.py)."""
|
||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.budget import Budget
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.budget import BudgetCreate, BudgetStatus, BudgetStatusResponse, BudgetUpdate
|
||||||
|
|
||||||
|
_EAGER = (selectinload(Budget.category),)
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(year: int, month: int) -> tuple[date, date]:
|
||||||
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
|
return date(year, month, 1), date(year, month, last_day)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_budgets(session: AsyncSession) -> list[Budget]:
|
||||||
|
result = await session.execute(select(Budget).options(*_EAGER).join(Category).order_by(Category.sort_order))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def create_budget(session: AsyncSession, payload: BudgetCreate) -> Budget:
|
||||||
|
category = await session.get(Category, payload.category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Category not found")
|
||||||
|
if category.kind != CategoryKind.EXPENSE:
|
||||||
|
raise HTTPException(status_code=400, detail="Budgets can only be set on expense categories")
|
||||||
|
|
||||||
|
existing = await session.execute(select(Budget).where(Budget.category_id == payload.category_id))
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"'{category.name}' already has a budget")
|
||||||
|
|
||||||
|
budget = Budget(category_id=payload.category_id, monthly_limit=payload.monthly_limit)
|
||||||
|
session.add(budget)
|
||||||
|
await session.commit()
|
||||||
|
refreshed = await session.execute(select(Budget).options(*_EAGER).where(Budget.id == budget.id))
|
||||||
|
return refreshed.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_budget(session: AsyncSession, budget_id: int, payload: BudgetUpdate) -> Budget:
|
||||||
|
budget = await session.get(Budget, budget_id)
|
||||||
|
if budget is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Budget not found")
|
||||||
|
budget.monthly_limit = payload.monthly_limit
|
||||||
|
await session.commit()
|
||||||
|
refreshed = await session.execute(select(Budget).options(*_EAGER).where(Budget.id == budget_id))
|
||||||
|
return refreshed.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_budget(session: AsyncSession, budget_id: int) -> None:
|
||||||
|
budget = await session.get(Budget, budget_id)
|
||||||
|
if budget is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Budget not found")
|
||||||
|
await session.delete(budget)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_budget_status(session: AsyncSession, year: int, month: int) -> BudgetStatusResponse:
|
||||||
|
start, end = _month_bounds(year, month)
|
||||||
|
|
||||||
|
budgets = await list_budgets(session)
|
||||||
|
if not budgets:
|
||||||
|
return BudgetStatusResponse(year=year, month=month, items=[])
|
||||||
|
|
||||||
|
spent_stmt = (
|
||||||
|
select(Transaction.category_id, func.coalesce(func.sum(Transaction.amount), 0))
|
||||||
|
.where(
|
||||||
|
Transaction.category_id.in_([b.category_id for b in budgets]),
|
||||||
|
Transaction.type == TransactionType.EXPENSE,
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date <= end,
|
||||||
|
)
|
||||||
|
.group_by(Transaction.category_id)
|
||||||
|
)
|
||||||
|
spent_by_category: dict[int, Decimal] = {row[0]: row[1] for row in (await session.execute(spent_stmt)).all()}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for budget in budgets:
|
||||||
|
spent = spent_by_category.get(budget.category_id, Decimal("0"))
|
||||||
|
percent = float(spent / budget.monthly_limit * 100) if budget.monthly_limit else 0.0
|
||||||
|
items.append(
|
||||||
|
BudgetStatus(
|
||||||
|
budget_id=budget.id,
|
||||||
|
category_id=budget.category_id,
|
||||||
|
category_name=budget.category.name,
|
||||||
|
category_color=budget.category.color,
|
||||||
|
category_icon=budget.category.icon,
|
||||||
|
monthly_limit=budget.monthly_limit,
|
||||||
|
spent=spent,
|
||||||
|
remaining=budget.monthly_limit - spent,
|
||||||
|
percent=percent,
|
||||||
|
is_over_budget=spent > budget.monthly_limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BudgetStatusResponse(year=year, month=month, items=items)
|
||||||
87
backend/app/services/cash_flow_service.py
Normal file
87
backend/app/services/cash_flow_service.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Month-by-month income vs. expense — the multi-month view neither the
|
||||||
|
Dashboard (locked to one month) nor Reports (one category at a time, or a
|
||||||
|
category ranking) answers on its own. Transfers between the user's own
|
||||||
|
accounts are excluded from both totals, same as the Dashboard breakdown.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import extract, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.cash_flow import CashFlowPoint, CashFlowResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _next_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
return (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cash_flow(
|
||||||
|
session: AsyncSession, start_date: date_ | None, end_date: date_ | None
|
||||||
|
) -> CashFlowResponse:
|
||||||
|
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
|
||||||
|
Transaction.type != TransactionType.TRANSFER
|
||||||
|
)
|
||||||
|
if start_date:
|
||||||
|
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
|
||||||
|
min_date, max_date = (await session.execute(bounds_stmt)).one()
|
||||||
|
|
||||||
|
effective_start = start_date or min_date
|
||||||
|
effective_end = end_date or max_date
|
||||||
|
|
||||||
|
empty = CashFlowResponse(
|
||||||
|
start_date=effective_start,
|
||||||
|
end_date=effective_end,
|
||||||
|
points=[],
|
||||||
|
total_income=Decimal("0"),
|
||||||
|
total_expense=Decimal("0"),
|
||||||
|
total_net=Decimal("0"),
|
||||||
|
)
|
||||||
|
if effective_start is None or effective_end is None:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
rows_stmt = (
|
||||||
|
select(
|
||||||
|
extract("year", Transaction.date).label("year"),
|
||||||
|
extract("month", Transaction.date).label("month"),
|
||||||
|
Transaction.type,
|
||||||
|
func.sum(Transaction.amount).label("amount"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.type != TransactionType.TRANSFER,
|
||||||
|
Transaction.date >= effective_start,
|
||||||
|
Transaction.date <= effective_end,
|
||||||
|
)
|
||||||
|
.group_by("year", "month", Transaction.type)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(rows_stmt)).all()
|
||||||
|
|
||||||
|
by_month: dict[tuple[int, int], dict[TransactionType, Decimal]] = defaultdict(dict)
|
||||||
|
for year, month, tx_type, amount in rows:
|
||||||
|
by_month[(int(year), int(month))][tx_type] = amount
|
||||||
|
|
||||||
|
points: list[CashFlowPoint] = []
|
||||||
|
year, month = effective_start.year, effective_start.month
|
||||||
|
while (year, month) <= (effective_end.year, effective_end.month):
|
||||||
|
totals = by_month.get((year, month), {})
|
||||||
|
income = totals.get(TransactionType.INCOME, Decimal("0"))
|
||||||
|
expense = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
||||||
|
points.append(CashFlowPoint(year=year, month=month, income=income, expense=expense, net=income - expense))
|
||||||
|
year, month = _next_month(year, month)
|
||||||
|
|
||||||
|
total_income = sum((p.income for p in points), Decimal("0"))
|
||||||
|
total_expense = sum((p.expense for p in points), Decimal("0"))
|
||||||
|
|
||||||
|
return CashFlowResponse(
|
||||||
|
start_date=effective_start,
|
||||||
|
end_date=effective_end,
|
||||||
|
points=points,
|
||||||
|
total_income=total_income,
|
||||||
|
total_expense=total_expense,
|
||||||
|
total_net=total_income - total_expense,
|
||||||
|
)
|
||||||
89
backend/app/services/dashboard_service.py
Normal file
89
backend/app/services/dashboard_service.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Aggregation logic behind the Overview dashboard."""
|
||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.dashboard import CategoryBreakdownItem, DashboardSummary
|
||||||
|
|
||||||
|
# Categorical slots are capped at 8 (dataviz skill: a 9th series folds into "Other",
|
||||||
|
# never a generated hue) — this is also the exact size of the default category set.
|
||||||
|
MAX_CHART_SLICES = 8
|
||||||
|
OTHER_SLICE_COLOR = "#898781" # muted ink, reserved for the non-categorical rollup
|
||||||
|
|
||||||
|
|
||||||
|
def _month_bounds(year: int, month: int) -> tuple[date, date]:
|
||||||
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
|
return date(year, month, 1), date(year, month, last_day)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dashboard_summary(session: AsyncSession, year: int, month: int) -> DashboardSummary:
|
||||||
|
start, end = _month_bounds(year, month)
|
||||||
|
|
||||||
|
totals_stmt = (
|
||||||
|
select(Transaction.type, func.coalesce(func.sum(Transaction.amount), 0))
|
||||||
|
.where(Transaction.date >= start, Transaction.date <= end)
|
||||||
|
.group_by(Transaction.type)
|
||||||
|
)
|
||||||
|
totals_result = await session.execute(totals_stmt)
|
||||||
|
totals: dict[TransactionType, Decimal] = {row[0]: row[1] for row in totals_result.all()}
|
||||||
|
|
||||||
|
real_income = totals.get(TransactionType.INCOME, Decimal("0"))
|
||||||
|
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
||||||
|
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
|
||||||
|
|
||||||
|
breakdown_stmt = (
|
||||||
|
select(
|
||||||
|
Category.id,
|
||||||
|
Category.name,
|
||||||
|
Category.color,
|
||||||
|
Category.icon,
|
||||||
|
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
|
||||||
|
)
|
||||||
|
.join(Category, Category.id == Transaction.category_id)
|
||||||
|
.where(
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date <= end,
|
||||||
|
Transaction.type == TransactionType.EXPENSE,
|
||||||
|
)
|
||||||
|
.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
|
||||||
|
.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
|
||||||
|
)
|
||||||
|
breakdown_result = await session.execute(breakdown_stmt)
|
||||||
|
rows = breakdown_result.all()
|
||||||
|
|
||||||
|
top_rows, rest_rows = rows[:MAX_CHART_SLICES], rows[MAX_CHART_SLICES:]
|
||||||
|
|
||||||
|
def _percent(amount: Decimal) -> float:
|
||||||
|
return float(amount / spent * 100) if spent else 0.0
|
||||||
|
|
||||||
|
spending_by_category = [
|
||||||
|
CategoryBreakdownItem(
|
||||||
|
category_id=cat_id, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount)
|
||||||
|
)
|
||||||
|
for cat_id, name, color, icon, amount in top_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
if rest_rows:
|
||||||
|
other_amount = sum((row.amount for row in rest_rows), Decimal("0"))
|
||||||
|
spending_by_category.append(
|
||||||
|
CategoryBreakdownItem(
|
||||||
|
category_id=None, name="Other", color=OTHER_SLICE_COLOR, icon="more-horizontal",
|
||||||
|
amount=other_amount, percent=_percent(other_amount),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DashboardSummary(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
real_income=real_income,
|
||||||
|
spent=spent,
|
||||||
|
net=real_income - spent,
|
||||||
|
transferred_out=transferred_out,
|
||||||
|
spending_by_category=spending_by_category,
|
||||||
|
)
|
||||||
94
backend/app/services/goal_service.py
Normal file
94
backend/app/services/goal_service.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Savings goals: CRUD for the goal itself, plus a running current_amount —
|
||||||
|
the sum of all logged GoalContribution rows, computed on read rather than
|
||||||
|
stored, so it's never out of sync with the log."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.engine import Row
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.goal import Goal, GoalContribution
|
||||||
|
from app.schemas.goal import GoalContributionCreate, GoalCreate, GoalRead, GoalUpdate
|
||||||
|
|
||||||
|
_SELECT_WITH_TOTAL = (
|
||||||
|
select(
|
||||||
|
Goal.id,
|
||||||
|
Goal.name,
|
||||||
|
Goal.target_amount,
|
||||||
|
Goal.target_date,
|
||||||
|
func.coalesce(func.sum(GoalContribution.amount), 0).label("current_amount"),
|
||||||
|
)
|
||||||
|
.outerjoin(GoalContribution, GoalContribution.goal_id == Goal.id)
|
||||||
|
.group_by(Goal.id, Goal.name, Goal.target_amount, Goal.target_date, Goal.created_at)
|
||||||
|
.order_by(Goal.created_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(row: Row) -> GoalRead:
|
||||||
|
current = row.current_amount
|
||||||
|
target = row.target_amount
|
||||||
|
percent = float(current / target * 100) if target else 0.0
|
||||||
|
return GoalRead(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
target_amount=target,
|
||||||
|
target_date=row.target_date,
|
||||||
|
current_amount=current,
|
||||||
|
remaining=target - current,
|
||||||
|
percent=percent,
|
||||||
|
is_reached=current >= target,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_one(session: AsyncSession, goal_id: int) -> GoalRead:
|
||||||
|
row = (await session.execute(_SELECT_WITH_TOTAL.where(Goal.id == goal_id))).one()
|
||||||
|
return _to_read(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_goals(session: AsyncSession) -> list[GoalRead]:
|
||||||
|
rows = (await session.execute(_SELECT_WITH_TOTAL)).all()
|
||||||
|
return [_to_read(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_goal(session: AsyncSession, payload: GoalCreate) -> GoalRead:
|
||||||
|
goal = Goal(name=payload.name, target_amount=payload.target_amount, target_date=payload.target_date)
|
||||||
|
session.add(goal)
|
||||||
|
await session.commit()
|
||||||
|
return GoalRead(
|
||||||
|
id=goal.id,
|
||||||
|
name=goal.name,
|
||||||
|
target_amount=goal.target_amount,
|
||||||
|
target_date=goal.target_date,
|
||||||
|
current_amount=Decimal("0"),
|
||||||
|
remaining=goal.target_amount,
|
||||||
|
percent=0.0,
|
||||||
|
is_reached=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_goal(session: AsyncSession, goal_id: int, payload: GoalUpdate) -> GoalRead:
|
||||||
|
goal = await session.get(Goal, goal_id)
|
||||||
|
if goal is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Goal not found")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(goal, field, value)
|
||||||
|
await session.commit()
|
||||||
|
return await _read_one(session, goal_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_goal(session: AsyncSession, goal_id: int) -> None:
|
||||||
|
goal = await session.get(Goal, goal_id)
|
||||||
|
if goal is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Goal not found")
|
||||||
|
await session.delete(goal)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def add_contribution(session: AsyncSession, goal_id: int, payload: GoalContributionCreate) -> GoalRead:
|
||||||
|
goal = await session.get(Goal, goal_id)
|
||||||
|
if goal is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Goal not found")
|
||||||
|
session.add(GoalContribution(goal_id=goal_id, amount=payload.amount, date=payload.date, note=payload.note))
|
||||||
|
await session.commit()
|
||||||
|
return await _read_one(session, goal_id)
|
||||||
119
backend/app/services/insights_service.py
Normal file
119
backend/app/services/insights_service.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Proactive early-warning checks, computed from data that already exists.
|
||||||
|
Four signals: sustained negative monthly cash flow, a sustained net-worth
|
||||||
|
decline, one or more over-budget categories, and too much capital in
|
||||||
|
medium/high risk tiers (the "80% at zero risk, 20% at most exposed" rule).
|
||||||
|
The first two only look at fully-elapsed calendar months, so a same-month
|
||||||
|
false alarm (rent already paid, salary not landed yet) never fires — their
|
||||||
|
"sustained for how long" thresholds, and the risk-allocation percentage, are
|
||||||
|
all user-configurable (Settings page), stored on AppSettings, see
|
||||||
|
get_or_create_app_settings. The budget check is the opposite: it deliberately
|
||||||
|
looks at the CURRENT, still-in-progress month, since the whole point is to
|
||||||
|
catch overspending while there's still time to react.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.insights import AlertsResponse, FinancialAlert
|
||||||
|
from app.schemas.net_worth import NetWorthSummary
|
||||||
|
from app.services.budget_service import get_budget_status
|
||||||
|
from app.services.dashboard_service import get_dashboard_summary
|
||||||
|
from app.services.net_worth_service import get_net_worth_summary
|
||||||
|
from app.services.settings_service import get_or_create_app_settings
|
||||||
|
|
||||||
|
MAX_LOOKBACK_MONTHS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _negative_cash_flow_streak(session: AsyncSession) -> int:
|
||||||
|
today = date.today()
|
||||||
|
year, month = _previous_month(today.year, today.month)
|
||||||
|
|
||||||
|
streak = 0
|
||||||
|
for _ in range(MAX_LOOKBACK_MONTHS):
|
||||||
|
summary = await get_dashboard_summary(session, year, month)
|
||||||
|
if summary.net >= 0:
|
||||||
|
break
|
||||||
|
streak += 1
|
||||||
|
year, month = _previous_month(year, month)
|
||||||
|
return streak
|
||||||
|
|
||||||
|
|
||||||
|
def _net_worth_decline_streak(summary: NetWorthSummary) -> int:
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Keep the last point seen for each (year, month) — `series` is ordered
|
||||||
|
# ascending by date, so that's the month-end value — and drop the
|
||||||
|
# current month, which is always partial.
|
||||||
|
month_end: dict[tuple[int, int], Decimal] = {}
|
||||||
|
for point in summary.series:
|
||||||
|
if (point.date.year, point.date.month) == (today.year, today.month):
|
||||||
|
continue
|
||||||
|
month_end[(point.date.year, point.date.month)] = point.value
|
||||||
|
|
||||||
|
ordered_months = sorted(month_end)
|
||||||
|
if len(ordered_months) < 2:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
streak = 0
|
||||||
|
for i in range(len(ordered_months) - 1, 0, -1):
|
||||||
|
if month_end[ordered_months[i]] < month_end[ordered_months[i - 1]]:
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return streak
|
||||||
|
|
||||||
|
|
||||||
|
async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
|
||||||
|
settings = await get_or_create_app_settings(session)
|
||||||
|
alerts: list[FinancialAlert] = []
|
||||||
|
|
||||||
|
cash_flow_streak = await _negative_cash_flow_streak(session)
|
||||||
|
if cash_flow_streak >= settings.negative_cash_flow_threshold_months:
|
||||||
|
alerts.append(
|
||||||
|
FinancialAlert(
|
||||||
|
key="negative_cash_flow_streak",
|
||||||
|
severity="warning",
|
||||||
|
params={"months": cash_flow_streak},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
net_worth_summary = await get_net_worth_summary(session, "all")
|
||||||
|
|
||||||
|
net_worth_streak = _net_worth_decline_streak(net_worth_summary)
|
||||||
|
if net_worth_streak >= settings.net_worth_decline_threshold_months:
|
||||||
|
alerts.append(
|
||||||
|
FinancialAlert(
|
||||||
|
key="net_worth_decline_streak",
|
||||||
|
severity="warning",
|
||||||
|
params={"months": net_worth_streak},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
risky_percent = sum(tier.percent for tier in net_worth_summary.risk_levels if tier.risk_level != "low")
|
||||||
|
if risky_percent > settings.risky_allocation_threshold_percent:
|
||||||
|
alerts.append(
|
||||||
|
FinancialAlert(
|
||||||
|
key="risky_allocation_exceeded",
|
||||||
|
severity="warning",
|
||||||
|
params={"percent": round(risky_percent), "threshold": settings.risky_allocation_threshold_percent},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
budget_status = await get_budget_status(session, today.year, today.month)
|
||||||
|
over_budget_count = sum(1 for item in budget_status.items if item.is_over_budget)
|
||||||
|
if over_budget_count > 0:
|
||||||
|
alerts.append(
|
||||||
|
FinancialAlert(
|
||||||
|
key="budget_exceeded",
|
||||||
|
severity="warning",
|
||||||
|
params={"count": over_budget_count},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AlertsResponse(alerts=alerts)
|
||||||
282
backend/app/services/net_worth_service.py
Normal file
282
backend/app/services/net_worth_service.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Net worth aggregation: a single trend line plus a percent breakdown by
|
||||||
|
asset class.
|
||||||
|
|
||||||
|
Design note: "Cash" is not stored anywhere — it's derived live from
|
||||||
|
Account/Transaction data (checking/savings/cash accounts only) so the
|
||||||
|
Transactions feature stays the single source of truth for liquid money.
|
||||||
|
Every other class (investments, crypto, real estate, vehicles, precious
|
||||||
|
metals, other) is tracked manually via Asset/AssetValuation rows. Both are
|
||||||
|
collapsed into one
|
||||||
|
daily, forward-filled series so the chart reads as one continuous line even
|
||||||
|
though the two halves are updated at very different cadences.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date as date_
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from itertools import groupby
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.asset import Asset, AssetValuation
|
||||||
|
from app.models.enums import AccountType, AssetClass, CapitalRole, RiskLevel, TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.net_worth import (
|
||||||
|
CapitalRoleSummary,
|
||||||
|
NetWorthBreakdownItem,
|
||||||
|
NetWorthPoint,
|
||||||
|
NetWorthSummary,
|
||||||
|
RiskLevelItem,
|
||||||
|
RiskLevelSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
CASH_ACCOUNT_TYPES = {AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH}
|
||||||
|
|
||||||
|
RANGE_DAYS = {"30d": 30, "90d": 90, "1y": 365, "5y": 365 * 5}
|
||||||
|
|
||||||
|
_CLASS_META: dict[str, tuple[str, str, str]] = {
|
||||||
|
# key -> (display name, color, lucide icon). Colors follow the dataviz
|
||||||
|
# skill's validated 8-slot order (blue, orange, aqua, yellow, magenta,
|
||||||
|
# green, violet, red) in this exact sequence — an adjacent-pair-safe
|
||||||
|
# order for a segmented bar/donut; do not reorder without re-validating.
|
||||||
|
"cash": ("Наличные", "#2a78d6", "wallet"), # slot 1 blue
|
||||||
|
AssetClass.INVESTMENTS.value: ("Инвестиции", "#eb6834", "trending-up"), # slot 2 orange
|
||||||
|
AssetClass.CRYPTO.value: ("Криптовалюта", "#1baf7a", "bitcoin"), # slot 3 aqua
|
||||||
|
AssetClass.REAL_ESTATE.value: ("Недвижимость", "#eda100", "building-2"), # slot 4 yellow
|
||||||
|
AssetClass.VEHICLES.value: ("Транспорт", "#e87ba4", "car"), # slot 5 magenta
|
||||||
|
AssetClass.PRECIOUS_METALS.value: ("Драгметаллы", "#008300", "gem"), # slot 6 green
|
||||||
|
AssetClass.OTHER.value: ("Прочее", "#4a3aa7", "package"), # slot 7 violet
|
||||||
|
}
|
||||||
|
|
||||||
|
_ROLE_META: dict[CapitalRole, tuple[str, str]] = {
|
||||||
|
# role -> (label, color). Status colors, not categorical ones — "good"
|
||||||
|
# and "critical" from the dataviz skill's fixed status palette, reused
|
||||||
|
# verbatim from how StatCard/NetWorthChart already color income vs.
|
||||||
|
# expense elsewhere in the app.
|
||||||
|
CapitalRole.INCOME: ("Доходный", "var(--success)"),
|
||||||
|
CapitalRole.NEUTRAL: ("Нейтральный", "var(--text-muted)"),
|
||||||
|
CapitalRole.DRAIN: ("Убыточный", "var(--danger)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_RISK_META: dict[RiskLevel, tuple[str, str]] = {
|
||||||
|
# Same status-color reuse as _ROLE_META — low risk reads as "good",
|
||||||
|
# high risk as "critical", not a categorical hue.
|
||||||
|
RiskLevel.LOW: ("Низкий", "var(--success)"),
|
||||||
|
RiskLevel.MEDIUM: ("Средний", "var(--text-muted)"),
|
||||||
|
RiskLevel.HIGH: ("Высокий", "var(--danger)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_series(events: list[tuple[date_, Decimal]], start: date_, end: date_) -> list[NetWorthPoint]:
|
||||||
|
"""Forward-fills a sparse, date-sorted list of cumulative totals into one
|
||||||
|
point per calendar day. `events` before `start` still count — they just
|
||||||
|
collapse into `start`'s opening value."""
|
||||||
|
events = sorted(events, key=lambda e: e[0])
|
||||||
|
points: list[NetWorthPoint] = []
|
||||||
|
idx, n = 0, len(events)
|
||||||
|
current = Decimal("0")
|
||||||
|
day = start
|
||||||
|
while day <= end:
|
||||||
|
while idx < n and events[idx][0] <= day:
|
||||||
|
current = events[idx][1]
|
||||||
|
idx += 1
|
||||||
|
points.append(NetWorthPoint(date=day, value=current))
|
||||||
|
day += timedelta(days=1)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
async def _cash_cumulative_events(session: AsyncSession) -> list[tuple[date_, Decimal]]:
|
||||||
|
accounts_result = await session.execute(select(Account.id, Account.type))
|
||||||
|
cash_account_ids = {acc_id for acc_id, acc_type in accounts_result.all() if acc_type in CASH_ACCOUNT_TYPES}
|
||||||
|
|
||||||
|
txns_result = await session.execute(
|
||||||
|
select(Transaction.date, Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
delta_by_date: dict[date_, Decimal] = defaultdict(Decimal)
|
||||||
|
for tx_date, tx_type, amount, account_id, transfer_account_id in txns_result.all():
|
||||||
|
if tx_type == TransactionType.INCOME and account_id in cash_account_ids:
|
||||||
|
delta_by_date[tx_date] += amount
|
||||||
|
elif tx_type == TransactionType.EXPENSE and account_id in cash_account_ids:
|
||||||
|
delta_by_date[tx_date] -= amount
|
||||||
|
elif tx_type == TransactionType.TRANSFER:
|
||||||
|
if account_id in cash_account_ids:
|
||||||
|
delta_by_date[tx_date] -= amount
|
||||||
|
if transfer_account_id in cash_account_ids:
|
||||||
|
delta_by_date[tx_date] += amount
|
||||||
|
|
||||||
|
events: list[tuple[date_, Decimal]] = []
|
||||||
|
running = Decimal("0")
|
||||||
|
for day in sorted(delta_by_date):
|
||||||
|
running += delta_by_date[day]
|
||||||
|
events.append((day, running))
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def _asset_events_and_class_totals(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> tuple[list[tuple[date_, Decimal]], dict[AssetClass, Decimal], dict[int, Decimal]]:
|
||||||
|
asset_class_result = await session.execute(select(Asset.id, Asset.asset_class))
|
||||||
|
asset_class_map = dict(asset_class_result.all())
|
||||||
|
|
||||||
|
valuations_result = await session.execute(
|
||||||
|
select(AssetValuation.asset_id, AssetValuation.as_of_date, AssetValuation.value).order_by(
|
||||||
|
AssetValuation.as_of_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = valuations_result.all()
|
||||||
|
|
||||||
|
current_by_asset: dict[int, Decimal] = {}
|
||||||
|
events: list[tuple[date_, Decimal]] = []
|
||||||
|
for day, group in groupby(rows, key=lambda row: row[1]):
|
||||||
|
for asset_id, _, value in group:
|
||||||
|
current_by_asset[asset_id] = value
|
||||||
|
events.append((day, sum(current_by_asset.values(), Decimal("0"))))
|
||||||
|
|
||||||
|
class_totals: dict[AssetClass, Decimal] = defaultdict(Decimal)
|
||||||
|
for asset_id, value in current_by_asset.items():
|
||||||
|
asset_class = asset_class_map.get(asset_id)
|
||||||
|
if asset_class is not None:
|
||||||
|
class_totals[asset_class] += value
|
||||||
|
|
||||||
|
return events, class_totals, current_by_asset
|
||||||
|
|
||||||
|
|
||||||
|
async def _capital_role_summary(session: AsyncSession, current_by_asset: dict[int, Decimal]) -> list[CapitalRoleSummary]:
|
||||||
|
"""Cross-cuts the same assets by how the user tagged them (income /
|
||||||
|
neutral / drain) instead of by asset class — always all three roles,
|
||||||
|
even at zero, so the block reads as a fixed scale rather than a list
|
||||||
|
that shuffles as assets are added."""
|
||||||
|
roles_result = await session.execute(select(Asset.id, Asset.capital_role, Asset.monthly_cash_flow))
|
||||||
|
|
||||||
|
totals_value: dict[CapitalRole, Decimal] = defaultdict(Decimal)
|
||||||
|
totals_flow: dict[CapitalRole, Decimal] = defaultdict(Decimal)
|
||||||
|
counts: dict[CapitalRole, int] = defaultdict(int)
|
||||||
|
for asset_id, role, cash_flow in roles_result.all():
|
||||||
|
totals_value[role] += current_by_asset.get(asset_id, Decimal("0"))
|
||||||
|
totals_flow[role] += cash_flow or Decimal("0")
|
||||||
|
counts[role] += 1
|
||||||
|
|
||||||
|
return [
|
||||||
|
CapitalRoleSummary(
|
||||||
|
role=role.value,
|
||||||
|
label=_ROLE_META[role][0],
|
||||||
|
color=_ROLE_META[role][1],
|
||||||
|
total_value=totals_value.get(role, Decimal("0")),
|
||||||
|
monthly_cash_flow=totals_flow.get(role, Decimal("0")),
|
||||||
|
count=counts.get(role, 0),
|
||||||
|
)
|
||||||
|
for role in CapitalRole
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _risk_level_summary(
|
||||||
|
session: AsyncSession, current_by_asset: dict[int, Decimal], cash_today: Decimal
|
||||||
|
) -> list[RiskLevelSummary]:
|
||||||
|
"""Cross-cuts Cash + assets by user-tagged risk of loss — unlike
|
||||||
|
capital_roles, Cash participates here: it's the zero-risk anchor an
|
||||||
|
80/20-style allocation rule ("80% of capital at zero risk, at most 20%
|
||||||
|
exposed") is measured against. Always all three tiers, even at zero,
|
||||||
|
same reasoning as capital_roles. Each tier's item list *is* its
|
||||||
|
diversification view — a tier that's one holding at 100% is
|
||||||
|
concentrated, several even-sized holdings aren't, no separate index."""
|
||||||
|
assets_result = await session.execute(select(Asset.id, Asset.name, Asset.risk_level))
|
||||||
|
asset_rows = assets_result.all()
|
||||||
|
|
||||||
|
totals: dict[RiskLevel, Decimal] = defaultdict(Decimal)
|
||||||
|
items_by_level: dict[RiskLevel, list[tuple[str, str, Decimal]]] = defaultdict(list)
|
||||||
|
|
||||||
|
if cash_today:
|
||||||
|
totals[RiskLevel.LOW] += cash_today
|
||||||
|
items_by_level[RiskLevel.LOW].append(("cash", _CLASS_META["cash"][0], cash_today))
|
||||||
|
|
||||||
|
for asset_id, name, risk_level in asset_rows:
|
||||||
|
value = current_by_asset.get(asset_id, Decimal("0"))
|
||||||
|
if value == 0:
|
||||||
|
continue
|
||||||
|
totals[risk_level] += value
|
||||||
|
items_by_level[risk_level].append((f"asset:{asset_id}", name, value))
|
||||||
|
|
||||||
|
grand_total = sum(totals.values(), Decimal("0"))
|
||||||
|
|
||||||
|
def _share(amount: Decimal, denominator: Decimal) -> float:
|
||||||
|
return float(amount / denominator * 100) if denominator else 0.0
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for level in RiskLevel:
|
||||||
|
tier_total = totals.get(level, Decimal("0"))
|
||||||
|
items = [
|
||||||
|
RiskLevelItem(key=key, name=name, amount=amount, percent=_share(amount, tier_total))
|
||||||
|
for key, name, amount in sorted(items_by_level.get(level, []), key=lambda item: item[2], reverse=True)
|
||||||
|
]
|
||||||
|
summaries.append(
|
||||||
|
RiskLevelSummary(
|
||||||
|
risk_level=level.value,
|
||||||
|
label=_RISK_META[level][0],
|
||||||
|
color=_RISK_META[level][1],
|
||||||
|
total_value=tier_total,
|
||||||
|
percent=_share(tier_total, grand_total),
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_start_date(range_key: str, cash_events: list[tuple[date_, Decimal]], asset_events: list[tuple[date_, Decimal]], today: date_) -> date_:
|
||||||
|
if range_key in RANGE_DAYS:
|
||||||
|
return today - timedelta(days=RANGE_DAYS[range_key] - 1)
|
||||||
|
|
||||||
|
all_dates = [e[0] for e in cash_events] + [e[0] for e in asset_events]
|
||||||
|
return min(all_dates) if all_dates else today
|
||||||
|
|
||||||
|
|
||||||
|
async def get_net_worth_summary(session: AsyncSession, range_key: str) -> NetWorthSummary:
|
||||||
|
today = date_.today()
|
||||||
|
cash_events = await _cash_cumulative_events(session)
|
||||||
|
asset_events, class_totals, current_by_asset = await _asset_events_and_class_totals(session)
|
||||||
|
capital_roles = await _capital_role_summary(session, current_by_asset)
|
||||||
|
|
||||||
|
start = _resolve_start_date(range_key, cash_events, asset_events, today)
|
||||||
|
|
||||||
|
cash_series = _daily_series(cash_events, start, today)
|
||||||
|
asset_series = _daily_series(asset_events, start, today)
|
||||||
|
series = [
|
||||||
|
NetWorthPoint(date=c.date, value=c.value + a.value) for c, a in zip(cash_series, asset_series, strict=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
current = series[-1].value if series else Decimal("0")
|
||||||
|
start_value = series[0].value if series else Decimal("0")
|
||||||
|
change_amount = current - start_value
|
||||||
|
change_percent = float(change_amount / start_value * 100) if start_value else None
|
||||||
|
|
||||||
|
# cash_events entries are already cumulative — the last one *is* today's total.
|
||||||
|
cash_today = cash_events[-1][1] if cash_events else Decimal("0")
|
||||||
|
risk_levels = await _risk_level_summary(session, current_by_asset, cash_today)
|
||||||
|
|
||||||
|
total = cash_today + sum(class_totals.values(), Decimal("0"))
|
||||||
|
|
||||||
|
def _percent(amount: Decimal) -> float:
|
||||||
|
return float(amount / total * 100) if total else 0.0
|
||||||
|
|
||||||
|
breakdown = []
|
||||||
|
name, color, icon = _CLASS_META["cash"]
|
||||||
|
breakdown.append(NetWorthBreakdownItem(key="cash", name=name, color=color, icon=icon, amount=cash_today, percent=_percent(cash_today)))
|
||||||
|
for asset_class in AssetClass:
|
||||||
|
name, color, icon = _CLASS_META[asset_class.value]
|
||||||
|
amount = class_totals.get(asset_class, Decimal("0"))
|
||||||
|
breakdown.append(
|
||||||
|
NetWorthBreakdownItem(key=asset_class.value, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount))
|
||||||
|
)
|
||||||
|
|
||||||
|
return NetWorthSummary(
|
||||||
|
range=range_key,
|
||||||
|
current=current,
|
||||||
|
change_amount=change_amount,
|
||||||
|
change_percent=change_percent,
|
||||||
|
series=series,
|
||||||
|
breakdown=breakdown,
|
||||||
|
capital_roles=capital_roles,
|
||||||
|
risk_levels=risk_levels,
|
||||||
|
)
|
||||||
167
backend/app/services/recurring_service.py
Normal file
167
backend/app/services/recurring_service.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Recurring transaction templates: CRUD, plus "post now" — a one-click
|
||||||
|
action that creates a real Transaction from the template. Deliberately not a
|
||||||
|
background job (see also insights_service.py's docstring on scope): the
|
||||||
|
schedule only advances when the user actually clicks Post, so a missed
|
||||||
|
week never silently back-fills a pile of transactions.
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
from datetime import date as date_
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import CategoryKind, RecurringFrequency, TransactionType
|
||||||
|
from app.models.recurring import RecurringTransaction
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.recurring import RecurringTransactionCreate, RecurringTransactionRead, RecurringTransactionUpdate
|
||||||
|
|
||||||
|
_EAGER = (
|
||||||
|
selectinload(RecurringTransaction.account),
|
||||||
|
selectinload(RecurringTransaction.category),
|
||||||
|
selectinload(RecurringTransaction.transfer_account),
|
||||||
|
)
|
||||||
|
|
||||||
|
_TYPE_TO_CATEGORY_KIND = {
|
||||||
|
TransactionType.INCOME: CategoryKind.INCOME,
|
||||||
|
TransactionType.EXPENSE: CategoryKind.EXPENSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_category_matches_type(
|
||||||
|
session: AsyncSession, category_id: int | None, transaction_type: TransactionType
|
||||||
|
) -> None:
|
||||||
|
if category_id is None:
|
||||||
|
return
|
||||||
|
expected_kind = _TYPE_TO_CATEGORY_KIND.get(transaction_type)
|
||||||
|
category = await session.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Category not found")
|
||||||
|
if expected_kind is not None and category.kind != expected_kind:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Category '{category.name}' is a {category.kind.value} category and cannot be used for a {transaction_type.value} recurring transaction",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance(day: date_, frequency: RecurringFrequency) -> date_:
|
||||||
|
if frequency == RecurringFrequency.WEEKLY:
|
||||||
|
return day + timedelta(days=7)
|
||||||
|
if frequency == RecurringFrequency.MONTHLY:
|
||||||
|
year = day.year + (day.month // 12)
|
||||||
|
month = day.month % 12 + 1
|
||||||
|
clamped_day = min(day.day, calendar.monthrange(year, month)[1])
|
||||||
|
return date_(year, month, clamped_day)
|
||||||
|
# YEARLY — Feb 29 anchors fall back to Feb 28 in non-leap years.
|
||||||
|
try:
|
||||||
|
return day.replace(year=day.year + 1)
|
||||||
|
except ValueError:
|
||||||
|
return day.replace(year=day.year + 1, day=28)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_due_date(recurring: RecurringTransaction) -> date_:
|
||||||
|
if recurring.last_posted_date is None:
|
||||||
|
return recurring.anchor_date
|
||||||
|
return _advance(recurring.last_posted_date, recurring.frequency)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_read(recurring: RecurringTransaction) -> RecurringTransactionRead:
|
||||||
|
next_due = _next_due_date(recurring)
|
||||||
|
today = date_.today()
|
||||||
|
return RecurringTransactionRead(
|
||||||
|
id=recurring.id,
|
||||||
|
account_id=recurring.account_id,
|
||||||
|
account_name=recurring.account.name,
|
||||||
|
category_id=recurring.category_id,
|
||||||
|
category_name=recurring.category.name if recurring.category else None,
|
||||||
|
category_color=recurring.category.color if recurring.category else None,
|
||||||
|
category_icon=recurring.category.icon if recurring.category else None,
|
||||||
|
transfer_account_id=recurring.transfer_account_id,
|
||||||
|
transfer_account_name=recurring.transfer_account.name if recurring.transfer_account else None,
|
||||||
|
type=recurring.type,
|
||||||
|
amount=recurring.amount,
|
||||||
|
description=recurring.description,
|
||||||
|
merchant=recurring.merchant,
|
||||||
|
notes=recurring.notes,
|
||||||
|
frequency=recurring.frequency,
|
||||||
|
anchor_date=recurring.anchor_date,
|
||||||
|
last_posted_date=recurring.last_posted_date,
|
||||||
|
is_active=recurring.is_active,
|
||||||
|
next_due_date=next_due,
|
||||||
|
is_due=recurring.is_active and next_due <= today,
|
||||||
|
days_until_due=(next_due - today).days,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_404(session: AsyncSession, recurring_id: int) -> RecurringTransaction:
|
||||||
|
result = await session.execute(
|
||||||
|
select(RecurringTransaction).options(*_EAGER).where(RecurringTransaction.id == recurring_id)
|
||||||
|
)
|
||||||
|
recurring = result.scalar_one_or_none()
|
||||||
|
if recurring is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring transaction not found")
|
||||||
|
return recurring
|
||||||
|
|
||||||
|
|
||||||
|
async def list_recurring(session: AsyncSession) -> list[RecurringTransactionRead]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(RecurringTransaction).options(*_EAGER).order_by(RecurringTransaction.id)
|
||||||
|
)
|
||||||
|
return [_to_read(row) for row in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_recurring(session: AsyncSession, payload: RecurringTransactionCreate) -> RecurringTransactionRead:
|
||||||
|
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||||
|
recurring = RecurringTransaction(**payload.model_dump())
|
||||||
|
session.add(recurring)
|
||||||
|
await session.commit()
|
||||||
|
return _to_read(await _get_or_404(session, recurring.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def update_recurring(
|
||||||
|
session: AsyncSession, recurring_id: int, payload: RecurringTransactionUpdate
|
||||||
|
) -> RecurringTransactionRead:
|
||||||
|
recurring = await _get_or_404(session, recurring_id)
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
effective_type = updates.get("type", recurring.type)
|
||||||
|
effective_category_id = updates.get("category_id", recurring.category_id)
|
||||||
|
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(recurring, field, value)
|
||||||
|
await session.commit()
|
||||||
|
return _to_read(await _get_or_404(session, recurring_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_recurring(session: AsyncSession, recurring_id: int) -> None:
|
||||||
|
recurring = await session.get(RecurringTransaction, recurring_id)
|
||||||
|
if recurring is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Recurring transaction not found")
|
||||||
|
await session.delete(recurring)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def post_recurring(session: AsyncSession, recurring_id: int) -> RecurringTransactionRead:
|
||||||
|
"""Creates a real Transaction from the template, dated today, and moves
|
||||||
|
last_posted_date forward — the only thing that advances the schedule."""
|
||||||
|
recurring = await _get_or_404(session, recurring_id)
|
||||||
|
today = date_.today()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=recurring.account_id,
|
||||||
|
category_id=recurring.category_id,
|
||||||
|
transfer_account_id=recurring.transfer_account_id,
|
||||||
|
type=recurring.type,
|
||||||
|
amount=recurring.amount,
|
||||||
|
description=recurring.description,
|
||||||
|
merchant=recurring.merchant,
|
||||||
|
notes=recurring.notes,
|
||||||
|
date=today,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recurring.last_posted_date = today
|
||||||
|
await session.commit()
|
||||||
|
return _to_read(await _get_or_404(session, recurring_id))
|
||||||
158
backend/app/services/reports_service.py
Normal file
158
backend/app/services/reports_service.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Long-range spending reports — the analysis the month-scoped Dashboard
|
||||||
|
breakdown can't answer on its own: "how much did I spend on X this month,
|
||||||
|
and how much in total over N years" (single-category detail), and "which
|
||||||
|
categories cost the most over this whole period" (ranking, across all
|
||||||
|
categories of one kind at once, not just the current month).
|
||||||
|
"""
|
||||||
|
from datetime import date as date_
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import extract, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.category import Category
|
||||||
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
|
from app.schemas.reports import (
|
||||||
|
CategoryRankingItem,
|
||||||
|
CategoryRankingReport,
|
||||||
|
CategorySpendingPoint,
|
||||||
|
CategorySpendingReport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
return (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_category_spending_report(
|
||||||
|
session: AsyncSession, category_id: int, start_date: date_ | None, end_date: date_ | None
|
||||||
|
) -> CategorySpendingReport:
|
||||||
|
category = await session.get(Category, category_id)
|
||||||
|
if category is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
|
||||||
|
Transaction.category_id == category_id
|
||||||
|
)
|
||||||
|
if start_date:
|
||||||
|
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
bounds_stmt = bounds_stmt.where(Transaction.date <= end_date)
|
||||||
|
min_date, max_date = (await session.execute(bounds_stmt)).one()
|
||||||
|
|
||||||
|
effective_start = start_date or min_date
|
||||||
|
effective_end = end_date or max_date
|
||||||
|
|
||||||
|
empty = CategorySpendingReport(
|
||||||
|
category_id=category.id,
|
||||||
|
category_name=category.name,
|
||||||
|
category_color=category.color,
|
||||||
|
category_icon=category.icon,
|
||||||
|
start_date=effective_start,
|
||||||
|
end_date=effective_end,
|
||||||
|
total_amount=Decimal("0"),
|
||||||
|
transaction_count=0,
|
||||||
|
average_per_month=Decimal("0"),
|
||||||
|
series=[],
|
||||||
|
)
|
||||||
|
if effective_start is None or effective_end is None:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
rows_stmt = (
|
||||||
|
select(
|
||||||
|
extract("year", Transaction.date).label("year"),
|
||||||
|
extract("month", Transaction.date).label("month"),
|
||||||
|
func.sum(Transaction.amount).label("amount"),
|
||||||
|
func.count(Transaction.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.category_id == category_id,
|
||||||
|
Transaction.date >= effective_start,
|
||||||
|
Transaction.date <= effective_end,
|
||||||
|
)
|
||||||
|
.group_by("year", "month")
|
||||||
|
)
|
||||||
|
rows = (await session.execute(rows_stmt)).all()
|
||||||
|
by_month: dict[tuple[int, int], Decimal] = {(int(row.year), int(row.month)): row.amount for row in rows}
|
||||||
|
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
||||||
|
total_count = sum(row.cnt for row in rows)
|
||||||
|
|
||||||
|
series: list[CategorySpendingPoint] = []
|
||||||
|
year, month = effective_start.year, effective_start.month
|
||||||
|
while (year, month) <= (effective_end.year, effective_end.month):
|
||||||
|
series.append(CategorySpendingPoint(year=year, month=month, amount=by_month.get((year, month), Decimal("0"))))
|
||||||
|
year, month = _next_month(year, month)
|
||||||
|
|
||||||
|
months_count = len(series)
|
||||||
|
average_per_month = (total_amount / months_count).quantize(Decimal("0.01")) if months_count else Decimal("0")
|
||||||
|
|
||||||
|
return CategorySpendingReport(
|
||||||
|
category_id=category.id,
|
||||||
|
category_name=category.name,
|
||||||
|
category_color=category.color,
|
||||||
|
category_icon=category.icon,
|
||||||
|
start_date=effective_start,
|
||||||
|
end_date=effective_end,
|
||||||
|
total_amount=total_amount,
|
||||||
|
transaction_count=total_count,
|
||||||
|
average_per_month=average_per_month,
|
||||||
|
series=series,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_KIND_TO_TRANSACTION_TYPE = {
|
||||||
|
CategoryKind.EXPENSE: TransactionType.EXPENSE,
|
||||||
|
CategoryKind.INCOME: TransactionType.INCOME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_category_ranking_report(
|
||||||
|
session: AsyncSession, kind: CategoryKind, start_date: date_ | None, end_date: date_ | None
|
||||||
|
) -> CategoryRankingReport:
|
||||||
|
"""All categories of one kind, ranked by total spent/earned over an
|
||||||
|
arbitrary period — "which category costs the most" across the whole
|
||||||
|
range, unlike the month-scoped Dashboard breakdown or the
|
||||||
|
single-category detail above."""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Category.id,
|
||||||
|
Category.name,
|
||||||
|
Category.color,
|
||||||
|
Category.icon,
|
||||||
|
func.sum(Transaction.amount).label("amount"),
|
||||||
|
func.count(Transaction.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.join(Category, Category.id == Transaction.category_id)
|
||||||
|
.where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
|
||||||
|
)
|
||||||
|
if start_date:
|
||||||
|
stmt = stmt.where(Transaction.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
stmt = stmt.where(Transaction.date <= end_date)
|
||||||
|
stmt = stmt.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
|
||||||
|
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
|
||||||
|
|
||||||
|
rows = (await session.execute(stmt)).all()
|
||||||
|
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
||||||
|
|
||||||
|
def _percent(amount: Decimal) -> float:
|
||||||
|
return float(amount / total_amount * 100) if total_amount else 0.0
|
||||||
|
|
||||||
|
items = [
|
||||||
|
CategoryRankingItem(
|
||||||
|
category_id=cat_id,
|
||||||
|
name=name,
|
||||||
|
color=color,
|
||||||
|
icon=icon,
|
||||||
|
amount=amount,
|
||||||
|
percent=_percent(amount),
|
||||||
|
transaction_count=cnt,
|
||||||
|
)
|
||||||
|
for cat_id, name, color, icon, amount, cnt in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return CategoryRankingReport(
|
||||||
|
start_date=start_date, end_date=end_date, total_amount=total_amount, items=items
|
||||||
|
)
|
||||||
15
backend/app/services/settings_service.py
Normal file
15
backend/app/services/settings_service.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.settings import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_app_settings(session: AsyncSession) -> AppSettings:
|
||||||
|
"""Singleton row (id=1) — get-or-create so callers never hit a missing
|
||||||
|
row even if app startup's seeding hasn't run yet (e.g. a fresh test DB)."""
|
||||||
|
settings = await session.get(AppSettings, 1)
|
||||||
|
if settings is None:
|
||||||
|
settings = AppSettings(id=1)
|
||||||
|
session.add(settings)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(settings)
|
||||||
|
return settings
|
||||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.141.1
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
python-dotenv==1.2.2
|
||||||
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
331
backend/scripts/seed_mock_data.py
Normal file
331
backend/scripts/seed_mock_data.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""Fills the database with realistic mock data for local UI testing.
|
||||||
|
|
||||||
|
Run it against a running stack with:
|
||||||
|
|
||||||
|
docker compose exec backend python -m scripts.seed_mock_data
|
||||||
|
|
||||||
|
It replaces any existing transactions/assets/extra accounts with a fresh,
|
||||||
|
deterministic (random.seed'd) five-month dataset — the default "Main
|
||||||
|
Account" and the auto-seeded categories are kept. Safe to re-run any time
|
||||||
|
you want a clean demo dataset again.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models import Account, Asset, AssetValuation, Category, Transaction
|
||||||
|
from app.models.enums import AccountType, AssetClass, TransactionType
|
||||||
|
|
||||||
|
random.seed(42)
|
||||||
|
|
||||||
|
TODAY = date.today()
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_month(d: date, months: int) -> date:
|
||||||
|
month_index = d.month - 1 + months
|
||||||
|
return date(d.year + month_index // 12, month_index % 12 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# Five months of history, ending with the current (partial) month.
|
||||||
|
_current_month_start = date(TODAY.year, TODAY.month, 1)
|
||||||
|
MONTH_STARTS = [_shift_month(_current_month_start, -i) for i in range(4, -1, -1)]
|
||||||
|
|
||||||
|
|
||||||
|
def day_in_month(month_start: date, day: int) -> date | None:
|
||||||
|
try:
|
||||||
|
d = date(month_start.year, month_start.month, day)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return d if d <= TODAY else None
|
||||||
|
|
||||||
|
|
||||||
|
def amount(lo: float, hi: float) -> Decimal:
|
||||||
|
return Decimal(str(round(random.uniform(lo, hi), 2)))
|
||||||
|
|
||||||
|
|
||||||
|
async def wipe_mock_data(session) -> Account:
|
||||||
|
"""Clears prior transactions/assets/extra accounts, keeps the default
|
||||||
|
seeded account + categories."""
|
||||||
|
await session.execute(delete(Transaction))
|
||||||
|
await session.execute(delete(AssetValuation))
|
||||||
|
await session.execute(delete(Asset))
|
||||||
|
await session.execute(delete(Account).where(Account.name != "Main Account"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.execute(select(Account).where(Account.name == "Main Account"))
|
||||||
|
main_account = result.scalar_one_or_none()
|
||||||
|
if main_account is None:
|
||||||
|
main_account = Account(name="Main Account", type=AccountType.CHECKING, currency="USD", color="#2a78d6")
|
||||||
|
session.add(main_account)
|
||||||
|
await session.flush()
|
||||||
|
return main_account
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_transactions(session, main_account: Account, savings: Account, credit_card: Account) -> None:
|
||||||
|
categories_result = await session.execute(select(Category))
|
||||||
|
categories = {c.name: c for c in categories_result.scalars().all()}
|
||||||
|
|
||||||
|
for month_start in MONTH_STARTS:
|
||||||
|
# --- income ---
|
||||||
|
salary_date = day_in_month(month_start, 1)
|
||||||
|
if salary_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Salary"].id,
|
||||||
|
type=TransactionType.INCOME,
|
||||||
|
amount=amount(8200, 8800),
|
||||||
|
description="Salary",
|
||||||
|
date=salary_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
freelance_date = day_in_month(month_start, 15)
|
||||||
|
if freelance_date and month_start.month % 2 == 0:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Freelance"].id,
|
||||||
|
type=TransactionType.INCOME,
|
||||||
|
amount=amount(500, 1500),
|
||||||
|
description="Freelance project",
|
||||||
|
date=freelance_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- expenses on the main (cash) account ---
|
||||||
|
rent_date = day_in_month(month_start, 2)
|
||||||
|
if rent_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Housing & Utilities"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(2500, 2750),
|
||||||
|
description="Rent & utilities",
|
||||||
|
date=rent_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for day, desc in [(4, "Groceries"), (11, "Groceries"), (18, "Groceries"), (25, "Groceries")]:
|
||||||
|
d = day_in_month(month_start, day)
|
||||||
|
if d:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Groceries"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(45, 95),
|
||||||
|
description=desc,
|
||||||
|
date=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for day, desc in [(3, "Restaurant"), (9, "Coffee shop"), (16, "Restaurant"), (23, "Takeout")]:
|
||||||
|
d = day_in_month(month_start, day)
|
||||||
|
if d:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Dining Out"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(15, 70),
|
||||||
|
description=desc,
|
||||||
|
date=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for day in (5, 15, 25):
|
||||||
|
d = day_in_month(month_start, day)
|
||||||
|
if d:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Transportation"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(35, 90),
|
||||||
|
description="Gas",
|
||||||
|
date=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
gym_date = day_in_month(month_start, 6)
|
||||||
|
if gym_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Health & Fitness"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(50, 90),
|
||||||
|
description="Gym membership",
|
||||||
|
date=gym_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
movie_date = day_in_month(month_start, 22)
|
||||||
|
if movie_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Entertainment"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(15, 55),
|
||||||
|
description="Movies",
|
||||||
|
date=movie_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subs_date = day_in_month(month_start, 1)
|
||||||
|
if subs_date:
|
||||||
|
for desc, value in [("Netflix", "15.99"), ("Spotify", "10.99"), ("Cloud storage", "9.99")]:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
category_id=categories["Subscriptions"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=Decimal(value),
|
||||||
|
description=desc,
|
||||||
|
date=subs_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- expenses on the credit card (excluded from Net Worth's Cash total) ---
|
||||||
|
cc_shopping_date = day_in_month(month_start, 20)
|
||||||
|
if cc_shopping_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=credit_card.id,
|
||||||
|
category_id=categories["Shopping"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(60, 220),
|
||||||
|
description="Online shopping",
|
||||||
|
date=cc_shopping_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cc_dining_date = day_in_month(month_start, 10)
|
||||||
|
if cc_dining_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=credit_card.id,
|
||||||
|
category_id=categories["Dining Out"].id,
|
||||||
|
type=TransactionType.EXPENSE,
|
||||||
|
amount=amount(20, 60),
|
||||||
|
description="Dinner out",
|
||||||
|
date=cc_dining_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- monthly transfer to savings ---
|
||||||
|
transfer_date = day_in_month(month_start, 27)
|
||||||
|
if transfer_date:
|
||||||
|
session.add(
|
||||||
|
Transaction(
|
||||||
|
account_id=main_account.id,
|
||||||
|
transfer_account_id=savings.id,
|
||||||
|
type=TransactionType.TRANSFER,
|
||||||
|
amount=amount(800, 1500),
|
||||||
|
description="Move to savings",
|
||||||
|
date=transfer_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_assets(session) -> None:
|
||||||
|
def d(year: int, month: int, day: int) -> date:
|
||||||
|
return min(date(year, month, day), TODAY)
|
||||||
|
|
||||||
|
assets_plan = [
|
||||||
|
(
|
||||||
|
"Brokerage account",
|
||||||
|
AssetClass.INVESTMENTS,
|
||||||
|
[
|
||||||
|
(d(2026, 4, 1), "380000.00"),
|
||||||
|
(d(2026, 5, 1), "392000.00"),
|
||||||
|
(d(2026, 6, 1), "405000.00"),
|
||||||
|
(d(2026, 7, 1), "415000.00"),
|
||||||
|
(d(2026, 8, 1), "422910.43"),
|
||||||
|
(d(2026, 8, 14), "428500.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Bitcoin",
|
||||||
|
AssetClass.CRYPTO,
|
||||||
|
[
|
||||||
|
(d(2026, 4, 1), "3000.00"),
|
||||||
|
(d(2026, 5, 1), "3800.00"),
|
||||||
|
(d(2026, 6, 1), "3200.00"),
|
||||||
|
(d(2026, 7, 1), "4100.00"),
|
||||||
|
(d(2026, 8, 1), "3500.00"),
|
||||||
|
(d(2026, 8, 14), "3900.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Apartment",
|
||||||
|
AssetClass.REAL_ESTATE,
|
||||||
|
[
|
||||||
|
(d(2026, 4, 1), "345000.00"),
|
||||||
|
(d(2026, 7, 1), "348000.00"),
|
||||||
|
(d(2026, 8, 1), "350000.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Car",
|
||||||
|
AssetClass.VEHICLES,
|
||||||
|
[
|
||||||
|
(d(2026, 4, 1), "24000.00"),
|
||||||
|
(d(2026, 6, 1), "23200.00"),
|
||||||
|
(d(2026, 8, 1), "22500.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Emergency fund (gold coins)",
|
||||||
|
AssetClass.OTHER,
|
||||||
|
[
|
||||||
|
(d(2026, 4, 1), "1500.00"),
|
||||||
|
(d(2026, 8, 1), "2000.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, asset_class, valuations in assets_plan:
|
||||||
|
asset = Asset(name=name, asset_class=asset_class, currency="USD")
|
||||||
|
session.add(asset)
|
||||||
|
await session.flush()
|
||||||
|
seen_dates: set[date] = set()
|
||||||
|
for as_of_date, value in valuations:
|
||||||
|
if as_of_date in seen_dates:
|
||||||
|
continue
|
||||||
|
seen_dates.add(as_of_date)
|
||||||
|
session.add(AssetValuation(asset_id=asset.id, value=Decimal(value), as_of_date=as_of_date))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
print("Wiping prior mock data (transactions, assets, extra accounts)...")
|
||||||
|
main_account = await wipe_mock_data(session)
|
||||||
|
|
||||||
|
savings = Account(name="Savings", type=AccountType.SAVINGS, currency="USD", color="#1baf7a")
|
||||||
|
credit_card = Account(name="Credit Card", type=AccountType.CREDIT_CARD, currency="USD", color="#e34948")
|
||||||
|
session.add_all([savings, credit_card])
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(savings)
|
||||||
|
await session.refresh(credit_card)
|
||||||
|
|
||||||
|
print(f"Seeding transactions for {len(MONTH_STARTS)} months...")
|
||||||
|
await seed_transactions(session, main_account, savings, credit_card)
|
||||||
|
|
||||||
|
print("Seeding assets and valuation history...")
|
||||||
|
await seed_assets(session)
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal 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
14
frontend/.dockerignore
Normal 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
21
frontend/Dockerfile
Normal 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
|
||||||
32
frontend/docker-entrypoint.d/20-basic-auth.sh
Normal file
32
frontend/docker-entrypoint.d/20-basic-auth.sh
Normal 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
14
frontend/index.html
Normal 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
37
frontend/nginx.conf
Normal 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
2467
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/public/favicon.svg
Normal file
12
frontend/public/favicon.svg
Normal 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
62
frontend/src/App.tsx
Normal 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
Reference in New Issue
Block a user