commit fe9b079c1203775db543c78ee0f41ec5c4c9e8dc Author: ZProger Date: Sun Aug 16 18:39:52 2026 +0300 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3516079 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0625fc9 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ed2e202 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +1. Никогда не удаляй комментарии, которые уже есть. Ты можешь изменить комментарии на более актуальные, но нельзя удалять их, чтобы код оставался без них. +2. Код делай атомарным, чтобы изменение в одном месте, не поломало другие функции. Код должен быть написан таким образом, чтобы он хорошо поддерживался, обновлялся и масштабировался. +3. Важные новые функции которые ты добавил, записывай в файл UPDATES.md указывая текущую дату и версию приложения, мне это нужно, чтобы потом добавить в changelog моих обновлений платформы. Также после этого делай коммит и перечисляй что ты добавил. +6. Во время разработки интерфейса сразу же адаптируй это под мобильную версию. +7. Всегда отвечай мне на русском. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e599ce2 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eb2ddae --- /dev/null +++ b/LICENSE @@ -0,0 +1,136 @@ +Required Notice: Copyright ZProger (https://github.com/ZProger) + +PolyForm Noncommercial License 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..15acaba --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +
+ +# Aurum + +**See where every dollar comes from. Know where every dollar goes.** + +Aurum is a source-available personal finance operating system — a complete, granular, real-time picture of your money: cash flow, net worth, budgets, subscriptions, assets, and every income stream you own. + +[![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-blue.svg)](LICENSE) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +[Features](#-core-features) • [Getting Started](#-getting-started) • [Security](#-security--self-hosting) • [Roadmap](#%EF%B8%8F-roadmap) • [Contributing](#-contributing) • [License](#-license) + +
+ +--- + +## 📖 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 +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. + +--- + +
+ +**If Aurum helps you understand your money better, consider giving it a ⭐ — it genuinely helps the project grow.** + +
diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..087ec12 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__ +*.pyc +.venv +.git +.env +.env.* +!.env.example +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..bfb7872 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..642a96b --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..9134d11 --- /dev/null +++ b/backend/alembic/env.py @@ -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()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/1152a14e9d55_add_capital_role_and_monthly_cash_flow_.py b/backend/alembic/versions/1152a14e9d55_add_capital_role_and_monthly_cash_flow_.py new file mode 100644 index 0000000..1a91e6e --- /dev/null +++ b/backend/alembic/versions/1152a14e9d55_add_capital_role_and_monthly_cash_flow_.py @@ -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 ### diff --git a/backend/alembic/versions/6721ea138cdf_add_assets_and_asset_valuations.py b/backend/alembic/versions/6721ea138cdf_add_assets_and_asset_valuations.py new file mode 100644 index 0000000..60d0623 --- /dev/null +++ b/backend/alembic/versions/6721ea138cdf_add_assets_and_asset_valuations.py @@ -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 ### diff --git a/backend/alembic/versions/9f3a2d7c5e11_add_app_settings.py b/backend/alembic/versions/9f3a2d7c5e11_add_app_settings.py new file mode 100644 index 0000000..7a3926f --- /dev/null +++ b/backend/alembic/versions/9f3a2d7c5e11_add_app_settings.py @@ -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') diff --git a/backend/alembic/versions/a71d9f0c3e58_add_risky_allocation_threshold.py b/backend/alembic/versions/a71d9f0c3e58_add_risky_allocation_threshold.py new file mode 100644 index 0000000..a62e96f --- /dev/null +++ b/backend/alembic/versions/a71d9f0c3e58_add_risky_allocation_threshold.py @@ -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') diff --git a/backend/alembic/versions/b4e7c1a9f203_add_alert_thresholds_to_app_settings.py b/backend/alembic/versions/b4e7c1a9f203_add_alert_thresholds_to_app_settings.py new file mode 100644 index 0000000..c138d3f --- /dev/null +++ b/backend/alembic/versions/b4e7c1a9f203_add_alert_thresholds_to_app_settings.py @@ -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') diff --git a/backend/alembic/versions/c7a5f9e2b816_add_budgets.py b/backend/alembic/versions/c7a5f9e2b816_add_budgets.py new file mode 100644 index 0000000..59902c1 --- /dev/null +++ b/backend/alembic/versions/c7a5f9e2b816_add_budgets.py @@ -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') diff --git a/backend/alembic/versions/cfcd12cc849b_initial_schema.py b/backend/alembic/versions/cfcd12cc849b_initial_schema.py new file mode 100644 index 0000000..daf2638 --- /dev/null +++ b/backend/alembic/versions/cfcd12cc849b_initial_schema.py @@ -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 ### diff --git a/backend/alembic/versions/d3f8a1c4b729_add_goals.py b/backend/alembic/versions/d3f8a1c4b729_add_goals.py new file mode 100644 index 0000000..1fc245c --- /dev/null +++ b/backend/alembic/versions/d3f8a1c4b729_add_goals.py @@ -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') diff --git a/backend/alembic/versions/e91b6d3c5a04_add_recurring_transactions.py b/backend/alembic/versions/e91b6d3c5a04_add_recurring_transactions.py new file mode 100644 index 0000000..e637920 --- /dev/null +++ b/backend/alembic/versions/e91b6d3c5a04_add_recurring_transactions.py @@ -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') diff --git a/backend/alembic/versions/f2c8e4a917b3_add_risk_level_to_assets.py b/backend/alembic/versions/f2c8e4a917b3_add_risk_level_to_assets.py new file mode 100644 index 0000000..a960ede --- /dev/null +++ b/backend/alembic/versions/f2c8e4a917b3_add_risk_level_to_assets.py @@ -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') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..1d9c3a6 --- /dev/null +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/accounts.py b/backend/app/api/routes/accounts.py new file mode 100644 index 0000000..0940c2d --- /dev/null +++ b/backend/app/api/routes/accounts.py @@ -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) diff --git a/backend/app/api/routes/advice.py b/backend/app/api/routes/advice.py new file mode 100644 index 0000000..aa2bd7b --- /dev/null +++ b/backend/app/api/routes/advice.py @@ -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) diff --git a/backend/app/api/routes/assets.py b/backend/app/api/routes/assets.py new file mode 100644 index 0000000..06859ed --- /dev/null +++ b/backend/app/api/routes/assets.py @@ -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() diff --git a/backend/app/api/routes/backup.py b/backend/app/api/routes/backup.py new file mode 100644 index 0000000..d5582fb --- /dev/null +++ b/backend/app/api/routes/backup.py @@ -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"} diff --git a/backend/app/api/routes/budgets.py b/backend/app/api/routes/budgets.py new file mode 100644 index 0000000..673c9ee --- /dev/null +++ b/backend/app/api/routes/budgets.py @@ -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) diff --git a/backend/app/api/routes/cash_flow.py b/backend/app/api/routes/cash_flow.py new file mode 100644 index 0000000..2d962cc --- /dev/null +++ b/backend/app/api/routes/cash_flow.py @@ -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) diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py new file mode 100644 index 0000000..ee42adc --- /dev/null +++ b/backend/app/api/routes/categories.py @@ -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() diff --git a/backend/app/api/routes/dashboard.py b/backend/app/api/routes/dashboard.py new file mode 100644 index 0000000..67a962c --- /dev/null +++ b/backend/app/api/routes/dashboard.py @@ -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) diff --git a/backend/app/api/routes/goals.py b/backend/app/api/routes/goals.py new file mode 100644 index 0000000..5a272a4 --- /dev/null +++ b/backend/app/api/routes/goals.py @@ -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) diff --git a/backend/app/api/routes/insights.py b/backend/app/api/routes/insights.py new file mode 100644 index 0000000..ad59c5e --- /dev/null +++ b/backend/app/api/routes/insights.py @@ -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) diff --git a/backend/app/api/routes/net_worth.py b/backend/app/api/routes/net_worth.py new file mode 100644 index 0000000..78d58bf --- /dev/null +++ b/backend/app/api/routes/net_worth.py @@ -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) diff --git a/backend/app/api/routes/recurring.py b/backend/app/api/routes/recurring.py new file mode 100644 index 0000000..e8ecea7 --- /dev/null +++ b/backend/app/api/routes/recurring.py @@ -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) diff --git a/backend/app/api/routes/reports.py b/backend/app/api/routes/reports.py new file mode 100644 index 0000000..aba75bf --- /dev/null +++ b/backend/app/api/routes/reports.py @@ -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) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py new file mode 100644 index 0000000..40de89b --- /dev/null +++ b/backend/app/api/routes/settings.py @@ -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 diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py new file mode 100644 index 0000000..7a5ae0f --- /dev/null +++ b/backend/app/api/routes/transactions.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..3fb78b4 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..3ccebde --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,6 @@ +"""Declarative base shared by every ORM model.""" +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py new file mode 100644 index 0000000..fe93f35 --- /dev/null +++ b/backend/app/db/seed.py @@ -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() diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..47887f1 --- /dev/null +++ b/backend/app/db/session.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e1f5a44 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..25b533b --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/account.py b/backend/app/models/account.py new file mode 100644 index 0000000..233b7f3 --- /dev/null +++ b/backend/app/models/account.py @@ -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", + ) diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py new file mode 100644 index 0000000..6b8044a --- /dev/null +++ b/backend/app/models/asset.py @@ -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") diff --git a/backend/app/models/budget.py b/backend/app/models/budget.py new file mode 100644 index 0000000..7e0d6f9 --- /dev/null +++ b/backend/app/models/budget.py @@ -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() diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..7b1a5f5 --- /dev/null +++ b/backend/app/models/category.py @@ -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") diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..64cb975 --- /dev/null +++ b/backend/app/models/enums.py @@ -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" diff --git a/backend/app/models/goal.py b/backend/app/models/goal.py new file mode 100644 index 0000000..436e6d1 --- /dev/null +++ b/backend/app/models/goal.py @@ -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") diff --git a/backend/app/models/mixins.py b/backend/app/models/mixins.py new file mode 100644 index 0000000..0b72c17 --- /dev/null +++ b/backend/app/models/mixins.py @@ -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() + ) diff --git a/backend/app/models/recurring.py b/backend/app/models/recurring.py new file mode 100644 index 0000000..ae416ce --- /dev/null +++ b/backend/app/models/recurring.py @@ -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() diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 0000000..329b925 --- /dev/null +++ b/backend/app/models/settings.py @@ -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) diff --git a/backend/app/models/transaction.py b/backend/app/models/transaction.py new file mode 100644 index 0000000..245c0b3 --- /dev/null +++ b/backend/app/models/transaction.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/account.py b/backend/app/schemas/account.py new file mode 100644 index 0000000..aa5b736 --- /dev/null +++ b/backend/app/schemas/account.py @@ -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 diff --git a/backend/app/schemas/advice.py b/backend/app/schemas/advice.py new file mode 100644 index 0000000..c0966a3 --- /dev/null +++ b/backend/app/schemas/advice.py @@ -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] diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py new file mode 100644 index 0000000..74007d5 --- /dev/null +++ b/backend/app/schemas/asset.py @@ -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_ diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py new file mode 100644 index 0000000..0b2603e --- /dev/null +++ b/backend/app/schemas/backup.py @@ -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")) diff --git a/backend/app/schemas/budget.py b/backend/app/schemas/budget.py new file mode 100644 index 0000000..21d0637 --- /dev/null +++ b/backend/app/schemas/budget.py @@ -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] diff --git a/backend/app/schemas/cash_flow.py b/backend/app/schemas/cash_flow.py new file mode 100644 index 0000000..8746129 --- /dev/null +++ b/backend/app/schemas/cash_flow.py @@ -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 diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py new file mode 100644 index 0000000..c50098a --- /dev/null +++ b/backend/app/schemas/category.py @@ -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 diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..ad4162a --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -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] diff --git a/backend/app/schemas/goal.py b/backend/app/schemas/goal.py new file mode 100644 index 0000000..1efd717 --- /dev/null +++ b/backend/app/schemas/goal.py @@ -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 diff --git a/backend/app/schemas/insights.py b/backend/app/schemas/insights.py new file mode 100644 index 0000000..24eb9dd --- /dev/null +++ b/backend/app/schemas/insights.py @@ -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] diff --git a/backend/app/schemas/net_worth.py b/backend/app/schemas/net_worth.py new file mode 100644 index 0000000..963c397 --- /dev/null +++ b/backend/app/schemas/net_worth.py @@ -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] diff --git a/backend/app/schemas/recurring.py b/backend/app/schemas/recurring.py new file mode 100644 index 0000000..8920a46 --- /dev/null +++ b/backend/app/schemas/recurring.py @@ -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 diff --git a/backend/app/schemas/reports.py b/backend/app/schemas/reports.py new file mode 100644 index 0000000..b2d32fa --- /dev/null +++ b/backend/app/schemas/reports.py @@ -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] diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 0000000..a4b12d7 --- /dev/null +++ b/backend/app/schemas/settings.py @@ -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) diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py new file mode 100644 index 0000000..697a8ff --- /dev/null +++ b/backend/app/schemas/transaction.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py new file mode 100644 index 0000000..78bff15 --- /dev/null +++ b/backend/app/services/account_service.py @@ -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() diff --git a/backend/app/services/advice_service.py b/backend/app/services/advice_service.py new file mode 100644 index 0000000..c009971 --- /dev/null +++ b/backend/app/services/advice_service.py @@ -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) diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py new file mode 100644 index 0000000..24adec5 --- /dev/null +++ b/backend/app/services/backup_service.py @@ -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 diff --git a/backend/app/services/budget_service.py b/backend/app/services/budget_service.py new file mode 100644 index 0000000..8668655 --- /dev/null +++ b/backend/app/services/budget_service.py @@ -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) diff --git a/backend/app/services/cash_flow_service.py b/backend/app/services/cash_flow_service.py new file mode 100644 index 0000000..4ae2228 --- /dev/null +++ b/backend/app/services/cash_flow_service.py @@ -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, + ) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..ae85758 --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -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, + ) diff --git a/backend/app/services/goal_service.py b/backend/app/services/goal_service.py new file mode 100644 index 0000000..a0105dd --- /dev/null +++ b/backend/app/services/goal_service.py @@ -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) diff --git a/backend/app/services/insights_service.py b/backend/app/services/insights_service.py new file mode 100644 index 0000000..ccfb7ac --- /dev/null +++ b/backend/app/services/insights_service.py @@ -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) diff --git a/backend/app/services/net_worth_service.py b/backend/app/services/net_worth_service.py new file mode 100644 index 0000000..8b5b932 --- /dev/null +++ b/backend/app/services/net_worth_service.py @@ -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, + ) diff --git a/backend/app/services/recurring_service.py b/backend/app/services/recurring_service.py new file mode 100644 index 0000000..e0a8ae8 --- /dev/null +++ b/backend/app/services/recurring_service.py @@ -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)) diff --git a/backend/app/services/reports_service.py b/backend/app/services/reports_service.py new file mode 100644 index 0000000..9576de8 --- /dev/null +++ b/backend/app/services/reports_service.py @@ -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 + ) diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py new file mode 100644 index 0000000..5c01248 --- /dev/null +++ b/backend/app/services/settings_service.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..549e6b7 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/seed_mock_data.py b/backend/scripts/seed_mock_data.py new file mode 100644 index 0000000..42820b3 --- /dev/null +++ b/backend/scripts/seed_mock_data.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed214e0 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c58b840 --- /dev/null +++ b/frontend/.dockerignore @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a098537 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/docker-entrypoint.d/20-basic-auth.sh b/frontend/docker-entrypoint.d/20-basic-auth.sh new file mode 100644 index 0000000..38b0a27 --- /dev/null +++ b/frontend/docker-entrypoint.d/20-basic-auth.sh @@ -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" < "$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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..59fac2e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Aurum — Личные финансы + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..be15acb --- /dev/null +++ b/frontend/nginx.conf @@ -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; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c51e542 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2467 @@ +{ + "name": "aurum-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aurum-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/immer": { + "version": "11.1.16", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", + "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cef9cd7 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..4882fdc --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + A + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..67e5c1e --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+ setCollapsed(!collapsed)} + mobileOpen={mobileNavOpen} + onCloseMobile={() => setMobileNavOpen(false)} + /> +
+ setMobileNavOpen(true)} /> +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts new file mode 100644 index 0000000..569159a --- /dev/null +++ b/frontend/src/api/accounts.ts @@ -0,0 +1,18 @@ +import { api } from "@/api/client"; +import type { AccountInput, AccountWithBalance } from "@/types"; + +export function fetchAccounts(includeArchived = false) { + return api.get(`/accounts?include_archived=${includeArchived}`); +} + +export function createAccount(input: AccountInput) { + return api.post("/accounts", input); +} + +export function updateAccount(id: number, input: Partial & { is_archived?: boolean }) { + return api.patch(`/accounts/${id}`, input); +} + +export function deleteAccount(id: number) { + return api.delete(`/accounts/${id}`); +} diff --git a/frontend/src/api/advice.ts b/frontend/src/api/advice.ts new file mode 100644 index 0000000..93b70a8 --- /dev/null +++ b/frontend/src/api/advice.ts @@ -0,0 +1,6 @@ +import { api } from "@/api/client"; +import type { AdviceResponse } from "@/types"; + +export function fetchAdvice() { + return api.get("/advice"); +} diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts new file mode 100644 index 0000000..148e6b3 --- /dev/null +++ b/frontend/src/api/assets.ts @@ -0,0 +1,22 @@ +import { api } from "@/api/client"; +import type { Asset, AssetInput, AssetUpdateInput, AssetValuationInput } from "@/types"; + +export function fetchAssets() { + return api.get("/assets"); +} + +export function createAsset(input: AssetInput) { + return api.post("/assets", input); +} + +export function updateAsset(id: number, input: AssetUpdateInput) { + return api.patch(`/assets/${id}`, input); +} + +export function addAssetValuation(id: number, input: AssetValuationInput) { + return api.post(`/assets/${id}/valuations`, input); +} + +export function deleteAsset(id: number) { + return api.delete(`/assets/${id}`); +} diff --git a/frontend/src/api/backup.ts b/frontend/src/api/backup.ts new file mode 100644 index 0000000..faf48f4 --- /dev/null +++ b/frontend/src/api/backup.ts @@ -0,0 +1,31 @@ +import { api } from "@/api/client"; +import { t } from "@/lib/i18n"; + +export async function exportBackup(): Promise { + const response = await fetch("/api/backup/export"); + if (!response.ok) { + throw new Error(await response.text()); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const date = new Date().toISOString().slice(0, 10); + + const link = document.createElement("a"); + link.href = url; + link.download = `aurum-backup-${date}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +export async function importBackup(file: File): Promise { + const text = await file.text(); + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + throw new Error(t("backup.invalidFile")); + } + await api.post<{ status: string }>("/backup/import", payload); +} diff --git a/frontend/src/api/budgets.ts b/frontend/src/api/budgets.ts new file mode 100644 index 0000000..61c5e30 --- /dev/null +++ b/frontend/src/api/budgets.ts @@ -0,0 +1,22 @@ +import { api } from "@/api/client"; +import type { Budget, BudgetInput, BudgetStatusResponse } from "@/types"; + +export function fetchBudgets() { + return api.get("/budgets"); +} + +export function fetchBudgetStatus(year: number, month: number) { + return api.get(`/budgets/status?year=${year}&month=${month}`); +} + +export function createBudget(input: BudgetInput) { + return api.post("/budgets", input); +} + +export function updateBudget(id: number, monthlyLimit: string) { + return api.patch(`/budgets/${id}`, { monthly_limit: monthlyLimit }); +} + +export function deleteBudget(id: number) { + return api.delete(`/budgets/${id}`); +} diff --git a/frontend/src/api/cashFlow.ts b/frontend/src/api/cashFlow.ts new file mode 100644 index 0000000..b52cd36 --- /dev/null +++ b/frontend/src/api/cashFlow.ts @@ -0,0 +1,10 @@ +import { api } from "@/api/client"; +import type { CashFlowResponse } from "@/types"; + +export function fetchCashFlow(startDate?: string, endDate?: string) { + const params = new URLSearchParams(); + if (startDate) params.set("start_date", startDate); + if (endDate) params.set("end_date", endDate); + const query = params.toString(); + return api.get(`/cash-flow${query ? `?${query}` : ""}`); +} diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 0000000..57c511e --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,6 @@ +import { api } from "@/api/client"; +import type { Category } from "@/types"; + +export function fetchCategories() { + return api.get("/categories"); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..c44991e --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,43 @@ +const API_BASE = "/api"; + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...init, + }); + + if (!response.ok) { + const body = await response.text(); + let message = body || response.statusText; + try { + const parsed = JSON.parse(body) as { detail?: unknown }; + if (typeof parsed.detail === "string") message = parsed.detail; + } catch { + // body wasn't JSON — fall back to the raw text set above + } + throw new ApiError(response.status, message); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..c75befd --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,6 @@ +import { api } from "@/api/client"; +import type { DashboardSummary } from "@/types"; + +export function fetchDashboardSummary(year: number, month: number) { + return api.get(`/dashboard/summary?year=${year}&month=${month}`); +} diff --git a/frontend/src/api/goals.ts b/frontend/src/api/goals.ts new file mode 100644 index 0000000..fd1074d --- /dev/null +++ b/frontend/src/api/goals.ts @@ -0,0 +1,22 @@ +import { api } from "@/api/client"; +import type { Goal, GoalContributionInput, GoalInput } from "@/types"; + +export function fetchGoals() { + return api.get("/goals"); +} + +export function createGoal(input: GoalInput) { + return api.post("/goals", input); +} + +export function updateGoal(id: number, input: Partial) { + return api.patch(`/goals/${id}`, input); +} + +export function deleteGoal(id: number) { + return api.delete(`/goals/${id}`); +} + +export function addGoalContribution(id: number, input: GoalContributionInput) { + return api.post(`/goals/${id}/contributions`, input); +} diff --git a/frontend/src/api/insights.ts b/frontend/src/api/insights.ts new file mode 100644 index 0000000..d2651dd --- /dev/null +++ b/frontend/src/api/insights.ts @@ -0,0 +1,6 @@ +import { api } from "@/api/client"; +import type { FinancialAlert } from "@/types"; + +export function fetchFinancialAlerts() { + return api.get<{ alerts: FinancialAlert[] }>("/insights/alerts"); +} diff --git a/frontend/src/api/netWorth.ts b/frontend/src/api/netWorth.ts new file mode 100644 index 0000000..a170e1b --- /dev/null +++ b/frontend/src/api/netWorth.ts @@ -0,0 +1,6 @@ +import { api } from "@/api/client"; +import type { NetWorthRange, NetWorthSummary } from "@/types"; + +export function fetchNetWorthSummary(range: NetWorthRange) { + return api.get(`/net-worth/summary?range=${range}`); +} diff --git a/frontend/src/api/recurring.ts b/frontend/src/api/recurring.ts new file mode 100644 index 0000000..2d28fc1 --- /dev/null +++ b/frontend/src/api/recurring.ts @@ -0,0 +1,22 @@ +import { api } from "@/api/client"; +import type { RecurringTransaction, RecurringTransactionInput } from "@/types"; + +export function fetchRecurring() { + return api.get("/recurring"); +} + +export function createRecurring(input: RecurringTransactionInput) { + return api.post("/recurring", input); +} + +export function updateRecurring(id: number, input: Partial) { + return api.patch(`/recurring/${id}`, input); +} + +export function deleteRecurring(id: number) { + return api.delete(`/recurring/${id}`); +} + +export function postRecurring(id: number) { + return api.post(`/recurring/${id}/post`, {}); +} diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..dc076cf --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,16 @@ +import { api } from "@/api/client"; +import type { CategoryKind, CategoryRankingReport, CategorySpendingReport } from "@/types"; + +export function fetchCategorySpendingReport(categoryId: number, startDate?: string, endDate?: string) { + const params = new URLSearchParams({ category_id: String(categoryId) }); + if (startDate) params.set("start_date", startDate); + if (endDate) params.set("end_date", endDate); + return api.get(`/reports/category-spending?${params.toString()}`); +} + +export function fetchCategoryRanking(kind: CategoryKind, startDate?: string, endDate?: string) { + const params = new URLSearchParams({ kind }); + if (startDate) params.set("start_date", startDate); + if (endDate) params.set("end_date", endDate); + return api.get(`/reports/category-ranking?${params.toString()}`); +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..4623bf7 --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,12 @@ +import { api } from "@/api/client"; +import type { AppSettings } from "@/types"; + +export function fetchSettings() { + return api.get("/settings"); +} + +// Partial — the backend applies whatever fields are present (PATCH +// semantics), so callers only send the field(s) they're changing. +export function updateSettings(input: Partial) { + return api.patch("/settings", input); +} diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts new file mode 100644 index 0000000..d81bdc3 --- /dev/null +++ b/frontend/src/api/transactions.ts @@ -0,0 +1,37 @@ +import { api } from "@/api/client"; +import type { Transaction, TransactionInput, TransactionPage } from "@/types"; + +export type TransactionSort = "date_desc" | "amount_desc" | "amount_asc"; + +export interface TransactionFilters { + year?: number; + month?: number; + start_date?: string; + end_date?: string; + account_id?: number; + category_id?: number; + type?: string; + sort?: TransactionSort; + page?: number; + page_size?: number; +} + +export function fetchTransactions(filters: TransactionFilters = {}) { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null) params.set(key, String(value)); + }); + return api.get(`/transactions?${params.toString()}`); +} + +export function createTransaction(input: TransactionInput) { + return api.post("/transactions", input); +} + +export function updateTransaction(id: number, input: Partial) { + return api.patch(`/transactions/${id}`, input); +} + +export function deleteTransaction(id: number) { + return api.delete(`/transactions/${id}`); +} diff --git a/frontend/src/components/accounts/AccountFormModal.tsx b/frontend/src/components/accounts/AccountFormModal.tsx new file mode 100644 index 0000000..bc26604 --- /dev/null +++ b/frontend/src/components/accounts/AccountFormModal.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label, Select } from "@/components/ui/Input"; +import { useCreateAccount, useUpdateAccount } from "@/hooks/useAccounts"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { Account, AccountType } from "@/types"; + +interface AccountFormModalProps { + open: boolean; + onClose: () => void; + account?: Account | null; +} + +const ACCOUNT_TYPES: AccountType[] = ["checking", "savings", "credit_card", "cash", "investment", "other"]; + +const EMPTY_FORM = { name: "", type: "checking" as AccountType }; + +export function AccountFormModal({ open, onClose, account }: AccountFormModalProps) { + const { t } = useTranslation(); + const createAccount = useCreateAccount(); + const updateAccount = useUpdateAccount(); + + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setForm(account ? { name: account.name, type: account.type } : EMPTY_FORM); + setError(null); + }, [open, account]); + + const isSaving = createAccount.isPending || updateAccount.isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + try { + if (account) { + await updateAccount.mutateAsync({ id: account.id, input: form }); + } else { + await createAccount.mutateAsync(form); + } + onClose(); + } catch { + setError(t("account.form.saveError")); + } + } + + return ( + +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + /> +
+ +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/accounts/AccountList.tsx b/frontend/src/components/accounts/AccountList.tsx new file mode 100644 index 0000000..1253ad1 --- /dev/null +++ b/frontend/src/components/accounts/AccountList.tsx @@ -0,0 +1,90 @@ +import { Archive, ArchiveRestore, Banknote, CreditCard, Package, Pencil, PiggyBank, TrendingUp, Trash2, Wallet, type LucideIcon } from "lucide-react"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { Account, AccountType, AccountWithBalance } from "@/types"; + +interface AccountListProps { + items: AccountWithBalance[]; + onEdit: (account: Account) => void; + onToggleArchived: (account: AccountWithBalance) => void; + onDelete: (account: AccountWithBalance) => void; +} + +const TYPE_ICONS: Record = { + checking: Wallet, + savings: PiggyBank, + credit_card: CreditCard, + cash: Banknote, + investment: TrendingUp, + other: Package, +}; + +export function AccountList({ items, onEdit, onToggleArchived, onDelete }: AccountListProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("account.empty")}

; + } + + return ( +
    + {items.map((account) => { + const Icon = TYPE_ICONS[account.type]; + const balance = Number(account.balance); + + return ( +
  • + + + + + + {account.name} + {account.is_archived && ( + + {t("account.archivedBadge")} + + )} + + + {t(`account.type.${account.type}` as TranslationKey)} + + + + {formatCurrency(balance)} + + + + + + +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/budget/BudgetFormModal.tsx b/frontend/src/components/budget/BudgetFormModal.tsx new file mode 100644 index 0000000..6705ed7 --- /dev/null +++ b/frontend/src/components/budget/BudgetFormModal.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label, Select } from "@/components/ui/Input"; +import { useCategories } from "@/hooks/useCategories"; +import { useCreateBudget, useUpdateBudget } from "@/hooks/useBudgets"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { Budget } from "@/types"; + +interface BudgetFormModalProps { + open: boolean; + onClose: () => void; + budget?: Budget | null; + /** Expense categories that already have a budget — excluded from the + * create picker since each category can only carry one budget. */ + excludeCategoryIds: number[]; +} + +export function BudgetFormModal({ open, onClose, budget, excludeCategoryIds }: BudgetFormModalProps) { + const { t, language } = useTranslation(); + const { data: categories } = useCategories(); + const createBudget = useCreateBudget(); + const updateBudget = useUpdateBudget(); + + const [categoryId, setCategoryId] = useState(""); + const [monthlyLimit, setMonthlyLimit] = useState(""); + const [error, setError] = useState(null); + + const availableCategories = (categories ?? []) + .filter((category) => category.kind === "expense" && !excludeCategoryIds.includes(category.id)) + .sort((a, b) => translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language)); + + useEffect(() => { + if (!open) return; + if (budget) { + setCategoryId(String(budget.category_id)); + setMonthlyLimit(budget.monthly_limit); + } else { + setCategoryId(availableCategories[0] ? String(availableCategories[0].id) : ""); + setMonthlyLimit(""); + } + setError(null); + // availableCategories is derived from `categories`, not a stable dep — only re-run on open/budget change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, budget]); + + const isSaving = createBudget.isPending || updateBudget.isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + try { + if (budget) { + await updateBudget.mutateAsync({ id: budget.id, monthlyLimit }); + } else { + await createBudget.mutateAsync({ category_id: Number(categoryId), monthly_limit: monthlyLimit }); + } + onClose(); + } catch { + setError(t("budget.form.saveError")); + } + } + + return ( + +
+
+ + {budget ? ( +

{translateCategoryName(budget.category_name)}

+ ) : availableCategories.length === 0 ? ( +

{t("budget.form.noCategoriesAvailable")}

+ ) : ( + + )} +
+ +
+ + setMonthlyLimit(event.target.value)} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/budget/BudgetList.tsx b/frontend/src/components/budget/BudgetList.tsx new file mode 100644 index 0000000..f19ea95 --- /dev/null +++ b/frontend/src/components/budget/BudgetList.tsx @@ -0,0 +1,85 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { BudgetStatus } from "@/types"; + +interface BudgetListProps { + items: BudgetStatus[]; + onEdit: (item: BudgetStatus) => void; + onDelete: (item: BudgetStatus) => void; +} + +export function BudgetList({ items, onEdit, onDelete }: BudgetListProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("budget.empty")}

; + } + + return ( +
    + {items.map((item) => { + const Icon = getCategoryIcon(item.category_icon); + const spent = Number(item.spent); + const limit = Number(item.monthly_limit); + const barColor = item.is_over_budget ? "var(--danger)" : "var(--success)"; + const fillPercent = Math.min(100, item.percent); + + return ( +
  • +
    + + + + + {translateCategoryName(item.category_name)} + + + {formatCurrency(spent)} / {formatCurrency(limit)} + + + + + +
    +
    + + + + + {item.percent.toFixed(0)}% + +
    +

    + {item.is_over_budget + ? t("budget.overBy", { amount: formatCurrency(spent - limit) }) + : t("budget.remaining", { amount: formatCurrency(limit - spent) })} +

    +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/cashflow/CashFlowChart.tsx b/frontend/src/components/cashflow/CashFlowChart.tsx new file mode 100644 index 0000000..45132b6 --- /dev/null +++ b/frontend/src/components/cashflow/CashFlowChart.tsx @@ -0,0 +1,157 @@ +import { Bar, BarChart, Legend, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import type { CashFlowResponse } from "@/types"; + +interface CashFlowChartProps { + cashFlow: CashFlowResponse | undefined; + isLoading: boolean; +} + +function monthKey(year: number, month: number): string { + return `${year}-${String(month).padStart(2, "0")}-01`; +} + +function formatMonthLabel(key: string): string { + return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", year: "numeric" }).format( + new Date(`${key}T00:00:00`) + ); +} + +/** Same landmark idea as the Net Worth and category-spending charts: year + * ticks only when the bars span more than one calendar year. */ +function computeYearTicks(keys: string[]): string[] { + if (keys.length < 2) return []; + const startYear = Number(keys[0].slice(0, 4)); + const endYear = Number(keys[keys.length - 1].slice(0, 4)); + if (startYear === endYear) return []; + const ticks: string[] = []; + for (let year = startYear + 1; year <= endYear; year++) { + const jan = `${year}-01-01`; + if (keys.includes(jan)) ticks.push(jan); + } + return ticks; +} + +interface ChartPoint { + key: string; + income: number; + expense: number; + net: number; +} + +function ChartTooltip({ + active, + payload, + incomeLabel, + expenseLabel, + netLabel, +}: { + active?: boolean; + payload?: Array<{ payload: ChartPoint }>; + incomeLabel: string; + expenseLabel: string; + netLabel: string; +}) { + if (!active || !payload?.length) return null; + const point = payload[0].payload; + return ( +
+

{formatMonthLabel(point.key)}

+

+ {incomeLabel}: {formatCurrency(point.income)} +

+

+ {expenseLabel}: {formatCurrency(point.expense)} +

+

+ {netLabel}: {formatSignedCurrency(point.net)} +

+
+ ); +} + +export function CashFlowChart({ cashFlow, isLoading }: CashFlowChartProps) { + const { t } = useTranslation(); + const incomeLabel = t("cashFlow.income"); + const expenseLabel = t("cashFlow.expense"); + const netLabel = t("cashFlow.net"); + + const chartData: ChartPoint[] = + cashFlow?.points.map((point) => ({ + key: monthKey(point.year, point.month), + income: Number(point.income), + expense: Number(point.expense), + net: Number(point.net), + })) ?? []; + const yearTicks = computeYearTicks(chartData.map((point) => point.key)); + + return ( + + +
+ {t("nav.cashFlow")} +

+ {isLoading || !cashFlow ? "…" : formatSignedCurrency(cashFlow.total_net)} +

+ {cashFlow && ( +

+ + {incomeLabel} {formatCurrency(cashFlow.total_income)} + + · + + {expenseLabel} {formatCurrency(cashFlow.total_expense)} + +

+ )} +
+
+ +
+ {isLoading || !cashFlow || chartData.length === 0 ? ( +
+ {isLoading ? t("common.loading") : t("cashFlow.noData")} +
+ ) : ( + + + {yearTicks.length > 0 && ( + value.slice(0, 4)} + axisLine={false} + tickLine={false} + tick={{ fill: "var(--text-muted)", fontSize: 11 }} + interval="preserveStartEnd" + /> + )} + } + cursor={{ fill: "var(--surface-2)" }} + /> + (value === "income" ? incomeLabel : expenseLabel)} + wrapperStyle={{ fontSize: 12, color: "var(--text-muted)" }} + /> + + + + + )} +
+ {chartData.length > 1 && ( +
+ {formatMonthLabel(chartData[0].key)} + {formatMonthLabel(chartData[chartData.length - 1].key)} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/RecentTransactionsCard.tsx b/frontend/src/components/dashboard/RecentTransactionsCard.tsx new file mode 100644 index 0000000..4bd4bcb --- /dev/null +++ b/frontend/src/components/dashboard/RecentTransactionsCard.tsx @@ -0,0 +1,62 @@ +import { Link } from "react-router-dom"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency, formatTransactionDate } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import { useTransactions } from "@/hooks/useTransactions"; + +export function RecentTransactionsCard() { + const { t } = useTranslation(); + const { data, isLoading } = useTransactions({ page: 1, page_size: 6 }); + + return ( + + + {t("dashboard.recentTransactionsTitle")} + + {t("dashboard.allTransactionsLink")} + + + + {isLoading ? ( +

{t("common.loading")}

+ ) : !data?.items.length ? ( +

{t("dashboard.noTransactionsYet")}

+ ) : ( +
    + {data.items.map((tx) => { + const Icon = getCategoryIcon(tx.category?.icon); + const isTransfer = tx.type === "transfer"; + const isExpense = tx.type === "expense"; + const color = tx.category?.color ?? "var(--text-muted)"; + return ( +
  • + + + + + {tx.description} + + {formatTransactionDate(tx.date)} · {tx.account.name} + + + + {isTransfer ? "" : isExpense ? "-" : "+"} + {formatCurrency(Number(tx.amount))} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/SpendingByCategoryCard.tsx b/frontend/src/components/dashboard/SpendingByCategoryCard.tsx new file mode 100644 index 0000000..e54701d --- /dev/null +++ b/frontend/src/components/dashboard/SpendingByCategoryCard.tsx @@ -0,0 +1,95 @@ +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { CategoryBreakdownItem } from "@/types"; + +interface SpendingByCategoryCardProps { + items: CategoryBreakdownItem[]; +} + +function DonutTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: CategoryBreakdownItem }> }) { + if (!active || !payload?.length) return null; + const item = payload[0].payload; + return ( +
+

{translateCategoryName(item.name)}

+

+ {formatCurrency(item.amount)} · {item.percent.toFixed(1)}% +

+
+ ); +} + +export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) { + const { t } = useTranslation(); + const hasData = items.length > 0; + // Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal + // is serialized as string to avoid float precision loss). + const chartData = items.map((item) => ({ ...item, amount: Number(item.amount) })); + + return ( + + + {t("dashboard.spendingByCategoryTitle")} + + + {!hasData ? ( +

{t("dashboard.noExpensesThisMonth")}

+ ) : ( +
+
+ + + + {items.map((item) => ( + + ))} + + } /> + + +
+ +
    + {items.map((item) => { + const Icon = getCategoryIcon(item.icon); + return ( +
  • + + + + + {translateCategoryName(item.name)} + + + {formatCurrency(item.amount)} + +
  • + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/StatCard.tsx b/frontend/src/components/dashboard/StatCard.tsx new file mode 100644 index 0000000..9f8f51b --- /dev/null +++ b/frontend/src/components/dashboard/StatCard.tsx @@ -0,0 +1,27 @@ +import { Card } from "@/components/ui/Card"; +import { cn } from "@/lib/utils"; + +interface StatCardProps { + label: string; + value: string; + caption: string; + tone?: "default" | "success" | "danger"; +} + +const TONE_CLASSES: Record, string> = { + default: "text-text-primary", + success: "text-success", + danger: "text-danger", +}; + +export function StatCard({ label, value, caption, tone = "default" }: StatCardProps) { + return ( + +

{label}

+

+ {value} +

+

{caption}

+
+ ); +} diff --git a/frontend/src/components/goals/GoalContributionModal.tsx b/frontend/src/components/goals/GoalContributionModal.tsx new file mode 100644 index 0000000..fc8be27 --- /dev/null +++ b/frontend/src/components/goals/GoalContributionModal.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label } from "@/components/ui/Input"; +import { useAddGoalContribution } from "@/hooks/useGoals"; +import { useTranslation } from "@/lib/i18n"; +import type { Goal } from "@/types"; + +interface GoalContributionModalProps { + open: boolean; + onClose: () => void; + goal: Goal | null; +} + +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +export function GoalContributionModal({ open, onClose, goal }: GoalContributionModalProps) { + const { t } = useTranslation(); + const addContribution = useAddGoalContribution(); + + const [amount, setAmount] = useState(""); + const [date, setDate] = useState(todayIso()); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setAmount(""); + setDate(todayIso()); + setError(null); + }, [open, goal]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!goal) return; + setError(null); + + try { + await addContribution.mutateAsync({ id: goal.id, input: { amount, date } }); + onClose(); + } catch { + setError(t("goal.contribution.saveError")); + } + } + + return ( + +
+
+
+ + setAmount(event.target.value)} + /> +
+
+ + setDate(event.target.value)} + /> +
+
+

{t("goal.contribution.hint")}

+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/goals/GoalFormModal.tsx b/frontend/src/components/goals/GoalFormModal.tsx new file mode 100644 index 0000000..2e336d5 --- /dev/null +++ b/frontend/src/components/goals/GoalFormModal.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label } from "@/components/ui/Input"; +import { useCreateGoal, useUpdateGoal } from "@/hooks/useGoals"; +import { useTranslation } from "@/lib/i18n"; +import type { Goal } from "@/types"; + +interface GoalFormModalProps { + open: boolean; + onClose: () => void; + goal?: Goal | null; +} + +const EMPTY_FORM = { name: "", target_amount: "", target_date: "" }; + +export function GoalFormModal({ open, onClose, goal }: GoalFormModalProps) { + const { t } = useTranslation(); + const createGoal = useCreateGoal(); + const updateGoal = useUpdateGoal(); + + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + if (goal) { + setForm({ name: goal.name, target_amount: goal.target_amount, target_date: goal.target_date ?? "" }); + } else { + setForm(EMPTY_FORM); + } + setError(null); + }, [open, goal]); + + const isSaving = createGoal.isPending || updateGoal.isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + const input = { + name: form.name, + target_amount: form.target_amount, + target_date: form.target_date || null, + }; + + try { + if (goal) { + await updateGoal.mutateAsync({ id: goal.id, input }); + } else { + await createGoal.mutateAsync(input); + } + onClose(); + } catch { + setError(t("goal.form.saveError")); + } + } + + return ( + +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + /> +
+ +
+
+ + setForm((prev) => ({ ...prev, target_amount: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, target_date: event.target.value }))} + /> +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/goals/GoalList.tsx b/frontend/src/components/goals/GoalList.tsx new file mode 100644 index 0000000..8fe6024 --- /dev/null +++ b/frontend/src/components/goals/GoalList.tsx @@ -0,0 +1,94 @@ +import { Flag, PiggyBank, Pencil, Trash2 } from "lucide-react"; +import { formatCurrency, getIntlLocale } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import type { Goal } from "@/types"; + +interface GoalListProps { + items: Goal[]; + onContribute: (goal: Goal) => void; + onEdit: (goal: Goal) => void; + onDelete: (goal: Goal) => void; +} + +function formatTargetDate(isoDate: string): string { + return new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format( + new Date(`${isoDate}T00:00:00`) + ); +} + +export function GoalList({ items, onContribute, onEdit, onDelete }: GoalListProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("goal.empty")}

; + } + + return ( +
    + {items.map((goal) => { + const current = Number(goal.current_amount); + const target = Number(goal.target_amount); + const remaining = Number(goal.remaining); + const fillPercent = Math.min(100, goal.percent); + + return ( +
  • +
    + + + + {goal.name} + + {formatCurrency(current)} / {formatCurrency(target)} + + + + + + +
    +
    + + + + + {goal.percent.toFixed(0)}% + +
    +

    + {goal.is_reached + ? t("goal.reached") + : goal.target_date + ? t("goal.remainingWithDate", { amount: formatCurrency(remaining), date: formatTargetDate(goal.target_date) }) + : t("goal.remaining", { amount: formatCurrency(remaining) })} +

    +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/insights/AlertBanner.tsx b/frontend/src/components/insights/AlertBanner.tsx new file mode 100644 index 0000000..93c56d0 --- /dev/null +++ b/frontend/src/components/insights/AlertBanner.tsx @@ -0,0 +1,57 @@ +import { TriangleAlert } from "lucide-react"; +import { useFinancialAlerts } from "@/hooks/useInsights"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { FinancialAlert } from "@/types"; + +// Maps the backend's alert key to the translation template that consumes its params. +const ALERT_MESSAGE_KEYS: Record = { + negative_cash_flow_streak: "insights.negativeCashFlow", + net_worth_decline_streak: "insights.netWorthDecline", + budget_exceeded: "insights.budgetExceeded", + risky_allocation_exceeded: "insights.riskyAllocationExceeded", +}; + +function alertMessage(alert: FinancialAlert, t: ReturnType["t"]): string { + // "1 categories"/"в 1 категориях" is bad grammar in both languages — + // singular gets its own pre-written key instead of trying to interpolate + // a plural-aware word form through a plain {{count}} template. + if (alert.key === "budget_exceeded" && alert.params.count === 1) { + return t("insights.budgetExceededOne"); + } + const messageKey = ALERT_MESSAGE_KEYS[alert.key]; + if (!messageKey) return alert.key; + return t(messageKey, alert.params); +} + +interface AlertBannerProps { + /** Alert keys to hide on this particular page — e.g. Dashboard omits + * risky_allocation_exceeded because it reads as too alarming next to the + * stat cards there; it still shows on Net Worth/Budget where the rest of + * this banner is used unfiltered. */ + excludeKeys?: string[]; +} + +/** Proactive early warnings — sustained negative cash flow or a sustained + * net-worth decline — computed server-side from data that already exists, + * no configuration. Renders nothing when there's nothing to flag. */ +export function AlertBanner({ excludeKeys }: AlertBannerProps = {}) { + const { data } = useFinancialAlerts(); + const { t } = useTranslation(); + const alerts = (data?.alerts ?? []).filter((alert) => !excludeKeys?.includes(alert.key)); + + if (alerts.length === 0) return null; + + return ( +
+ {alerts.map((alert) => ( +
+ + {alertMessage(alert, t)} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/layout/Logo.tsx b/frontend/src/components/layout/Logo.tsx new file mode 100644 index 0000000..2e2e24b --- /dev/null +++ b/frontend/src/components/layout/Logo.tsx @@ -0,0 +1,38 @@ +/** + * Aurum's mark: a coin with an "A" monogram. Rendered as inline SVG (not the + * 🜚 alchemical-gold emoji this replaced) because emoji glyph coverage for + * that character is inconsistent across platforms/fonts and rendered + * mangled for the user — an SVG always looks the same everywhere. Kept in + * sync with public/favicon.svg. + */ +interface LogoProps { + size?: number; + className?: string; +} + +export function Logo({ size = 24, className }: LogoProps) { + return ( + + ); +} diff --git a/frontend/src/components/layout/MonthSelector.tsx b/frontend/src/components/layout/MonthSelector.tsx new file mode 100644 index 0000000..555a547 --- /dev/null +++ b/frontend/src/components/layout/MonthSelector.tsx @@ -0,0 +1,37 @@ +import { getMonthLabels } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; + +interface MonthSelectorProps { + month: number; // 1-12 + onChange: (month: number) => void; +} + +export function MonthSelector({ month, onChange }: MonthSelectorProps) { + const { language } = useTranslation(); + const monthLabels = getMonthLabels(language); + + return ( +
+ {monthLabels.map((label, index) => { + const value = index + 1; + const active = value === month; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/layout/PillSelector.tsx b/frontend/src/components/layout/PillSelector.tsx new file mode 100644 index 0000000..9a79d59 --- /dev/null +++ b/frontend/src/components/layout/PillSelector.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; + +interface PillOption { + value: T; + label: string; +} + +interface PillSelectorProps { + options: Array>; + value: T; + onChange: (value: T) => void; +} + +export function PillSelector({ options, value, onChange }: PillSelectorProps) { + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/layout/RangeSelector.tsx b/frontend/src/components/layout/RangeSelector.tsx new file mode 100644 index 0000000..d436b49 --- /dev/null +++ b/frontend/src/components/layout/RangeSelector.tsx @@ -0,0 +1,21 @@ +import { PillSelector } from "@/components/layout/PillSelector"; +import { useTranslation } from "@/lib/i18n"; +import type { NetWorthRange } from "@/types"; + +interface RangeSelectorProps { + value: NetWorthRange; + onChange: (value: NetWorthRange) => void; +} + +export function RangeSelector({ value, onChange }: RangeSelectorProps) { + const { t } = useTranslation(); + const ranges: Array<{ value: NetWorthRange; label: string }> = [ + { value: "30d", label: "30D" }, + { value: "90d", label: "90D" }, + { value: "1y", label: "1Y" }, + { value: "5y", label: "5Y" }, + { value: "all", label: t("common.allShort") }, + ]; + + return ; +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..bc8f577 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,142 @@ +import { useEffect } from "react"; +import { NavLink } from "react-router-dom"; +import { PanelLeftClose, PanelLeftOpen, X } from "lucide-react"; +import { Logo } from "@/components/layout/Logo"; +import { NAV_ITEMS } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "@/lib/i18n"; + +interface NavListProps { + collapsed: boolean; + onNavigate?: () => void; +} + +function NavList({ collapsed, onNavigate }: NavListProps) { + const { t } = useTranslation(); + + return ( + + ); +} + +interface SidebarProps { + collapsed: boolean; + onToggleCollapsed: () => void; + mobileOpen: boolean; + onCloseMobile: () => void; +} + +export function Sidebar({ collapsed, onToggleCollapsed, mobileOpen, onCloseMobile }: SidebarProps) { + const { t } = useTranslation(); + + useEffect(() => { + if (!mobileOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onCloseMobile(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [mobileOpen, onCloseMobile]); + + return ( + <> + {/* Desktop: persistent rail, collapsible between icon-only and full width. */} + + + {/* Mobile: off-canvas drawer over a backdrop. */} + {mobileOpen && ( +
+
+ +
+ )} + + ); +} diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..72d613d --- /dev/null +++ b/frontend/src/components/layout/Topbar.tsx @@ -0,0 +1,28 @@ +import { useLocation } from "react-router-dom"; +import { Menu } from "lucide-react"; +import { NAV_ITEMS } from "@/lib/navigation"; +import { useTranslation } from "@/lib/i18n"; + +interface TopbarProps { + onOpenMobileNav: () => void; +} + +export function Topbar({ onOpenMobileNav }: TopbarProps) { + const location = useLocation(); + const { t } = useTranslation(); + const activeItem = NAV_ITEMS.find((item) => (item.to === "/" ? location.pathname === "/" : location.pathname.startsWith(item.to))); + + return ( +
+ +

{activeItem ? t(activeItem.labelKey) : "Aurum"}

+
+ ); +} diff --git a/frontend/src/components/networth/AssetAllocationCard.tsx b/frontend/src/components/networth/AssetAllocationCard.tsx new file mode 100644 index 0000000..df7eed3 --- /dev/null +++ b/frontend/src/components/networth/AssetAllocationCard.tsx @@ -0,0 +1,96 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { NetWorthBreakdownItem } from "@/types"; + +interface AssetAllocationCardProps { + breakdown: NetWorthBreakdownItem[]; + isLoading: boolean; +} + +// Breakdown items come from the backend with a machine-readable `key` +// ("cash" or an AssetClass value) — translate the label locally instead of +// showing the backend's (Russian-only) pre-rendered `name`. +function breakdownLabelKey(key: string): TranslationKey { + return `netWorth.assetClass.${key}` as TranslationKey; +} + +export function AssetAllocationCard({ breakdown, isLoading }: AssetAllocationCardProps) { + const { t } = useTranslation(); + const total = breakdown.reduce((sum, item) => sum + Number(item.amount), 0); + + return ( + + + + {t("netWorth.assetsTitlePrefix")} · {formatCurrency(total)} + + + + {isLoading ? ( +

{t("common.loading")}

+ ) : ( + <> +
+ {breakdown.map((item) => + item.percent > 0 ? ( +
+ ) : null + )} +
+ +
    + {breakdown.map((item) => ( +
  • + + {t(breakdownLabelKey(item.key))} {item.percent.toFixed(0)}% +
  • + ))} +
+ +
    + {breakdown.map((item) => { + const Icon = getCategoryIcon(item.icon); + return ( +
  • + + + + + {t(breakdownLabelKey(item.key))} + + + + + + + {item.percent.toFixed(0)}% + + + + {item.percent.toFixed(0)}% + + + {formatCurrency(item.amount)} + +
  • + ); + })} +
+ + )} + + + ); +} diff --git a/frontend/src/components/networth/AssetFormModal.tsx b/frontend/src/components/networth/AssetFormModal.tsx new file mode 100644 index 0000000..3d21ff9 --- /dev/null +++ b/frontend/src/components/networth/AssetFormModal.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label, Select } from "@/components/ui/Input"; +import { useAddAssetValuation, useCreateAsset, useUpdateAsset } from "@/hooks/useAssets"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { Asset, AssetClass, CapitalRole, RiskLevel } from "@/types"; + +interface AssetFormModalProps { + open: boolean; + onClose: () => void; + asset?: Asset | null; +} + +const ASSET_CLASSES: AssetClass[] = ["investments", "crypto", "real_estate", "vehicles", "precious_metals", "other"]; +const CAPITAL_ROLES: CapitalRole[] = ["income", "neutral", "drain"]; +const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"]; + +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +const EMPTY_FORM = { + name: "", + asset_class: "investments" as AssetClass, + value: "", + as_of_date: todayIso(), + notes: "", + capital_role: "neutral" as CapitalRole, + monthly_cash_flow: "", + risk_level: "medium" as RiskLevel, +}; + +export function AssetFormModal({ open, onClose, asset }: AssetFormModalProps) { + const { t } = useTranslation(); + const createAsset = useCreateAsset(); + const updateAsset = useUpdateAsset(); + const addValuation = useAddAssetValuation(); + + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + if (asset) { + setForm({ + name: asset.name, + asset_class: asset.asset_class, + value: asset.current_value, + as_of_date: todayIso(), + notes: asset.notes ?? "", + capital_role: asset.capital_role, + monthly_cash_flow: asset.monthly_cash_flow ?? "", + risk_level: asset.risk_level, + }); + } else { + setForm(EMPTY_FORM); + } + setError(null); + }, [open, asset]); + + const isSaving = createAsset.isPending || updateAsset.isPending || addValuation.isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + try { + if (asset) { + await updateAsset.mutateAsync({ + id: asset.id, + input: { + name: form.name, + asset_class: form.asset_class, + notes: form.notes || null, + capital_role: form.capital_role, + monthly_cash_flow: form.monthly_cash_flow || null, + risk_level: form.risk_level, + }, + }); + if (form.value !== asset.current_value) { + await addValuation.mutateAsync({ id: asset.id, input: { value: form.value, as_of_date: form.as_of_date } }); + } + } else { + await createAsset.mutateAsync({ + name: form.name, + asset_class: form.asset_class, + value: form.value, + as_of_date: form.as_of_date, + notes: form.notes || null, + capital_role: form.capital_role, + monthly_cash_flow: form.monthly_cash_flow || null, + risk_level: form.risk_level, + }); + } + onClose(); + } catch { + setError(t("netWorth.form.saveError")); + } + } + + return ( + +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + /> +
+ +
+ + +
+ +
+
+ + setForm((prev) => ({ ...prev, value: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, as_of_date: event.target.value }))} + /> +
+
+ +
+ + +

+ {t(`netWorth.capitalRoleFormHint.${form.capital_role}` as TranslationKey)} +

+
+ +
+ + +

+ {t(`netWorth.riskLevelFormHint.${form.risk_level}` as TranslationKey)} +

+
+ +
+ + setForm((prev) => ({ ...prev, monthly_cash_flow: event.target.value }))} + /> +

{t("netWorth.form.cashFlowHint")}

+
+ +
+ + setForm((prev) => ({ ...prev, notes: event.target.value }))} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/networth/AssetsTable.tsx b/frontend/src/components/networth/AssetsTable.tsx new file mode 100644 index 0000000..c3e4b24 --- /dev/null +++ b/frontend/src/components/networth/AssetsTable.tsx @@ -0,0 +1,101 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency, formatTransactionDate } from "@/lib/format"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { Asset, CapitalRole } from "@/types"; + +const CLASS_ICONS: Record = { + investments: "trending-up", + crypto: "bitcoin", + real_estate: "building-2", + vehicles: "car", + precious_metals: "gem", + other: "package", +}; + +const ROLE_COLORS: Record = { + income: "var(--success)", + neutral: "var(--text-muted)", + drain: "var(--danger)", +}; + +interface AssetsTableProps { + items: Asset[]; + onEdit: (asset: Asset) => void; + onDelete: (asset: Asset) => void; +} + +export function AssetsTable({ items, onEdit, onDelete }: AssetsTableProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("netWorth.assetsTable.empty")}

; + } + + return ( +
    + {items.map((asset) => { + const Icon = getCategoryIcon(CLASS_ICONS[asset.asset_class]); + const roleColor = ROLE_COLORS[asset.capital_role]; + const cashFlow = asset.monthly_cash_flow !== null ? Number(asset.monthly_cash_flow) : null; + const currentValue = Number(asset.current_value); + // Annual ROI only makes sense for money coming in against what was + // paid for the asset — same formula as RoiCalculatorCard. + const annualRoiPercent = cashFlow !== null && cashFlow > 0 && currentValue > 0 ? (cashFlow * 12) / currentValue * 100 : null; + + return ( +
  • + + + + + {asset.name} + + {t(`netWorth.assetClass.${asset.asset_class}` as TranslationKey)} ·{" "} + {t("netWorth.assetsTable.asOf", { date: formatTransactionDate(asset.as_of_date) })} + + · + {t(`netWorth.capitalRole.${asset.capital_role}` as TranslationKey)} + + + + + + {formatCurrency(asset.current_value)} + + {cashFlow !== null && cashFlow !== 0 && ( + 0 ? "var(--success)" : "var(--danger)" }} + > + {cashFlow > 0 ? "+" : ""} + {formatCurrency(cashFlow)} + {t("common.perMonth")} + {annualRoiPercent !== null && ` · ${t("netWorth.assetsTable.annualRoi", { percent: annualRoiPercent.toFixed(1) })}`} + + )} + + + + + +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/networth/CapitalRoleSummaryCard.tsx b/frontend/src/components/networth/CapitalRoleSummaryCard.tsx new file mode 100644 index 0000000..36ac42a --- /dev/null +++ b/frontend/src/components/networth/CapitalRoleSummaryCard.tsx @@ -0,0 +1,62 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { formatCurrency, pluralizeRu } from "@/lib/format"; +import { useTranslation, type Language, type TranslationKey } from "@/lib/i18n"; +import type { CapitalRoleSummary } from "@/types"; + +interface CapitalRoleSummaryCardProps { + roles: CapitalRoleSummary[]; + isLoading: boolean; +} + +function assetsCountLabel(count: number, language: Language): string { + if (language === "ru") return pluralizeRu(count, "актив", "актива", "активов"); + return count === 1 ? "asset" : "assets"; +} + +export function CapitalRoleSummaryCard({ roles, isLoading }: CapitalRoleSummaryCardProps) { + const { t, language } = useTranslation(); + + return ( + + + {t("netWorth.capitalByType")} + + + {isLoading ? ( +

{t("common.loading")}

+ ) : ( +
+ {roles.map((role) => { + const cashFlow = Number(role.monthly_cash_flow); + return ( +
+

+ + {t(`netWorth.capitalRole.${role.role}` as TranslationKey)} +

+

+ {formatCurrency(role.total_value)} +

+

+ {role.count} {assetsCountLabel(role.count, language)} ·{" "} + {t(`netWorth.capitalRoleHint.${role.role}` as TranslationKey)} +

+ {cashFlow !== 0 && ( +

0 ? "var(--success)" : "var(--danger)" }} + > + {cashFlow > 0 ? "+" : ""} + {formatCurrency(cashFlow)} + {t("common.perMonth")} +

+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/networth/NetWorthChart.tsx b/frontend/src/components/networth/NetWorthChart.tsx new file mode 100644 index 0000000..394b58a --- /dev/null +++ b/frontend/src/components/networth/NetWorthChart.tsx @@ -0,0 +1,133 @@ +import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { RangeSelector } from "@/components/layout/RangeSelector"; +import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import type { NetWorthRange, NetWorthSummary } from "@/types"; + +interface NetWorthChartProps { + summary: NetWorthSummary | undefined; + isLoading: boolean; + range: NetWorthRange; + onRangeChange: (range: NetWorthRange) => void; +} + +function formatAxisDate(iso: string): string { + return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric", year: "numeric" }).format( + new Date(`${iso}T00:00:00`) + ); +} + +/** Jan-1 of every year strictly inside the range, so a multi-year chart gets + * a year landmark on its axis instead of just two endpoint dates — lets you + * place "5 years ago" without hovering pixel-by-pixel. Single-year ranges + * return an empty array and the axis stays hidden, unchanged from before. */ +function computeYearTicks(dates: string[]): string[] { + if (dates.length < 2) return []; + const start = dates[0]; + const end = dates[dates.length - 1]; + const startYear = Number(start.slice(0, 4)); + const endYear = Number(end.slice(0, 4)); + if (startYear === endYear) return []; + + const ticks: string[] = []; + for (let year = startYear + 1; year <= endYear; year++) { + const jan1 = `${year}-01-01`; + if (jan1 >= start && jan1 <= end) ticks.push(jan1); + } + return ticks; +} + +function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { date: string; value: number } }> }) { + if (!active || !payload?.length) return null; + const point = payload[0].payload; + return ( +
+

{formatAxisDate(point.date)}

+

{formatCurrency(point.value)}

+
+ ); +} + +export function NetWorthChart({ summary, isLoading, range, onRangeChange }: NetWorthChartProps) { + const { t } = useTranslation(); + const isPositive = summary ? Number(summary.change_amount) >= 0 : true; + const trendColor = isPositive ? "var(--success)" : "var(--danger)"; + const chartData = summary?.series.map((point) => ({ date: point.date, value: Number(point.value) })) ?? []; + const yearTicks = computeYearTicks(chartData.map((point) => point.date)); + + return ( + + +
+ {t("nav.netWorth")} +

+ {isLoading ? "…" : formatCurrency(summary?.current ?? 0)} +

+ {summary && ( +

+ {isPositive ? : } + {formatSignedCurrency(summary.change_amount)} + {summary.change_percent !== null && ` (${isPositive ? "+" : ""}${summary.change_percent.toFixed(1)}%)`} + {t("netWorth.periodSuffix")} +

+ )} +
+ +
+ +
+ {isLoading || chartData.length === 0 ? ( +
+ {isLoading ? t("common.loading") : t("netWorth.noChartData")} +
+ ) : ( + + + + + + + + + + {yearTicks.length > 0 && ( + value.slice(0, 4)} + axisLine={false} + tickLine={false} + tick={{ fill: "var(--text-muted)", fontSize: 11 }} + interval="preserveStartEnd" + /> + )} + } cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} /> + + + + )} +
+ {chartData.length > 1 && ( +
+ {formatAxisDate(chartData[0].date)} + {formatAxisDate(chartData[chartData.length - 1].date)} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/networth/RiskAllocationCard.tsx b/frontend/src/components/networth/RiskAllocationCard.tsx new file mode 100644 index 0000000..d8a10da --- /dev/null +++ b/frontend/src/components/networth/RiskAllocationCard.tsx @@ -0,0 +1,62 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import type { RiskLevelSummary } from "@/types"; + +interface RiskAllocationCardProps { + riskLevels: RiskLevelSummary[]; + isLoading: boolean; +} + +export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCardProps) { + const { t } = useTranslation(); + + return ( + + + {t("netWorth.riskAllocationTitle")} + + + {isLoading ? ( +

{t("common.loading")}

+ ) : ( +
+ {riskLevels.map((tier) => ( +
+

+ + {t(`netWorth.riskLevel.${tier.risk_level}` as TranslationKey)} +

+

+ {formatCurrency(tier.total_value)} +

+

{tier.percent.toFixed(0)}% {t("netWorth.riskAllocationOfCapital")}

+ + {tier.items.length > 0 && ( +
    + {tier.items.map((item) => ( +
  • + + {item.key === "cash" ? t("netWorth.assetClass.cash") : item.name} + + + + + + {item.percent.toFixed(0)}% + +
  • + ))} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/recurring/RecurringFormModal.tsx b/frontend/src/components/recurring/RecurringFormModal.tsx new file mode 100644 index 0000000..ea042ca --- /dev/null +++ b/frontend/src/components/recurring/RecurringFormModal.tsx @@ -0,0 +1,263 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label, Select } from "@/components/ui/Input"; +import { useAccounts } from "@/hooks/useAccounts"; +import { useCategories } from "@/hooks/useCategories"; +import { useCreateRecurring, useUpdateRecurring } from "@/hooks/useRecurring"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { RecurringFrequency, RecurringTransaction, TransactionType } from "@/types"; + +interface RecurringFormModalProps { + open: boolean; + onClose: () => void; + recurring?: RecurringTransaction | null; +} + +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +const EMPTY_FORM = { + type: "expense" as TransactionType, + account_id: "", + category_id: "", + transfer_account_id: "", + amount: "", + description: "", + merchant: "", + frequency: "monthly" as RecurringFrequency, + anchor_date: todayIso(), +}; + +export function RecurringFormModal({ open, onClose, recurring }: RecurringFormModalProps) { + const { t } = useTranslation(); + const { data: accounts } = useAccounts(); + const { data: categories } = useCategories(); + const createRecurring = useCreateRecurring(); + const updateRecurring = useUpdateRecurring(); + + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + if (recurring) { + setForm({ + type: recurring.type, + account_id: String(recurring.account_id), + category_id: recurring.category_id ? String(recurring.category_id) : "", + transfer_account_id: recurring.transfer_account_id ? String(recurring.transfer_account_id) : "", + amount: recurring.amount, + description: recurring.description, + merchant: recurring.merchant ?? "", + frequency: recurring.frequency, + anchor_date: recurring.anchor_date, + }); + } else { + setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" }); + } + setError(null); + }, [open, recurring, accounts]); + + const relevantCategories = (categories ?? []).filter((category) => + form.type === "income" ? category.kind === "income" : category.kind === "expense" + ); + + const isSaving = createRecurring.isPending || updateRecurring.isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + if (!form.account_id) { + setError(t("transactions.form.errorSelectAccount")); + return; + } + if (form.type === "transfer" && !form.transfer_account_id) { + setError(t("transactions.form.errorSelectDestination")); + return; + } + if (form.type === "transfer" && form.transfer_account_id === form.account_id) { + setError(t("transactions.form.errorSameAccount")); + return; + } + + const payload = { + type: form.type, + account_id: Number(form.account_id), + category_id: form.type === "transfer" ? null : form.category_id ? Number(form.category_id) : null, + transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null, + amount: form.amount, + description: form.description, + merchant: form.merchant || null, + frequency: form.frequency, + anchor_date: form.anchor_date, + }; + + try { + if (recurring) { + await updateRecurring.mutateAsync({ id: recurring.id, input: payload }); + } else { + await createRecurring.mutateAsync(payload); + } + onClose(); + } catch { + setError(t("recurring.form.saveError")); + } + } + + return ( + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + setForm((prev) => ({ ...prev, amount: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, anchor_date: event.target.value }))} + /> +
+
+ +
+ + setForm((prev) => ({ ...prev, description: event.target.value }))} + /> +
+ +
+ + +
+ + {form.type === "transfer" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + +
+ + setForm((prev) => ({ ...prev, merchant: event.target.value }))} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/recurring/RecurringList.tsx b/frontend/src/components/recurring/RecurringList.tsx new file mode 100644 index 0000000..6d3b935 --- /dev/null +++ b/frontend/src/components/recurring/RecurringList.tsx @@ -0,0 +1,103 @@ +import { ArrowLeftRight, CalendarCheck, Pencil, Trash2 } from "lucide-react"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency, getIntlLocale } from "@/lib/format"; +import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { RecurringTransaction } from "@/types"; + +interface RecurringListProps { + items: RecurringTransaction[]; + onPost: (item: RecurringTransaction) => void; + onEdit: (item: RecurringTransaction) => void; + onDelete: (item: RecurringTransaction) => void; + isPosting: boolean; +} + +function formatDueDate(isoDate: string): string { + return new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format( + new Date(`${isoDate}T00:00:00`) + ); +} + +export function RecurringList({ items, onPost, onEdit, onDelete, isPosting }: RecurringListProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("recurring.empty")}

; + } + + return ( +
    + {items.map((item) => { + const isTransfer = item.type === "transfer"; + const isExpense = item.type === "expense"; + const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(item.category_icon); + const color = isTransfer ? "var(--text-muted)" : item.category_color ?? "var(--text-muted)"; + + return ( +
  • + + + + + + {item.description} + + {t(`recurring.frequency.${item.frequency}` as TranslationKey)} + {item.category_name && · {translateCategoryName(item.category_name)}} + + ·{" "} + {item.is_due + ? item.days_until_due < 0 + ? t("recurring.overdueDays", { days: Math.abs(item.days_until_due) }) + : t("recurring.dueToday") + : t("recurring.dueInDays", { days: item.days_until_due, date: formatDueDate(item.next_due_date) })} + + + + + + {isTransfer ? "" : isExpense ? "-" : "+"} + {formatCurrency(item.amount)} + + + + + + + +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/reports/CategoryRankingCard.tsx b/frontend/src/components/reports/CategoryRankingCard.tsx new file mode 100644 index 0000000..e12c5ad --- /dev/null +++ b/frontend/src/components/reports/CategoryRankingCard.tsx @@ -0,0 +1,80 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { CategoryRankingItem } from "@/types"; + +interface CategoryRankingCardProps { + items: CategoryRankingItem[]; + isLoading: boolean; + selectedCategoryId: number | null; + onSelectCategory: (categoryId: number) => void; +} + +/** Ranks every expense category by total spent over the currently selected + * period — unlike the Dashboard breakdown (locked to one month) or the + * detail chart below (locked to one category), this is "which category + * costs the most" across the whole range at once. Clicking a row drills + * into that category in the detail chart/transaction list below. */ +export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSelectCategory }: CategoryRankingCardProps) { + const { t } = useTranslation(); + + return ( + + + {t("reports.categoryRankingTitle")} + + + {isLoading ? ( +

{t("common.loading")}

+ ) : items.length === 0 ? ( +

{t("reports.noRankingData")}

+ ) : ( +
    + {items.map((item, index) => { + const Icon = getCategoryIcon(item.icon); + const isSelected = item.category_id === selectedCategoryId; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/reports/CategorySpendingChart.tsx b/frontend/src/components/reports/CategorySpendingChart.tsx new file mode 100644 index 0000000..4c34736 --- /dev/null +++ b/frontend/src/components/reports/CategorySpendingChart.tsx @@ -0,0 +1,114 @@ +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { formatCurrency, getIntlLocale, pluralizeRu } from "@/lib/format"; +import { useTranslation, type Language } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { CategorySpendingReport } from "@/types"; + +interface CategorySpendingChartProps { + report: CategorySpendingReport | undefined; + isLoading: boolean; +} + +function monthKey(year: number, month: number): string { + return `${year}-${String(month).padStart(2, "0")}-01`; +} + +function formatMonthLabel(key: string): string { + return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", year: "numeric" }).format( + new Date(`${key}T00:00:00`) + ); +} + +function transactionsCountLabel(count: number, language: Language): string { + if (language === "ru") return pluralizeRu(count, "транзакция", "транзакции", "транзакций"); + return count === 1 ? "transaction" : "transactions"; +} + +/** Same landmark idea as the Net Worth chart: year ticks only when the bars + * span more than one calendar year, so a 5-year report reads at a glance. */ +function computeYearTicks(keys: string[]): string[] { + if (keys.length < 2) return []; + const startYear = Number(keys[0].slice(0, 4)); + const endYear = Number(keys[keys.length - 1].slice(0, 4)); + if (startYear === endYear) return []; + const ticks: string[] = []; + for (let year = startYear + 1; year <= endYear; year++) { + const jan = `${year}-01-01`; + if (keys.includes(jan)) ticks.push(jan); + } + return ticks; +} + +function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { key: string; amount: number } }> }) { + if (!active || !payload?.length) return null; + const point = payload[0].payload; + return ( +
+

{formatMonthLabel(point.key)}

+

{formatCurrency(point.amount)}

+
+ ); +} + +export function CategorySpendingChart({ report, isLoading }: CategorySpendingChartProps) { + const { t, language } = useTranslation(); + const chartData = report?.series.map((point) => ({ + key: monthKey(point.year, point.month), + amount: Number(point.amount), + })) ?? []; + const yearTicks = computeYearTicks(chartData.map((point) => point.key)); + + return ( + + +
+ {report ? translateCategoryName(report.category_name) : t("reports.defaultChartTitle")} +

+ {isLoading || !report ? "…" : formatCurrency(report.total_amount)} +

+ {report && ( +

+ {t("reports.averagePerMonth", { amount: formatCurrency(report.average_per_month) })} ·{" "} + {report.transaction_count} {transactionsCountLabel(report.transaction_count, language)} +

+ )} +
+
+ +
+ {isLoading || !report || chartData.length === 0 ? ( +
+ {isLoading ? t("common.loading") : t("reports.noDataForPeriod")} +
+ ) : ( + + + {yearTicks.length > 0 && ( + value.slice(0, 4)} + axisLine={false} + tickLine={false} + tick={{ fill: "var(--text-muted)", fontSize: 11 }} + interval="preserveStartEnd" + /> + )} + } cursor={{ fill: "var(--surface-2)" }} /> + + + + )} +
+ {chartData.length > 1 && ( +
+ {formatMonthLabel(chartData[0].key)} + {formatMonthLabel(chartData[chartData.length - 1].key)} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/roi/RoiCalculatorCard.tsx b/frontend/src/components/roi/RoiCalculatorCard.tsx new file mode 100644 index 0000000..5f1cac0 --- /dev/null +++ b/frontend/src/components/roi/RoiCalculatorCard.tsx @@ -0,0 +1,87 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Input, Label } from "@/components/ui/Input"; +import { useTranslation } from "@/lib/i18n"; + +interface RoiCalculatorCardProps { + investment: string; + onInvestmentChange: (value: string) => void; + monthlyIncome: string; + onMonthlyIncomeChange: (value: string) => void; + annualRoiPercent: number | null; + paybackYears: number | null; +} + +/** Quick "is this a good deal" check — purchase price + expected monthly + * income in, annual return % and payback period out. Purely a scratchpad: + * not tied to any Asset, doesn't persist, doesn't read anything else you've + * tracked in the app — only these two numbers. Same formula AssetsTable + * already shows inline for assets that have monthly_cash_flow set, just + * usable before you've committed to adding one. The multi-year, compound- + * interest projection built from these same numbers lives in + * RoiProjectionCard, rendered alongside this one on RoiPage. */ +export function RoiCalculatorCard({ + investment, + onInvestmentChange, + monthlyIncome, + onMonthlyIncomeChange, + annualRoiPercent, + paybackYears, +}: RoiCalculatorCardProps) { + const { t } = useTranslation(); + + return ( + + + {t("roi.calculator.title")} + + +
+
+ + onInvestmentChange(event.target.value)} + /> +
+
+ + onMonthlyIncomeChange(event.target.value)} + /> +
+
+ + {annualRoiPercent === null ? ( +

{t("roi.calculator.hint")}

+ ) : ( +
+

+ {t("roi.calculator.annualRoiLabel")} +

+

= 0 ? "var(--success)" : "var(--danger)" }} + > + {annualRoiPercent.toFixed(1)}% +

+

+ {paybackYears !== null + ? t("roi.calculator.payback", { years: paybackYears.toFixed(1) }) + : t("roi.calculator.noPayback")} +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/roi/RoiProjectionCard.tsx b/frontend/src/components/roi/RoiProjectionCard.tsx new file mode 100644 index 0000000..56203f4 --- /dev/null +++ b/frontend/src/components/roi/RoiProjectionCard.tsx @@ -0,0 +1,162 @@ +import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { formatCurrency } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; + +interface RoiProjectionCardProps { + investmentAmount: number; + annualIncome: number; + annualRoiPercent: number; +} + +const CHART_MAX_YEARS = 30; +const CHART_TICKS = [0, 5, 10, 15, 20, 25, 30]; +const TABLE_YEARS = [1, 5, 10, 20, 30]; + +interface ChartPoint { + year: number; + compound: number; + simple: number; +} + +function ChartTooltip({ + active, + payload, + compoundLabel, + simpleLabel, +}: { + active?: boolean; + payload?: Array<{ payload: ChartPoint }>; + compoundLabel: string; + simpleLabel: string; +}) { + if (!active || !payload?.length) return null; + const point = payload[0].payload; + return ( +
+

{point.year}

+

+ {compoundLabel}: {formatCurrency(point.compound)} +

+

+ {simpleLabel}: {formatCurrency(point.simple)} +

+
+ ); +} + +/** Everything here is derived only from the two numbers typed into + * RoiCalculatorCard (investment amount, monthly income) — no data from + * Asset/AssetValuation or anywhere else in the app. Two scenarios, + * compounded annually from the same starting point: + * compound(year) = investment × (1 + annualRoiPercent)^year — the whole + * annual return (rent/dividends) is assumed reinvested back into the + * same investment every year, so next year's return is earned on a + * larger base too. + * simple(year) = investment + annualIncome × year — the investment + * itself never grows, income is just set aside in cash each year. + * The gap between the two lines *is* what compounding is worth. */ +export function RoiProjectionCard({ investmentAmount, annualIncome, annualRoiPercent }: RoiProjectionCardProps) { + const { t } = useTranslation(); + const rate = annualRoiPercent / 100; + const compoundLabel = t("roi.calculator.chartCompoundLabel"); + const simpleLabel = t("roi.calculator.chartSimpleLabel"); + + const chartData: ChartPoint[] = Array.from({ length: CHART_MAX_YEARS + 1 }, (_, year) => ({ + year, + compound: investmentAmount * Math.pow(1 + rate, year), + simple: investmentAmount + annualIncome * year, + })); + + return ( + + + {t("roi.calculator.projectionTitle")} + + +

{t("roi.calculator.projectionHint", { percent: annualRoiPercent.toFixed(1) })}

+ +
+ + + + } cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} /> + (value === "compound" ? compoundLabel : simpleLabel)} + wrapperStyle={{ fontSize: 12, color: "var(--text-muted)" }} + /> + + + + +
+ +
+

{t("roi.calculator.tableTitle")}

+
    +
  • + + {t("roi.calculator.colCompound")} + {t("roi.calculator.colSimple")} + {t("roi.calculator.colDifference")} +
  • + {TABLE_YEARS.map((year) => { + const compound = investmentAmount * Math.pow(1 + rate, year); + const simple = investmentAmount + annualIncome * year; + // Rounded to the cent before comparing sign — compound and simple + // agree exactly in year 1, but Math.pow's floating-point error + // can leave a sub-cent residue. `|| 0` also normalizes -0 (which + // is >= 0 but still formats as "-$0") to a plain positive 0. + const difference = Math.round((compound - simple) * 100) / 100 || 0; + return ( +
  • + + {year === 1 ? t("roi.calculator.yearOne") : t("roi.calculator.yearN", { years: year })} + + + {formatCurrency(compound)} + + + {formatCurrency(simple)} + + = 0 ? "var(--success)" : "var(--danger)" }} + > + {difference >= 0 ? "+" : ""} + {formatCurrency(difference)} + +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/frontend/src/components/settings/AlertThresholdsCard.tsx b/frontend/src/components/settings/AlertThresholdsCard.tsx new file mode 100644 index 0000000..66406fb --- /dev/null +++ b/frontend/src/components/settings/AlertThresholdsCard.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Input, Label } from "@/components/ui/Input"; +import { useTranslation } from "@/lib/i18n"; +import { useAppSettings, useUpdateAppSettings } from "@/hooks/useSettings"; + +const MIN_MONTHS = 1; +const MAX_MONTHS = 24; +const MIN_PERCENT = 1; +const MAX_PERCENT = 100; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function AlertThresholdsCard() { + const { t } = useTranslation(); + const { data: settings } = useAppSettings(); + const updateSettings = useUpdateAppSettings(); + + const [cashFlowMonths, setCashFlowMonths] = useState(""); + const [netWorthMonths, setNetWorthMonths] = useState(""); + const [riskyPercent, setRiskyPercent] = useState(""); + + useEffect(() => { + if (!settings) return; + setCashFlowMonths(String(settings.negative_cash_flow_threshold_months)); + setNetWorthMonths(String(settings.net_worth_decline_threshold_months)); + setRiskyPercent(String(settings.risky_allocation_threshold_percent)); + }, [settings]); + + function commitCashFlowMonths() { + const value = clamp(Number(cashFlowMonths) || MIN_MONTHS, MIN_MONTHS, MAX_MONTHS); + setCashFlowMonths(String(value)); + if (settings && value !== settings.negative_cash_flow_threshold_months) { + updateSettings.mutate({ negative_cash_flow_threshold_months: value }); + } + } + + function commitNetWorthMonths() { + const value = clamp(Number(netWorthMonths) || MIN_MONTHS, MIN_MONTHS, MAX_MONTHS); + setNetWorthMonths(String(value)); + if (settings && value !== settings.net_worth_decline_threshold_months) { + updateSettings.mutate({ net_worth_decline_threshold_months: value }); + } + } + + function commitRiskyPercent() { + const value = clamp(Number(riskyPercent) || MIN_PERCENT, MIN_PERCENT, MAX_PERCENT); + setRiskyPercent(String(value)); + if (settings && value !== settings.risky_allocation_threshold_percent) { + updateSettings.mutate({ risky_allocation_threshold_percent: value }); + } + } + + return ( + + + {t("settings.alertsTitle")} + + +
+
+ + setCashFlowMonths(event.target.value)} + onBlur={commitCashFlowMonths} + /> +
+
+ + setNetWorthMonths(event.target.value)} + onBlur={commitNetWorthMonths} + /> +
+
+ + setRiskyPercent(event.target.value)} + onBlur={commitRiskyPercent} + /> +
+
+

{t("settings.alertsHint")}

+
+
+ ); +} diff --git a/frontend/src/components/settings/BackupCard.tsx b/frontend/src/components/settings/BackupCard.tsx new file mode 100644 index 0000000..05b8fa3 --- /dev/null +++ b/frontend/src/components/settings/BackupCard.tsx @@ -0,0 +1,100 @@ +import { useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Download, Upload } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { ApiError } from "@/api/client"; +import { exportBackup, importBackup } from "@/api/backup"; +import { useTranslation } from "@/lib/i18n"; + +export function BackupCard() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + + const [isExporting, setIsExporting] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null); + + async function handleExport() { + setIsExporting(true); + setMessage(null); + try { + await exportBackup(); + setMessage({ kind: "success", text: t("backup.exportSuccess") }); + } catch { + // Never surface error.message here — it's the raw backend/HTTP + // response text (English, unlocalized), unlike every other error + // path in the app, which always shows a translated fallback. + setMessage({ kind: "error", text: t("backup.exportError") }); + } finally { + setIsExporting(false); + } + } + + function handleImportClick() { + fileInputRef.current?.click(); + } + + async function handleFileSelected(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + const confirmed = window.confirm(t("backup.confirmImport", { filename: file.name })); + if (!confirmed) return; + + setIsImporting(true); + setMessage(null); + try { + await importBackup(file); + await queryClient.invalidateQueries(); + setMessage({ kind: "success", text: t("backup.importSuccess") }); + } catch (error) { + // importBackup throws a plain Error with an already-translated message + // for a malformed file (t("backup.invalidFile")) — relay that one + // as-is. A failed API call throws ApiError with the raw backend detail + // text (English, unlocalized) instead — never show that, fall back to + // the generic translated message like every other error path in the app. + const text = error instanceof ApiError ? t("backup.importError") : error instanceof Error ? error.message : t("backup.importError"); + setMessage({ kind: "error", text }); + } finally { + setIsImporting(false); + } + } + + return ( + + + {t("backup.title")} + + +

{t("backup.description")}

+ +
+ + + +
+ + {message && ( +

{message.text}

+ )} + +

{t("backup.footerWarning")}

+
+
+ ); +} diff --git a/frontend/src/components/settings/CurrencyCard.tsx b/frontend/src/components/settings/CurrencyCard.tsx new file mode 100644 index 0000000..caa4f83 --- /dev/null +++ b/frontend/src/components/settings/CurrencyCard.tsx @@ -0,0 +1,34 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Select } from "@/components/ui/Input"; +import { useTranslation } from "@/lib/i18n"; +import { CURRENCIES, getCurrencyLabel } from "@/lib/currency"; +import { useAppSettings, useUpdateAppSettings } from "@/hooks/useSettings"; + +export function CurrencyCard() { + const { t, language } = useTranslation(); + const { data: settings, isLoading } = useAppSettings(); + const updateSettings = useUpdateAppSettings(); + + return ( + + + {t("settings.currency")} + + + +

{t("settings.currencyHint")}

+
+
+ ); +} diff --git a/frontend/src/components/settings/LanguageCard.tsx b/frontend/src/components/settings/LanguageCard.tsx new file mode 100644 index 0000000..f3e9647 --- /dev/null +++ b/frontend/src/components/settings/LanguageCard.tsx @@ -0,0 +1,23 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { PillSelector } from "@/components/layout/PillSelector"; +import { useTranslation, type Language } from "@/lib/i18n"; + +export function LanguageCard() { + const { t, language, setLanguage } = useTranslation(); + + const options: Array<{ value: Language; label: string }> = [ + { value: "ru", label: t("settings.languageRussian") }, + { value: "en", label: t("settings.languageEnglish") }, + ]; + + return ( + + + {t("settings.language")} + + + + + + ); +} diff --git a/frontend/src/components/transactions/TransactionFormModal.tsx b/frontend/src/components/transactions/TransactionFormModal.tsx new file mode 100644 index 0000000..0462c31 --- /dev/null +++ b/frontend/src/components/transactions/TransactionFormModal.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/Dialog"; +import { Button } from "@/components/ui/Button"; +import { Input, Label, Select } from "@/components/ui/Input"; +import { useAccounts } from "@/hooks/useAccounts"; +import { useCategories } from "@/hooks/useCategories"; +import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { Transaction, TransactionInput, TransactionType } from "@/types"; + +interface TransactionFormModalProps { + open: boolean; + onClose: () => void; + transaction?: Transaction | null; +} + +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +const EMPTY_FORM = { + type: "expense" as TransactionType, + account_id: "", + category_id: "", + transfer_account_id: "", + amount: "", + description: "", + merchant: "", + notes: "", + date: todayIso(), +}; + +export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) { + const { t } = useTranslation(); + const { data: accounts } = useAccounts(); + const { data: categories } = useCategories(); + const createTransaction = useCreateTransaction(); + const updateTransaction = useUpdateTransaction(); + + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + if (transaction) { + setForm({ + type: transaction.type, + account_id: String(transaction.account_id), + category_id: transaction.category_id ? String(transaction.category_id) : "", + transfer_account_id: transaction.transfer_account_id ? String(transaction.transfer_account_id) : "", + amount: transaction.amount, + description: transaction.description, + merchant: transaction.merchant ?? "", + notes: transaction.notes ?? "", + date: transaction.date, + }); + } else { + setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" }); + } + setError(null); + }, [open, transaction, accounts]); + + const relevantCategories = (categories ?? []).filter((category) => + form.type === "income" ? category.kind === "income" : category.kind === "expense" + ); + + const isSaving = createTransaction.isPending || updateTransaction.isPending; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + if (!form.account_id) { + setError(t("transactions.form.errorSelectAccount")); + return; + } + if (form.type === "transfer" && !form.transfer_account_id) { + setError(t("transactions.form.errorSelectDestination")); + return; + } + if (form.type === "transfer" && form.transfer_account_id === form.account_id) { + setError(t("transactions.form.errorSameAccount")); + return; + } + + const payload: TransactionInput = { + type: form.type, + account_id: Number(form.account_id), + category_id: form.type === "transfer" ? null : form.category_id ? Number(form.category_id) : null, + transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null, + amount: form.amount, + description: form.description, + merchant: form.merchant || null, + notes: form.notes || null, + date: form.date, + }; + + try { + if (transaction) { + await updateTransaction.mutateAsync({ id: transaction.id, input: payload }); + } else { + await createTransaction.mutateAsync(payload); + } + onClose(); + } catch { + setError(t("transactions.form.saveError")); + } + } + + return ( + +
+
+ + +
+ +
+
+ + setForm((prev) => ({ ...prev, amount: event.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, date: event.target.value }))} + /> +
+
+ +
+ + setForm((prev) => ({ ...prev, description: event.target.value }))} + /> +
+ +
+ + +
+ + {form.type === "transfer" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + +
+ + setForm((prev) => ({ ...prev, merchant: event.target.value }))} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/transactions/TransactionsTable.tsx b/frontend/src/components/transactions/TransactionsTable.tsx new file mode 100644 index 0000000..8a58c8c --- /dev/null +++ b/frontend/src/components/transactions/TransactionsTable.tsx @@ -0,0 +1,79 @@ +import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react"; +import { getCategoryIcon } from "@/lib/icons"; +import { formatCurrency, formatTransactionDate } from "@/lib/format"; +import { useTranslation } from "@/lib/i18n"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import type { Transaction } from "@/types"; + +interface TransactionsTableProps { + items: Transaction[]; + onEdit: (transaction: Transaction) => void; + onDelete: (transaction: Transaction) => void; +} + +export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("transactions.noneFound")}

; + } + + return ( +
    + {items.map((tx) => { + const isTransfer = tx.type === "transfer"; + const isExpense = tx.type === "expense"; + const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon); + const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)"; + + return ( +
  • + + + + + + {tx.description} + + {formatTransactionDate(tx.date)} · {tx.account.name} + {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""} + {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""} + + + + + {isTransfer ? "" : isExpense ? "-" : "+"} + {formatCurrency(tx.amount)} + + + + + + +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..8ac65a2 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,14 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +export function Badge({ className, ...props }: HTMLAttributes) { + return ( + + ); +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..378f484 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,28 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +type Variant = "primary" | "secondary" | "ghost" | "danger"; + +const VARIANT_CLASSES: Record = { + primary: "bg-text-primary text-surface-1 hover:opacity-90", + secondary: "bg-surface-2 text-text-primary hover:bg-surface-2/70", + ghost: "bg-transparent text-text-secondary hover:bg-surface-2", + danger: "bg-danger text-white hover:opacity-90", +}; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; +} + +export function Button({ className, variant = "primary", ...props }: ButtonProps) { + return ( + +
+ {children} +
+ + ); +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..b19b281 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,32 @@ +import type { InputHTMLAttributes, LabelHTMLAttributes, SelectHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ( + + ); +} + +export function Select({ className, children, ...props }: SelectHTMLAttributes) { + return ( + + ); +} + +export function Label({ className, ...props }: LabelHTMLAttributes) { + return