diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c8aaa0a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git -C c:/Data/git/finance_app status --short)" + ] + } +} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 1639887..0000000 --- a/.editorconfig +++ /dev/null @@ -1,59 +0,0 @@ -[*] -cpp_indent_braces=false -cpp_indent_multi_line_relative_to=innermost_parenthesis -cpp_indent_within_parentheses=indent -cpp_indent_preserve_within_parentheses=false -cpp_indent_case_labels=false -cpp_indent_case_contents=true -cpp_indent_case_contents_when_block=false -cpp_indent_lambda_braces_when_parameter=true -cpp_indent_goto_labels=one_left -cpp_indent_preprocessor=leftmost_column -cpp_indent_access_specifiers=false -cpp_indent_namespace_contents=true -cpp_indent_preserve_comments=false -cpp_new_line_before_open_brace_namespace=ignore -cpp_new_line_before_open_brace_type=ignore -cpp_new_line_before_open_brace_function=ignore -cpp_new_line_before_open_brace_block=ignore -cpp_new_line_before_open_brace_lambda=ignore -cpp_new_line_scope_braces_on_separate_lines=false -cpp_new_line_close_brace_same_line_empty_type=false -cpp_new_line_close_brace_same_line_empty_function=false -cpp_new_line_before_catch=true -cpp_new_line_before_else=true -cpp_new_line_before_while_in_do_while=false -cpp_space_before_function_open_parenthesis=remove -cpp_space_within_parameter_list_parentheses=false -cpp_space_between_empty_parameter_list_parentheses=false -cpp_space_after_keywords_in_control_flow_statements=true -cpp_space_within_control_flow_statement_parentheses=false -cpp_space_before_lambda_open_parenthesis=false -cpp_space_within_cast_parentheses=false -cpp_space_after_cast_close_parenthesis=false -cpp_space_within_expression_parentheses=false -cpp_space_before_block_open_brace=true -cpp_space_between_empty_braces=false -cpp_space_before_initializer_list_open_brace=false -cpp_space_within_initializer_list_braces=true -cpp_space_preserve_in_initializer_list=true -cpp_space_before_open_square_bracket=false -cpp_space_within_square_brackets=false -cpp_space_before_empty_square_brackets=false -cpp_space_between_empty_square_brackets=false -cpp_space_group_square_brackets=true -cpp_space_within_lambda_brackets=false -cpp_space_between_empty_lambda_brackets=false -cpp_space_before_comma=false -cpp_space_after_comma=true -cpp_space_remove_around_member_operators=true -cpp_space_before_inheritance_colon=true -cpp_space_before_constructor_colon=true -cpp_space_remove_before_semicolon=true -cpp_space_after_semicolon=false -cpp_space_remove_around_unary_operator=true -cpp_space_around_binary_operator=insert -cpp_space_around_assignment_operator=insert -cpp_space_pointer_reference_alignment=left -cpp_space_around_ternary_operator=insert -cpp_wrap_preserve_blocks=one_liners diff --git a/.env b/.env deleted file mode 100644 index 34645b9..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -DATABASE_URL=postgresql://finance_user:DY4xPf7SQWEwcXDfcfkg@192.168.1.150:9332/finance_db -SECRET_KEY=ZOEJZWEecjKUlNTvn8EX \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..085610e --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Root-level docker-compose env +POSTGRES_DB=financedb +POSTGRES_USER=financeuser +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +SECRET_KEY=CHANGE_ME_32_BYTES_HEX +ALLOWED_ORIGINS=https://your-domain.com + +# Admin initial password (used by seed.py once) +ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e546bd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Env files +.env +backend/.env +android/.env + +# Python +__pycache__/ +*.pyc +.venv/ +*.egg-info/ + +# Node +node_modules/ +frontend/node_modules/ +android/node_modules/ + +# Builds +frontend/dist/ +android/dist/ +android/.expo/ +android/android/ +android/ios/ + +# DB +*.sqlite3 + +# SSL certs (never commit private keys) +nginx/certs/ + +# IDE +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7d56da --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Finance App — Система финансового учёта + +Приложение для управления личными финансами по принципу **1С:Деньги**. +Работает через **веб-браузер** и **Android** (React Native + Expo). + +--- + +## Технологический стек + +| Компонент | Технология | +|---|---| +| База данных | PostgreSQL 16 | +| Backend API | FastAPI + SQLAlchemy 2.0 + Alembic | +| Веб-фронтенд | React 18 + Vite + Recharts | +| Android | React Native + Expo | +| Аутентификация | JWT (access + refresh) + bcrypt | +| Реверс-прокси | Nginx (HTTPS + rate-limiting) | +| Развёртывание | Docker + docker-compose | + +--- + +## Структура проекта + +``` +finance_app/ +├── backend/ FastAPI-сервер +│ ├── app/ +│ │ ├── models/ SQLAlchemy-модели (13 таблиц) +│ │ ├── schemas/ Pydantic-схемы +│ │ ├── auth/ JWT, RBAC +│ │ └── api/v1/ REST-роуты +│ ├── alembic/ Миграции БД +│ ├── seed.py Начальные данные +│ └── Dockerfile +├── frontend/ React-приложение +│ └── src/ +│ ├── pages/ Dashboard, Accounts, Operations, Budgets, +│ │ ExchangeRates, Locations, Calendar, Admin +│ └── api/ HTTP-клиент (axios) +├── android/ React Native (Expo) +│ └── src/ +│ ├── screens/ 5 экранов +│ └── navigation/ Bottom tabs +├── nginx/ nginx.conf с HTTPS +├── docs/ +│ └── ER_diagram.md Mermaid ER-диаграмма +└── docker-compose.yml +``` + +--- + +## Быстрый старт (Docker) + +### 1. Скопировать конфиг +```bash +cp backend/.env.example backend/.env +# Заполнить POSTGRES_PASSWORD, SECRET_KEY, ALLOWED_ORIGINS +``` + +### 2. Сгенерировать SSL-сертификат (dev) +```bash +mkdir -p nginx/certs +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout nginx/certs/server.key \ + -out nginx/certs/server.crt \ + -subj "/CN=localhost" +``` + +### 3. Запустить +```bash +docker-compose up --build +``` + +Сервисы: +- **Веб-приложение**: https://localhost +- **API**: https://localhost/api/v1 +- **Swagger Docs** (только DEBUG=true): http://localhost:8000/docs + +--- + +## Разработка без Docker + +### Backend +```bash +cd backend +python -m venv .venv && .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env # настроить DATABASE_URL +alembic upgrade head +python seed.py +uvicorn app.main:app --reload +``` + +### Frontend +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +### Android +```bash +cd android +npm install +npx expo start # открыть Expo Go на телефоне или запустить эмулятор +``` + +> В `android/src/api/client.ts` поменяйте `BASE_URL` на адрес вашего сервера. + +--- + +## Учётные данные по умолчанию + +| Роль | Логин | Пароль (из `ADMIN_PASSWORD` или по умолчанию) | +|---|---|---| +| admin | `admin` | `Admin1234!` | + +⚠️ Смените пароль после первого входа! + +--- + +## Функциональность + +- **Валюты** — справочник (RUB, USD, EUR, GBP, CNY, BTC) +- **Курсы валют** — ввод вручную по датам, конвертация при операциях +- **Счета** — наличные, банк, кредитка, вклад, инвест., крипто. Хранение баланса +- **Категории** — двухуровневая иерархия (системные + пользовательские) +- **Бюджеты** — лимиты по категориям (месяц/неделя/год), прогресс-бар, перерасход +- **Местоположения** — магазины, банки, банкоматы с иерархией +- **Операции** — доходы, расходы, переводы между счетами (автобаланс) +- **Связи операций** — transfer, split, correction +- **Календарь событий** — разовые и повторяющиеся события с напоминанием +- **Дашборд** — KPI, Area-chart (30 дней), Pie по категориям, статус бюджетов +- **Управление пользователями** (admin) — роли, блокировка, управление валютами + +--- + +## ER-диаграмма + +Смотрите [docs/ER_diagram.md](docs/ER_diagram.md) — диаграмма в формате Mermaid. + +--- + +## API-документация + +При `DEBUG=true` доступны: +- Swagger UI: `/docs` +- ReDoc: `/redoc` diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc deleted file mode 100644 index 13337b7..0000000 Binary files a/__pycache__/app.cpython-313.pyc and /dev/null differ diff --git a/android/App.tsx b/android/App.tsx new file mode 100644 index 0000000..4a05a36 --- /dev/null +++ b/android/App.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { AuthProvider } from "./src/contexts/AuthContext"; +import AppNavigator from "./src/navigation/AppNavigator"; + +export default function App() { + return ( + + + + + + + ); +} diff --git a/android/app.json b/android/app.json new file mode 100644 index 0000000..cc05c05 --- /dev/null +++ b/android/app.json @@ -0,0 +1,25 @@ +{ + "expo": { + "name": "FinanceApp", + "slug": "finance-app", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#1e2a3a" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#1e2a3a" + }, + "package": "com.financeapp", + "permissions": ["INTERNET"] + }, + "plugins": ["expo-router", "expo-secure-store"], + "scheme": "financeapp" + } +} diff --git a/android/package.json b/android/package.json new file mode 100644 index 0000000..85a0995 --- /dev/null +++ b/android/package.json @@ -0,0 +1,31 @@ +{ + "name": "finance-app-android", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios" + }, + "dependencies": { + "expo": "~51.0.0", + "expo-router": "~3.5.0", + "expo-secure-store": "~13.0.0", + "expo-status-bar": "~1.12.1", + "react": "18.2.0", + "react-native": "0.74.1", + "react-native-safe-area-context": "4.10.1", + "react-native-screens": "3.31.1", + "@react-navigation/native": "^6.1.17", + "@react-navigation/bottom-tabs": "^6.5.20", + "@react-navigation/stack": "^6.3.29", + "axios": "^1.7.2", + "react-native-vector-icons": "^10.1.0", + "date-fns": "^3.6.0" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~18.2.79", + "typescript": "~5.3.3" + } +} diff --git a/android/src/api/client.ts b/android/src/api/client.ts new file mode 100644 index 0000000..46eba7c --- /dev/null +++ b/android/src/api/client.ts @@ -0,0 +1,83 @@ +import axios from "axios"; +import * as SecureStore from "expo-secure-store"; + +// Change to your server address +const BASE_URL = process.env.EXPO_PUBLIC_API_URL || "https://your-server.com/api/v1"; + +const api = axios.create({ + baseURL: BASE_URL, + headers: { "Content-Type": "application/json" }, + timeout: 15000, +}); + +api.interceptors.request.use(async (config) => { + const token = await SecureStore.getItemAsync("access_token"); + if (token) config.headers!.Authorization = `Bearer ${token}`; + return config; +}); + +api.interceptors.response.use( + (r) => r, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + const refresh = await SecureStore.getItemAsync("refresh_token"); + if (refresh) { + try { + const { data } = await axios.post(`${BASE_URL}/auth/refresh`, null, { + params: { refresh_token: refresh }, + }); + await SecureStore.setItemAsync("access_token", data.access_token); + await SecureStore.setItemAsync("refresh_token", data.refresh_token); + original.headers.Authorization = `Bearer ${data.access_token}`; + return api(original); + } catch { + await SecureStore.deleteItemAsync("access_token"); + await SecureStore.deleteItemAsync("refresh_token"); + } + } + } + return Promise.reject(error); + } +); + +// ── Auth ──────────────────────────────────────────────── +export const login = (username: string, password: string) => + api.post("/auth/login/json", { username, password }); +export const getMe = () => api.get("/users/me"); + +// ── Accounts ───────────────────────────────────────────── +export const getAccountTypes = () => api.get("/accounts/types"); +export const getAccounts = () => api.get("/accounts"); +export const createAccount = (d: any) => api.post("/accounts", d); +export const updateAccount = (id: number, d: any) => api.patch(`/accounts/${id}`, d); +export const deleteAccount = (id: number) => api.delete(`/accounts/${id}`); + +// ── Operations ──────────────────────────────────────────── +export const getOperationTypes = () => api.get("/operations/types"); +export const getOperations = (params?: any) => api.get("/operations", { params }); +export const createOperation = (d: any) => api.post("/operations", d); +export const createTransfer = (d: any) => api.post("/operations/transfer", d); +export const deleteOperation = (id: number) => api.delete(`/operations/${id}`); + +// ── Budgets ─────────────────────────────────────────────── +export const getBudgets = () => api.get("/budgets"); +export const createBudget = (d: any) => api.post("/budgets", d); + +// ── Categories ──────────────────────────────────────────── +export const getCategories = () => api.get("/categories"); + +// ── Currencies ──────────────────────────────────────────── +export const getCurrencies = () => api.get("/currencies"); +export const getExchangeRates = (params?: any) => api.get("/exchange-rates", { params }); + +// ── Locations ───────────────────────────────────────────── +export const getLocations = () => api.get("/locations"); + +// ── Calendar ────────────────────────────────────────────── +export const getCalendarEvents = (params?: any) => api.get("/calendar", { params }); +export const createCalendarEvent = (d: any) => api.post("/calendar", d); +export const updateCalendarEvent = (id: number, d: any) => api.patch(`/calendar/${id}`, d); + +export default api; diff --git a/android/src/contexts/AuthContext.tsx b/android/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..b8462f5 --- /dev/null +++ b/android/src/contexts/AuthContext.tsx @@ -0,0 +1,50 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; +import * as SecureStore from "expo-secure-store"; +import { login as apiLogin, getMe } from "../api/client"; + +interface User { id: number; username: string; email: string; role: { name: string }; } +interface AuthCtx { user: User | null; loading: boolean; login(u: string, p: string): Promise; logout(): void; isAdmin: boolean; } + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const loadUser = useCallback(async () => { + const token = await SecureStore.getItemAsync("access_token"); + if (!token) { setLoading(false); return; } + try { + const { data } = await getMe(); + setUser(data); + } catch { + await SecureStore.deleteItemAsync("access_token"); + await SecureStore.deleteItemAsync("refresh_token"); + } finally { setLoading(false); } + }, []); + + useEffect(() => { loadUser(); }, []); + + const login = async (username: string, password: string) => { + const { data } = await apiLogin(username, password); + await SecureStore.setItemAsync("access_token", data.access_token); + await SecureStore.setItemAsync("refresh_token", data.refresh_token); + await loadUser(); + }; + + const logout = async () => { + await SecureStore.deleteItemAsync("access_token"); + await SecureStore.deleteItemAsync("refresh_token"); + setUser(null); + }; + + const isAdmin = user?.role?.name === "admin"; + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext)!; diff --git a/android/src/navigation/AppNavigator.tsx b/android/src/navigation/AppNavigator.tsx new file mode 100644 index 0000000..7bb73e5 --- /dev/null +++ b/android/src/navigation/AppNavigator.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { NavigationContainer } from "@react-navigation/native"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { createStackNavigator } from "@react-navigation/stack"; +import { Text, ActivityIndicator, View } from "react-native"; +import { useAuth } from "../contexts/AuthContext"; + +import LoginScreen from "../screens/LoginScreen"; +import DashboardScreen from "../screens/DashboardScreen"; +import AccountsScreen from "../screens/AccountsScreen"; +import OperationsScreen from "../screens/OperationsScreen"; +import BudgetsScreen from "../screens/BudgetsScreen"; +import CalendarScreen from "../screens/CalendarScreen"; + +const Tab = createBottomTabNavigator(); +const Stack = createStackNavigator(); + +const THEME = { + primary: "#3b82f6", + surface: "#ffffff", + bg: "#f4f6fa", + text: "#1e293b", + muted: "#64748b", +}; + +function TabNavigator() { + const tabIcon = (emoji: string) => ({ focused }: any) => ( + {emoji} + ); + return ( + + + + + + + + ); +} + +export default function AppNavigator() { + const { user, loading } = useAuth(); + + if (loading) { + return ( + + + + ); + } + + return ( + + + {user + ? + : + } + + + ); +} diff --git a/android/src/screens/AccountsScreen.tsx b/android/src/screens/AccountsScreen.tsx new file mode 100644 index 0000000..b47b744 --- /dev/null +++ b/android/src/screens/AccountsScreen.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Modal, Alert, RefreshControl } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getAccounts, getAccountTypes, getCurrencies, createAccount, deleteAccount } from "../api/client"; + +const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" }; + +export default function AccountsScreen() { + const [accounts, setAccounts] = useState([]); + const [types, setTypes] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [modal, setModal] = useState(false); + const [form, setForm] = useState({ name: "", account_type_id: "", currency_id: "", initial_balance: "0", color: "#3b82f6" }); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const [a, t, c] = await Promise.all([getAccounts(), getAccountTypes(), getCurrencies()]); + setAccounts(a.data); setTypes(t.data); setCurrencies(c.data.filter((c: any) => c.is_active)); + }, []); + + useEffect(() => { load(); }, []); + + const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); }; + + const save = async () => { + if (!form.name || !form.account_type_id || !form.currency_id) { + Alert.alert("Ошибка", "Заполните все обязательные поля"); return; + } + try { + await createAccount({ name: form.name, account_type_id: parseInt(form.account_type_id), currency_id: parseInt(form.currency_id), initial_balance: parseFloat(form.initial_balance) || 0, color: form.color }); + await load(); setModal(false); + } catch (e: any) { Alert.alert("Ошибка", e.response?.data?.detail || "Не удалось создать счёт"); } + }; + + const remove = (id: number) => { + Alert.alert("Удалить счёт?", "Все операции по счёту останутся.", [ + { text: "Отмена" }, + { text: "Удалить", style: "destructive", onPress: async () => { await deleteAccount(id); load(); } }, + ]); + }; + + const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 }); + + return ( + + + Счета + setModal(true)}> + + Добавить + + + + }> + {accounts.length === 0 + ? Счетов нет. Нажмите «+ Добавить» + : accounts.map((a: any) => ( + + + + + {a.name} + {a.account_type?.name} · {a.currency?.code} + + + = 0 ? C.success : C.danger }]}> + {fmt(parseFloat(a.balance))} {a.currency?.symbol} + + {a.is_active ? "Активен" : "Закрыт"} + + + + remove(a.id)}> + 🗑️ Удалить + + + + )) + } + + + + + + Новый счёт + setModal(false)}>✕ + + + Название * + setForm({ ...form, name: v })} placeholder="Например: Наличные" /> + + Тип счёта * + {types.map((t: any) => ( + setForm({ ...form, account_type_id: String(t.id) })} + style={[s.radioItem, form.account_type_id === String(t.id) && s.radioSelected]}> + {t.name} + + ))} + + Валюта * + + {currencies.map((c: any) => ( + setForm({ ...form, currency_id: String(c.id) })} + style={[s.currencyChip, form.currency_id === String(c.id) && s.currencyChipSelected]}> + {c.code} + + ))} + + + Начальный баланс + setForm({ ...form, initial_balance: v })} keyboardType="decimal-pad" /> + + + Создать счёт + + + + + + ); +} + +const s = StyleSheet.create({ + header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" }, + title: { color: "#fff", fontSize: 20, fontWeight: "800" }, + addBtn: { backgroundColor: C.primary, paddingVertical: 7, paddingHorizontal: 14, borderRadius: 8 }, + addBtnText: { color: "#fff", fontWeight: "700", fontSize: 13 }, + empty: { textAlign: "center", color: C.muted, padding: 40, fontSize: 15 }, + card: { backgroundColor: C.surface, borderRadius: 12, padding: 16, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 }, + dot: { width: 12, height: 12, borderRadius: 6, marginRight: 12 }, + cardTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" }, + cardSub: { fontSize: 12, color: C.muted, marginTop: 2 }, + balance: { fontSize: 16, fontWeight: "800" }, + cardActions: { flexDirection: "row", justifyContent: "flex-end", marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: C.border }, + deleteBtn: { paddingVertical: 4, paddingHorizontal: 12 }, + deleteText: { color: C.danger, fontSize: 13 }, + modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 20, borderBottomWidth: 1, borderBottomColor: C.border }, + modalTitle: { fontSize: 18, fontWeight: "800", color: "#1e293b" }, + label: { fontSize: 13, fontWeight: "600", color: "#1e293b", marginBottom: 6 }, + input: { borderWidth: 1, borderColor: C.border, borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 14, backgroundColor: C.surface, color: "#1e293b" }, + radioItem: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: C.border, marginBottom: 6, backgroundColor: C.surface }, + radioSelected: { borderColor: C.primary, backgroundColor: "#dbeafe" }, + radioText: { color: "#1e293b" }, + radioTextSelected: { color: C.primary, fontWeight: "700" }, + currencyChip: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, borderWidth: 1, borderColor: C.border, marginRight: 8, backgroundColor: C.surface }, + currencyChipSelected: { backgroundColor: C.primary, borderColor: C.primary }, + saveBtn: { backgroundColor: C.primary, borderRadius: 12, padding: 16, alignItems: "center", marginTop: 24 }, + saveBtnText: { color: "#fff", fontWeight: "800", fontSize: 16 }, +}); + +const C_primary = "#3b82f6"; diff --git a/android/src/screens/BudgetsScreen.tsx b/android/src/screens/BudgetsScreen.tsx new file mode 100644 index 0000000..a8aeae4 --- /dev/null +++ b/android/src/screens/BudgetsScreen.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { View, Text, ScrollView, StyleSheet, RefreshControl } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getBudgets } from "../api/client"; + +const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" }; +const PERIOD: Record = { monthly: "Ежемесячно", weekly: "Еженедельно", yearly: "Ежегодно", custom: "Произвольный" }; + +export default function BudgetsScreen() { + const [budgets, setBudgets] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const { data } = await getBudgets(); + setBudgets(data); + }, []); + + useEffect(() => { load(); }, []); + const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); }; + + const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 }); + + const active = budgets.filter((b: any) => b.is_active); + const inactive = budgets.filter((b: any) => !b.is_active); + + const BudgetCard = ({ b }: { b: any }) => { + const spent = parseFloat(b.spent || 0); + const limit = parseFloat(b.amount); + const pct = limit > 0 ? Math.min(spent / limit, 1) : 0; + const over = spent > limit; + return ( + + + {b.name} + + {b.is_active ? "Активен" : "Неактивен"} + + + {b.category?.name} · {PERIOD[b.period_type] || b.period_type} + + Потрачено: {fmt(spent)} + Лимит: {fmt(limit)} {b.currency?.symbol} + + + + + {over + ? Перерасход: {fmt(spent - limit)} + : Остаток: {fmt(limit - spent)} + } + + ); + }; + + return ( + + + Бюджеты + + }> + {budgets.length === 0 + ? Бюджеты не настроены. Добавьте их в веб-версии. + : <> + {active.length > 0 && ( + <> + Активные + {active.map((b: any) => )} + + )} + {inactive.length > 0 && ( + <> + Неактивные + {inactive.map((b: any) => )} + + )} + + } + + + ); +} + +const s = StyleSheet.create({ + header: { padding: 16, backgroundColor: "#1e2a3a" }, + title: { color: "#fff", fontSize: 20, fontWeight: "800" }, + empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 }, + sectionTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b", marginTop: 12, marginBottom: 8 }, + card: { backgroundColor: "#fff", borderRadius: 12, padding: 16, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 }, + cardTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" }, + cardSub: { fontSize: 12, color: "#64748b", marginTop: 2 }, + badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 12 }, + progressBg: { height: 10, backgroundColor: "#e2e8f0", borderRadius: 5, overflow: "hidden" }, + progressFill: { height: 10, borderRadius: 5 }, +}); diff --git a/android/src/screens/CalendarScreen.tsx b/android/src/screens/CalendarScreen.tsx new file mode 100644 index 0000000..3f37f78 --- /dev/null +++ b/android/src/screens/CalendarScreen.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getCalendarEvents, updateCalendarEvent } from "../api/client"; +import { format, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns"; +import { ru } from "date-fns/locale"; + +const C = { primary: "#3b82f6", success: "#22c55e", warning: "#f59e0b", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0", danger: "#ef4444" }; + +export default function CalendarScreen() { + const [events, setEvents] = useState([]); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const from = format(startOfMonth(currentMonth), "yyyy-MM-dd"); + const to = format(endOfMonth(currentMonth), "yyyy-MM-dd"); + const { data } = await getCalendarEvents({ date_from: from, date_to: to, limit: 500 }); + setEvents(data); + }, [currentMonth]); + + useEffect(() => { load(); }, [currentMonth]); + const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); }; + + const toggle = async (ev: any) => { + await updateCalendarEvent(ev.id, { is_completed: !ev.is_completed }); + load(); + }; + + // Group events by date + const grouped: Record = {}; + events.forEach((ev: any) => { + if (!grouped[ev.event_date]) grouped[ev.event_date] = []; + grouped[ev.event_date].push(ev); + }); + const sortedDates = Object.keys(grouped).sort(); + + const pending = events.filter((e: any) => !e.is_completed).length; + const completed = events.filter((e: any) => e.is_completed).length; + + return ( + + + setCurrentMonth(subMonths(currentMonth, 1))}> + ‹ + + + Календарь + {format(currentMonth, "LLLL yyyy", { locale: ru })} + + setCurrentMonth(addMonths(currentMonth, 1))}> + › + + + + {/* Stats */} + + + {pending} + Ожидается + + + {completed} + Выполнено + + + + }> + {sortedDates.length === 0 + ? Событий в этом месяце нет + : sortedDates.map(date => ( + + {format(new Date(date), "d MMMM, EEEE", { locale: ru })} + {grouped[date].map((ev: any) => ( + + toggle(ev)} style={s.checkBtn}> + {ev.is_completed ? "✅" : "⏳"} + + + {ev.title} + + {ev.event_time && 🕐 {ev.event_time.slice(0, 5)}} + {ev.amount && 💰 {parseFloat(ev.amount).toLocaleString("ru-RU")}} + {ev.category?.name && {ev.category.name}} + {ev.is_recurring && 🔁 {ev.recurrence_pattern}} + {ev.reminder_days && 🔔 за {ev.reminder_days} дн.} + + {ev.description && {ev.description}} + + + ))} + + )) + } + + + ); +} + +const s = StyleSheet.create({ + header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" }, + navArrow: { color: "#cbd5e1", fontSize: 28, fontWeight: "300", paddingHorizontal: 8 }, + title: { color: "#fff", fontSize: 20, fontWeight: "800", textAlign: "center" }, + subtitle: { color: "#94a3b8", fontSize: 13, textAlign: "center", marginTop: 2 }, + statsRow: { flexDirection: "row", padding: 12, gap: 0 }, + statCard: { backgroundColor: "#fff", borderRadius: 10, padding: 12, alignItems: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 3, elevation: 1 }, + statNum: { fontSize: 24, fontWeight: "800", color: "#1e293b" }, + statLabel: { fontSize: 12, color: "#64748b", marginTop: 2 }, + empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 }, + dateHeader: { fontSize: 14, fontWeight: "700", color: "#64748b", textTransform: "capitalize", marginBottom: 8 }, + eventCard: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 8, flexDirection: "row", alignItems: "flex-start", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 }, + eventCompleted: { opacity: 0.6, backgroundColor: "#f8fafc" }, + checkBtn: { marginRight: 12, paddingTop: 2 }, + eventTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" }, + strikethrough: { textDecorationLine: "line-through", color: "#94a3b8" }, + tag: { fontSize: 11, color: "#64748b", backgroundColor: "#f1f5f9", paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10 }, + eventDesc: { fontSize: 12, color: "#94a3b8", marginTop: 6, fontStyle: "italic" }, +}); diff --git a/android/src/screens/DashboardScreen.tsx b/android/src/screens/DashboardScreen.tsx new file mode 100644 index 0000000..d737a34 --- /dev/null +++ b/android/src/screens/DashboardScreen.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getAccounts, getOperations, getBudgets } from "../api/client"; +import { useAuth } from "../contexts/AuthContext"; +import { startOfMonth, endOfMonth, format } from "date-fns"; + +const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" }; + +const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +export default function DashboardScreen() { + const { user, logout } = useAuth(); + const [accounts, setAccounts] = useState([]); + const [operations, setOperations] = useState([]); + const [budgets, setBudgets] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const from = startOfMonth(new Date()).toISOString(); + const to = endOfMonth(new Date()).toISOString(); + const [a, o, b] = await Promise.all([ + getAccounts(), getOperations({ date_from: from, date_to: to, limit: 500 }), getBudgets(), + ]); + setAccounts(a.data); setOperations(o.data); setBudgets(b.data); + }, []); + + useEffect(() => { load(); }, []); + + const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); }; + + const totalBalance = accounts.reduce((s: number, a: any) => s + parseFloat(a.balance), 0); + const income = operations.filter((o: any) => o.operation_type?.name === "income").reduce((s: number, o: any) => s + parseFloat(o.amount), 0); + const expense = operations.filter((o: any) => o.operation_type?.name === "expense").reduce((s: number, o: any) => s + parseFloat(o.amount), 0); + + return ( + + + + Дашборд + {user?.username} + + + Выйти + + + + }> + {/* KPI Cards */} + + + Общий баланс + {fmt(totalBalance)} ₽ + {accounts.length} счётов + + + + + Доходы (мес.) + {fmt(income)} + + + Расходы (мес.) + {fmt(expense)} + + + + {/* Accounts */} + Мои счета + {accounts.length === 0 + ? Счетов нет + : accounts.map((a: any) => ( + + + + {a.name} + {a.account_type?.name} · {a.currency?.code} + + = 0 ? C.success : C.danger }]}> + {fmt(parseFloat(a.balance))} {a.currency?.symbol} + + + )) + } + + {/* Active budgets */} + {budgets.filter((b: any) => b.is_active).length > 0 && ( + <> + Бюджеты + {budgets.filter((b: any) => b.is_active).map((b: any) => { + const spent = parseFloat(b.spent || 0); + const limit = parseFloat(b.amount); + const pct = limit > 0 ? Math.min(spent / limit, 1) : 0; + const over = spent > limit; + return ( + + + {b.name} + {fmt(spent)} / {fmt(limit)} + + + + + {over && Перерасход: {fmt(spent - limit)}} + + ); + })} + + )} + + {/* Recent operations */} + Последние операции + {operations.slice(0, 10).map((o: any) => { + const isIncome = o.operation_type?.name === "income"; + const isExpense = o.operation_type?.name === "expense"; + return ( + + {isIncome ? "📈" : isExpense ? "📉" : "🔄"} + + {o.category?.name || o.description || "—"} + {o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : ""} + + + {isIncome ? "+" : isExpense ? "−" : ""}{fmt(parseFloat(o.amount))} {o.currency?.symbol} + + + ); + })} + + + ); +} + +const s = StyleSheet.create({ + header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" }, + headerTitle: { color: "#fff", fontSize: 20, fontWeight: "800" }, + headerSub: { color: "#94a3b8", fontSize: 13, marginTop: 2 }, + logoutBtn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 8, borderWidth: 1, borderColor: "rgba(255,255,255,0.3)" }, + logoutText: { color: "#cbd5e1", fontSize: 13, fontWeight: "600" }, + kpiRow: { flexDirection: "row", marginBottom: 10 }, + kpiCard: { backgroundColor: "#fff", borderRadius: 12, padding: 16, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 }, + kpiLabel: { fontSize: 11, fontWeight: "600", color: "#64748b", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 4 }, + kpiValue: { fontSize: 22, fontWeight: "800", color: "#1e293b" }, + kpiSub: { fontSize: 12, color: "#64748b", marginTop: 2 }, + sectionTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b", marginTop: 20, marginBottom: 10 }, + empty: { color: "#64748b", textAlign: "center", padding: 20 }, + listItem: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", borderRadius: 10, padding: 14, marginBottom: 8, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 }, + colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 }, + itemTitle: { fontSize: 14, fontWeight: "600", color: "#1e293b" }, + itemSub: { fontSize: 12, color: "#64748b", marginTop: 2 }, + itemAmount: { fontWeight: "700", fontSize: 14 }, + card: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 }, + progressBg: { height: 8, backgroundColor: "#e2e8f0", borderRadius: 4 }, + progressFill: { height: 8, borderRadius: 4 }, +}); diff --git a/android/src/screens/LoginScreen.tsx b/android/src/screens/LoginScreen.tsx new file mode 100644 index 0000000..93a52b7 --- /dev/null +++ b/android/src/screens/LoginScreen.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ActivityIndicator } from "react-native"; +import { useAuth } from "../contexts/AuthContext"; + +export default function LoginScreen() { + const { login } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handle = async () => { + if (!username || !password) return; + setLoading(true); + try { + await login(username, password); + } catch (err: any) { + Alert.alert("Ошибка", err.response?.data?.detail || "Неверный логин или пароль"); + } finally { + setLoading(false); + } + }; + + return ( + + + FinanceApp + Управление личными финансами + + Логин или Email + + + Пароль + + + + {loading ? : Войти} + + + + ); +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#f4f6fa", alignItems: "center", justifyContent: "center", padding: 20 }, + card: { width: "100%", maxWidth: 400, backgroundColor: "#fff", borderRadius: 16, padding: 32, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 8, elevation: 4 }, + logo: { fontSize: 28, fontWeight: "800", textAlign: "center", color: "#1e293b", marginBottom: 4 }, + accent: { color: "#3b82f6" }, + subtitle: { textAlign: "center", color: "#64748b", marginBottom: 28, fontSize: 14 }, + label: { fontSize: 14, fontWeight: "600", color: "#1e293b", marginBottom: 6 }, + input: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 16, color: "#1e293b" }, + btn: { backgroundColor: "#3b82f6", borderRadius: 10, padding: 14, alignItems: "center", marginTop: 4 }, + btnDisabled: { opacity: 0.6 }, + btnText: { color: "#fff", fontWeight: "700", fontSize: 16 }, +}); diff --git a/android/src/screens/OperationsScreen.tsx b/android/src/screens/OperationsScreen.tsx new file mode 100644 index 0000000..4b8363e --- /dev/null +++ b/android/src/screens/OperationsScreen.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Modal, Alert, RefreshControl } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getOperations, getOperationTypes, getAccounts, getCategories, getCurrencies, createOperation, deleteOperation } from "../api/client"; +import { format } from "date-fns"; + +const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", warning: "#f59e0b", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" }; +const TYPE_LABELS: Record = { income: "Доход", expense: "Расход", transfer: "Перевод" }; + +export default function OperationsScreen() { + const [ops, setOps] = useState([]); + const [opTypes, setOpTypes] = useState([]); + const [accounts, setAccounts] = useState([]); + const [categories, setCategories] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [modal, setModal] = useState(false); + const [form, setForm] = useState({ account_id: "", operation_type_id: "", amount: "", currency_id: "", category_id: "", description: "", date: format(new Date(), "yyyy-MM-dd'T'HH:mm") }); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async () => { + const [o, ot, a, c, cu] = await Promise.all([ + getOperations({ limit: 100 }), getOperationTypes(), getAccounts(), getCategories(), getCurrencies() + ]); + setOps(o.data); setOpTypes(ot.data); setAccounts(a.data); setCategories(c.data); setCurrencies(cu.data.filter((c: any) => c.is_active)); + }, []); + + useEffect(() => { load(); }, []); + const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); }; + + const save = async () => { + if (!form.account_id || !form.operation_type_id || !form.amount || !form.currency_id) { + Alert.alert("Ошибка", "Заполните счёт, тип, сумму и валюту"); return; + } + try { + await createOperation({ account_id: parseInt(form.account_id), operation_type_id: parseInt(form.operation_type_id), amount: parseFloat(form.amount), currency_id: parseInt(form.currency_id), category_id: form.category_id ? parseInt(form.category_id) : null, description: form.description || null, date: form.date }); + await load(); setModal(false); + } catch (e: any) { Alert.alert("Ошибка", e.response?.data?.detail || "Не удалось создать операцию"); } + }; + + const remove = (id: number) => { + Alert.alert("Удалить операцию?", "", [ + { text: "Отмена" }, + { text: "Удалить", style: "destructive", onPress: async () => { await deleteOperation(id); load(); } }, + ]); + }; + + const typeName = (id: number) => opTypes.find((t: any) => t.id === id)?.name || ""; + const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 }); + + return ( + + + Операции + setModal(true)}> + + Добавить + + + + }> + {ops.length === 0 + ? Операций нет + : ops.map((o: any) => { + const tn = typeName(o.operation_type_id); + const color = tn === "income" ? C.success : tn === "expense" ? C.danger : C.warning; + const sign = tn === "income" ? "+" : tn === "expense" ? "−" : "↔"; + return ( + + + {tn === "income" ? "📈" : tn === "expense" ? "📉" : "🔄"} + + {o.category?.name || o.description || TYPE_LABELS[tn] || "—"} + {o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : "—"} · {accounts.find((a: any) => a.id === o.account_id)?.name} + {o.description && o.category?.name && {o.description}} + + + {sign}{fmt(parseFloat(o.amount))} {o.currency?.symbol} + {o.is_planned ? "Запланировано" : "Выполнено"} + + + remove(o.id)} style={s.deleteRow}> + 🗑️ Удалить + + + ); + }) + } + + + + + + Новая операция + setModal(false)}>✕ + + + Тип операции * + {opTypes.filter((t: any) => t.name !== "transfer").map((t: any) => ( + setForm({ ...form, operation_type_id: String(t.id) })} + style={[s.radioItem, form.operation_type_id === String(t.id) && s.radioSelected]}> + {TYPE_LABELS[t.name] || t.name} + + ))} + + Счёт * + {accounts.map((a: any) => ( + setForm({ ...form, account_id: String(a.id) })} + style={[s.radioItem, form.account_id === String(a.id) && s.radioSelected]}> + {a.name} ({a.currency?.code}) + + ))} + + Сумма * + setForm({ ...form, amount: v })} keyboardType="decimal-pad" placeholder="0.00" /> + + Валюта * + + {currencies.map((c: any) => ( + setForm({ ...form, currency_id: String(c.id) })} + style={[s.chip, form.currency_id === String(c.id) && s.chipSel]}> + {c.code} + + ))} + + + Категория + + {[{ id: "", name: "Без категории" }, ...categories].map((c: any) => ( + setForm({ ...form, category_id: String(c.id) })} + style={[s.chip, form.category_id === String(c.id) && s.chipSel]}> + {c.name} + + ))} + + + Описание + setForm({ ...form, description: v })} placeholder="Необязательно" /> + + + Сохранить + + + + + + ); +} + +const s = StyleSheet.create({ + header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" }, + title: { color: "#fff", fontSize: 20, fontWeight: "800" }, + addBtn: { backgroundColor: "#3b82f6", paddingVertical: 7, paddingHorizontal: 14, borderRadius: 8 }, + addBtnText: { color: "#fff", fontWeight: "700", fontSize: 13 }, + empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 }, + card: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 }, + cardTitle: { fontSize: 14, fontWeight: "700", color: "#1e293b" }, + cardSub: { fontSize: 12, color: "#64748b", marginTop: 2 }, + amount: { fontSize: 15, fontWeight: "800" }, + deleteRow: { flexDirection: "row", justifyContent: "flex-end", paddingTop: 8, marginTop: 8, borderTopWidth: 1, borderTopColor: "#e2e8f0" }, + modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 20, borderBottomWidth: 1, borderBottomColor: "#e2e8f0" }, + modalTitle: { fontSize: 18, fontWeight: "800", color: "#1e293b" }, + label: { fontSize: 13, fontWeight: "600", color: "#1e293b", marginBottom: 6 }, + input: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 14, backgroundColor: "#fff", color: "#1e293b" }, + radioItem: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: "#e2e8f0", marginBottom: 6, backgroundColor: "#fff" }, + radioSelected: { borderColor: "#3b82f6", backgroundColor: "#dbeafe" }, + radioText: { color: "#1e293b" }, + radioTextSel: { color: "#3b82f6", fontWeight: "700" }, + chip: { paddingVertical: 7, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: "#e2e8f0", marginRight: 8, backgroundColor: "#fff" }, + chipSel: { backgroundColor: "#3b82f6", borderColor: "#3b82f6" }, + saveBtn: { backgroundColor: "#3b82f6", borderRadius: 12, padding: 16, alignItems: "center", marginTop: 24 }, + saveBtnText: { color: "#fff", fontWeight: "800", fontSize: 16 }, +}); diff --git a/app.py b/app.py deleted file mode 100644 index ccdcbad..0000000 --- a/app.py +++ /dev/null @@ -1,164 +0,0 @@ -import os -from flask import Flask, render_template, request, redirect, url_for, flash, session -from flask_sqlalchemy import SQLAlchemy -from werkzeug.security import generate_password_hash, check_password_hash -from datetime import datetime, date -from dotenv import load_dotenv -from functools import wraps - -load_dotenv() - -app = Flask(__name__) -app.secret_key = os.getenv('SECRET_KEY', 'supersecretkey') -app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'postgresql://user:password@localhost/finance_db') -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -db = SQLAlchemy(app) - -# Модель пользователя -class User(db.Model): - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80), unique=True, nullable=False) - email = db.Column(db.String(120), unique=True, nullable=False) - password = db.Column(db.String(200), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - - def set_password(self, password): - self.password = generate_password_hash(password) - - def check_password(self, password): - return check_password_hash(self.password, password) - -# Модель транзакции (обновлена с привязкой к пользователю) -class Transaction(db.Model): - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) - type = db.Column(db.String(10), nullable=False) - amount = db.Column(db.Float, nullable=False) - category = db.Column(db.String(50), nullable=False) - date = db.Column(db.Date, nullable=False, default=date.today()) - description = db.Column(db.Text) - - user = db.relationship('User', backref=db.backref('transactions', lazy=True)) - -# Декоратор для проверки аутентификации -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'user_id' not in session: - flash('Пожалуйста, войдите в систему для доступа к этой странице', 'warning') - return redirect(url_for('login')) - return f(*args, **kwargs) - return decorated_function - -# Регистрация пользователя -@app.route('/register', methods=['GET', 'POST']) -def register(): - if request.method == 'POST': - username = request.form['username'] - email = request.form['email'] - password = request.form['password'] - confirm_password = request.form['confirm_password'] - - # Проверки - if password != confirm_password: - flash('Пароли не совпадают', 'danger') - return redirect(url_for('register')) - - if User.query.filter_by(username=username).first(): - flash('Это имя пользователя уже занято', 'danger') - return redirect(url_for('register')) - - if User.query.filter_by(email=email).first(): - flash('Этот email уже используется', 'danger') - return redirect(url_for('register')) - - # Создание пользователя - new_user = User(username=username, email=email) - new_user.set_password(password) - db.session.add(new_user) - db.session.commit() - - flash('Регистрация успешна! Теперь вы можете войти', 'success') - return redirect(url_for('login')) - - return render_template('register.html') - -# Вход в систему -@app.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] - remember = 'remember' in request.form - - user = User.query.filter_by(username=username).first() - - if user and user.check_password(password): - session['user_id'] = user.id - session['username'] = user.username - flash('Вы успешно вошли в систему!', 'success') - return redirect(url_for('index')) - - flash('Неверное имя пользователя или пароль', 'danger') - - return render_template('login.html') - -# Выход из системы -@app.route('/logout') -@login_required -def logout(): - session.pop('user_id', None) - session.pop('username', None) - flash('Вы успешно вышли из системы', 'info') - return redirect(url_for('login')) - -# Главная страница (требует аутентификации) -@app.route('/') -@login_required -def index(): - user_id = session['user_id'] - transactions = Transaction.query.filter_by(user_id=user_id).order_by(Transaction.date.desc()).all() - - total_income = sum(t.amount for t in transactions if t.type == 'income') - total_expense = sum(t.amount for t in transactions if t.type == 'expense') - balance = total_income - total_expense - - categories = {} - for t in transactions: - if t.category not in categories: - categories[t.category] = {'income': 0, 'expense': 0} - categories[t.category][t.type] += t.amount - - return render_template('index.html', - transactions=transactions, - total_income=total_income, - total_expense=total_expense, - balance=balance, - categories=categories, - current_date=date.today(), - username=session['username']) - -# Остальные роуты (add, edit, delete) аналогично защищаем @login_required -# и добавляем user_id при создании/редактировании транзакций - -@app.route('/add', methods=['GET', 'POST']) -@login_required -def add_transaction(): - if request.method == 'POST': - user_id = session['user_id'] - # ... остальной код ... - new_transaction = Transaction( - user_id=user_id, # Добавляем привязку к пользователю - # ... остальные поля ... - ) - # ... сохранение ... - - return render_template('add.html', current_date=date.today()) - -# Аналогично для edit и delete добавляем проверку, что пользователь редактирует свою транзакцию - -if __name__ == '__main__': - with app.app_context(): - db.create_all() - app.run(debug=True) \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e1e0c3c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and fill in all values — never commit .env + +# PostgreSQL +POSTGRES_DB=financedb +POSTGRES_USER=financeuser +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +# Full DSN used by SQLAlchemy (set automatically in docker-compose, or set manually) +DATABASE_URL=postgresql://financeuser:CHANGE_ME@localhost:5432/financedb + +# JWT — generate with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=CHANGE_ME_32_BYTES_HEX + +# CORS — comma-separated origins allowed to call the API +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 + +# App +DEBUG=false diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8d057d5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Run migrations then seed then start server +CMD ["sh", "-c", "alembic upgrade head && python seed.py && 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..4b7653a --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,44 @@ +import os +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Import all models so Alembic sees them +from app.database import Base +import app.models # noqa: F401 + +target_metadata = Base.metadata + +DATABASE_URL = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url", "")) +if DATABASE_URL: + config.set_main_option("sqlalchemy.url", DATABASE_URL) + + +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 run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..4614609 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,211 @@ +"""Initial schema + +Revision ID: 001 +Revises: +Create Date: 2026-04-28 +""" +from alembic import op +import sqlalchemy as sa + +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "roles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(50), unique=True, nullable=False), + sa.Column("description", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("username", sa.String(100), unique=True, nullable=False), + sa.Column("email", sa.String(255), unique=True, nullable=False), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("role_id", sa.Integer, sa.ForeignKey("roles.id"), nullable=False), + sa.Column("is_active", sa.Boolean, default=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_users_username", "users", ["username"]) + op.create_index("ix_users_email", "users", ["email"]) + + op.create_table( + "currencies", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("code", sa.String(3), unique=True, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("symbol", sa.String(10)), + sa.Column("is_active", sa.Boolean, default=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "exchange_rates", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("currency_from_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False), + sa.Column("currency_to_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False), + sa.Column("rate", sa.Numeric(20, 6), nullable=False), + sa.Column("rate_date", sa.Date, nullable=False), + sa.Column("source", sa.String(100)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("currency_from_id", "currency_to_id", "rate_date", name="uq_exchange_rate_day"), + ) + + op.create_table( + "locations", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("type", sa.String(50)), + sa.Column("address", sa.Text), + sa.Column("city", sa.String(100)), + sa.Column("country", sa.String(100)), + sa.Column("latitude", sa.Numeric(9, 6)), + sa.Column("longitude", sa.Numeric(9, 6)), + sa.Column("parent_id", sa.Integer, sa.ForeignKey("locations.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "account_types", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(100), unique=True, nullable=False), + sa.Column("icon", sa.String(50)), + sa.Column("description", sa.Text), + ) + + op.create_table( + "accounts", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("account_type_id", sa.Integer, sa.ForeignKey("account_types.id"), nullable=False), + sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False), + sa.Column("balance", sa.Numeric(20, 2), default=0, nullable=False), + sa.Column("initial_balance", sa.Numeric(20, 2), default=0, nullable=False), + sa.Column("is_active", sa.Boolean, default=True, nullable=False), + sa.Column("credit_limit", sa.Numeric(20, 2)), + sa.Column("description", sa.Text), + sa.Column("icon", sa.String(50)), + sa.Column("color", sa.String(7)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_accounts_user_id", "accounts", ["user_id"]) + + op.create_table( + "categories", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("type", sa.String(20), nullable=False), + sa.Column("parent_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True), + sa.Column("icon", sa.String(50)), + sa.Column("color", sa.String(7)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_categories_user_id", "categories", ["user_id"]) + + op.create_table( + "budgets", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=False), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("amount", sa.Numeric(20, 2), nullable=False), + sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False), + sa.Column("period_type", sa.String(20), nullable=False), + sa.Column("start_date", sa.Date, nullable=False), + sa.Column("end_date", sa.Date), + sa.Column("is_active", sa.Boolean, default=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_budgets_user_id", "budgets", ["user_id"]) + + op.create_table( + "operation_types", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(50), unique=True, nullable=False), + sa.Column("description", sa.Text), + ) + + op.create_table( + "operations", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("account_id", sa.Integer, sa.ForeignKey("accounts.id"), nullable=False), + sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True), + sa.Column("operation_type_id", sa.Integer, sa.ForeignKey("operation_types.id"), nullable=False), + sa.Column("amount", sa.Numeric(20, 2), nullable=False), + sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False), + sa.Column("amount_converted", sa.Numeric(20, 2)), + sa.Column("exchange_rate", sa.Numeric(20, 6)), + sa.Column("date", sa.DateTime(timezone=True), nullable=False), + sa.Column("description", sa.Text), + sa.Column("location_id", sa.Integer, sa.ForeignKey("locations.id"), nullable=True), + sa.Column("is_planned", sa.Boolean, default=False, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_operations_user_id", "operations", ["user_id"]) + op.create_index("ix_operations_account_id", "operations", ["account_id"]) + op.create_index("ix_operations_date", "operations", ["date"]) + + op.create_table( + "operation_relations", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("operation_from_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=False), + sa.Column("operation_to_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=False), + sa.Column("relation_type", sa.String(50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + op.create_table( + "calendar_events", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text), + sa.Column("event_date", sa.Date, nullable=False), + sa.Column("event_time", sa.Time), + sa.Column("is_recurring", sa.Boolean, default=False, nullable=False), + sa.Column("recurrence_pattern", sa.String(50)), + sa.Column("recurrence_end_date", sa.Date), + sa.Column("related_operation_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=True), + sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True), + sa.Column("amount", sa.Numeric(20, 2)), + sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=True), + sa.Column("reminder_days", sa.Integer), + sa.Column("is_completed", sa.Boolean, default=False, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_calendar_events_user_id", "calendar_events", ["user_id"]) + op.create_index("ix_calendar_events_event_date", "calendar_events", ["event_date"]) + + # Grant permissions to app user (runs only if the role exists) + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'finance_app_user') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO finance_app_user; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO finance_app_user; + END IF; + END$$; + """) + + +def downgrade() -> None: + for table in [ + "calendar_events", "operation_relations", "operations", "operation_types", + "budgets", "categories", "accounts", "account_types", + "locations", "exchange_rates", "currencies", "users", "roles", + ]: + op.drop_table(table) 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/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/accounts.py b/backend/app/api/v1/accounts.py new file mode 100644 index 0000000..2cb79af --- /dev/null +++ b/backend/app/api/v1/accounts.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.account import Account, AccountType +from app.models.user import User +from app.schemas.account import AccountCreate, AccountUpdate, AccountOut, AccountTypeOut + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.get("/types", response_model=list[AccountTypeOut]) +def list_types(db: Session = Depends(get_db), _=Depends(get_current_user)): + return db.query(AccountType).order_by(AccountType.name).all() + + +@router.get("", response_model=list[AccountOut]) +def list_accounts(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + return db.query(Account).filter(Account.user_id == current_user.id).order_by(Account.name).all() + + +@router.get("/{account_id}", response_model=AccountOut) +def get_account(account_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Account, account_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Account not found") + return obj + + +@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED) +def create_account(body: AccountCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = Account( + **body.model_dump(), + user_id=current_user.id, + balance=body.initial_balance, + ) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.patch("/{account_id}", response_model=AccountOut) +def update_account( + account_id: int, body: AccountUpdate, + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(Account, account_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Account not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_account(account_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Account, account_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Account not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..a6bcca2 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from jose import JWTError +from sqlalchemy.orm import Session +from app.auth.security import verify_password, create_access_token, create_refresh_token, decode_token +from app.database import get_db +from app.models.user import User, Role +from app.schemas.auth import Token, LoginRequest +from app.schemas.user import UserCreate, UserOut + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _authenticate(db: Session, username: str, password: str) -> User: + user = db.query(User).filter( + (User.username == username) | (User.email == username) + ).first() + if not user or not verify_password(password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled") + return user + + +@router.post("/login", response_model=Token) +def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = _authenticate(db, form.username, form.password) + return Token( + access_token=create_access_token(user.id, user.role.name), + refresh_token=create_refresh_token(user.id), + ) + + +@router.post("/login/json", response_model=Token) +def login_json(body: LoginRequest, db: Session = Depends(get_db)): + user = _authenticate(db, body.username, body.password) + return Token( + access_token=create_access_token(user.id, user.role.name), + refresh_token=create_refresh_token(user.id), + ) + + +@router.post("/refresh", response_model=Token) +def refresh(refresh_token: str, db: Session = Depends(get_db)): + try: + payload = decode_token(refresh_token) + if payload.get("type") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + user = db.get(User, int(payload["sub"])) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token") + return Token( + access_token=create_access_token(user.id, user.role.name), + refresh_token=create_refresh_token(user.id), + ) + + +@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def register(body: UserCreate, db: Session = Depends(get_db)): + from app.auth.security import hash_password + if db.query(User).filter(User.username == body.username).first(): + raise HTTPException(status_code=400, detail="Username already taken") + if db.query(User).filter(User.email == body.email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + role = db.get(Role, body.role_id) or db.query(Role).filter(Role.name == "user").first() + user = User( + username=body.username, + email=body.email, + password_hash=hash_password(body.password), + role_id=role.id, + ) + db.add(user) + db.commit() + db.refresh(user) + return user diff --git a/backend/app/api/v1/budgets.py b/backend/app/api/v1/budgets.py new file mode 100644 index 0000000..aa9edb4 --- /dev/null +++ b/backend/app/api/v1/budgets.py @@ -0,0 +1,80 @@ +from datetime import date +from decimal import Decimal +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.budget import Budget +from app.models.operation import Operation +from app.models.user import User +from app.schemas.budget import BudgetCreate, BudgetUpdate, BudgetOut + +router = APIRouter(prefix="/budgets", tags=["budgets"]) + + +def _compute_spent(db: Session, budget: Budget, current_user: User) -> Decimal: + period_start = budget.start_date + period_end = budget.end_date or date.today() + total = db.query(func.sum(Operation.amount)).filter( + Operation.user_id == current_user.id, + Operation.category_id == budget.category_id, + func.date(Operation.date) >= period_start, + func.date(Operation.date) <= period_end, + Operation.is_planned == False, + ).scalar() or Decimal("0") + return total + + +@router.get("", response_model=list[BudgetOut]) +def list_budgets(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + budgets = db.query(Budget).filter(Budget.user_id == current_user.id).all() + result = [] + for b in budgets: + out = BudgetOut.model_validate(b) + out.spent = _compute_spent(db, b, current_user) + result.append(out) + return result + + +@router.get("/{budget_id}", response_model=BudgetOut) +def get_budget(budget_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Budget, budget_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Budget not found") + out = BudgetOut.model_validate(obj) + out.spent = _compute_spent(db, obj, current_user) + return out + + +@router.post("", response_model=BudgetOut, status_code=status.HTTP_201_CREATED) +def create_budget(body: BudgetCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = Budget(**body.model_dump(), user_id=current_user.id) + db.add(obj) + db.commit() + db.refresh(obj) + return BudgetOut.model_validate(obj) + + +@router.patch("/{budget_id}", response_model=BudgetOut) +def update_budget( + budget_id: int, body: BudgetUpdate, + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(Budget, budget_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Budget not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return BudgetOut.model_validate(obj) + + +@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_budget(budget_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Budget, budget_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Budget not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/calendar.py b/backend/app/api/v1/calendar.py new file mode 100644 index 0000000..dcf7f2f --- /dev/null +++ b/backend/app/api/v1/calendar.py @@ -0,0 +1,73 @@ +from datetime import date +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.calendar import CalendarEvent +from app.models.user import User +from app.schemas.calendar import CalendarEventCreate, CalendarEventUpdate, CalendarEventOut + +router = APIRouter(prefix="/calendar", tags=["calendar"]) + + +@router.get("", response_model=list[CalendarEventOut]) +def list_events( + date_from: date | None = None, + date_to: date | None = None, + is_completed: bool | None = None, + category_id: int | None = None, + limit: int = Query(100, le=500), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = db.query(CalendarEvent).filter(CalendarEvent.user_id == current_user.id) + if date_from: + q = q.filter(CalendarEvent.event_date >= date_from) + if date_to: + q = q.filter(CalendarEvent.event_date <= date_to) + if is_completed is not None: + q = q.filter(CalendarEvent.is_completed == is_completed) + if category_id: + q = q.filter(CalendarEvent.category_id == category_id) + return q.order_by(CalendarEvent.event_date, CalendarEvent.event_time).limit(limit).all() + + +@router.get("/{event_id}", response_model=CalendarEventOut) +def get_event(event_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(CalendarEvent, event_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Event not found") + return obj + + +@router.post("", response_model=CalendarEventOut, status_code=status.HTTP_201_CREATED) +def create_event(body: CalendarEventCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = CalendarEvent(**body.model_dump(), user_id=current_user.id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.patch("/{event_id}", response_model=CalendarEventOut) +def update_event( + event_id: int, body: CalendarEventUpdate, + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(CalendarEvent, event_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Event not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_event(event_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(CalendarEvent, event_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Event not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/categories.py b/backend/app/api/v1/categories.py new file mode 100644 index 0000000..4466961 --- /dev/null +++ b/backend/app/api/v1/categories.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.category import Category +from app.models.user import User +from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryOut + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.get("", response_model=list[CategoryOut]) +def list_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + return db.query(Category).filter( + (Category.user_id == current_user.id) | (Category.user_id == None) + ).order_by(Category.type, Category.name).all() + + +@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) +def create_category(body: CategoryCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = Category(**body.model_dump(), user_id=current_user.id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.patch("/{category_id}", response_model=CategoryOut) +def update_category( + category_id: int, body: CategoryUpdate, + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(Category, category_id) + if not obj or (obj.user_id and obj.user_id != current_user.id): + raise HTTPException(status_code=404, detail="Category not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_category(category_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Category, category_id) + if not obj or (obj.user_id and obj.user_id != current_user.id): + raise HTTPException(status_code=404, detail="Category not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/currencies.py b/backend/app/api/v1/currencies.py new file mode 100644 index 0000000..646732b --- /dev/null +++ b/backend/app/api/v1/currencies.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user, require_role +from app.database import get_db +from app.models.currency import Currency +from app.schemas.currency import CurrencyCreate, CurrencyUpdate, CurrencyOut + +router = APIRouter(prefix="/currencies", tags=["currencies"]) + +require_accountant = require_role("accountant") + + +@router.get("", response_model=list[CurrencyOut]) +def list_currencies(db: Session = Depends(get_db), _=Depends(get_current_user)): + return db.query(Currency).order_by(Currency.code).all() + + +@router.get("/{currency_id}", response_model=CurrencyOut) +def get_currency(currency_id: int, db: Session = Depends(get_db), _=Depends(get_current_user)): + obj = db.get(Currency, currency_id) + if not obj: + raise HTTPException(status_code=404, detail="Currency not found") + return obj + + +@router.post("", response_model=CurrencyOut, status_code=status.HTTP_201_CREATED) +def create_currency(body: CurrencyCreate, db: Session = Depends(get_db), _=Depends(require_accountant)): + if db.query(Currency).filter(Currency.code == body.code.upper()).first(): + raise HTTPException(status_code=400, detail="Currency code already exists") + obj = Currency(**body.model_dump(), code=body.code.upper()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.patch("/{currency_id}", response_model=CurrencyOut) +def update_currency(currency_id: int, body: CurrencyUpdate, db: Session = Depends(get_db), _=Depends(require_accountant)): + obj = db.get(Currency, currency_id) + if not obj: + raise HTTPException(status_code=404, detail="Currency not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{currency_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_currency(currency_id: int, db: Session = Depends(get_db), _=Depends(require_accountant)): + obj = db.get(Currency, currency_id) + if not obj: + raise HTTPException(status_code=404, detail="Currency not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/exchange_rates.py b/backend/app/api/v1/exchange_rates.py new file mode 100644 index 0000000..685ef26 --- /dev/null +++ b/backend/app/api/v1/exchange_rates.py @@ -0,0 +1,61 @@ +from datetime import date +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user, require_role +from app.database import get_db +from app.models.currency import ExchangeRate +from app.schemas.currency import ExchangeRateCreate, ExchangeRateOut + +router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"]) + +require_accountant = require_role("accountant") + + +@router.get("", response_model=list[ExchangeRateOut]) +def list_rates( + currency_from_id: int | None = None, + currency_to_id: int | None = None, + rate_date: date | None = None, + limit: int = Query(50, le=500), + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + q = db.query(ExchangeRate) + if currency_from_id: + q = q.filter(ExchangeRate.currency_from_id == currency_from_id) + if currency_to_id: + q = q.filter(ExchangeRate.currency_to_id == currency_to_id) + if rate_date: + q = q.filter(ExchangeRate.rate_date == rate_date) + return q.order_by(ExchangeRate.rate_date.desc()).limit(limit).all() + + +@router.post("", response_model=ExchangeRateOut, status_code=status.HTTP_201_CREATED) +def create_rate(body: ExchangeRateCreate, db: Session = Depends(get_db), _=Depends(require_accountant)): + if body.currency_from_id == body.currency_to_id: + raise HTTPException(status_code=400, detail="Cannot set rate between the same currency") + existing = db.query(ExchangeRate).filter( + ExchangeRate.currency_from_id == body.currency_from_id, + ExchangeRate.currency_to_id == body.currency_to_id, + ExchangeRate.rate_date == body.rate_date, + ).first() + if existing: + for field, value in body.model_dump(exclude_none=True).items(): + setattr(existing, field, value) + db.commit() + db.refresh(existing) + return existing + obj = ExchangeRate(**body.model_dump()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{rate_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_rate(rate_id: int, db: Session = Depends(get_db), _=Depends(require_accountant)): + obj = db.get(ExchangeRate, rate_id) + if not obj: + raise HTTPException(status_code=404, detail="Exchange rate not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/locations.py b/backend/app/api/v1/locations.py new file mode 100644 index 0000000..8bf8f55 --- /dev/null +++ b/backend/app/api/v1/locations.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.location import Location +from app.models.user import User +from app.schemas.location import LocationCreate, LocationUpdate, LocationOut + +router = APIRouter(prefix="/locations", tags=["locations"]) + + +@router.get("", response_model=list[LocationOut]) +def list_locations(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + return db.query(Location).filter( + (Location.user_id == current_user.id) | (Location.user_id == None) + ).order_by(Location.name).all() + + +@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED) +def create_location(body: LocationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = Location(**body.model_dump(), user_id=current_user.id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.patch("/{location_id}", response_model=LocationOut) +def update_location( + location_id: int, body: LocationUpdate, + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(Location, location_id) + if not obj or (obj.user_id and obj.user_id != current_user.id and current_user.role.name != "admin"): + raise HTTPException(status_code=404, detail="Location not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_location(location_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Location, location_id) + if not obj or (obj.user_id and obj.user_id != current_user.id and current_user.role.name != "admin"): + raise HTTPException(status_code=404, detail="Location not found") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/operations.py b/backend/app/api/v1/operations.py new file mode 100644 index 0000000..1a9ddd7 --- /dev/null +++ b/backend/app/api/v1/operations.py @@ -0,0 +1,163 @@ +from datetime import datetime +from decimal import Decimal +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.account import Account +from app.models.operation import Operation, OperationType, OperationRelation +from app.models.user import User +from app.schemas.operation import ( + OperationCreate, OperationUpdate, OperationOut, + OperationTypeOut, TransferCreate, + OperationRelationOut, +) + +router = APIRouter(prefix="/operations", tags=["operations"]) + + +def _assert_account_ownership(db: Session, account_id: int, user_id: int) -> Account: + account = db.get(Account, account_id) + if not account or account.user_id != user_id: + raise HTTPException(status_code=404, detail="Account not found") + return account + + +def _apply_balance(account: Account, op_type_name: str, amount: Decimal, sign: int = 1): + if op_type_name == "income": + account.balance += amount * sign + elif op_type_name == "expense": + account.balance -= amount * sign + + +@router.get("/types", response_model=list[OperationTypeOut]) +def list_types(db: Session = Depends(get_db), _=Depends(get_current_user)): + return db.query(OperationType).all() + + +@router.get("", response_model=list[OperationOut]) +def list_operations( + account_id: int | None = None, + category_id: int | None = None, + operation_type_id: int | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + is_planned: bool | None = None, + limit: int = Query(100, le=1000), + offset: int = 0, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = db.query(Operation).filter(Operation.user_id == current_user.id) + if account_id: + q = q.filter(Operation.account_id == account_id) + if category_id: + q = q.filter(Operation.category_id == category_id) + if operation_type_id: + q = q.filter(Operation.operation_type_id == operation_type_id) + if date_from: + q = q.filter(Operation.date >= date_from) + if date_to: + q = q.filter(Operation.date <= date_to) + if is_planned is not None: + q = q.filter(Operation.is_planned == is_planned) + return q.order_by(Operation.date.desc()).offset(offset).limit(limit).all() + + +@router.get("/{operation_id}", response_model=OperationOut) +def get_operation(operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + obj = db.get(Operation, operation_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Operation not found") + return obj + + +@router.post("", response_model=OperationOut, status_code=status.HTTP_201_CREATED) +def create_operation( + body: OperationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + account = _assert_account_ownership(db, body.account_id, current_user.id) + op_type = db.get(OperationType, body.operation_type_id) + if not op_type: + raise HTTPException(status_code=400, detail="Invalid operation type") + obj = Operation(**body.model_dump(), user_id=current_user.id) + db.add(obj) + _apply_balance(account, op_type.name, body.amount) + db.commit() + db.refresh(obj) + return obj + + +@router.post("/transfer", response_model=list[OperationOut], status_code=status.HTTP_201_CREATED) +def create_transfer( + body: TransferCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + from_account = _assert_account_ownership(db, body.from_account_id, current_user.id) + to_account = _assert_account_ownership(db, body.to_account_id, current_user.id) + transfer_type = db.query(OperationType).filter(OperationType.name == "transfer").first() + if not transfer_type: + raise HTTPException(status_code=500, detail="Transfer operation type not configured") + + op_out = Operation( + user_id=current_user.id, account_id=body.from_account_id, + operation_type_id=transfer_type.id, amount=body.amount, + currency_id=body.currency_id, exchange_rate=body.exchange_rate, + amount_converted=body.amount_converted, date=body.date, + description=body.description, location_id=body.location_id, + ) + amount_in = body.amount_converted or body.amount + op_in = Operation( + user_id=current_user.id, account_id=body.to_account_id, + operation_type_id=transfer_type.id, amount=amount_in, + currency_id=to_account.currency_id, date=body.date, + description=body.description, location_id=body.location_id, + ) + from_account.balance -= body.amount + to_account.balance += amount_in + db.add_all([op_out, op_in]) + db.flush() + + relation = OperationRelation( + operation_from_id=op_out.id, operation_to_id=op_in.id, relation_type="transfer" + ) + db.add(relation) + db.commit() + db.refresh(op_out) + db.refresh(op_in) + return [op_out, op_in] + + +@router.patch("/{operation_id}", response_model=OperationOut) +def update_operation( + operation_id: int, body: OperationUpdate, + db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(Operation, operation_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Operation not found") + + if body.amount is not None and body.amount != obj.amount: + account = db.get(Account, obj.account_id) + op_type = db.get(OperationType, obj.operation_type_id) + _apply_balance(account, op_type.name, obj.amount, sign=-1) + _apply_balance(account, op_type.name, body.amount, sign=1) + + for field, value in body.model_dump(exclude_none=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{operation_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_operation( + operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(Operation, operation_id) + if not obj or obj.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Operation not found") + account = db.get(Account, obj.account_id) + op_type = db.get(OperationType, obj.operation_type_id) + _apply_balance(account, op_type.name, obj.amount, sign=-1) + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/relations.py b/backend/app/api/v1/relations.py new file mode 100644 index 0000000..71f593e --- /dev/null +++ b/backend/app/api/v1/relations.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user +from app.database import get_db +from app.models.operation import Operation, OperationRelation +from app.models.user import User +from app.schemas.operation import OperationRelationCreate, OperationRelationOut + +router = APIRouter(prefix="/operation-relations", tags=["operation-relations"]) + + +@router.get("/{operation_id}", response_model=list[OperationRelationOut]) +def list_relations( + operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + op = db.get(Operation, operation_id) + if not op or op.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Operation not found") + return db.query(OperationRelation).filter( + (OperationRelation.operation_from_id == operation_id) | + (OperationRelation.operation_to_id == operation_id) + ).all() + + +@router.post("", response_model=OperationRelationOut, status_code=status.HTTP_201_CREATED) +def create_relation( + body: OperationRelationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + for op_id in (body.operation_from_id, body.operation_to_id): + op = db.get(Operation, op_id) + if not op or op.user_id != current_user.id: + raise HTTPException(status_code=404, detail=f"Operation {op_id} not found") + obj = OperationRelation(**body.model_dump()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{relation_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_relation( + relation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +): + obj = db.get(OperationRelation, relation_id) + if not obj: + raise HTTPException(status_code=404, detail="Relation not found") + op = db.get(Operation, obj.operation_from_id) + if not op or op.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + db.delete(obj) + db.commit() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..1f44efd --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter +from app.api.v1 import auth, users, currencies, exchange_rates, locations, accounts, categories, budgets, operations, relations, calendar + +router = APIRouter() +router.include_router(auth.router) +router.include_router(users.router) +router.include_router(currencies.router) +router.include_router(exchange_rates.router) +router.include_router(locations.router) +router.include_router(accounts.router) +router.include_router(categories.router) +router.include_router(budgets.router) +router.include_router(operations.router) +router.include_router(relations.router) +router.include_router(calendar.router) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..f008937 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_current_user, require_role +from app.auth.security import hash_password +from app.database import get_db +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate, UserOut, UserWithRole + +router = APIRouter(prefix="/users", tags=["users"]) + +require_admin = require_role("admin") + + +@router.get("/me", response_model=UserWithRole) +def get_me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.get("", response_model=list[UserWithRole]) +def list_users(db: Session = Depends(get_db), _=Depends(require_admin)): + return db.query(User).all() + + +@router.get("/{user_id}", response_model=UserWithRole) +def get_user(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserOut) +def update_user(user_id: int, body: UserUpdate, db: Session = Depends(get_db), _=Depends(require_admin)): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + for field, value in body.model_dump(exclude_none=True).items(): + setattr(user, field, value) + db.commit() + db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_admin)): + if user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + db.delete(user) + db.commit() diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000..8311af7 --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -0,0 +1,54 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError +from sqlalchemy.orm import Session +from app.auth.security import decode_token +from app.database import get_db +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +ROLES = {"admin": 4, "accountant": 3, "user": 2, "viewer": 1} + + +def _get_current_user(token: str, db: Session) -> User: + credentials_exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(token) + if payload.get("type") != "access": + raise credentials_exc + user_id = int(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise credentials_exc + + user = db.get(User, user_id) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User inactive or not found") + return user + + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + return _get_current_user(token, db) + + +def require_role(minimum_role: str): + """Dependency factory — blocks users whose role rank is below minimum_role.""" + min_rank = ROLES[minimum_role] + + def _check(current_user: User = Depends(get_current_user)) -> User: + user_rank = ROLES.get(current_user.role.name, 0) + if user_rank < min_rank: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return current_user + + return _check + + +require_viewer = Depends(require_role("viewer")) +require_user = Depends(require_role("user")) +require_accountant = Depends(require_role("accountant")) +require_admin = Depends(require_role("admin")) diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py new file mode 100644 index 0000000..16ceb1a --- /dev/null +++ b/backend/app/auth/security.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta, timezone +from typing import Any +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.config import get_settings + +settings = get_settings() + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def _create_token(data: dict[str, Any], expires_delta: timedelta) -> str: + payload = data.copy() + payload["exp"] = datetime.now(timezone.utc) + expires_delta + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def create_access_token(user_id: int, role: str) -> str: + return _create_token( + {"sub": str(user_id), "role": role, "type": "access"}, + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + + +def create_refresh_token(user_id: int) -> str: + return _create_token( + {"sub": str(user_id), "type": "refresh"}, + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), + ) + + +def decode_token(token: str) -> dict[str, Any]: + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..7e604bb --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,25 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + DEBUG: bool = False + API_V1_PREFIX: str = "/api/v1" + ALLOWED_ORIGINS: str = "http://localhost:3000" + + @property + def origins_list(self) -> list[str]: + return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")] + + class Config: + env_file = ".env" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..51993aa --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.config import get_settings + +settings = get_settings() + +_connect_args: dict = {} +if settings.DATABASE_URL.startswith("postgresql"): + _connect_args = {"sslmode": "prefer"} + +engine = create_engine( + settings.DATABASE_URL, + connect_args=_connect_args, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + pool_recycle=3600, +) + + +class Base(DeclarativeBase): + pass + + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5930d22 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import get_settings +from app.api.v1.router import router as api_router + +settings = get_settings() + +app = FastAPI( + title="Finance App API", + description="Personal finance management inspired by 1C:Money", + version="1.0.0", + docs_url="/docs" if settings.DEBUG else None, + redoc_url="/redoc" if settings.DEBUG else None, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix=settings.API_V1_PREFIX) + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..b6d8b5d --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +from app.models.user import Role, User +from app.models.currency import Currency, ExchangeRate +from app.models.location import Location +from app.models.account import AccountType, Account +from app.models.category import Category +from app.models.budget import Budget +from app.models.operation import OperationType, Operation, OperationRelation +from app.models.calendar import CalendarEvent + +__all__ = [ + "Role", "User", + "Currency", "ExchangeRate", + "Location", + "AccountType", "Account", + "Category", + "Budget", + "OperationType", "Operation", "OperationRelation", + "CalendarEvent", +] diff --git a/backend/app/models/account.py b/backend/app/models/account.py new file mode 100644 index 0000000..5623074 --- /dev/null +++ b/backend/app/models/account.py @@ -0,0 +1,42 @@ +from datetime import datetime +from decimal import Decimal +from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class AccountType(Base): + __tablename__ = "account_types" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + icon: Mapped[str | None] = mapped_column(String(50)) + description: Mapped[str | None] = mapped_column(Text) + + accounts: Mapped[list["Account"]] = relationship("Account", back_populates="account_type") + + +class Account(Base): + __tablename__ = "accounts" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + account_type_id: Mapped[int] = mapped_column(ForeignKey("account_types.id"), nullable=False) + currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False) + balance: Mapped[Decimal] = mapped_column(Numeric(20, 2), default=0, nullable=False) + initial_balance: Mapped[Decimal] = mapped_column(Numeric(20, 2), default=0, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(20, 2)) + description: Mapped[str | None] = mapped_column(Text) + icon: Mapped[str | None] = mapped_column(String(50)) + color: Mapped[str | None] = mapped_column(String(7)) # hex #RRGGBB + 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() + ) + + user: Mapped["User"] = relationship("User", back_populates="accounts") + account_type: Mapped["AccountType"] = relationship("AccountType", back_populates="accounts") + currency: Mapped["Currency"] = relationship("Currency", back_populates="accounts") + operations: Mapped[list] = relationship("Operation", back_populates="account") diff --git a/backend/app/models/budget.py b/backend/app/models/budget.py new file mode 100644 index 0000000..aba6e91 --- /dev/null +++ b/backend/app/models/budget.py @@ -0,0 +1,25 @@ +from datetime import datetime, date +from decimal import Decimal +from sqlalchemy import String, Boolean, Numeric, ForeignKey, Date, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class Budget(Base): + __tablename__ = "budgets" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False) + currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False) + period_type: Mapped[str] = mapped_column(String(20), nullable=False) # monthly | weekly | yearly | custom + start_date: Mapped[date] = mapped_column(Date, nullable=False) + end_date: Mapped[date | None] = mapped_column(Date) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped["User"] = relationship("User", back_populates="budgets") + category: Mapped["Category"] = relationship("Category", back_populates="budgets") + currency: Mapped["Currency"] = relationship("Currency", back_populates="budgets") diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py new file mode 100644 index 0000000..cdc83ac --- /dev/null +++ b/backend/app/models/calendar.py @@ -0,0 +1,34 @@ +from datetime import datetime, date, time +from decimal import Decimal +from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, Date, Time, DateTime, Integer, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class CalendarEvent(Base): + __tablename__ = "calendar_events" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + event_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + event_time: Mapped[time | None] = mapped_column(Time) + is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + recurrence_pattern: Mapped[str | None] = mapped_column(String(50)) # daily | weekly | monthly | yearly + recurrence_end_date: Mapped[date | None] = mapped_column(Date) + related_operation_id: Mapped[int | None] = mapped_column(ForeignKey("operations.id"), nullable=True) + category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True) + amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2)) + currency_id: Mapped[int | None] = mapped_column(ForeignKey("currencies.id"), nullable=True) + reminder_days: Mapped[int | None] = mapped_column(Integer) + is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + 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() + ) + + user: Mapped["User"] = relationship("User", back_populates="calendar_events") + related_operation: Mapped["Operation | None"] = relationship("Operation", back_populates="calendar_events") + category: Mapped["Category | None"] = relationship("Category", back_populates="calendar_events") + currency: Mapped["Currency | None"] = relationship("Currency", back_populates="calendar_events") diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..99bd550 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,26 @@ +from datetime import datetime +from sqlalchemy import String, ForeignKey, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + type: Mapped[str] = mapped_column(String(20), nullable=False) # income | expense | transfer + parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True) + icon: Mapped[str | None] = mapped_column(String(50)) + color: Mapped[str | None] = mapped_column(String(7)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped["User | None"] = relationship("User", back_populates="categories") + parent: Mapped["Category | None"] = relationship( + "Category", remote_side="Category.id", back_populates="children" + ) + children: Mapped[list["Category"]] = relationship("Category", back_populates="parent") + operations: Mapped[list] = relationship("Operation", back_populates="category") + budgets: Mapped[list] = relationship("Budget", back_populates="category") + calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="category") diff --git a/backend/app/models/currency.py b/backend/app/models/currency.py new file mode 100644 index 0000000..07a1fac --- /dev/null +++ b/backend/app/models/currency.py @@ -0,0 +1,49 @@ +from datetime import datetime, date +from decimal import Decimal +from sqlalchemy import String, Boolean, Numeric, ForeignKey, Date, DateTime, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class Currency(Base): + __tablename__ = "currencies" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String(3), unique=True, nullable=False) # ISO 4217 + name: Mapped[str] = mapped_column(String(100), nullable=False) + symbol: Mapped[str | None] = mapped_column(String(10)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + exchange_rates_from: Mapped[list["ExchangeRate"]] = relationship( + "ExchangeRate", foreign_keys="ExchangeRate.currency_from_id", back_populates="currency_from" + ) + exchange_rates_to: Mapped[list["ExchangeRate"]] = relationship( + "ExchangeRate", foreign_keys="ExchangeRate.currency_to_id", back_populates="currency_to" + ) + accounts: Mapped[list] = relationship("Account", back_populates="currency") + operations: Mapped[list] = relationship("Operation", back_populates="currency") + budgets: Mapped[list] = relationship("Budget", back_populates="currency") + calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="currency") + + +class ExchangeRate(Base): + __tablename__ = "exchange_rates" + __table_args__ = ( + UniqueConstraint("currency_from_id", "currency_to_id", "rate_date", name="uq_exchange_rate_day"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + currency_from_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False) + currency_to_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False) + rate: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False) + rate_date: Mapped[date] = mapped_column(Date, nullable=False) + source: Mapped[str | None] = mapped_column(String(100)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + currency_from: Mapped["Currency"] = relationship( + "Currency", foreign_keys=[currency_from_id], back_populates="exchange_rates_from" + ) + currency_to: Mapped["Currency"] = relationship( + "Currency", foreign_keys=[currency_to_id], back_populates="exchange_rates_to" + ) diff --git a/backend/app/models/location.py b/backend/app/models/location.py new file mode 100644 index 0000000..4266542 --- /dev/null +++ b/backend/app/models/location.py @@ -0,0 +1,26 @@ +from datetime import datetime +from decimal import Decimal +from sqlalchemy import String, Text, Numeric, ForeignKey, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class Location(Base): + __tablename__ = "locations" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) # None = system location + name: Mapped[str] = mapped_column(String(200), nullable=False) + type: Mapped[str | None] = mapped_column(String(50)) # store, bank, atm, online, other + address: Mapped[str | None] = mapped_column(Text) + city: Mapped[str | None] = mapped_column(String(100)) + country: Mapped[str | None] = mapped_column(String(100)) + latitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6)) + longitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6)) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + user: Mapped["User | None"] = relationship("User", back_populates="locations") + parent: Mapped["Location | None"] = relationship("Location", remote_side="Location.id", back_populates="children") + children: Mapped[list["Location"]] = relationship("Location", back_populates="parent") + operations: Mapped[list] = relationship("Operation", back_populates="location") diff --git a/backend/app/models/operation.py b/backend/app/models/operation.py new file mode 100644 index 0000000..bca9803 --- /dev/null +++ b/backend/app/models/operation.py @@ -0,0 +1,70 @@ +from datetime import datetime +from decimal import Decimal +from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, DateTime, func, ARRAY +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class OperationType(Base): + __tablename__ = "operation_types" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # income | expense | transfer + description: Mapped[str | None] = mapped_column(Text) + + operations: Mapped[list["Operation"]] = relationship("Operation", back_populates="operation_type") + + +class Operation(Base): + __tablename__ = "operations" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False, index=True) + category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True) + operation_type_id: Mapped[int] = mapped_column(ForeignKey("operation_types.id"), nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False) + currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False) + amount_converted: Mapped[Decimal | None] = mapped_column(Numeric(20, 2)) # in account currency + exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(20, 6)) + date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text) + location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True) + is_planned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + 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() + ) + + user: Mapped["User"] = relationship("User", back_populates="operations") + account: Mapped["Account"] = relationship("Account", back_populates="operations") + category: Mapped["Category | None"] = relationship("Category", back_populates="operations") + operation_type: Mapped["OperationType"] = relationship("OperationType", back_populates="operations") + currency: Mapped["Currency"] = relationship("Currency", back_populates="operations") + location: Mapped["Location | None"] = relationship("Location", back_populates="operations") + + relations_from: Mapped[list["OperationRelation"]] = relationship( + "OperationRelation", foreign_keys="OperationRelation.operation_from_id", back_populates="operation_from" + ) + relations_to: Mapped[list["OperationRelation"]] = relationship( + "OperationRelation", foreign_keys="OperationRelation.operation_to_id", back_populates="operation_to" + ) + calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="related_operation") + + +class OperationRelation(Base): + """Links two operations — e.g., the debit and credit legs of a transfer.""" + __tablename__ = "operation_relations" + + id: Mapped[int] = mapped_column(primary_key=True) + operation_from_id: Mapped[int] = mapped_column(ForeignKey("operations.id"), nullable=False) + operation_to_id: Mapped[int] = mapped_column(ForeignKey("operations.id"), nullable=False) + relation_type: Mapped[str] = mapped_column(String(50), nullable=False) # transfer | split | correction + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + operation_from: Mapped["Operation"] = relationship( + "Operation", foreign_keys=[operation_from_id], back_populates="relations_from" + ) + operation_to: Mapped["Operation"] = relationship( + "Operation", foreign_keys=[operation_to_id], back_populates="relations_to" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8503068 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,38 @@ +from datetime import datetime +from sqlalchemy import String, Boolean, ForeignKey, DateTime, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + users: Mapped[list["User"]] = relationship("User", back_populates="role") + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + 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() + ) + + role: Mapped["Role"] = relationship("Role", back_populates="users") + accounts: Mapped[list] = relationship("Account", back_populates="user") + operations: Mapped[list] = relationship("Operation", back_populates="user") + budgets: Mapped[list] = relationship("Budget", back_populates="user") + categories: Mapped[list] = relationship("Category", back_populates="user") + calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="user") + locations: Mapped[list] = relationship("Location", back_populates="user") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..6034b43 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,29 @@ +from app.schemas.user import RoleOut, UserCreate, UserUpdate, UserOut, UserWithRole +from app.schemas.currency import CurrencyCreate, CurrencyUpdate, CurrencyOut, ExchangeRateCreate, ExchangeRateOut +from app.schemas.location import LocationCreate, LocationUpdate, LocationOut +from app.schemas.account import AccountTypeOut, AccountCreate, AccountUpdate, AccountOut +from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryOut +from app.schemas.budget import BudgetCreate, BudgetUpdate, BudgetOut +from app.schemas.operation import ( + OperationTypeOut, + OperationCreate, OperationUpdate, OperationOut, + OperationRelationCreate, OperationRelationOut, + TransferCreate, +) +from app.schemas.calendar import CalendarEventCreate, CalendarEventUpdate, CalendarEventOut +from app.schemas.auth import Token, TokenData, LoginRequest + +__all__ = [ + "RoleOut", "UserCreate", "UserUpdate", "UserOut", "UserWithRole", + "CurrencyCreate", "CurrencyUpdate", "CurrencyOut", + "ExchangeRateCreate", "ExchangeRateOut", + "LocationCreate", "LocationUpdate", "LocationOut", + "AccountTypeOut", "AccountCreate", "AccountUpdate", "AccountOut", + "CategoryCreate", "CategoryUpdate", "CategoryOut", + "BudgetCreate", "BudgetUpdate", "BudgetOut", + "OperationTypeOut", + "OperationCreate", "OperationUpdate", "OperationOut", + "OperationRelationCreate", "OperationRelationOut", "TransferCreate", + "CalendarEventCreate", "CalendarEventUpdate", "CalendarEventOut", + "Token", "TokenData", "LoginRequest", +] diff --git a/backend/app/schemas/account.py b/backend/app/schemas/account.py new file mode 100644 index 0000000..7aa6c18 --- /dev/null +++ b/backend/app/schemas/account.py @@ -0,0 +1,53 @@ +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, Field +from app.schemas.currency import CurrencyOut + + +class AccountTypeOut(BaseModel): + id: int + name: str + icon: str | None = None + description: str | None = None + + model_config = {"from_attributes": True} + + +class AccountCreate(BaseModel): + name: str = Field(..., max_length=200) + account_type_id: int + currency_id: int + initial_balance: Decimal = Decimal("0.00") + credit_limit: Decimal | None = None + description: str | None = None + icon: str | None = None + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + + +class AccountUpdate(BaseModel): + name: str | None = Field(None, max_length=200) + is_active: bool | None = None + credit_limit: Decimal | None = None + description: str | None = None + icon: str | None = None + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + + +class AccountOut(BaseModel): + id: int + user_id: int + name: str + account_type_id: int + account_type: AccountTypeOut + currency_id: int + currency: CurrencyOut + balance: Decimal + initial_balance: Decimal + is_active: bool + credit_limit: Decimal | None = None + description: str | None = None + icon: str | None = None + color: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..6c5c762 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + user_id: int | None = None + role: str | None = None diff --git a/backend/app/schemas/budget.py b/backend/app/schemas/budget.py new file mode 100644 index 0000000..c13f893 --- /dev/null +++ b/backend/app/schemas/budget.py @@ -0,0 +1,43 @@ +from datetime import datetime, date +from decimal import Decimal +from pydantic import BaseModel, Field +from app.schemas.category import CategoryOut +from app.schemas.currency import CurrencyOut + + +class BudgetCreate(BaseModel): + category_id: int + name: str = Field(..., max_length=200) + amount: Decimal = Field(..., gt=0) + currency_id: int + period_type: str = Field(..., pattern="^(monthly|weekly|yearly|custom)$") + start_date: date + end_date: date | None = None + is_active: bool = True + + +class BudgetUpdate(BaseModel): + name: str | None = None + amount: Decimal | None = Field(None, gt=0) + period_type: str | None = None + end_date: date | None = None + is_active: bool | None = None + + +class BudgetOut(BaseModel): + id: int + user_id: int + category_id: int + category: CategoryOut + name: str + amount: Decimal + currency_id: int + currency: CurrencyOut + period_type: str + start_date: date + end_date: date | None = None + is_active: bool + created_at: datetime + spent: Decimal | None = None # computed field + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py new file mode 100644 index 0000000..605d2e3 --- /dev/null +++ b/backend/app/schemas/calendar.py @@ -0,0 +1,55 @@ +from datetime import datetime, date, time +from decimal import Decimal +from pydantic import BaseModel, Field + + +class CalendarEventCreate(BaseModel): + title: str = Field(..., max_length=200) + description: str | None = None + event_date: date + event_time: time | None = None + is_recurring: bool = False + recurrence_pattern: str | None = Field(None, pattern="^(daily|weekly|monthly|yearly)$") + recurrence_end_date: date | None = None + related_operation_id: int | None = None + category_id: int | None = None + amount: Decimal | None = None + currency_id: int | None = None + reminder_days: int | None = Field(None, ge=0) + is_completed: bool = False + + +class CalendarEventUpdate(BaseModel): + title: str | None = Field(None, max_length=200) + description: str | None = None + event_date: date | None = None + event_time: time | None = None + is_recurring: bool | None = None + recurrence_pattern: str | None = None + recurrence_end_date: date | None = None + related_operation_id: int | None = None + category_id: int | None = None + amount: Decimal | None = None + reminder_days: int | None = None + is_completed: bool | None = None + + +class CalendarEventOut(BaseModel): + id: int + user_id: int + title: str + description: str | None = None + event_date: date + event_time: time | None = None + is_recurring: bool + recurrence_pattern: str | None = None + recurrence_end_date: date | None = None + related_operation_id: int | None = None + category_id: int | None = None + amount: Decimal | None = None + currency_id: int | None = None + reminder_days: int | None = None + is_completed: bool + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py new file mode 100644 index 0000000..0a1c26b --- /dev/null +++ b/backend/app/schemas/category.py @@ -0,0 +1,30 @@ +from datetime import datetime +from pydantic import BaseModel, Field + + +class CategoryCreate(BaseModel): + name: str = Field(..., max_length=200) + type: str = Field(..., pattern="^(income|expense|transfer)$") + parent_id: int | None = None + icon: str | None = None + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + + +class CategoryUpdate(BaseModel): + name: str | None = Field(None, max_length=200) + parent_id: int | None = None + icon: str | None = None + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + + +class CategoryOut(BaseModel): + id: int + user_id: int | None = None + name: str + type: str + parent_id: int | None = None + icon: str | None = None + color: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/currency.py b/backend/app/schemas/currency.py new file mode 100644 index 0000000..439fa36 --- /dev/null +++ b/backend/app/schemas/currency.py @@ -0,0 +1,48 @@ +from datetime import datetime, date +from decimal import Decimal +from pydantic import BaseModel, Field + + +class CurrencyCreate(BaseModel): + code: str = Field(..., min_length=3, max_length=3) + name: str = Field(..., max_length=100) + symbol: str | None = Field(None, max_length=10) + is_active: bool = True + + +class CurrencyUpdate(BaseModel): + name: str | None = Field(None, max_length=100) + symbol: str | None = Field(None, max_length=10) + is_active: bool | None = None + + +class CurrencyOut(BaseModel): + id: int + code: str + name: str + symbol: str | None = None + is_active: bool + + model_config = {"from_attributes": True} + + +class ExchangeRateCreate(BaseModel): + currency_from_id: int + currency_to_id: int + rate: Decimal = Field(..., gt=0) + rate_date: date + source: str | None = None + + +class ExchangeRateOut(BaseModel): + id: int + currency_from_id: int + currency_to_id: int + rate: Decimal + rate_date: date + source: str | None = None + created_at: datetime + currency_from: CurrencyOut + currency_to: CurrencyOut + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py new file mode 100644 index 0000000..4194dba --- /dev/null +++ b/backend/app/schemas/location.py @@ -0,0 +1,41 @@ +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, Field + + +class LocationCreate(BaseModel): + name: str = Field(..., max_length=200) + type: str | None = Field(None, max_length=50) + address: str | None = None + city: str | None = Field(None, max_length=100) + country: str | None = Field(None, max_length=100) + latitude: Decimal | None = None + longitude: Decimal | None = None + parent_id: int | None = None + + +class LocationUpdate(BaseModel): + name: str | None = Field(None, max_length=200) + type: str | None = None + address: str | None = None + city: str | None = None + country: str | None = None + latitude: Decimal | None = None + longitude: Decimal | None = None + parent_id: int | None = None + + +class LocationOut(BaseModel): + id: int + user_id: int | None = None + name: str + type: str | None = None + address: str | None = None + city: str | None = None + country: str | None = None + latitude: Decimal | None = None + longitude: Decimal | None = None + parent_id: int | None = None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/operation.py b/backend/app/schemas/operation.py new file mode 100644 index 0000000..5d41873 --- /dev/null +++ b/backend/app/schemas/operation.py @@ -0,0 +1,89 @@ +from datetime import datetime +from decimal import Decimal +from pydantic import BaseModel, Field +from app.schemas.currency import CurrencyOut +from app.schemas.category import CategoryOut +from app.schemas.location import LocationOut + + +class OperationTypeOut(BaseModel): + id: int + name: str + description: str | None = None + + model_config = {"from_attributes": True} + + +class OperationCreate(BaseModel): + account_id: int + category_id: int | None = None + operation_type_id: int + amount: Decimal = Field(..., gt=0) + currency_id: int + amount_converted: Decimal | None = None + exchange_rate: Decimal | None = None + date: datetime + description: str | None = None + location_id: int | None = None + is_planned: bool = False + + +class OperationUpdate(BaseModel): + category_id: int | None = None + amount: Decimal | None = Field(None, gt=0) + date: datetime | None = None + description: str | None = None + location_id: int | None = None + is_planned: bool | None = None + + +class OperationOut(BaseModel): + id: int + user_id: int + account_id: int + category_id: int | None = None + category: CategoryOut | None = None + operation_type_id: int + operation_type: OperationTypeOut + amount: Decimal + currency_id: int + currency: CurrencyOut + amount_converted: Decimal | None = None + exchange_rate: Decimal | None = None + date: datetime + description: str | None = None + location_id: int | None = None + location: LocationOut | None = None + is_planned: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class OperationRelationCreate(BaseModel): + operation_from_id: int + operation_to_id: int + relation_type: str = Field(..., pattern="^(transfer|split|correction)$") + + +class OperationRelationOut(BaseModel): + id: int + operation_from_id: int + operation_to_id: int + relation_type: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class TransferCreate(BaseModel): + """Convenience schema: creates two linked operations in one request.""" + from_account_id: int + to_account_id: int + amount: Decimal = Field(..., gt=0) + currency_id: int + amount_converted: Decimal | None = None + exchange_rate: Decimal | None = None + date: datetime + description: str | None = None + location_id: int | None = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..223fe7d --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,39 @@ +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + + +class RoleOut(BaseModel): + id: int + name: str + description: str | None = None + + model_config = {"from_attributes": True} + + +class UserCreate(BaseModel): + username: str = Field(..., min_length=3, max_length=100) + email: EmailStr + password: str = Field(..., min_length=8) + role_id: int = 3 # default: user + + +class UserUpdate(BaseModel): + username: str | None = Field(None, min_length=3, max_length=100) + email: EmailStr | None = None + is_active: bool | None = None + role_id: int | None = None + + +class UserOut(BaseModel): + id: int + username: str + email: str + role_id: int + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserWithRole(UserOut): + role: RoleOut diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9734d9a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +pydantic-settings==2.2.1 +pydantic[email]==2.7.1 +httpx==0.27.0 +python-dotenv==1.0.1 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..d2b15bf --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,121 @@ +"""Seed initial reference data. Run once after migrations: python seed.py""" +import os +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from app.auth.security import hash_password +from app.models import Role, User, Currency, AccountType, OperationType, Category + +engine = create_engine(os.environ["DATABASE_URL"]) + +ROLES = [ + ("admin", "Full access — manages users, all financial data"), + ("accountant", "Manages all financial data, cannot manage users"), + ("user", "Manages own financial data"), + ("viewer", "Read-only access to own data"), +] + +CURRENCIES = [ + ("RUB", "Российский рубль", "₽"), + ("USD", "Доллар США", "$"), + ("EUR", "Евро", "€"), + ("GBP", "Фунт стерлингов", "£"), + ("CNY", "Китайский юань", "¥"), + ("BTC", "Биткоин", "₿"), +] + +ACCOUNT_TYPES = [ + ("Наличные", "wallet", "Наличные деньги"), + ("Банковский счёт", "bank", "Текущий/расчётный счёт"), + ("Кредитная карта", "credit-card", "Кредитная карта"), + ("Накопительный", "piggy-bank", "Вклад / накопительный счёт"), + ("Инвестиционный", "chart-line", "Брокерский или инвест. счёт"), + ("Криптовалюта", "bitcoin", "Криптовалютный кошелёк"), + ("Другое", "dots", "Прочий тип счёта"), +] + +OPERATION_TYPES = [ + ("income", "Доход — пополнение счёта"), + ("expense", "Расход — списание со счёта"), + ("transfer", "Перевод между счетами"), +] + +CATEGORIES = [ + # (name, type, icon, color) + # --- Доходы --- + ("Зарплата", "income", "briefcase", "#4CAF50"), + ("Фриланс", "income", "laptop", "#8BC34A"), + ("Инвестиции", "income", "chart-line", "#00BCD4"), + ("Подарки", "income", "gift", "#E91E63"), + ("Прочие доходы", "income", "plus-circle", "#9E9E9E"), + # --- Расходы --- + ("Продукты", "expense", "shopping-cart","#FF5722"), + ("Транспорт", "expense", "car", "#FF9800"), + ("ЖКХ", "expense", "home", "#795548"), + ("Здоровье", "expense", "heart", "#F44336"), + ("Рестораны и кафе", "expense", "utensils", "#E91E63"), + ("Развлечения", "expense", "gamepad", "#9C27B0"), + ("Одежда", "expense", "tshirt", "#3F51B5"), + ("Образование", "expense", "book", "#2196F3"), + ("Путешествия", "expense", "plane", "#03A9F4"), + ("Связь и интернет", "expense", "wifi", "#009688"), + ("Подписки", "expense", "repeat", "#607D8B"), + ("Прочие расходы", "expense", "minus-circle", "#9E9E9E"), + # --- Переводы --- + ("Переводы", "transfer", "arrows-h", "#607D8B"), +] + + +def seed(): + with Session(engine) as db: + # Roles + role_map: dict[str, Role] = {} + for name, desc in ROLES: + role = db.query(Role).filter(Role.name == name).first() + if not role: + role = Role(name=name, description=desc) + db.add(role) + db.flush() + role_map[name] = role + + # Admin user + if not db.query(User).filter(User.username == "admin").first(): + admin_pwd = os.getenv("ADMIN_PASSWORD", "Admin1234!") + admin = User( + username="admin", + email="admin@finance.local", + password_hash=hash_password(admin_pwd), + role_id=role_map["admin"].id, + ) + db.add(admin) + print(f"[seed] Admin user created. Password from ADMIN_PASSWORD env (default: Admin1234!)") + + # Currencies + for code, name, symbol in CURRENCIES: + if not db.query(Currency).filter(Currency.code == code).first(): + db.add(Currency(code=code, name=name, symbol=symbol)) + + # Account types + for name, icon, desc in ACCOUNT_TYPES: + if not db.query(AccountType).filter(AccountType.name == name).first(): + db.add(AccountType(name=name, icon=icon, description=desc)) + + # Operation types + for name, desc in OPERATION_TYPES: + if not db.query(OperationType).filter(OperationType.name == name).first(): + db.add(OperationType(name=name, description=desc)) + + # System categories (user_id=None) + for name, ctype, icon, color in CATEGORIES: + if not db.query(Category).filter(Category.name == name, Category.user_id == None).first(): + db.add(Category(name=name, type=ctype, icon=icon, color=color, user_id=None)) + + db.commit() + print("[seed] Done.") + + +if __name__ == "__main__": + seed() diff --git a/backend/sql/init_roles.sql b/backend/sql/init_roles.sql new file mode 100644 index 0000000..584dce7 --- /dev/null +++ b/backend/sql/init_roles.sql @@ -0,0 +1,9 @@ +-- PostgreSQL DB-level roles for connection security +-- App uses a single app user; RLS enforces row-level isolation + +CREATE ROLE finance_app_user WITH LOGIN PASSWORD 'CHANGE_IN_ENV' NOSUPERUSER NOCREATEDB NOCREATEROLE; +GRANT CONNECT ON DATABASE financedb TO finance_app_user; +GRANT USAGE ON SCHEMA public TO finance_app_user; + +-- Permissions granted after table creation via Alembic migration +-- See backend/alembic/versions/001_initial_schema.py for GRANT statements diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9108a65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + container_name: finance_db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-financedb} + POSTGRES_USER: ${POSTGRES_USER:-financeuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/sql/init_roles.sql:/docker-entrypoint-initdb.d/01_init_roles.sql:ro + ports: + - "5432:5432" + networks: + - finance_net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-financeuser} -d ${POSTGRES_DB:-financedb}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: finance_backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-financeuser}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-financedb} + SECRET_KEY: ${SECRET_KEY} + ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: 30 + REFRESH_TOKEN_EXPIRE_DAYS: 7 + DEBUG: "false" + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost,http://localhost:3000} + depends_on: + postgres: + condition: service_healthy + networks: + - finance_net + volumes: + - ./backend:/app + expose: + - "8000" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: finance_frontend + restart: unless-stopped + networks: + - finance_net + expose: + - "80" + + nginx: + image: nginx:alpine + container_name: finance_nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro + depends_on: + - backend + - frontend + networks: + - finance_net + +volumes: + postgres_data: + +networks: + finance_net: + driver: bridge diff --git a/docs/ER_diagram.md b/docs/ER_diagram.md new file mode 100644 index 0000000..592ca83 --- /dev/null +++ b/docs/ER_diagram.md @@ -0,0 +1,230 @@ +# ER-диаграмма базы данных Finance App + +```mermaid +erDiagram + + roles { + int id PK + varchar(50) name UK + text description + timestamptz created_at + } + + users { + int id PK + varchar(100) username UK + varchar(255) email UK + varchar(255) password_hash + int role_id FK + boolean is_active + timestamptz created_at + timestamptz updated_at + } + + currencies { + int id PK + varchar(3) code UK "ISO 4217" + varchar(100) name + varchar(10) symbol + boolean is_active + timestamptz created_at + } + + exchange_rates { + int id PK + int currency_from_id FK + int currency_to_id FK + numeric(20_6) rate + date rate_date + varchar(100) source + timestamptz created_at + } + + locations { + int id PK + int user_id FK "NULL = системное" + varchar(200) name + varchar(50) type "store|bank|atm|online|other" + text address + varchar(100) city + varchar(100) country + numeric(9_6) latitude + numeric(9_6) longitude + int parent_id FK "самоссылка" + timestamptz created_at + } + + account_types { + int id PK + varchar(100) name UK + varchar(50) icon + text description + } + + accounts { + int id PK + int user_id FK + varchar(200) name + int account_type_id FK + int currency_id FK + numeric(20_2) balance + numeric(20_2) initial_balance + boolean is_active + numeric(20_2) credit_limit + text description + varchar(50) icon + varchar(7) color "hex #RRGGBB" + timestamptz created_at + timestamptz updated_at + } + + categories { + int id PK + int user_id FK "NULL = системная" + varchar(200) name + varchar(20) type "income|expense|transfer" + int parent_id FK "иерархия" + varchar(50) icon + varchar(7) color + timestamptz created_at + } + + budgets { + int id PK + int user_id FK + int category_id FK + varchar(200) name + numeric(20_2) amount + int currency_id FK + varchar(20) period_type "monthly|weekly|yearly|custom" + date start_date + date end_date + boolean is_active + timestamptz created_at + } + + operation_types { + int id PK + varchar(50) name UK "income|expense|transfer" + text description + } + + operations { + int id PK + int user_id FK + int account_id FK + int category_id FK + int operation_type_id FK + numeric(20_2) amount + int currency_id FK + numeric(20_2) amount_converted "в валюте счёта" + numeric(20_6) exchange_rate + timestamptz date + text description + int location_id FK + boolean is_planned + timestamptz created_at + timestamptz updated_at + } + + operation_relations { + int id PK + int operation_from_id FK + int operation_to_id FK + varchar(50) relation_type "transfer|split|correction" + timestamptz created_at + } + + calendar_events { + int id PK + int user_id FK + varchar(200) title + text description + date event_date + time event_time + boolean is_recurring + varchar(50) recurrence_pattern "daily|weekly|monthly|yearly" + date recurrence_end_date + int related_operation_id FK + int category_id FK + numeric(20_2) amount + int currency_id FK + int reminder_days + boolean is_completed + timestamptz created_at + timestamptz updated_at + } + + %% ── Связи ────────────────────────────────────────────────────── + + roles ||--o{ users : "назначается" + users ||--o{ accounts : "владеет" + users ||--o{ operations : "создаёт" + users ||--o{ budgets : "планирует" + users ||--o{ calendar_events : "назначает" + users ||--o{ categories : "определяет" + users ||--o{ locations : "добавляет" + + currencies ||--o{ accounts : "деноминирует" + currencies ||--o{ exchange_rates : "исходная" + currencies ||--o{ exchange_rates : "целевая" + currencies ||--o{ operations : "валюта операции" + currencies ||--o{ budgets : "валюта бюджета" + currencies ||--o{ calendar_events : "валюта события" + + account_types ||--o{ accounts : "классифицирует" + accounts ||--o{ operations : "содержит" + + categories ||--o{ operations : "классифицирует" + categories ||--o{ budgets : "ограничивает" + categories ||--o{ calendar_events : "ассоциирует" + categories ||--o{ categories : "родитель→ребёнок" + + operation_types ||--o{ operations : "определяет тип" + locations ||--o{ operations : "место проведения" + locations ||--o{ locations : "родитель→дочернее" + + operations ||--o{ operation_relations : "от (from)" + operations ||--o{ operation_relations : "к (to)" + operations ||--o{ calendar_events : "привязанная операция" +``` + +--- + +## Описание сущностей + +| Таблица | Назначение | +|---|---| +| **roles** | Роли доступа: `admin`, `accountant`, `user`, `viewer` | +| **users** | Учётные записи пользователей | +| **currencies** | Справочник валют (ISO 4217: RUB, USD, EUR…) | +| **exchange_rates** | Ежедневные курсы между парами валют | +| **locations** | Места проведения операций (магазины, банки, банкоматы) с иерархией | +| **account_types** | Типы счетов: наличные, банковский счёт, кредитная карта, вклад, инвест., крипто | +| **accounts** | Финансовые счета пользователя с балансом и валютой | +| **categories** | Иерархические категории доходов/расходов (системные + пользовательские) | +| **budgets** | Лимиты расходов по категориям за период | +| **operation_types** | Типы операций: `income`, `expense`, `transfer` | +| **operations** | Финансовые операции — транзакции по счетам | +| **operation_relations** | Связи операций: перевод (debit ↔ credit), разделение, коррекция | +| **calendar_events** | Плановые события с напоминанием (разовые и повторяющиеся) | + +## Разграничение прав доступа (RBAC) + +| Роль | Описание | Права | +|---|---|---| +| **admin** | Администратор | Полный доступ + управление пользователями | +| **accountant** | Бухгалтер | Все финансовые операции, без управления пользователями | +| **user** | Пользователь | CRUD своих данных (счета, операции, бюджеты) | +| **viewer** | Наблюдатель | Только чтение своих данных | + +Изоляция данных обеспечивается на уровне приложения: каждый запрос фильтруется по `user_id`. При необходимости — PostgreSQL Row Level Security (RLS). + +## Безопасность подключения к БД + +- **SSL/TLS**: `sslmode=prefer` (может быть переключён на `require` для продакшена) +- **Переменные окружения**: учётные данные никогда не хранятся в коде +- **Connection pooling**: SQLAlchemy pool (size=10, max_overflow=20, recycle=3600s) +- **Nginx**: HTTPS терминация + rate limiting на auth-эндпоинтах (10 req/min) +- **JWT**: access token (30 мин) + refresh token (7 дней) +- **Пароли**: bcrypt с salt diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..42b7b20 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bcd88a9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Finance App + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..7998733 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,12 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/javascript; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..79322f9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "finance-app-web", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "recharts": "^2.12.7", + "react-datepicker": "^6.9.0", + "date-fns": "^3.6.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.2.12" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..4adbd25 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,49 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; +import Layout from "./components/Layout"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import Accounts from "./pages/Accounts"; +import Operations from "./pages/Operations"; +import Budgets from "./pages/Budgets"; +import ExchangeRates from "./pages/ExchangeRates"; +import Locations from "./pages/Locations"; +import CalendarPage from "./pages/Calendar"; +import Admin from "./pages/Admin"; + +function PrivateRoute({ children, adminOnly = false }) { + const { user, loading } = useAuth(); + if (loading) return
Загрузка…
; + if (!user) return ; + if (adminOnly && user.role?.name !== "admin") return ; + return children; +} + +function AppRoutes() { + const { user } = useAuth(); + return ( + + : } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..a668e9d --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,101 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: "/api/v1", + headers: { "Content-Type": "application/json" }, +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem("access_token"); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +api.interceptors.response.use( + (r) => r, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + const refresh = localStorage.getItem("refresh_token"); + if (refresh) { + try { + const { data } = await axios.post("/api/v1/auth/refresh", null, { + params: { refresh_token: refresh }, + }); + localStorage.setItem("access_token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + original.headers.Authorization = `Bearer ${data.access_token}`; + return api(original); + } catch { + localStorage.clear(); + window.location.href = "/login"; + } + } + } + return Promise.reject(error); + } +); + +// ── Auth ──────────────────────────────────────────────── +export const login = (username, password) => + api.post("/auth/login/json", { username, password }); +export const register = (data) => api.post("/auth/register", data); +export const getMe = () => api.get("/users/me"); + +// ── Users (admin) ──────────────────────────────────────── +export const getUsers = () => api.get("/users"); +export const updateUser = (id, data) => api.patch(`/users/${id}`, data); +export const deleteUser = (id) => api.delete(`/users/${id}`); + +// ── Currencies ─────────────────────────────────────────── +export const getCurrencies = () => api.get("/currencies"); +export const createCurrency = (d) => api.post("/currencies", d); +export const updateCurrency = (id, d) => api.patch(`/currencies/${id}`, d); +export const deleteCurrency = (id) => api.delete(`/currencies/${id}`); + +// ── Exchange rates ──────────────────────────────────────── +export const getExchangeRates = (params) => api.get("/exchange-rates", { params }); +export const createExchangeRate = (d) => api.post("/exchange-rates", d); +export const deleteExchangeRate = (id) => api.delete(`/exchange-rates/${id}`); + +// ── Accounts ────────────────────────────────────────────── +export const getAccountTypes = () => api.get("/accounts/types"); +export const getAccounts = () => api.get("/accounts"); +export const createAccount = (d) => api.post("/accounts", d); +export const updateAccount = (id, d) => api.patch(`/accounts/${id}`, d); +export const deleteAccount = (id) => api.delete(`/accounts/${id}`); + +// ── Categories ──────────────────────────────────────────── +export const getCategories = () => api.get("/categories"); +export const createCategory = (d) => api.post("/categories", d); +export const updateCategory = (id, d) => api.patch(`/categories/${id}`, d); +export const deleteCategory = (id) => api.delete(`/categories/${id}`); + +// ── Budgets ─────────────────────────────────────────────── +export const getBudgets = () => api.get("/budgets"); +export const createBudget = (d) => api.post("/budgets", d); +export const updateBudget = (id, d) => api.patch(`/budgets/${id}`, d); +export const deleteBudget = (id) => api.delete(`/budgets/${id}`); + +// ── Locations ───────────────────────────────────────────── +export const getLocations = () => api.get("/locations"); +export const createLocation = (d) => api.post("/locations", d); +export const updateLocation = (id, d) => api.patch(`/locations/${id}`, d); +export const deleteLocation = (id) => api.delete(`/locations/${id}`); + +// ── Operations ──────────────────────────────────────────── +export const getOperationTypes = () => api.get("/operations/types"); +export const getOperations = (params) => api.get("/operations", { params }); +export const createOperation = (d) => api.post("/operations", d); +export const createTransfer = (d) => api.post("/operations/transfer", d); +export const updateOperation = (id, d) => api.patch(`/operations/${id}`, d); +export const deleteOperation = (id) => api.delete(`/operations/${id}`); + +// ── Calendar ────────────────────────────────────────────── +export const getCalendarEvents = (params) => api.get("/calendar", { params }); +export const createCalendarEvent = (d) => api.post("/calendar", d); +export const updateCalendarEvent = (id, d) => api.patch(`/calendar/${id}`, d); +export const deleteCalendarEvent = (id) => api.delete(`/calendar/${id}`); + +export default api; diff --git a/frontend/src/assets/styles.css b/frontend/src/assets/styles.css new file mode 100644 index 0000000..be45cb5 --- /dev/null +++ b/frontend/src/assets/styles.css @@ -0,0 +1,156 @@ +/* ── Reset & tokens ──────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f4f6fa; + --surface: #ffffff; + --sidebar: #1e2a3a; + --sidebar-hover: #2d3f55; + --primary: #3b82f6; + --primary-dark: #2563eb; + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; + --text: #1e293b; + --muted: #64748b; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04); +} + +body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } +a { color: inherit; text-decoration: none; } +button { cursor: pointer; font-family: inherit; } + +/* ── Layout ─────────────────────────────────────── */ +.app-shell { display: flex; height: 100vh; overflow: hidden; } + +.sidebar { + width: 240px; flex-shrink: 0; + background: var(--sidebar); color: #cbd5e1; + display: flex; flex-direction: column; + overflow-y: auto; +} +.sidebar-logo { + padding: 24px 20px 16px; + font-size: 1.1rem; font-weight: 700; color: #fff; + border-bottom: 1px solid rgba(255,255,255,.08); + letter-spacing: .5px; +} +.sidebar-logo span { color: var(--primary); } +.sidebar nav { padding: 12px 8px; flex: 1; } +.nav-link { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border-radius: var(--radius); + font-size: .9rem; transition: background .15s, color .15s; + margin-bottom: 2px; +} +.nav-link:hover, .nav-link.active { background: var(--sidebar-hover); color: #fff; } +.nav-link.active { font-weight: 600; } +.nav-icon { width: 18px; text-align: center; font-size: 1rem; } +.sidebar-footer { padding: 16px 12px; border-top: 1px solid rgba(255,255,255,.08); } +.sidebar-user { font-size: .8rem; color: #94a3b8; margin-bottom: 8px; } + +.main-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } +.topbar { + height: 56px; background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; padding: 0 24px; + gap: 12px; +} +.topbar-title { font-size: 1.05rem; font-weight: 600; flex: 1; } +.page-content { flex: 1; overflow-y: auto; padding: 24px; } + +/* ── Cards / Surfaces ───────────────────────────── */ +.card { + background: var(--surface); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 20px; +} +.card-title { font-size: .75rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; } +.card-value { font-size: 1.6rem; font-weight: 700; } + +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } + +/* ── Buttons ────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: var(--radius); + border: none; font-size: .875rem; font-weight: 500; + transition: background .15s, opacity .15s; +} +.btn:disabled { opacity: .5; pointer-events: none; } +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-dark); } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { opacity: .85; } +.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); } +.btn-ghost:hover { background: var(--bg); } +.btn-sm { padding: 5px 10px; font-size: .8rem; } +.btn-icon { padding: 6px; border-radius: 6px; background: transparent; border: 1px solid var(--border); color: var(--muted); } +.btn-icon:hover { background: var(--bg); color: var(--text); } + +/* ── Forms ──────────────────────────────────────── */ +.form-group { margin-bottom: 16px; } +.form-label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: 6px; } +.form-control { + width: 100%; padding: 9px 12px; + border: 1px solid var(--border); border-radius: var(--radius); + font-size: .9rem; background: var(--surface); color: var(--text); + transition: border-color .15s; +} +.form-control:focus { outline: none; border-color: var(--primary); } +select.form-control { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; } + +/* ── Table ──────────────────────────────────────── */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .875rem; } +th { text-align: left; padding: 10px 12px; font-size: .75rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; border-bottom: 2px solid var(--border); } +td { padding: 12px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--bg); } + +/* ── Modal ──────────────────────────────────────── */ +.modal-backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,.4); + display: flex; align-items: center; justify-content: center; z-index: 100; +} +.modal { + background: var(--surface); border-radius: 12px; + width: 100%; max-width: 480px; max-height: 90vh; + overflow-y: auto; padding: 28px; + box-shadow: 0 20px 60px rgba(0,0,0,.2); +} +.modal-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 20px; } +.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; } + +/* ── Badges ─────────────────────────────────────── */ +.badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: .75rem; font-weight: 600; } +.badge-income { background: #dcfce7; color: #166534; } +.badge-expense { background: #fee2e2; color: #991b1b; } +.badge-transfer{ background: #dbeafe; color: #1e40af; } +.badge-active { background: #dcfce7; color: #166534; } +.badge-inactive{ background: #f1f5f9; color: var(--muted); } + +/* ── Progress bar ───────────────────────────────── */ +.progress { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; } +.progress-bar { height: 100%; border-radius: 4px; background: var(--primary); transition: width .3s; } +.progress-bar.over { background: var(--danger); } + +/* ── Misc ───────────────────────────────────────── */ +.loader { display: flex; align-items: center; justify-content: center; height: 100vh; font-size: 1.1rem; color: var(--muted); } +.empty { text-align: center; padding: 48px; color: var(--muted); } +.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } +.page-header h1 { font-size: 1.3rem; font-weight: 700; } +.text-success { color: var(--success); } +.text-danger { color: var(--danger); } +.text-muted { color: var(--muted); } +.flex-gap { display: flex; gap: 8px; } +.mt-16 { margin-top: 16px; } +.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); } +.login-card { width: 100%; max-width: 400px; padding: 40px 36px; } +.login-logo { text-align: center; margin-bottom: 28px; font-size: 1.4rem; font-weight: 800; } +.login-logo span { color: var(--primary); } +.error-msg { color: var(--danger); font-size: .85rem; margin-top: 8px; } +.color-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..98387fa --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,80 @@ +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; + +const NAV = [ + { to: "/", icon: "📊", label: "Дашборд" }, + { to: "/accounts", icon: "🏦", label: "Счета" }, + { to: "/operations", icon: "💸", label: "Операции" }, + { to: "/budgets", icon: "🎯", label: "Бюджеты" }, + { to: "/exchange-rates",icon: "💱", label: "Курсы валют" }, + { to: "/locations", icon: "📍", label: "Местоположения" }, + { to: "/calendar", icon: "📅", label: "Календарь" }, +]; + +const PAGE_TITLES = { + "/": "Дашборд", + "/accounts": "Счета", + "/operations": "Операции", + "/budgets": "Бюджеты", + "/exchange-rates": "Курсы валют", + "/locations": "Местоположения", + "/calendar": "Календарь событий", + "/admin": "Управление пользователями", +}; + +export default function Layout() { + const { user, logout, isAdmin } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate("/login"); + }; + + const currentTitle = PAGE_TITLES[window.location.pathname] || "Finance App"; + + return ( +
+ + +
+
+ {currentTitle} + {user?.email} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..f21a83b --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { login as apiLogin, getMe } from "../api/client"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const loadUser = useCallback(async () => { + const token = localStorage.getItem("access_token"); + if (!token) { setLoading(false); return; } + try { + const { data } = await getMe(); + setUser(data); + } catch { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadUser(); }, [loadUser]); + + const login = async (username, password) => { + const { data } = await apiLogin(username, password); + localStorage.setItem("access_token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + await loadUser(); + }; + + const logout = () => { + localStorage.clear(); + setUser(null); + }; + + const isAdmin = user?.role?.name === "admin"; + const isAccountant = ["admin", "accountant"].includes(user?.role?.name); + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..e4bb174 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.jsx"; +import "./assets/styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/frontend/src/pages/Accounts.jsx b/frontend/src/pages/Accounts.jsx new file mode 100644 index 0000000..71d8a21 --- /dev/null +++ b/frontend/src/pages/Accounts.jsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import { getAccounts, getAccountTypes, getCurrencies, createAccount, updateAccount, deleteAccount } from "../api/client"; + +const empty = { name: "", account_type_id: "", currency_id: "", initial_balance: "0", credit_limit: "", description: "", color: "#3b82f6" }; + +export default function Accounts() { + const [accounts, setAccounts] = useState([]); + const [types, setTypes] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [modal, setModal] = useState(null); // null | "create" | account obj + const [form, setForm] = useState(empty); + const [error, setError] = useState(""); + + const load = () => getAccounts().then(r => setAccounts(r.data)); + + useEffect(() => { + load(); + getAccountTypes().then(r => setTypes(r.data)); + getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active))); + }, []); + + const openCreate = () => { setForm(empty); setError(""); setModal("create"); }; + const openEdit = (a) => { setForm({ name: a.name, account_type_id: a.account_type_id, currency_id: a.currency_id, initial_balance: a.initial_balance, credit_limit: a.credit_limit || "", description: a.description || "", color: a.color || "#3b82f6", is_active: a.is_active }); setError(""); setModal(a); }; + const close = () => setModal(null); + + const save = async (e) => { + e.preventDefault(); setError(""); + try { + const payload = { ...form, initial_balance: parseFloat(form.initial_balance) || 0, credit_limit: form.credit_limit ? parseFloat(form.credit_limit) : null, account_type_id: parseInt(form.account_type_id), currency_id: parseInt(form.currency_id) }; + if (modal === "create") await createAccount(payload); + else await updateAccount(modal.id, { name: form.name, is_active: form.is_active ?? true, credit_limit: payload.credit_limit, description: form.description, color: form.color }); + await load(); close(); + } catch (err) { setError(err.response?.data?.detail || "Ошибка"); } + }; + + const remove = async (id) => { + if (!confirm("Удалить счёт?")) return; + await deleteAccount(id); load(); + }; + + const fmt = (v) => parseFloat(v).toLocaleString("ru-RU", { minimumFractionDigits: 2 }); + + return ( +
+
+

Счета

+ +
+ +
+ {accounts.length === 0 + ?
Счетов пока нет. Создайте первый!
+ :
+ + + + + + {accounts.map(a => ( + + + + + + + + + + ))} + +
НазваниеТипВалютаБалансЛимитСтатус
{a.name}{a.account_type?.name}{a.currency?.code} {a.currency?.symbol}= 0 ? "var(--success)" : "var(--danger)" }}> + {fmt(a.balance)} + {a.credit_limit ? fmt(a.credit_limit) : "—"}{a.is_active ? "Активен" : "Закрыт"} +
+ + +
+
+
+ } +
+ + {modal && ( +
+
e.stopPropagation()}> +
{modal === "create" ? "Новый счёт" : "Редактировать счёт"}
+
+
+ + setForm({ ...form, name: e.target.value })} required /> +
+ {modal === "create" && ( + <> +
+ + +
+
+ + +
+
+ + setForm({ ...form, initial_balance: e.target.value })} /> +
+ + )} +
+ + setForm({ ...form, credit_limit: e.target.value })} placeholder="Не задан" /> +
+
+ + setForm({ ...form, description: e.target.value })} /> +
+
+ + setForm({ ...form, color: e.target.value })} /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..bb5c09f --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { getUsers, updateUser, deleteUser, getCurrencies, createCurrency, updateCurrency, deleteCurrency } from "../api/client"; +import { useAuth } from "../contexts/AuthContext"; +import { format } from "date-fns"; + +export default function Admin() { + const { user: me } = useAuth(); + const [users, setUsers] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [tab, setTab] = useState("users"); + const [editUser, setEditUser] = useState(null); + const [currModal, setCurrModal] = useState(null); + const [currForm, setCurrForm] = useState({ code: "", name: "", symbol: "", is_active: true }); + const [error, setError] = useState(""); + + const loadUsers = () => getUsers().then(r => setUsers(r.data)); + const loadCurr = () => getCurrencies().then(r => setCurrencies(r.data)); + + useEffect(() => { loadUsers(); loadCurr(); }, []); + + const toggleActive = async (u) => { + if (u.id === me.id) return; + await updateUser(u.id, { is_active: !u.is_active }); + loadUsers(); + }; + + const changeRole = async (u, role_id) => { + await updateUser(u.id, { role_id: parseInt(role_id) }); + loadUsers(); + }; + + const removeUser = async (id) => { + if (id === me.id || !confirm("Удалить пользователя?")) return; + await deleteUser(id); loadUsers(); + }; + + const saveCurr = async (e) => { + e.preventDefault(); setError(""); + try { + if (!currModal) await createCurrency(currForm); + else await updateCurrency(currModal.id, { name: currForm.name, symbol: currForm.symbol, is_active: currForm.is_active }); + await loadCurr(); setCurrModal(undefined); setCurrForm({ code: "", name: "", symbol: "", is_active: true }); + } catch (err) { setError(err.response?.data?.detail || "Ошибка"); } + }; + + const deleteCurr = async (id) => { if (!confirm("Удалить валюту?")) return; await deleteCurrency(id); loadCurr(); }; + + const ROLES = [ + { id: 1, name: "viewer", label: "Наблюдатель" }, + { id: 2, name: "user", label: "Пользователь" }, + { id: 3, name: "accountant", label: "Бухгалтер" }, + { id: 4, name: "admin", label: "Администратор" }, + ]; + + return ( +
+

Управление системой

+ +
+ + +
+ + {tab === "users" && ( +
+
+ + + + {users.map(u => ( + + + + + + + + + + ))} + +
IDЛогинEmailРольСтатусСоздан
{u.id}{u.username}{u.id === me.id && (Вы)}{u.email} + + + + {u.created_at ? format(new Date(u.created_at), "dd.MM.yyyy") : "—"} + {u.id !== me.id && ( + + )} +
+
+
+ )} + + {tab === "currencies" && ( +
+
+ +
+
+
+ + + + {currencies.map(c => ( + + + + + + + + ))} + +
КодНазваниеСимволСтатус
{c.code}{c.name}{c.symbol}{c.is_active ? "Активна" : "Отключена"} +
+ + +
+
+
+
+ + {currModal !== undefined && ( +
setCurrModal(undefined)}> +
e.stopPropagation()}> +
{!currModal ? "Новая валюта" : "Редактировать валюту"}
+
+ {!currModal && ( +
+ + setCurrForm({ ...currForm, code: e.target.value.toUpperCase() })} required /> +
+ )} +
+ + setCurrForm({ ...currForm, name: e.target.value })} required /> +
+
+ + setCurrForm({ ...currForm, symbol: e.target.value })} /> +
+ {currModal && ( +
+ +
+ )} + {error &&

{error}

} +
+ + +
+
+
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Budgets.jsx b/frontend/src/pages/Budgets.jsx new file mode 100644 index 0000000..455be9c --- /dev/null +++ b/frontend/src/pages/Budgets.jsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { getBudgets, getCategories, getCurrencies, createBudget, updateBudget, deleteBudget } from "../api/client"; +import { format } from "date-fns"; + +const empty = { name: "", category_id: "", amount: "", currency_id: "", period_type: "monthly", start_date: format(new Date(), "yyyy-MM-dd"), end_date: "", is_active: true }; + +export default function Budgets() { + const [budgets, setBudgets] = useState([]); + const [categories, setCategories] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [modal, setModal] = useState(null); + const [form, setForm] = useState(empty); + const [error, setError] = useState(""); + + const load = () => getBudgets().then(r => setBudgets(r.data)); + + useEffect(() => { + load(); + getCategories().then(r => setCategories(r.data.filter(c => c.type === "expense"))); + getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active))); + }, []); + + const close = () => { setModal(null); setError(""); }; + + const openEdit = (b) => { + setForm({ name: b.name, category_id: b.category_id, amount: b.amount, currency_id: b.currency_id, period_type: b.period_type, start_date: b.start_date, end_date: b.end_date || "", is_active: b.is_active }); + setModal(b); + }; + + const save = async (e) => { + e.preventDefault(); setError(""); + try { + const payload = { ...form, category_id: parseInt(form.category_id), amount: parseFloat(form.amount), currency_id: parseInt(form.currency_id), end_date: form.end_date || null }; + if (!modal) await createBudget(payload); + else await updateBudget(modal.id, { name: form.name, amount: payload.amount, period_type: form.period_type, end_date: payload.end_date, is_active: form.is_active }); + await load(); close(); + } catch (err) { setError(err.response?.data?.detail || "Ошибка"); } + }; + + const remove = async (id) => { if (!confirm("Удалить бюджет?")) return; await deleteBudget(id); load(); }; + + const fmt = (v) => parseFloat(v || 0).toLocaleString("ru-RU", { minimumFractionDigits: 2 }); + const periodLabel = { monthly: "Ежемесячно", weekly: "Еженедельно", yearly: "Ежегодно", custom: "Произвольный" }; + + return ( +
+
+

Бюджеты

+ +
+ +
+ {budgets.length === 0 + ?
Бюджеты не настроены
+ : budgets.map(b => { + const spent = parseFloat(b.spent || 0); + const limit = parseFloat(b.amount); + const pct = limit > 0 ? Math.min((spent / limit) * 100, 100) : 0; + const over = spent > limit; + return ( +
+
+
+
{b.name}
+
+ {b.category?.name} · {periodLabel[b.period_type]} · с {b.start_date}{b.end_date ? ` по ${b.end_date}` : ""} +
+
+
+ {b.is_active ? "Активен" : "Неактивен"} + + +
+
+
+ Потрачено: {fmt(spent)} + Лимит: {fmt(limit)} {b.currency?.symbol} + {over ? `Перерасход ${fmt(spent - limit)}` : `Остаток ${fmt(limit - spent)}`} +
+
+
+
+
+ ); + }) + } +
+ + {modal !== null && ( +
+
e.stopPropagation()}> +
{!modal ? "Новый бюджет" : "Редактировать бюджет"}
+
+
+ + setForm({ ...form, name: e.target.value })} required /> +
+ {!modal && ( + <> +
+ + +
+
+ + +
+
+ + setForm({ ...form, start_date: e.target.value })} required /> +
+ + )} +
+ + setForm({ ...form, amount: e.target.value })} required /> +
+
+ + +
+ {form.period_type === "custom" && ( +
+ + setForm({ ...form, end_date: e.target.value })} /> +
+ )} + {modal && ( +
+ +
+ )} + {error &&

{error}

} +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Calendar.jsx b/frontend/src/pages/Calendar.jsx new file mode 100644 index 0000000..19dd72d --- /dev/null +++ b/frontend/src/pages/Calendar.jsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from "react"; +import { getCalendarEvents, getCategories, getCurrencies, createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from "../api/client"; +import { format, startOfMonth, endOfMonth, addMonths, subMonths, eachDayOfInterval, isSameDay, parseISO } from "date-fns"; +import { ru } from "date-fns/locale"; + +const empty = { title: "", description: "", event_date: format(new Date(), "yyyy-MM-dd"), event_time: "", is_recurring: false, recurrence_pattern: "", recurrence_end_date: "", category_id: "", amount: "", currency_id: "", reminder_days: "", is_completed: false }; + +export default function CalendarPage() { + const [events, setEvents] = useState([]); + const [categories, setCategories] = useState([]); + const [currencies, setCurrencies] = useState([]); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [selectedDay, setSelectedDay] = useState(null); + const [modal, setModal] = useState(null); + const [form, setForm] = useState(empty); + const [error, setError] = useState(""); + + const load = () => { + const from = format(startOfMonth(currentMonth), "yyyy-MM-dd"); + const to = format(endOfMonth(currentMonth), "yyyy-MM-dd"); + getCalendarEvents({ date_from: from, date_to: to, limit: 500 }).then(r => setEvents(r.data)); + }; + + useEffect(() => { load(); }, [currentMonth]); + useEffect(() => { + getCategories().then(r => setCategories(r.data)); + getCurrencies().then(r => setCurrencies(r.data.filter(c => c.is_active))); + }, []); + + const days = eachDayOfInterval({ start: startOfMonth(currentMonth), end: endOfMonth(currentMonth) }); + const dayEvents = (day) => events.filter(e => e.event_date === format(day, "yyyy-MM-dd")); + + const close = () => { setModal(null); setError(""); }; + + const openCreate = (day) => { + setForm({ ...empty, event_date: format(day, "yyyy-MM-dd") }); + setModal("create"); + }; + + const openEdit = (ev) => { + setForm({ title: ev.title, description: ev.description || "", event_date: ev.event_date, event_time: ev.event_time || "", is_recurring: ev.is_recurring, recurrence_pattern: ev.recurrence_pattern || "", recurrence_end_date: ev.recurrence_end_date || "", category_id: ev.category_id || "", amount: ev.amount || "", currency_id: ev.currency_id || "", reminder_days: ev.reminder_days || "", is_completed: ev.is_completed }); + setModal(ev); + }; + + const save = async (e) => { + e.preventDefault(); setError(""); + try { + const payload = { ...form, category_id: form.category_id ? parseInt(form.category_id) : null, currency_id: form.currency_id ? parseInt(form.currency_id) : null, amount: form.amount ? parseFloat(form.amount) : null, reminder_days: form.reminder_days ? parseInt(form.reminder_days) : null, event_time: form.event_time || null, recurrence_pattern: form.recurrence_pattern || null, recurrence_end_date: form.recurrence_end_date || null, description: form.description || null }; + if (modal === "create") await createCalendarEvent(payload); + else await updateCalendarEvent(modal.id, payload); + await load(); close(); + } catch (err) { setError(err.response?.data?.detail || "Ошибка"); } + }; + + const remove = async (id) => { if (!confirm("Удалить событие?")) return; await deleteCalendarEvent(id); load(); }; + + const toggle = async (ev) => { await updateCalendarEvent(ev.id, { is_completed: !ev.is_completed }); load(); }; + + const selectedDayEvents = selectedDay ? dayEvents(selectedDay) : []; + + return ( +
+
+

Календарь событий

+
+ + {format(currentMonth, "LLLL yyyy", { locale: ru })} + +
+
+ +
+ {/* Calendar grid */} +
+
+ {["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].map(d =>
{d}
)} +
+
+ {/* Leading empty cells */} + {Array.from({ length: (days[0].getDay() + 6) % 7 }).map((_, i) =>
)} + {days.map(day => { + const de = dayEvents(day); + const isSelected = selectedDay && isSameDay(day, selectedDay); + const isToday = isSameDay(day, new Date()); + return ( +
setSelectedDay(day)} + style={{ padding: "6px 4px", borderRadius: 6, cursor: "pointer", textAlign: "center", background: isSelected ? "var(--primary)" : isToday ? "#dbeafe" : "transparent", color: isSelected ? "#fff" : "inherit", border: isToday && !isSelected ? "1px solid var(--primary)" : "1px solid transparent", minHeight: 48, position: "relative" }}> +
{format(day, "d")}
+ {de.length > 0 && ( +
+ {de.slice(0, 3).map(ev => ( +
+ ))} + {de.length > 3 &&
+{de.length - 3}
} +
+ )} +
+ ); + })} +
+
+ + {/* Day events panel */} +
+ {!selectedDay + ?
Выберите день
+ : <> +
+
{format(selectedDay, "d MMMM yyyy", { locale: ru })}
+ +
+ {selectedDayEvents.length === 0 + ?
Событий нет
+ : selectedDayEvents.map(ev => ( +
+ +
+
{ev.title}
+ {ev.event_time &&
🕐 {ev.event_time}
} + {ev.amount &&
💰 {parseFloat(ev.amount).toLocaleString("ru-RU")} {currencies.find(c => c.id === ev.currency_id)?.symbol}
} + {ev.description &&
{ev.description}
} + {ev.is_recurring && 🔁 {ev.recurrence_pattern}} +
+
+ + +
+
+ )) + } + + } +
+
+ + {modal && ( +
+
e.stopPropagation()}> +
{modal === "create" ? "Новое событие" : "Редактировать событие"}
+
+
+ + setForm({ ...form, title: e.target.value })} required /> +
+
+
+ + setForm({ ...form, event_date: e.target.value })} required /> +
+
+ + setForm({ ...form, event_time: e.target.value })} /> +
+
+
+ +
+ setForm({ ...form, amount: e.target.value })} placeholder="Не задана" /> + +
+
+
+ + +
+
+ + setForm({ ...form, reminder_days: e.target.value })} /> +
+
+ +
+ {form.is_recurring && ( +
+
+ + +
+
+ + setForm({ ...form, recurrence_end_date: e.target.value })} /> +
+
+ )} +
+ + -
- - -
-
- - \ No newline at end of file diff --git a/templates/edit.html b/templates/edit.html deleted file mode 100644 index f6182a2..0000000 --- a/templates/edit.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - Редактировать операцию - - - - -
-
-

Редактировать операцию

- - Назад - -
- -
-
- -
- - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
-
- - \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index c9439d7..0000000 --- a/templates/index.html +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - Финансовый учет - - - - -
-
-

Финансовый учет

-
-
- Доходы - {{ total_income|int }} ₽ -
-
- Расходы - {{ total_expense|int }} ₽ -
-
- Баланс - {{ balance|int }} ₽ -
-
-
- Привет, {{ username }}! - -
-
- - - -
-

Расходы по категориям

-
- {% for category, amounts in categories.items() %} - {% if amounts['expense'] > 0 %} -
- {{ category }} - {{ amounts['expense']|int }} ₽ -
- {% endif %} - {% endfor %} -
-
- -
-

История операций

- - - - - - - - - - - - {% for t in transactions %} - - - - - - - - {% else %} - - - - {% endfor %} - -
ДатаКатегорияОписаниеСуммаДействия
{{ t.date.strftime('%d.%m.%Y') }}{{ t.category }}{{ t.description }} - {{ t.amount|int }} ₽ - - - - - - - -
Нет операций
-
- -
-

Сегодня: {{ current_date.strftime('%d.%m.%Y') }}

-

"Учет финансов - первый шаг к финансовой свободе!"

-
-
- - - - - \ No newline at end of file diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index 3c0d8b3..0000000 --- a/templates/login.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - Вход в систему - - - - -
-
-

Вход в систему

- -
-
- - -
- -
- - -
- -
- - -
- - - - -
-
-
- - \ No newline at end of file diff --git a/templates/register.html b/templates/register.html deleted file mode 100644 index 8339a63..0000000 --- a/templates/register.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - Регистрация - - - - -
-
-

Регистрация

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - - - -
-
-
- - \ No newline at end of file