diff --git a/backend/package-lock.json b/backend/package-lock.json
index cb9ab35..184d4d3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -8,9 +8,11 @@
"name": "finance-tracker-backend",
"version": "1.0.0",
"dependencies": {
+ "bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+ "jsonwebtoken": "^9.0.3",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"telegraf": "^4.16.3"
@@ -247,6 +249,29 @@
],
"license": "MIT"
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/bcrypt/node_modules/node-addon-api": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
+ "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -370,6 +395,12 @@
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
"license": "MIT"
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/buffer-fill": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
@@ -659,6 +690,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1428,6 +1468,97 @@
"license": "ISC",
"optional": true
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1781,6 +1912,17 @@
"node": ">= 10.12.0"
}
},
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
diff --git a/backend/package.json b/backend/package.json
index e4dd182..cc1c63e 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -9,9 +9,11 @@
"dev": "nodemon src/index.js"
},
"dependencies": {
+ "bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+ "jsonwebtoken": "^9.0.3",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"telegraf": "^4.16.3"
diff --git a/backend/src/bot.js b/backend/src/bot.js
index 940736c..56cc5d1 100644
--- a/backend/src/bot.js
+++ b/backend/src/bot.js
@@ -1,40 +1,10 @@
import { Telegraf, Markup } from 'telegraf';
import { getDb } from './db.js';
-import crypto from 'crypto';
let bot;
-// Helper to generate a secure random token
-function generateAuthToken() {
- return crypto.randomBytes(24).toString('hex');
-}
-
-// Simple category auto-detection based on keywords
-function detectCategory(description) {
- const desc = description.toLowerCase();
-
- const foodKeywords = ['еда', 'обед', 'ужин', 'завтрак', 'кафе', 'ресторан', 'продукт', 'супермаркет', 'хлеб', 'молоко', 'мясо', 'пицца', 'кофе', 'чай', 'мак'];
- const transportKeywords = ['такси', 'метро', 'автобус', 'самолет', 'поезд', 'бензин', 'заправка', 'билет', 'шеринг', 'самокат'];
- const entertainmentKeywords = ['кино', 'театр', 'игра', 'steam', 'playstation', 'книга', 'музей', 'концерт', 'бар', 'клуб', 'подписка', 'netflix', 'youtube', 'музыка'];
- const housingKeywords = ['аренда', 'квартира', 'жкх', 'коммуналка', 'свет', 'вода', 'интернет', 'отопление'];
- const clothesKeywords = ['одежда', 'обувь', 'куртка', 'кроссовки', 'футболка', 'штаны', 'носки'];
- const healthKeywords = ['аптека', 'лекарство', 'врач', 'больница', 'анализы', 'витамины', 'стоматолог'];
- const hygieneKeywords = ['мыло', 'шампунь', 'зубная', 'порошок', 'косметика', 'стрижка', 'парикмахер'];
-
- if (foodKeywords.some(kw => desc.includes(kw))) return 'Еда';
- if (transportKeywords.some(kw => desc.includes(kw))) return 'Транспорт';
- if (entertainmentKeywords.some(kw => desc.includes(kw))) return 'Развлечения';
- if (housingKeywords.some(kw => desc.includes(kw))) return 'Жилье';
- if (clothesKeywords.some(kw => desc.includes(kw))) return 'Одежда';
- if (healthKeywords.some(kw => desc.includes(kw))) return 'Здоровье';
- if (hygieneKeywords.some(kw => desc.includes(kw))) return 'Гигиена';
-
- return 'Прочее';
-}
-
export async function initBot() {
const token = process.env.TELEGRAM_BOT_TOKEN;
- const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
if (!token || token === 'your_telegram_bot_token_here') {
console.warn('⚠️ TELEGRAM_BOT_TOKEN is not configured. Telegram bot will not start.');
@@ -42,253 +12,40 @@ export async function initBot() {
}
bot = new Telegraf(token);
- const db = getDb();
- // Middleware to register/update user in the database
- bot.use(async (ctx, next) => {
- if (ctx.from) {
- const { id, username, first_name } = ctx.from;
- const user = await db.get('SELECT * FROM users WHERE id = ?', id);
-
- if (!user) {
- const token = generateAuthToken();
- await db.run(
- 'INSERT INTO users (id, username, first_name, auth_token) VALUES (?, ?, ?, ?)',
- id, username || null, first_name || null, token
- );
- console.log(`Registered new user: ${first_name} (${id})`);
- } else {
- // Update profile information if changed
- await db.run(
- 'UPDATE users SET username = ?, first_name = ? WHERE id = ?',
- username || null, first_name || null, id
- );
- }
- }
- return next();
- });
-
- // /start command - Welcomes user and offers web app
+ // /start command
bot.start(async (ctx) => {
- const webAppLink = `${frontendUrl}`;
const name = ctx.from.first_name || 'друг';
await ctx.reply(
- `Привет, ${name}! 👋\n\nЯ помогу тебе вести учет финансов.\n\n` +
- `✍️ **Как добавлять расходы и доходы:**\n` +
- `• Чтобы добавить расход: просто напиши сумму и описание. Например: \`350 кофе и булочка\`\n` +
- `• Чтобы добавить доход: напиши плюс (+), затем сумму и описание. Например: \`+ 45000 Аванс\`\n\n` +
- `🛒 **Список покупок:**\n` +
- `• Добавить: \`/buy [название] [цена]\`. Например: \`/buy Молоко 90\`\n\n` +
- `📅 **Подписки:**\n` +
- `• Добавить: \`/sub [название] [цена]\`. Например: \`/sub Netflix 800\`\n\n` +
- `📊 **Аналитика:**\n` +
- `• Используй команду /stats для быстрой сводки расходов.\n` +
- `• Или открой полноценный Dashboard с графиками по кнопке ниже! 👇`,
- Markup.keyboard([
- ['➕ Доход', '➖ Расход'],
- ['📊 Статистика', '🛒 Покупки'],
- ['📅 Подписки', Markup.button.webApp('📊 Открыть Dashboard', webAppLink)]
- ]).resize()
+ `Привет, ${name}! 👋\n\n` +
+ `Я твой финансовый ассистент. Теперь мы используем веб-приложение для основного учета!\n\n` +
+ `Чтобы получать напоминания о подписках и долгах, привяжи этот аккаунт к профилю на сайте.\n` +
+ `Зайди в настройки на сайте, сгенерируй код привязки и отправь мне команду:\n\n` +
+ `/link <твой-код>`
);
});
- // /login command - Generates temporary link for desktop/other browser login
- bot.command('login', async (ctx) => {
- const db = getDb();
- const user = await db.get('SELECT auth_token FROM users WHERE id = ?', ctx.from.id);
- let token = user?.auth_token;
-
- if (!token) {
- token = generateAuthToken();
- await db.run('UPDATE users SET auth_token = ? WHERE id = ?', token, ctx.from.id);
- }
-
- const loginUrl = `${frontendUrl}?token=${token}`;
- await ctx.reply(
- `Вот твоя ссылка для входа в панель управления через обычный браузер (например, на ПК):\n\n` +
- `🔗 ${loginUrl}\n\n` +
- `⚠️ Не передавай эту ссылку никому, она дает доступ к твоим финансам!`,
- { disable_web_page_preview: true }
- );
- });
-
- const statsHandler = async (ctx) => {
- const db = getDb();
- const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
-
- // Get total expenses
- const totalExp = await db.get(
- `SELECT SUM(amount) as total FROM transactions
- WHERE user_id = ? AND type = 'expense' AND date LIKE ?`,
- ctx.from.id, `${currentMonth}%`
- );
-
- // Get total incomes
- const totalInc = await db.get(
- `SELECT SUM(amount) as total FROM transactions
- WHERE user_id = ? AND type = 'income' AND date LIKE ?`,
- ctx.from.id, `${currentMonth}%`
- );
-
- // Get expenses by category
- const categories = await db.all(
- `SELECT category, SUM(amount) as total FROM transactions
- WHERE user_id = ? AND type = 'expense' AND date LIKE ?
- GROUP BY category ORDER BY total DESC`,
- ctx.from.id, `${currentMonth}%`
- );
-
- const expenseSum = totalExp?.total || 0;
- const incomeSum = totalInc?.total || 0;
-
- let message = `📊 **Статистика за текущий месяц (${currentMonth}):**\n\n`;
- message += `💰 Доходы: *${incomeSum.toFixed(2)} ₽*\n`;
- message += `💸 Расходы: *${expenseSum.toFixed(2)} ₽*\n`;
- message += `📈 Баланс: *${(incomeSum - expenseSum).toFixed(2)} ₽*\n\n`;
-
- if (categories.length > 0) {
- message += `🔍 **Расходы по категориям:**\n`;
- categories.forEach(cat => {
- const percentage = expenseSum > 0 ? ((cat.total / expenseSum) * 100).toFixed(0) : 0;
- message += `• ${cat.category}: *${cat.total.toFixed(2)} ₽* (${percentage}%)\n`;
- });
- } else {
- message += `Нет расходов в этом месяце.`;
- }
-
- await ctx.replyWithMarkdownV2(
- message.replace(/\./g, '\\.').replace(/-/g, '\\-').replace(/\+/g, '\\+')
- );
- };
-
- // /stats command - Show summary of expenses for current month
- bot.command('stats', statsHandler);
- bot.hears('📊 Статистика', statsHandler);
-
- // /buy command - Adds item to shopping list
- bot.command('buy', async (ctx) => {
- const text = ctx.message.text.replace('/buy', '').trim();
- if (!text) {
- return ctx.reply('Использование: /buy [название] [примерная цена]\nПример: /buy Молоко 90');
- }
-
- // Regex to split item name and price
- const match = text.match(/^(.+?)\s+(\d+(?:\.\d+)?)$/);
- let itemName = text;
- let price = 0;
-
- if (match) {
- itemName = match[1].trim();
- price = parseFloat(match[2]);
- }
-
- const db = getDb();
- await db.run(
- 'INSERT INTO shopping_list (user_id, item_name, estimated_price, priority) VALUES (?, ?, ?, ?)',
- ctx.from.id, itemName, price, 'medium'
- );
-
- await ctx.reply(`🛒 Добавлено в список покупок: *${itemName}* (~${price} ₽)`, { parse_mode: 'Markdown' });
- });
-
- // /sub command - Adds subscription
- bot.command('sub', async (ctx) => {
- const text = ctx.message.text.replace('/sub', '').trim();
- if (!text) {
- return ctx.reply('Использование: /sub [название] [стоимость]\nПример: /sub Netflix 800');
- }
-
- const match = text.match(/^(.+?)\s+(\d+(?:\.\d+)?)$/);
- if (!match) {
- return ctx.reply('Пожалуйста, укажите название подписки и стоимость. Пример: /sub Netflix 800');
- }
-
- const name = match[1].trim();
- const price = parseFloat(match[2]);
-
- // Set next payment date to 1 month from now
- const nextPayment = new Date();
- nextPayment.setMonth(nextPayment.getMonth() + 1);
- const nextPaymentStr = nextPayment.toISOString().slice(0, 10);
-
- const db = getDb();
- await db.run(
- 'INSERT INTO subscriptions (user_id, name, amount, period, next_payment_date, active) VALUES (?, ?, ?, ?, ?, 1)',
- ctx.from.id, name, price, 'monthly', nextPaymentStr
- );
-
- await ctx.reply(
- `📅 Добавлена подписка: *${name}*\n` +
- `💳 Стоимость: *${price} ₽ / мес*\n` +
- `📆 След. платеж: *${nextPaymentStr}*`,
- { parse_mode: 'Markdown' }
- );
- });
-
- // Text message handler for quick income/expense input
- bot.on('message', async (ctx) => {
- if (!ctx.message.text) return;
-
+ // /link command
+ bot.command('link', async (ctx) => {
const text = ctx.message.text.trim();
+ const code = text.split(' ')[1];
+
+ if (!code) {
+ return ctx.reply('Использование: /link <код>\nНапример: /link A1B2C3D4');
+ }
+
const db = getDb();
- const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
+ const user = await db.get('SELECT * FROM users WHERE telegram_link_code = ?', code.toUpperCase());
- // Handle interactive button text replies
- if (text === '➕ Доход') {
- return ctx.reply('Введите сумму и описание дохода, начиная с "+", например:\n\n`+ 15000 Зарплата`', { parse_mode: 'Markdown' });
- }
- if (text === '➖ Расход') {
- return ctx.reply('Введите сумму и описание расхода, например:\n\n`450 Кофе и круассан`', { parse_mode: 'Markdown' });
- }
- if (text === '🛒 Покупки') {
- return ctx.reply('Чтобы добавить покупку, используй команду /buy, например:\n\n`/buy Молоко 90`', { parse_mode: 'Markdown' });
- }
- if (text === '📅 Подписки') {
- return ctx.reply('Чтобы добавить подписку, используй команду /sub, например:\n\n`/sub Netflix 800`', { parse_mode: 'Markdown' });
+ if (!user) {
+ return ctx.reply('❌ Неверный или устаревший код привязки. Проверьте код на сайте.');
}
- // 1. Check for Income (starts with '+' or '+ ')
- const incomeMatch = text.match(/^\+\s*(\d+(?:\.\d+)?)\s+(.+)$/);
- if (incomeMatch) {
- const amount = parseFloat(incomeMatch[1]);
- const description = incomeMatch[2].trim();
- const category = 'Доходы';
+ // Link telegram account
+ await db.run('UPDATE users SET telegram_id = ?, telegram_link_code = NULL WHERE id = ?', ctx.from.id, user.id);
- await db.run(
- 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
- ctx.from.id, 'income', amount, category, description, today
- );
-
- return ctx.reply(`💰 Добавлен доход: *${amount} ₽* (${description})`, { parse_mode: 'Markdown' });
- }
-
- // 2. Check for Expense (number followed by description)
- const expenseMatch = text.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
- if (expenseMatch) {
- const amount = parseFloat(expenseMatch[1]);
- const description = expenseMatch[2].trim();
- const category = detectCategory(description);
-
- await db.run(
- 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
- ctx.from.id, 'expense', amount, category, description, today
- );
-
- return ctx.reply(
- `💸 Добавлен расход: *${amount} ₽*\n` +
- `📂 Категория: *${category}*\n` +
- `📝 Описание: *${description}*`,
- { parse_mode: 'Markdown' }
- );
- }
-
- // If text doesn't match, reply with instructions
- await ctx.reply(
- `Не понял формат сообщения. 🧐\n\n` +
- `• Чтобы записать расход, введи: \`[сумма] [описание]\` (например, \`200 такси\`)\n` +
- `• Чтобы записать доход, введи: \`+ [сумма] [описание]\` (например, \`+ 1500 продажа книг\`)`
- );
+ await ctx.reply('✅ Аккаунт успешно привязан! Теперь я буду присылать тебе напоминания о важных финансовых событиях.');
});
bot.catch((err, ctx) => {
@@ -301,7 +58,6 @@ export async function initBot() {
console.error('Failed to launch bot:', err);
});
- // Enable graceful stop
process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));
diff --git a/backend/src/db.js b/backend/src/db.js
index 3d331ee..cd7e4d9 100644
--- a/backend/src/db.js
+++ b/backend/src/db.js
@@ -20,10 +20,12 @@ export async function initDb() {
// Create tables
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY,
- username TEXT,
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT UNIQUE,
+ password_hash TEXT,
first_name TEXT,
- auth_token TEXT,
+ telegram_id INTEGER,
+ telegram_link_code TEXT,
currency TEXT DEFAULT 'RUB',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -62,6 +64,29 @@ export async function initDb() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
+
+ CREATE TABLE IF NOT EXISTS debts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ person_name TEXT NOT NULL,
+ amount REAL NOT NULL,
+ type TEXT CHECK(type IN ('owe', 'owed')) NOT NULL,
+ due_date TEXT, -- YYYY-MM-DD
+ status TEXT CHECK(status IN ('pending', 'paid')) DEFAULT 'pending',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS constant_incomes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ source_name TEXT NOT NULL,
+ amount REAL NOT NULL,
+ day_of_month INTEGER NOT NULL, -- 1-31
+ last_credited_month TEXT, -- YYYY-MM
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
`);
console.log('Database initialized at', dbPath);
diff --git a/backend/src/index.js b/backend/src/index.js
index a6bb37e..e7792ae 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -1,124 +1,118 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
-import crypto from 'crypto';
+import bcrypt from 'bcrypt';
+import jwt from 'jsonwebtoken';
import path from 'path';
import { fileURLToPath } from 'url';
import { initDb, getDb } from './db.js';
import { initBot } from './bot.js';
import { startScheduler } from './scheduler.js';
+import crypto from 'crypto';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
+const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_key_for_finance_tracker_123!';
app.use(cors());
app.use(express.json());
-// Verification helper for Telegram WebApp initData
-function verifyTelegramInitData(initData, botToken) {
- if (!botToken || botToken === 'your_telegram_bot_token_here') {
- return false;
- }
- try {
- const params = new URLSearchParams(initData);
- const hash = params.get('hash');
- params.delete('hash');
-
- const pairs = [];
- for (const [key, value] of params.entries()) {
- pairs.push(`${key}=${value}`);
- }
- pairs.sort();
- const dataCheckString = pairs.join('\n');
-
- const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest();
- const calculatedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
-
- return calculatedHash === hash;
- } catch (err) {
- console.error('Error verifying Telegram initData:', err);
- return false;
- }
-}
-
// Authentication Middleware
async function authMiddleware(req, res, next) {
- const db = getDb();
- const botToken = process.env.TELEGRAM_BOT_TOKEN;
-
- // 1. Check for Telegram Mini App initData
- const initData = req.headers['x-telegram-init-data'];
- if (initData) {
- const isValid = verifyTelegramInitData(initData, botToken);
-
- // In development mode, if bot token is placeholder, let it pass or use mock user
- const isDevPlaceholder = !botToken || botToken === 'your_telegram_bot_token_here';
-
- if (isValid || isDevPlaceholder) {
- try {
- const params = new URLSearchParams(initData);
- const userObjStr = params.get('user');
- if (userObjStr) {
- const tgUser = JSON.parse(userObjStr);
-
- // Ensure user exists in database
- let user = await db.get('SELECT * FROM users WHERE id = ?', tgUser.id);
- if (!user) {
- await db.run(
- 'INSERT INTO users (id, username, first_name) VALUES (?, ?, ?)',
- tgUser.id, tgUser.username || null, tgUser.first_name || null
- );
- user = { id: tgUser.id, username: tgUser.username, first_name: tgUser.first_name };
- }
-
- req.user = user;
- return next();
- }
- } catch (err) {
- console.error('Failed to parse user in initData:', err);
- }
- }
- }
-
- // 2. Check for Token (Bearer token for standard browser access)
const authHeader = req.headers['authorization'];
- if (authHeader && authHeader.startsWith('Bearer ')) {
- const token = authHeader.split(' ')[1];
- const user = await db.get('SELECT * FROM users WHERE auth_token = ?', token);
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'Unauthorized: No token provided' });
+ }
+
+ const token = authHeader.split(' ')[1];
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+ const db = getDb();
+ const user = await db.get('SELECT id, email, first_name, telegram_id, currency FROM users WHERE id = ?', decoded.userId);
- if (user) {
- req.user = user;
- return next();
- }
- }
-
- // 3. Fallback for Local Dev testing if no auth headers provided
- if (process.env.NODE_ENV === 'development' || !botToken || botToken === 'your_telegram_bot_token_here') {
- // Return a mock user for local testing
- let user = await db.get('SELECT * FROM users WHERE id = 1');
if (!user) {
- await db.run(
- 'INSERT INTO users (id, username, first_name, auth_token) VALUES (1, "test_user", "Test User", "test_token_123")'
- );
- user = { id: 1, username: 'test_user', first_name: 'Test User', auth_token: 'test_token_123' };
+ return res.status(401).json({ error: 'Unauthorized: User not found' });
}
+
req.user = user;
- return next();
+ next();
+ } catch (err) {
+ return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
-
- return res.status(401).json({ error: 'Unauthorized' });
}
-// --- API ENDPOINTS ---
+// --- AUTH ENDPOINTS ---
-// GET /api/user/profile
+app.post('/api/auth/register', async (req, res) => {
+ const { email, password, first_name } = req.body;
+ if (!email || !password || !first_name) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+
+ try {
+ const db = getDb();
+ const existing = await db.get('SELECT id FROM users WHERE email = ?', email);
+ if (existing) {
+ return res.status(400).json({ error: 'User with this email already exists' });
+ }
+
+ const saltRounds = 10;
+ const password_hash = await bcrypt.hash(password, saltRounds);
+
+ const result = await db.run(
+ 'INSERT INTO users (email, password_hash, first_name) VALUES (?, ?, ?)',
+ email, password_hash, first_name
+ );
+
+ const token = jwt.sign({ userId: result.lastID }, JWT_SECRET, { expiresIn: '7d' });
+ res.status(201).json({ token, user: { id: result.lastID, email, first_name } });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/auth/login', async (req, res) => {
+ const { email, password } = req.body;
+ if (!email || !password) {
+ return res.status(400).json({ error: 'Missing email or password' });
+ }
+
+ try {
+ const db = getDb();
+ const user = await db.get('SELECT * FROM users WHERE email = ?', email);
+ if (!user) {
+ return res.status(400).json({ error: 'Invalid email or password' });
+ }
+
+ const match = await bcrypt.compare(password, user.password_hash);
+ if (!match) {
+ return res.status(400).json({ error: 'Invalid email or password' });
+ }
+
+ const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
+ res.json({ token, user: { id: user.id, email: user.email, first_name: user.first_name, telegram_id: user.telegram_id } });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/auth/generate-link-code', authMiddleware, async (req, res) => {
+ try {
+ const db = getDb();
+ const code = crypto.randomBytes(4).toString('hex').toUpperCase(); // e.g., A1B2C3D4
+ await db.run('UPDATE users SET telegram_link_code = ? WHERE id = ?', code, req.user.id);
+ res.json({ code });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- USER PROFILE ---
app.get('/api/user/profile', authMiddleware, async (req, res) => {
try {
const db = getDb();
-
- // Get total income & expense
const totals = await db.get(
`SELECT
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income,
@@ -128,51 +122,32 @@ app.get('/api/user/profile', authMiddleware, async (req, res) => {
);
const balance = (totals?.income || 0) - (totals?.expense || 0);
-
- res.json({
- user: {
- id: req.user.id,
- username: req.user.username,
- first_name: req.user.first_name,
- currency: req.user.currency,
- auth_token: req.user.auth_token
- },
- balance,
- totalIncome: totals?.income || 0,
- totalExpense: totals?.expense || 0
- });
+ res.json({ user: req.user, balance, totalIncome: totals?.income || 0, totalExpense: totals?.expense || 0 });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
-// GET /api/transactions
+// --- TRANSACTIONS ---
app.get('/api/transactions', authMiddleware, async (req, res) => {
try {
const db = getDb();
- const transactions = await db.all(
- 'SELECT * FROM transactions WHERE user_id = ? ORDER BY date DESC, id DESC LIMIT 200',
- req.user.id
- );
+ const transactions = await db.all('SELECT * FROM transactions WHERE user_id = ? ORDER BY date DESC, id DESC LIMIT 200', req.user.id);
res.json(transactions);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
-// POST /api/transactions
app.post('/api/transactions', authMiddleware, async (req, res) => {
const { type, amount, category, description, date } = req.body;
- if (!type || !amount || !category || !date) {
- return res.status(400).json({ error: 'Missing required fields' });
- }
+ if (!type || !amount || !category || !date) return res.status(400).json({ error: 'Missing required fields' });
try {
const db = getDb();
const result = await db.run(
'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
req.user.id, type, parseFloat(amount), category, description || null, date
);
-
const newTx = await db.get('SELECT * FROM transactions WHERE id = ?', result.lastID);
res.status(201).json(newTx);
} catch (err) {
@@ -180,48 +155,141 @@ app.post('/api/transactions', authMiddleware, async (req, res) => {
}
});
-// DELETE /api/transactions/:id
app.delete('/api/transactions/:id', authMiddleware, async (req, res) => {
try {
const db = getDb();
- const result = await db.run(
- 'DELETE FROM transactions WHERE id = ? AND user_id = ?',
- req.params.id, req.user.id
- );
- if (result.changes === 0) {
- return res.status(404).json({ error: 'Transaction not found' });
- }
+ const result = await db.run('DELETE FROM transactions WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
+ if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
-// GET /api/subscriptions
+// --- CONSTANT INCOMES (SALARY) ---
+app.get('/api/incomes', authMiddleware, async (req, res) => {
+ try {
+ const db = getDb();
+ const incomes = await db.all('SELECT * FROM constant_incomes WHERE user_id = ? ORDER BY day_of_month ASC', req.user.id);
+ res.json(incomes);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/incomes', authMiddleware, async (req, res) => {
+ const { source_name, amount, day_of_month } = req.body;
+ if (!source_name || !amount || !day_of_month) return res.status(400).json({ error: 'Missing fields' });
+ try {
+ const db = getDb();
+ const result = await db.run(
+ 'INSERT INTO constant_incomes (user_id, source_name, amount, day_of_month) VALUES (?, ?, ?, ?)',
+ req.user.id, source_name, parseFloat(amount), parseInt(day_of_month)
+ );
+ const item = await db.get('SELECT * FROM constant_incomes WHERE id = ?', result.lastID);
+ res.status(201).json(item);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.delete('/api/incomes/:id', authMiddleware, async (req, res) => {
+ try {
+ const db = getDb();
+ const result = await db.run('DELETE FROM constant_incomes WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
+ if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- DEBTS ---
+app.get('/api/debts', authMiddleware, async (req, res) => {
+ try {
+ const db = getDb();
+ const debts = await db.all('SELECT * FROM debts WHERE user_id = ? ORDER BY status ASC, due_date ASC', req.user.id);
+ res.json(debts);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.post('/api/debts', authMiddleware, async (req, res) => {
+ const { person_name, amount, type, due_date } = req.body;
+ if (!person_name || !amount || !type) return res.status(400).json({ error: 'Missing fields' });
+ try {
+ const db = getDb();
+ const result = await db.run(
+ 'INSERT INTO debts (user_id, person_name, amount, type, due_date) VALUES (?, ?, ?, ?, ?)',
+ req.user.id, person_name, parseFloat(amount), type, due_date || null
+ );
+ const debt = await db.get('SELECT * FROM debts WHERE id = ?', result.lastID);
+ res.status(201).json(debt);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.put('/api/debts/:id/pay', authMiddleware, async (req, res) => {
+ try {
+ const db = getDb();
+ const debt = await db.get('SELECT * FROM debts WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
+ if (!debt) return res.status(404).json({ error: 'Not found' });
+
+ await db.run('BEGIN TRANSACTION');
+ await db.run('UPDATE debts SET status = "paid" WHERE id = ?', req.params.id);
+
+ // Add transaction
+ const today = new Date().toISOString().slice(0, 10);
+ const txType = debt.type === 'owe' ? 'expense' : 'income';
+ const category = debt.type === 'owe' ? 'Возврат долга' : 'Вернули долг';
+
+ await db.run(
+ 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
+ req.user.id, txType, debt.amount, category, `Долг: ${debt.person_name}`, today
+ );
+ await db.run('COMMIT');
+
+ const updated = await db.get('SELECT * FROM debts WHERE id = ?', req.params.id);
+ res.json(updated);
+ } catch (err) {
+ const db = getDb();
+ try { await db.run('ROLLBACK'); } catch (_) {}
+ res.status(500).json({ error: err.message });
+ }
+});
+
+app.delete('/api/debts/:id', authMiddleware, async (req, res) => {
+ try {
+ const db = getDb();
+ const result = await db.run('DELETE FROM debts WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
+ if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
+ res.json({ success: true });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// --- SUBSCRIPTIONS ---
app.get('/api/subscriptions', authMiddleware, async (req, res) => {
try {
const db = getDb();
- const subscriptions = await db.all(
- 'SELECT * FROM subscriptions WHERE user_id = ? ORDER BY next_payment_date ASC',
- req.user.id
- );
+ const subscriptions = await db.all('SELECT * FROM subscriptions WHERE user_id = ? ORDER BY next_payment_date ASC', req.user.id);
res.json(subscriptions);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
-// POST /api/subscriptions
app.post('/api/subscriptions', authMiddleware, async (req, res) => {
- const { name, amount, period, next_payment_date } = req.body;
- if (!name || !amount || !next_payment_date) {
- return res.status(400).json({ error: 'Missing required fields' });
- }
+ const { name, amount, next_payment_date } = req.body;
+ if (!name || !amount || !next_payment_date) return res.status(400).json({ error: 'Missing fields' });
try {
const db = getDb();
const result = await db.run(
- 'INSERT INTO subscriptions (user_id, name, amount, period, next_payment_date, active) VALUES (?, ?, ?, ?, ?, 1)',
- req.user.id, name, parseFloat(amount), period || 'monthly', next_payment_date
+ 'INSERT INTO subscriptions (user_id, name, amount, period, next_payment_date, active) VALUES (?, ?, ?, "monthly", ?, 1)',
+ req.user.id, name, parseFloat(amount), next_payment_date
);
const newSub = await db.get('SELECT * FROM subscriptions WHERE id = ?', result.lastID);
res.status(201).json(newSub);
@@ -230,149 +298,11 @@ app.post('/api/subscriptions', authMiddleware, async (req, res) => {
}
});
-// PUT /api/subscriptions/:id
-app.put('/api/subscriptions/:id', authMiddleware, async (req, res) => {
- const { name, amount, period, next_payment_date, active } = req.body;
- try {
- const db = getDb();
- const currentSub = await db.get('SELECT * FROM subscriptions WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
- if (!currentSub) {
- return res.status(404).json({ error: 'Subscription not found' });
- }
-
- await db.run(
- `UPDATE subscriptions SET
- name = ?,
- amount = ?,
- period = ?,
- next_payment_date = ?,
- active = ?
- WHERE id = ? AND user_id = ?`,
- name !== undefined ? name : currentSub.name,
- amount !== undefined ? parseFloat(amount) : currentSub.amount,
- period !== undefined ? period : currentSub.period,
- next_payment_date !== undefined ? next_payment_date : currentSub.next_payment_date,
- active !== undefined ? parseInt(active) : currentSub.active,
- req.params.id, req.user.id
- );
-
- const updatedSub = await db.get('SELECT * FROM subscriptions WHERE id = ?', req.params.id);
- res.json(updatedSub);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// DELETE /api/subscriptions/:id
app.delete('/api/subscriptions/:id', authMiddleware, async (req, res) => {
try {
const db = getDb();
- const result = await db.run(
- 'DELETE FROM subscriptions WHERE id = ? AND user_id = ?',
- req.params.id, req.user.id
- );
- if (result.changes === 0) {
- return res.status(404).json({ error: 'Subscription not found' });
- }
- res.json({ success: true });
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// GET /api/shopping
-app.get('/api/shopping', authMiddleware, async (req, res) => {
- try {
- const db = getDb();
- const items = await db.all(
- 'SELECT * FROM shopping_list WHERE user_id = ? ORDER BY bought ASC, priority DESC, id DESC',
- req.user.id
- );
- res.json(items);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// POST /api/shopping
-app.post('/api/shopping', authMiddleware, async (req, res) => {
- const { item_name, estimated_price, priority } = req.body;
- if (!item_name) {
- return res.status(400).json({ error: 'Missing item name' });
- }
- try {
- const db = getDb();
- const result = await db.run(
- 'INSERT INTO shopping_list (user_id, item_name, estimated_price, priority, bought) VALUES (?, ?, ?, ?, 0)',
- req.user.id, item_name, parseFloat(estimated_price || 0), priority || 'medium'
- );
- const newItem = await db.get('SELECT * FROM shopping_list WHERE id = ?', result.lastID);
- res.status(201).json(newItem);
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
-// POST /api/shopping/:id/buy
-app.post('/api/shopping/:id/buy', authMiddleware, async (req, res) => {
- try {
- const db = getDb();
- const item = await db.get('SELECT * FROM shopping_list WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
- if (!item) {
- return res.status(404).json({ error: 'Item not found' });
- }
-
- if (item.bought === 1) {
- return res.status(400).json({ error: 'Item already bought' });
- }
-
- // Start database transaction to ensure atomicity
- await db.run('BEGIN TRANSACTION');
-
- // 1. Mark item as bought
- await db.run('UPDATE shopping_list SET bought = 1 WHERE id = ?', req.params.id);
-
- // 2. Add to transaction log as expense
- const today = new Date().toISOString().slice(0, 10);
-
- // Helper to detect category based on item name
- const detectCategory = (name) => {
- const n = name.toLowerCase();
- if (['хлеб', 'молоко', 'еда', 'сыр', 'масло', 'мясо', 'овощи', 'фрукты'].some(k => n.includes(k))) return 'Еда';
- if (['такси', 'бензин', 'билет', 'проезд'].some(k => k.includes(n))) return 'Транспорт';
- if (['куртка', 'джинсы', 'обувь', 'кроссовки', 'одежда'].some(k => n.includes(k))) return 'Одежда';
- if (['фильм', 'игра', 'кино', 'развлечение'].some(k => n.includes(k))) return 'Развлечения';
- return 'Покупки';
- };
-
- const category = detectCategory(item.item_name);
-
- await db.run(
- 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
- req.user.id, 'expense', item.estimated_price, category, `Покупка: ${item.item_name}`, today
- );
-
- await db.run('COMMIT');
-
- const updatedItem = await db.get('SELECT * FROM shopping_list WHERE id = ?', req.params.id);
- res.json({ success: true, item: updatedItem });
- } catch (err) {
- try { await db.run('ROLLBACK'); } catch (_) {}
- res.status(500).json({ error: err.message });
- }
-});
-
-// DELETE /api/shopping/:id
-app.delete('/api/shopping/:id', authMiddleware, async (req, res) => {
- try {
- const db = getDb();
- const result = await db.run(
- 'DELETE FROM shopping_list WHERE id = ? AND user_id = ?',
- req.params.id, req.user.id
- );
- if (result.changes === 0) {
- return res.status(404).json({ error: 'Item not found' });
- }
+ const result = await db.run('DELETE FROM subscriptions WHERE id = ? AND user_id = ?', req.params.id, req.user.id);
+ if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -381,41 +311,22 @@ app.delete('/api/shopping/:id', authMiddleware, async (req, res) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-
-// Serve static frontend files
const frontendDistPath = path.resolve(__dirname, '../dist');
app.use(express.static(frontendDistPath));
-// Health check / API status
-app.get('/api/status', (req, res) => {
- res.json({ status: 'ok', time: new Date() });
-});
+app.get('/api/status', (req, res) => res.json({ status: 'ok', time: new Date() }));
-// Fallback to React SPA index.html for non-API routes
app.get('*', (req, res, next) => {
- if (req.path.startsWith('/api')) {
- return next();
- }
- res.sendFile(path.join(frontendDistPath, 'index.html'), (err) => {
- if (err) {
- // In development or if frontend dist is missing, send a helper message
- res.status(404).send('Frontend is not built or index.html is missing. Run frontend build first.');
- }
- });
+ if (req.path.startsWith('/api')) return next();
+ res.sendFile(path.join(frontendDistPath, 'index.html'));
});
-// Start Express server & bot
async function startServer() {
try {
await initDb();
const bot = await initBot();
- if (bot) {
- startScheduler(bot);
- }
-
- app.listen(PORT, () => {
- console.log(`🚀 Server running on port ${PORT}`);
- });
+ startScheduler(bot);
+ app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
} catch (err) {
console.error('Fatal initialization error:', err);
process.exit(1);
diff --git a/backend/src/scheduler.js b/backend/src/scheduler.js
index 82831f2..74ce0d6 100644
--- a/backend/src/scheduler.js
+++ b/backend/src/scheduler.js
@@ -1,106 +1,115 @@
import { getDb } from './db.js';
-// Helper to add months or years to a date string (YYYY-MM-DD)
-function advanceDate(dateStr, period) {
- const date = new Date(dateStr);
- if (period === 'yearly') {
- date.setFullYear(date.getFullYear() + 1);
- } else {
- date.setMonth(date.getMonth() + 1);
- }
- return date.toISOString().slice(0, 10);
+let botInstance = null;
+
+export function startScheduler(bot) {
+ botInstance = bot;
+ // Run checks every hour
+ const ONE_HOUR = 60 * 60 * 1000;
+ setInterval(runChecks, ONE_HOUR);
+
+ // Run immediately on start
+ runChecks();
+ console.log('⏰ Scheduler started (checks every hour).');
}
-export async function checkSubscriptions(bot) {
- if (!bot) {
- console.log('Scheduler: Bot is not initialized. Skipping subscription checks.');
- return;
+async function runChecks() {
+ console.log('🕰️ Running scheduled checks...');
+ try {
+ await checkSalaries();
+ await checkSubscriptions();
+ await checkDebts();
+ } catch (err) {
+ console.error('Error during scheduled checks:', err);
}
+}
- console.log('🕰️ Running scheduled subscription checks...');
+async function checkSalaries() {
const db = getDb();
+ const today = new Date();
+ const currentDay = today.getDate();
+ const currentMonthStr = today.toISOString().slice(0, 7); // YYYY-MM
+ const fullDateStr = today.toISOString().slice(0, 10);
+
+ const incomes = await db.all('SELECT * FROM constant_incomes');
- const todayStr = new Date().toISOString().slice(0, 10);
-
- // Calculate tomorrow's date
+ for (const income of incomes) {
+ // If today is the payout day or later, and it hasn't been credited this month
+ if (currentDay >= income.day_of_month && income.last_credited_month !== currentMonthStr) {
+ // 1. Add transaction
+ await db.run(
+ 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
+ income.user_id, 'income', income.amount, 'Регулярный доход', income.source_name, fullDateStr
+ );
+
+ // 2. Update last credited month
+ await db.run(
+ 'UPDATE constant_incomes SET last_credited_month = ? WHERE id = ?',
+ currentMonthStr, income.id
+ );
+
+ // 3. Notify user if telegram linked
+ const user = await db.get('SELECT telegram_id FROM users WHERE id = ?', income.user_id);
+ if (user && user.telegram_id && botInstance) {
+ try {
+ await botInstance.telegram.sendMessage(
+ user.telegram_id,
+ `💰 Начислен регулярный доход!\n*${income.source_name}*: +${income.amount} ₽`,
+ { parse_mode: 'Markdown' }
+ );
+ } catch (e) {
+ console.error(`Failed to send salary notification to ${user.telegram_id}`);
+ }
+ }
+ }
+ }
+}
+
+async function checkSubscriptions() {
+ const db = getDb();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().slice(0, 10);
- try {
- // 1. Pre-notification for tomorrow's payments
- const upcomingSubs = await db.all(
- 'SELECT * FROM subscriptions WHERE active = 1 AND next_payment_date = ?',
- tomorrowStr
- );
+ const subs = await db.all('SELECT * FROM subscriptions WHERE active = 1 AND next_payment_date = ?', tomorrowStr);
- for (const sub of upcomingSubs) {
+ for (const sub of subs) {
+ const user = await db.get('SELECT telegram_id FROM users WHERE id = ?', sub.user_id);
+ if (user && user.telegram_id && botInstance) {
try {
- await bot.telegram.sendMessage(
- sub.user_id,
- `🔔 **Напоминание о подписке!**\n\nЗавтра ожидается оплата подписки *${sub.name}* на сумму *${sub.amount} ₽*.`,
+ await botInstance.telegram.sendMessage(
+ user.telegram_id,
+ `⚠️ Напоминание: Завтра (${tomorrowStr}) будет списание за подписку *${sub.name}* на сумму ${sub.amount} ₽.`,
{ parse_mode: 'Markdown' }
);
- console.log(`Sent upcoming subscription warning to user ${sub.user_id} for sub: ${sub.name}`);
- } catch (err) {
- console.error(`Failed to send Telegram message to user ${sub.user_id}:`, err.message);
+ } catch (e) {
+ console.error(`Failed to send subscription notification to ${user.telegram_id}`);
}
}
-
- // 2. Process today's payments (Notify + Log Expense + Advance Date)
- const todaySubs = await db.all(
- 'SELECT * FROM subscriptions WHERE active = 1 AND next_payment_date <= ?',
- todayStr
- );
-
- for (const sub of todaySubs) {
- try {
- await db.run('BEGIN TRANSACTION');
-
- // 1. Log transaction expense
- await db.run(
- 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)',
- sub.user_id, 'expense', sub.amount, 'Развлечения', `Подписка: ${sub.name}`, todayStr
- );
-
- // 2. Advance next payment date
- const newPaymentDate = advanceDate(sub.next_payment_date, sub.period);
- await db.run(
- 'UPDATE subscriptions SET next_payment_date = ? WHERE id = ?',
- newPaymentDate, sub.id
- );
-
- await db.run('COMMIT');
-
- // 3. Notify user in Telegram
- await bot.telegram.sendMessage(
- sub.user_id,
- `📅 **Списание по подписке!**\n\nСегодня оплачена подписка *${sub.name}* на сумму *${sub.amount} ₽*.\n` +
- `💸 Расход автоматически добавлен в историю.\n` +
- `📆 Следующий платеж запланирован на: *${newPaymentDate}*`,
- { parse_mode: 'Markdown' }
- );
- console.log(`Processed subscription payment for sub: ${sub.name}, advanced to: ${newPaymentDate}`);
- } catch (err) {
- try { await db.run('ROLLBACK'); } catch (_) {}
- console.error(`Failed to process payment for subscription ${sub.name} (${sub.id}):`, err);
- }
- }
- } catch (err) {
- console.error('Error checking subscriptions:', err);
}
}
-// Start daily check intervals
-export function startScheduler(bot) {
- // Run check immediately on start
- setTimeout(() => checkSubscriptions(bot), 5000);
+async function checkDebts() {
+ const db = getDb();
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const tomorrowStr = tomorrow.toISOString().slice(0, 10);
- // Run checks every 12 hours (12 * 60 * 60 * 1000 ms)
- const intervalMs = 12 * 60 * 60 * 1000;
- setInterval(() => {
- checkSubscriptions(bot);
- }, intervalMs);
+ const debts = await db.all('SELECT * FROM debts WHERE status = "pending" AND due_date = ?', tomorrowStr);
- console.log('⏰ Subscription scheduler successfully started (checks every 12 hours).');
+ for (const debt of debts) {
+ const user = await db.get('SELECT telegram_id FROM users WHERE id = ?', debt.user_id);
+ if (user && user.telegram_id && botInstance) {
+ const typeText = debt.type === 'owe' ? 'Вам нужно вернуть долг' : 'Вам должны вернуть долг';
+ try {
+ await botInstance.telegram.sendMessage(
+ user.telegram_id,
+ `📅 Напоминание о долге: Завтра крайний срок!\n${typeText}\n*Человек*: ${debt.person_name}\n*Сумма*: ${debt.amount} ₽`,
+ { parse_mode: 'Markdown' }
+ );
+ } catch (e) {
+ console.error(`Failed to send debt notification to ${user.telegram_id}`);
+ }
+ }
+ }
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index dd25710..921a86c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,9 +8,10 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
- "lucide-react": "^1.24.0",
+ "lucide-react": "^1.26.0",
"react": "^19.2.7",
- "react-dom": "^19.2.7"
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^19.2.17",
@@ -727,6 +728,19 @@
}
}
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1039,9 +1053,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.24.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
- "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz",
+ "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -1185,6 +1199,44 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-router": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+ "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
+ "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -1225,6 +1277,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 998d803..9eacc44 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,9 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
- "lucide-react": "^1.24.0",
+ "lucide-react": "^1.26.0",
"react": "^19.2.7",
- "react-dom": "^19.2.7"
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^19.2.17",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 29578e7..6884f70 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,1231 +1,29 @@
-import React, { useState, useEffect } from 'react';
-import {
- TrendingUp,
- TrendingDown,
- DollarSign,
- Calendar,
- ShoppingCart,
- Plus,
- Trash2,
- LogOut,
- Check,
- X,
- Lock,
- RefreshCw,
- Play,
- Pause,
- Wallet,
- ListFilter
-} from 'lucide-react';
-import {
- initTelegram,
- getTelegramUser,
- getAuthHeaders,
- saveTokenFromUrl,
- logout,
- tg
-} from './TelegramSDK';
-
-const API_URL = window.location.hostname === 'localhost' ? 'http://localhost:5000' : window.location.origin;
-
-function App() {
- // Navigation & Authentication
- const [activeTab, setActiveTab] = useState('dashboard');
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const [user, setUser] = useState(null);
-
- // Data States
- const [profile, setProfile] = useState({ balance: 0, totalIncome: 0, totalExpense: 0 });
- const [transactions, setTransactions] = useState([]);
- const [subscriptions, setSubscriptions] = useState([]);
- const [shoppingList, setShoppingList] = useState([]);
-
- // UI States
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState('');
- const [successMsg, setSuccessMsg] = useState('');
-
- // Modal States
- const [showTxModal, setShowTxModal] = useState(false);
- const [showSubModal, setShowSubModal] = useState(false);
- const [showShoppingModal, setShowShoppingModal] = useState(false);
-
- // Form States
- const [txForm, setTxForm] = useState({ type: 'expense', amount: '', category: 'Еда', description: '', date: new Date().toISOString().slice(0, 10) });
- const [subForm, setSubForm] = useState({ name: '', amount: '', period: 'monthly', next_payment_date: new Date().toISOString().slice(0, 10) });
- const [shoppingForm, setShoppingForm] = useState({ item_name: '', estimated_price: '', priority: 'medium' });
-
- // Filter States
- const [txFilter, setTxFilter] = useState('all'); // all, income, expense
- const [txCategoryFilter, setTxCategoryFilter] = useState('all');
-
- // Categories list
- const categories = ['Еда', 'Транспорт', 'Развлечения', 'Жилье', 'Одежда', 'Здоровье', 'Гигиена', 'Прочее'];
-
- // Initialize App
- useEffect(() => {
- initTelegram();
- const token = saveTokenFromUrl();
-
- // If inside Telegram, initData is used automatically. Otherwise check token
- if ((tg && tg.initData) || token || window.location.hostname === 'localhost') {
- setIsAuthenticated(true);
- fetchAllData();
- } else {
- setIsLoading(false);
- }
- }, []);
-
- // Fetch all data from backend
- const fetchAllData = async () => {
- setIsLoading(true);
- setError('');
- try {
- const headers = getAuthHeaders();
-
- // 1. Profile
- const profileRes = await fetch(`${API_URL}/api/user/profile`, { headers });
- if (!profileRes.ok) throw new Error('Ошибка авторизации');
- const profileData = await profileRes.json();
- setProfile(profileData);
- setUser(profileData.user);
-
- // 2. Transactions
- const txRes = await fetch(`${API_URL}/api/transactions`, { headers });
- const txData = await txRes.json();
- setTransactions(txData);
-
- // 3. Subscriptions
- const subRes = await fetch(`${API_URL}/api/subscriptions`, { headers });
- const subData = await subRes.json();
- setSubscriptions(subData);
-
- // 4. Shopping list
- const shopRes = await fetch(`${API_URL}/api/shopping`, { headers });
- const shopData = await shopRes.json();
- setShoppingList(shopData);
-
- setIsAuthenticated(true);
- } catch (err) {
- console.error('Fetch error:', err);
- setError('Не удалось загрузить данные. Проверьте соединение.');
- // If error is auth, clear auth state
- if (err.message === 'Ошибка авторизации') {
- setIsAuthenticated(false);
- }
- } finally {
- setIsLoading(false);
- }
- };
-
- // Alert message helper
- const showMessage = (msg, type = 'success') => {
- if (type === 'success') {
- setSuccessMsg(msg);
- setTimeout(() => setSuccessMsg(''), 4000);
- } else {
- setError(msg);
- setTimeout(() => setError(''), 4000);
- }
- };
-
- // --- API MUTATIONS ---
-
- // Manually log in as test user (localhost development bypass)
- const handleTestLogin = () => {
- localStorage.setItem('finance_auth_token', 'test_token_123');
- setIsAuthenticated(true);
- fetchAllData();
- };
-
- // Transactions CRUD
- const handleAddTransaction = async (e) => {
- e.preventDefault();
- if (!txForm.amount || isNaN(txForm.amount) || parseFloat(txForm.amount) <= 0) {
- return showMessage('Введите корректную сумму', 'error');
- }
-
- try {
- const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' };
- const res = await fetch(`${API_URL}/api/transactions`, {
- method: 'POST',
- headers,
- body: JSON.stringify(txForm)
- });
-
- if (!res.ok) throw new Error('Ошибка добавления транзакции');
-
- const newTx = await res.json();
- setTransactions([newTx, ...transactions]);
-
- // Update profile totals
- const amountNum = parseFloat(txForm.amount);
- setProfile(prev => {
- const isInc = txForm.type === 'income';
- return {
- ...prev,
- balance: prev.balance + (isInc ? amountNum : -amountNum),
- totalIncome: prev.totalIncome + (isInc ? amountNum : 0),
- totalExpense: prev.totalExpense + (isInc ? 0 : amountNum)
- };
- });
-
- setShowTxModal(false);
- // Reset form
- setTxForm({ type: 'expense', amount: '', category: 'Еда', description: '', date: new Date().toISOString().slice(0, 10) });
- showMessage('Транзакция успешно добавлена!');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- const handleDeleteTransaction = async (id, amount, type) => {
- if (!window.confirm('Вы действительно хотите удалить эту транзакцию?')) return;
- try {
- const headers = getAuthHeaders();
- const res = await fetch(`${API_URL}/api/transactions/${id}`, {
- method: 'DELETE',
- headers
- });
- if (!res.ok) throw new Error('Ошибка удаления');
-
- setTransactions(transactions.filter(t => t.id !== id));
-
- // Update profile totals
- setProfile(prev => {
- const isInc = type === 'income';
- return {
- ...prev,
- balance: prev.balance - (isInc ? amount : -amount),
- totalIncome: prev.totalIncome - (isInc ? amount : 0),
- totalExpense: prev.totalExpense - (isInc ? 0 : amount)
- };
- });
- showMessage('Транзакция удалена');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- // Subscriptions CRUD
- const handleAddSubscription = async (e) => {
- e.preventDefault();
- if (!subForm.name || !subForm.amount || isNaN(subForm.amount)) {
- return showMessage('Заполните обязательные поля корректно', 'error');
- }
-
- try {
- const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' };
- const res = await fetch(`${API_URL}/api/subscriptions`, {
- method: 'POST',
- headers,
- body: JSON.stringify(subForm)
- });
-
- if (!res.ok) throw new Error('Ошибка добавления подписки');
- const newSub = await res.json();
- setSubscriptions([...subscriptions, newSub]);
-
- setShowSubModal(false);
- setSubForm({ name: '', amount: '', period: 'monthly', next_payment_date: new Date().toISOString().slice(0, 10) });
- showMessage('Подписка добавлена');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- const handleToggleSubActive = async (id, currentStatus) => {
- try {
- const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' };
- const newStatus = currentStatus === 1 ? 0 : 1;
- const res = await fetch(`${API_URL}/api/subscriptions/${id}`, {
- method: 'PUT',
- headers,
- body: JSON.stringify({ active: newStatus })
- });
- if (!res.ok) throw new Error('Не удалось обновить статус');
-
- setSubscriptions(subscriptions.map(s => s.id === id ? { ...s, active: newStatus } : s));
- showMessage(newStatus === 1 ? 'Подписка возобновлена' : 'Подписка приостановлена');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- const handleDeleteSubscription = async (id) => {
- if (!window.confirm('Удалить эту подписку?')) return;
- try {
- const headers = getAuthHeaders();
- const res = await fetch(`${API_URL}/api/subscriptions/${id}`, {
- method: 'DELETE',
- headers
- });
- if (!res.ok) throw new Error('Ошибка удаления');
-
- setSubscriptions(subscriptions.filter(s => s.id !== id));
- showMessage('Подписка удалена');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- // Shopping List CRUD
- const handleAddShoppingItem = async (e) => {
- e.preventDefault();
- if (!shoppingForm.item_name) return showMessage('Введите название товара', 'error');
-
- try {
- const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' };
- const res = await fetch(`${API_URL}/api/shopping`, {
- method: 'POST',
- headers,
- body: JSON.stringify({
- item_name: shoppingForm.item_name,
- estimated_price: shoppingForm.estimated_price ? parseFloat(shoppingForm.estimated_price) : 0,
- priority: shoppingForm.priority
- })
- });
-
- if (!res.ok) throw new Error('Ошибка добавления');
- const newItem = await res.json();
- setShoppingList([newItem, ...shoppingList]);
-
- setShowShoppingModal(false);
- setShoppingForm({ item_name: '', estimated_price: '', priority: 'medium' });
- showMessage('Товар добавлен в список покупок');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- const handleBuyShoppingItem = async (id, name, price) => {
- try {
- const headers = getAuthHeaders();
- const res = await fetch(`${API_URL}/api/shopping/${id}/buy`, {
- method: 'POST',
- headers
- });
- if (!res.ok) throw new Error('Ошибка покупки');
-
- // Update shopping list state (mark as bought)
- setShoppingList(shoppingList.map(item => item.id === id ? { ...item, bought: 1 } : item));
-
- // Refetch profile & transactions to get new transaction
- const txRes = await fetch(`${API_URL}/api/transactions`, { headers });
- const txData = await txRes.json();
- setTransactions(txData);
-
- const profileRes = await fetch(`${API_URL}/api/user/profile`, { headers });
- const profileData = await profileRes.json();
- setProfile(profileData);
-
- showMessage(`Товар "${name}" куплен! Расход записан.`);
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- const handleDeleteShoppingItem = async (id) => {
- try {
- const headers = getAuthHeaders();
- const res = await fetch(`${API_URL}/api/shopping/${id}`, {
- method: 'DELETE',
- headers
- });
- if (!res.ok) throw new Error('Ошибка удаления');
-
- setShoppingList(shoppingList.filter(item => item.id !== id));
- showMessage('Товар удален из списка');
- } catch (err) {
- showMessage(err.message, 'error');
- }
- };
-
- // --- STATS CALCULATIONS ---
-
- // Category breakdown
- const categoryExpenses = transactions
- .filter(t => t.type === 'expense')
- .reduce((acc, t) => {
- acc[t.category] = (acc[t.category] || 0) + t.amount;
- return acc;
- }, {});
-
- // Convert categories object to sorted array
- const categoryList = Object.keys(categoryExpenses).map(cat => ({
- name: cat,
- value: categoryExpenses[cat],
- color: cat === 'Еда' ? '#3b82f6' :
- cat === 'Транспорт' ? '#10b981' :
- cat === 'Развлечения' ? '#ec4899' :
- cat === 'Жилье' ? '#f59e0b' :
- cat === 'Одежда' ? '#8b5cf6' :
- cat === 'Здоровье' ? '#ef4444' :
- cat === 'Гигиена' ? '#06b6d4' : '#6b7280'
- })).sort((a, b) => b.value - a.value);
-
- // Weekly stats for simple SVG chart (last 7 days of expenses)
- const getLast7DaysExpenses = () => {
- const expenses = new Array(7).fill(0).map((_, i) => {
- const d = new Date();
- d.setDate(d.getDate() - i);
- return {
- dateStr: d.toISOString().slice(0, 10),
- dayName: d.toLocaleDateString('ru-RU', { weekday: 'short' }),
- amount: 0
- };
- }).reverse();
-
- transactions.forEach(t => {
- if (t.type === 'expense') {
- const item = expenses.find(e => e.dateStr === t.date);
- if (item) {
- item.amount += t.amount;
- }
- }
- });
-
- return expenses;
- };
-
- const weeklyExpenses = getLast7DaysExpenses();
- const maxWeeklyExpense = Math.max(...weeklyExpenses.map(w => w.amount), 100);
-
- // Total recurring subscriptions cost
- const totalSubCost = subscriptions
- .filter(s => s.active === 1)
- .reduce((sum, s) => sum + s.amount, 0);
-
- // Format currency helper
- const formatCurrency = (val) => {
- return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(val);
- };
-
- // --- RENDER SCREEN ---
-
- if (isLoading) {
- return (
-
-
-
-
Загрузка финансов...
-
-
- );
- }
-
- // Not authenticated screen (Magic Link required or developer mock button)
- if (!isAuthenticated) {
- return (
-
-
-
-
-
-
Личный Финансовый Трекер
-
Доступ ограничен. Войдите через Telegram-бота
-
-
- Напишите нашему боту в Telegram и нажмите кнопку 📊 Открыть Dashboard .
- Или запросите ссылку на ПК с помощью команды /login в чате с ботом.
-
-
- {/* Dev Bypass Button */}
-
- Войти как тестовый пользователь (Локально)
-
-
-
- );
- }
-
- return (
-
- {/* Sidebar - Desktop */}
-
-
- {/* Bottom Nav - Mobile */}
-
- setActiveTab('dashboard')}
- className={`mobile-nav-btn ${activeTab === 'dashboard' ? 'active' : ''}`}
- id="m-nav-dashboard"
- >
-
- Панель
-
- setActiveTab('transactions')}
- className={`mobile-nav-btn ${activeTab === 'transactions' ? 'active' : ''}`}
- id="m-nav-transactions"
- >
-
- Транзакции
-
- setActiveTab('subscriptions')}
- className={`mobile-nav-btn ${activeTab === 'subscriptions' ? 'active' : ''}`}
- id="m-nav-subscriptions"
- >
-
- Подписки
-
- setActiveTab('shopping')}
- className={`mobile-nav-btn ${activeTab === 'shopping' ? 'active' : ''}`}
- id="m-nav-shopping"
- >
-
- Покупки
-
-
-
- {/* Main Content Area */}
-
- {/* Messages */}
- {successMsg && {successMsg}
}
- {error && {error}
}
-
- {/* --- TAB: DASHBOARD --- */}
- {activeTab === 'dashboard' && (
-
-
-
Финансовая Панель
-
- Обновить
-
-
-
- {/* Metrics */}
-
-
-
- Общий Баланс
-
-
-
{formatCurrency(profile.balance)}
-
-
-
-
- Доходы за месяц
-
-
-
{formatCurrency(profile.totalIncome)}
-
-
-
-
- Расходы за месяц
-
-
-
{formatCurrency(profile.totalExpense)}
-
-
-
-
- Регулярные подписки
-
-
-
{formatCurrency(totalSubCost)} / мес
-
-
-
- {/* Charts */}
-
- {/* Daily Bar Chart */}
-
-
- Расходы за последние 7 дней
-
-
-
- {/* SVG Bar Chart */}
-
- {weeklyExpenses.map((w, index) => {
- const pctHeight = maxWeeklyExpense > 0 ? (w.amount / maxWeeklyExpense) * 100 : 0;
- return (
-
-
- {w.amount > 0 ? Math.round(w.amount) : ''}
-
- {/* Bar */}
-
0 ? 'linear-gradient(to top, var(--primary), var(--secondary))' : 'rgba(255, 255, 255, 0.05)',
- borderRadius: '6px 6px 0 0',
- boxShadow: w.amount > 0 ? '0 0 10px rgba(168, 85, 247, 0.3)' : 'none',
- transition: 'height 0.5s ease'
- }}
- title={`${w.dateStr}: ${formatCurrency(w.amount)}`}
- />
-
- {w.dayName}
-
-
- );
- })}
-
-
-
- {/* Category Breakdown */}
-
-
- Категории расходов
-
-
-
- {categoryList.length > 0 ? (
- categoryList.map((c, i) => {
- const totalExpensesVal = profile.totalExpense || 1;
- const percent = ((c.value / totalExpensesVal) * 100).toFixed(0);
- return (
-
-
- {c.name}
- {formatCurrency(c.value)} ({percent}%)
-
-
-
- );
- })
- ) : (
-
- Нет расходов за этот период
-
- )}
-
-
-
-
- {/* Quick list of upcoming subscriptions */}
-
-
Ближайшие платежи по подпискам
-
- {subscriptions.filter(s => s.active === 1).length > 0 ? (
-
-
-
- Название
- Стоимость
- Период
- След. платеж
-
-
-
- {subscriptions
- .filter(s => s.active === 1)
- .slice(0, 5)
- .map(s => (
-
- {s.name}
- {formatCurrency(s.amount)}
- {s.period === 'monthly' ? 'Ежемесячно' : 'Ежегодно'}
- {s.next_payment_date}
-
- ))}
-
-
- ) : (
-
Активных подписок не найдено
- )}
-
-
-
- )}
-
- {/* --- TAB: TRANSACTIONS --- */}
- {activeTab === 'transactions' && (
-
-
-
История транзакций
-
setShowTxModal(true)} className="btn btn-primary" id="btn-add-tx">
- Добавить транзакцию
-
-
-
- {/* Filters */}
-
-
- Тип:
- setTxFilter(e.target.value)}
- className="form-input form-select"
- style={{ width: '130px', padding: '8px 12px' }}
- id="filter-type"
- >
- Все
- Доходы
- Расходы
-
-
-
-
- Категория:
- setTxCategoryFilter(e.target.value)}
- className="form-input form-select"
- style={{ width: '160px', padding: '8px 12px' }}
- id="filter-category"
- >
- Все категории
- Доходы
- {categories.map(c => (
- {c}
- ))}
-
-
-
-
- {/* Transactions Table */}
-
-
- {transactions.length > 0 ? (
-
-
-
- Тип
- Категория
- Описание
- Дата
- Сумма
- Действия
-
-
-
- {transactions
- .filter(t => txFilter === 'all' || t.type === txFilter)
- .filter(t => txCategoryFilter === 'all' || t.category === txCategoryFilter)
- .map(t => (
-
-
-
- {t.type === 'income' ? 'Доход' : 'Расход'}
-
-
- {t.category}
- {t.description || '—'}
- {t.date}
-
-
- {t.type === 'income' ? '+' : '-'} {formatCurrency(t.amount)}
-
-
-
- handleDeleteTransaction(t.id, t.amount, t.type)}
- className="btn btn-secondary"
- style={{ padding: '6px 10px', color: 'var(--expense)' }}
- title="Удалить"
- id={`delete-tx-${t.id}`}
- >
-
-
-
-
- ))}
-
-
- ) : (
-
Транзакций не найдено
- )}
-
-
-
- )}
-
- {/* --- TAB: SUBSCRIPTIONS --- */}
- {activeTab === 'subscriptions' && (
-
-
-
Мои Подписки
-
setShowSubModal(true)} className="btn btn-primary" id="btn-add-sub">
- Добавить подписку
-
-
-
-
- {subscriptions.length > 0 ? (
- subscriptions.map(s => (
-
-
-
{s.name}
-
handleToggleSubActive(s.id, s.active)}
- className={`btn ${s.active === 1 ? 'btn-danger' : 'btn-success'}`}
- style={{ padding: '4px 8px', fontSize: '12px' }}
- id={`toggle-sub-${s.id}`}
- >
- {s.active === 1 ? : }
-
-
-
-
{formatCurrency(s.amount)} / мес
-
-
-
Период: {s.period === 'monthly' ? 'Ежемесячно' : 'Ежегодно'}
-
След. оплата: {s.next_payment_date}
-
-
-
- handleDeleteSubscription(s.id)}
- className="btn btn-secondary"
- style={{ width: '100%', color: 'var(--expense)' }}
- id={`delete-sub-${s.id}`}
- >
- Удалить
-
-
-
- ))
- ) : (
-
- Нет подписок. Создайте новую подписку здесь или в боте с помощью /sub!
-
- )}
-
-
- )}
-
- {/* --- TAB: SHOPPING --- */}
- {activeTab === 'shopping' && (
-
-
-
Список покупок
-
setShowShoppingModal(true)} className="btn btn-primary" id="btn-add-shop">
- Добавить покупку
-
-
-
-
- {/* HIGH PRIORITY */}
-
-
🔥 Высокий приоритет
- {shoppingList.filter(i => i.bought === 0 && i.priority === 'high').map(item => (
-
-
- {item.item_name}
- {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : 'Цена не указана'}
-
-
- handleBuyShoppingItem(item.id, item.item_name, item.estimated_price)}
- className="btn btn-success"
- style={{ padding: '6px 10px' }}
- title="Купить"
- id={`buy-shop-${item.id}`}
- >
-
-
- handleDeleteShoppingItem(item.id)}
- className="btn btn-secondary"
- style={{ padding: '6px 10px', color: 'var(--expense)' }}
- title="Удалить"
- id={`delete-shop-${item.id}`}
- >
-
-
-
-
- ))}
-
-
- {/* MEDIUM PRIORITY */}
-
-
⚡ Средний приоритет
- {shoppingList.filter(i => i.bought === 0 && i.priority === 'medium').map(item => (
-
-
- {item.item_name}
- {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : 'Цена не указана'}
-
-
- handleBuyShoppingItem(item.id, item.item_name, item.estimated_price)}
- className="btn btn-success"
- style={{ padding: '6px 10px' }}
- title="Купить"
- id={`buy-shop-${item.id}`}
- >
-
-
- handleDeleteShoppingItem(item.id)}
- className="btn btn-secondary"
- style={{ padding: '6px 10px', color: 'var(--expense)' }}
- title="Удалить"
- id={`delete-shop-${item.id}`}
- >
-
-
-
-
- ))}
-
-
- {/* LOW PRIORITY */}
-
-
🌱 Низкий приоритет
- {shoppingList.filter(i => i.bought === 0 && i.priority === 'low').map(item => (
-
-
- {item.item_name}
- {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : 'Цена не указана'}
-
-
- handleBuyShoppingItem(item.id, item.item_name, item.estimated_price)}
- className="btn btn-success"
- style={{ padding: '6px 10px' }}
- title="Купить"
- id={`buy-shop-${item.id}`}
- >
-
-
- handleDeleteShoppingItem(item.id)}
- className="btn btn-secondary"
- style={{ padding: '6px 10px', color: 'var(--expense)' }}
- title="Удалить"
- id={`delete-shop-${item.id}`}
- >
-
-
-
-
- ))}
-
-
- {/* ALREADY BOUGHT */}
-
-
✅ Куплено
- {shoppingList.filter(i => i.bought === 1).map(item => (
-
-
- {item.item_name}
- {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : ''}
-
-
- handleDeleteShoppingItem(item.id)}
- className="btn btn-secondary"
- style={{ padding: '6px 10px', color: 'var(--expense)' }}
- title="Удалить"
- id={`delete-shop-${item.id}`}
- >
-
-
-
-
- ))}
-
-
-
- )}
-
-
- {/* --- MODAL: ADD TRANSACTION --- */}
- {showTxModal && (
-
-
-
-
Новая операция
- setShowTxModal(false)} className="modal-close" id="close-tx-modal">
-
-
-
-
-
-
-
- )}
-
- {/* --- MODAL: ADD SUBSCRIPTION --- */}
- {showSubModal && (
-
-
-
-
Новая подписка
- setShowSubModal(false)} className="modal-close" id="close-sub-modal">
-
-
-
-
-
-
-
- )}
-
- {/* --- MODAL: ADD SHOPPING ITEM --- */}
- {showShoppingModal && (
-
-
-
-
Добавить покупку
- setShowShoppingModal(false)} className="modal-close" id="close-shop-modal">
-
-
-
-
-
-
-
- )}
-
- );
+import React from 'react';
+import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
+import Layout from './components/Layout';
+import Auth from './pages/Auth';
+import Dashboard from './pages/Dashboard';
+import Transactions from './pages/Transactions';
+import Debts from './pages/Debts';
+import Incomes from './pages/Incomes';
+import Settings from './pages/Settings';
+
+function PrivateRoute({ children }) {
+ const token = localStorage.getItem('token');
+ return token ? {children} : ;
}
-export default App;
+export default function App() {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
new file mode 100644
index 0000000..95e2bf1
--- /dev/null
+++ b/frontend/src/components/Layout.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { Home, List, CreditCard, Banknote, Settings, LogOut } from 'lucide-react';
+
+export default function Layout({ children }) {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const handleLogout = () => {
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ navigate('/auth');
+ };
+
+ const navItems = [
+ { path: '/', label: 'Дашборд', icon: },
+ { path: '/transactions', label: 'Транзакции', icon:
},
+ { path: '/incomes', label: 'Регулярные Доходы', icon: },
+ { path: '/debts', label: 'Долги', icon: },
+ { path: '/settings', label: 'Настройки', icon: },
+ ];
+
+ return (
+
+
+
+ Finance Manager
+
+
+ {navItems.map(item => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+
+ Выйти
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 632761e..86da61b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,759 +1,255 @@
-/* Premium Minimalistic Dark Theme Design System */
-
:root {
- /* Colors */
- --bg-color: #090a0f;
-
- --card-bg: #11141e;
- --card-border: #1e2330;
- --card-border-hover: #2d3446;
-
- --primary: #8b5cf6;
- --secondary: #06b6d4;
-
- --income: #10b981;
- --income-bg: rgba(16, 185, 129, 0.08);
- --expense: #f43f5e;
- --expense-bg: rgba(244, 63, 94, 0.08);
-
- --text-primary: #f3f4f6;
- --text-secondary: #9ca3af;
- --text-muted: #4b5563;
-
- --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
-
- /* Fonts */
- --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
- --font-display: 'Outfit', sans-serif;
+ --bg-primary: #121212;
+ --bg-secondary: #1e1e1e;
+ --bg-card: #252526;
+ --text-primary: #e0e0e0;
+ --text-secondary: #a0a0a0;
+ --accent-color: #007acc;
+ --accent-hover: #0098ff;
+ --danger: #d32f2f;
+ --success: #388e3c;
+ --border-color: #333333;
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
- font-family: var(--font-sans);
- -webkit-tap-highlight-color: transparent;
}
body {
- background: var(--bg-color);
+ background-color: var(--bg-primary);
color: var(--text-primary);
- min-height: 100vh;
- overflow-x: hidden;
+ font-family: var(--font-family);
+ -webkit-font-smoothing: antialiased;
line-height: 1.5;
}
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 5px;
- height: 5px;
+h1, h2, h3, h4 {
+ font-weight: 600;
+ color: #ffffff;
}
-::-webkit-scrollbar-track {
- background: transparent;
+
+a {
+ color: var(--accent-color);
+ text-decoration: none;
}
-::-webkit-scrollbar-thumb {
- background: #1f2937;
+
+a:hover {
+ text-decoration: underline;
+}
+
+button {
+ cursor: pointer;
+ font-family: inherit;
+ border: none;
+ background: var(--accent-color);
+ color: #fff;
+ padding: 0.5rem 1rem;
border-radius: 4px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: background 0.2s;
}
-::-webkit-scrollbar-thumb:hover {
- background: var(--text-muted);
+
+button:hover {
+ background: var(--accent-hover);
+}
+
+button.secondary {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+}
+
+button.secondary:hover {
+ background: var(--bg-secondary);
+}
+
+button.danger {
+ background: var(--danger);
+}
+
+button.danger:hover {
+ background: #f44336;
+}
+
+input, select {
+ width: 100%;
+ padding: 0.6rem;
+ margin-bottom: 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ border-radius: 4px;
+ font-family: inherit;
+}
+
+input:focus, select:focus {
+ outline: none;
+ border-color: var(--accent-color);
}
/* Layout */
-#root {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
-}
-
.app-container {
display: flex;
min-height: 100vh;
- width: 100%;
- max-width: 1400px;
- margin: 0 auto;
}
-@media (max-width: 768px) {
- .app-container {
- flex-direction: column;
- padding-bottom: 80px;
- }
-}
-
-/* Minimalist Panels */
-.glass-panel {
- background: var(--card-bg);
- border: 1px solid var(--card-border);
- border-radius: 12px;
- box-shadow: var(--shadow);
- transition: border-color 0.2s ease, transform 0.2s ease;
-}
-
-.glass-panel:hover {
- border-color: var(--card-border-hover);
-}
-
-.glass-panel.interactive:hover {
- transform: translateY(-2px);
-}
-
-/* Sidebar Navigation */
.sidebar {
width: 250px;
- padding: 35px 20px;
+ background-color: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
- border-right: 1px solid var(--card-border);
- background: #0b0d14;
- position: sticky;
- top: 0;
- height: 100vh;
}
-.logo-container {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 40px;
- padding-left: 5px;
+.sidebar-header {
+ padding: 1.5rem;
+ font-size: 1.25rem;
+ font-weight: 700;
+ border-bottom: 1px solid var(--border-color);
+ color: #fff;
}
-.logo-icon {
- color: var(--primary);
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.logo-text {
- font-family: var(--font-display);
- font-size: 19px;
- font-weight: 600;
- letter-spacing: -0.3px;
- color: #ffffff;
-}
-
-.nav-links {
+.sidebar-nav {
display: flex;
flex-direction: column;
- gap: 6px;
- list-style: none;
+ padding: 1rem 0;
+ flex: 1;
}
-.nav-link-btn {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: transparent;
- border: none;
- border-radius: 8px;
+.nav-item {
+ padding: 0.75rem 1.5rem;
color: var(--text-secondary);
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.15s ease;
- text-align: left;
-}
-
-.nav-link-btn:hover {
- background: rgba(255, 255, 255, 0.03);
- color: var(--text-primary);
-}
-
-.nav-link-btn.active {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--card-border-hover);
- color: #ffffff;
-}
-
-/* User Info Sidebar */
-.user-profile {
- margin-top: auto;
- padding: 12px;
display: flex;
align-items: center;
- gap: 10px;
- border-radius: 8px;
- border: 1px solid var(--card-border);
- background: rgba(255, 255, 255, 0.01);
+ gap: 0.75rem;
+ text-decoration: none;
}
-.user-avatar {
- width: 34px;
- height: 34px;
- border-radius: 50%;
- background: var(--card-border-hover);
- display: flex;
- align-items: center;
- justify-content: center;
- font-family: var(--font-display);
- font-weight: 600;
- font-size: 14px;
+.nav-item:hover, .nav-item.active {
+ background-color: var(--bg-card);
color: var(--text-primary);
+ text-decoration: none;
}
-.user-details {
- display: flex;
- flex-direction: column;
- overflow: hidden;
+.sidebar-footer {
+ padding: 1rem;
+ border-top: 1px solid var(--border-color);
}
-.user-name {
- font-weight: 500;
- font-size: 13px;
- color: var(--text-primary);
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
-}
-
-.user-role {
- font-size: 11px;
- color: var(--text-secondary);
-}
-
-/* Main Content */
.main-content {
flex: 1;
- padding: 50px 60px;
+ padding: 2rem;
overflow-y: auto;
}
-@media (max-width: 768px) {
- .main-content {
- padding: 24px 20px;
- }
- .sidebar {
- display: none;
- }
-}
-
-/* Mobile Bottom Navigation */
-.mobile-nav {
- display: none;
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- height: 65px;
- background: #0b0d14;
- border-top: 1px solid var(--card-border);
- z-index: 100;
- justify-content: space-around;
- align-items: center;
-}
-
-@media (max-width: 768px) {
- .mobile-nav {
- display: flex;
- }
-}
-
-.mobile-nav-btn {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 3px;
- background: transparent;
- border: none;
- color: var(--text-secondary);
- font-size: 10px;
- font-weight: 500;
- cursor: pointer;
-}
-
-.mobile-nav-btn.active {
- color: var(--primary);
-}
-
-.mobile-nav-btn svg {
- width: 20px;
- height: 20px;
-}
-
-/* Metrics Dashboard */
-.metrics-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 16px;
- margin-bottom: 24px;
-}
-
-.metric-card {
- padding: 20px;
-}
-
-.metric-header {
+.page-header {
+ margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
- color: var(--text-secondary);
- font-size: 13px;
- margin-bottom: 8px;
}
-.metric-icon {
- color: var(--text-muted);
+/* Cards & Tables */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
}
-.metric-value {
- font-family: var(--font-display);
- font-size: 24px;
- font-weight: 600;
- letter-spacing: -0.3px;
-}
-
-/* Charts Layout */
-.charts-grid {
+.dashboard-grid {
display: grid;
- grid-template-columns: 1.5fr 1fr;
- gap: 20px;
- margin-bottom: 24px;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1.5rem;
}
-@media (max-width: 1024px) {
- .charts-grid {
- grid-template-columns: 1fr;
- }
+.stat-card {
+ text-align: center;
}
-.chart-card {
- padding: 20px;
- min-height: 320px;
- display: flex;
- flex-direction: column;
-}
-
-.chart-title {
- font-family: var(--font-display);
- font-size: 15px;
- font-weight: 600;
- color: var(--text-primary);
- margin-bottom: 20px;
-}
-
-/* Minimal Buttons */
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 8px 14px;
- border-radius: 6px;
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.15s ease;
- border: 1px solid transparent;
- outline: none;
-}
-
-.btn:active {
- transform: scale(0.98);
-}
-
-.btn-primary {
- background: var(--primary);
- color: white;
-}
-
-.btn-primary:hover {
- background: #7c3aed;
-}
-
-.btn-secondary {
- background: rgba(255, 255, 255, 0.02);
- border-color: var(--card-border);
- color: var(--text-primary);
-}
-
-.btn-secondary:hover {
- background: rgba(255, 255, 255, 0.05);
- border-color: var(--card-border-hover);
-}
-
-.btn-danger {
- background: var(--expense-bg);
- border-color: rgba(244, 63, 94, 0.15);
- color: #fb7185;
-}
-
-.btn-danger:hover {
- background: rgba(244, 63, 94, 0.12);
-}
-
-.btn-success {
- background: var(--income-bg);
- border-color: rgba(16, 185, 129, 0.15);
- color: #34d399;
-}
-
-.btn-success:hover {
- background: rgba(16, 185, 129, 0.12);
-}
-
-/* Forms */
-.form-group {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 12px;
-}
-
-.form-label {
- font-size: 12px;
- font-weight: 500;
+.stat-card h3 {
color: var(--text-secondary);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 0.5rem;
}
-.form-input {
- width: 100%;
- padding: 10px 12px;
- background: #090a0f;
- border: 1px solid var(--card-border);
- border-radius: 6px;
- color: white;
- font-size: 13px;
- transition: border-color 0.15s ease;
- outline: none;
+.stat-card .value {
+ font-size: 2rem;
+ font-weight: 700;
}
-.form-input:focus {
- border-color: var(--primary);
-}
+.stat-card .value.income { color: var(--success); }
+.stat-card .value.expense { color: var(--danger); }
-.form-select {
- appearance: none;
- background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
- background-repeat: no-repeat;
- background-position: right 10px center;
- background-size: 14px;
- padding-right: 32px;
-}
-
-/* Modal Popup */
-.modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.6);
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
-}
-
-.modal-content {
- width: 100%;
- max-width: 400px;
- padding: 24px;
- position: relative;
-}
-
-.modal-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
-}
-
-.modal-title {
- font-family: var(--font-display);
- font-size: 17px;
- font-weight: 600;
-}
-
-.modal-close {
- background: transparent;
- border: none;
- color: var(--text-secondary);
- cursor: pointer;
- display: flex;
-}
-
-.modal-close:hover {
- color: white;
-}
-
-/* Tables & Lists */
-.table-container {
- overflow-x: auto;
-}
-
-.data-table {
+table {
width: 100%;
border-collapse: collapse;
+}
+
+th, td {
+ padding: 1rem;
text-align: left;
+ border-bottom: 1px solid var(--border-color);
}
-.data-table th {
- padding: 10px 12px;
- font-size: 11px;
- font-weight: 600;
+th {
color: var(--text-secondary);
- border-bottom: 1px solid var(--card-border);
+ font-weight: 500;
+ font-size: 0.85rem;
text-transform: uppercase;
- letter-spacing: 0.5px;
}
-.data-table td {
- padding: 12px 12px;
- font-size: 13px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.02);
+tr:hover {
+ background-color: rgba(255, 255, 255, 0.02);
}
-.data-table tr:last-child td {
- border-bottom: none;
-}
-
-.data-table tr:hover td {
- background: rgba(255, 255, 255, 0.01);
-}
-
-/* Transaction Styles */
-.tx-type-indicator {
- display: inline-flex;
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 10px;
- font-weight: 600;
-}
-
-.tx-type-indicator.income {
- background: var(--income-bg);
- color: var(--income);
-}
-
-.tx-type-indicator.expense {
- background: var(--expense-bg);
- color: var(--expense);
-}
-
-.tx-amount {
- font-family: var(--font-display);
- font-weight: 500;
-}
-.tx-amount.income { color: var(--income); }
-.tx-amount.expense { color: var(--expense); }
-
-/* Subscriptions */
-.sub-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
- gap: 16px;
-}
-
-.sub-card {
- padding: 20px;
- display: flex;
- flex-direction: column;
- position: relative;
-}
-
-.sub-card.inactive {
- opacity: 0.5;
-}
-
-.sub-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 12px;
-}
-
-.sub-name {
- font-family: var(--font-display);
- font-size: 16px;
- font-weight: 500;
-}
-
-.sub-amount {
- font-family: var(--font-display);
- font-size: 20px;
- font-weight: 600;
- color: white;
- margin-bottom: 8px;
-}
-
-.sub-details {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 16px;
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-.sub-actions {
- display: flex;
- gap: 8px;
- margin-top: auto;
-}
-
-/* Shopping List */
-.shopping-columns {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
- gap: 20px;
- margin-bottom: 24px;
-}
-
-.shopping-column {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.column-title {
- font-family: var(--font-display);
- font-size: 14px;
- font-weight: 600;
- display: flex;
- align-items: center;
- gap: 6px;
- padding-bottom: 6px;
- border-bottom: 1px solid var(--card-border);
-}
-
-.column-title.high { color: var(--expense); }
-.column-title.medium { color: #eab308; }
-.column-title.low { color: var(--secondary); }
-.column-title.bought { color: var(--text-muted); }
-
-.shopping-card {
- padding: 12px 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.shopping-item-info {
- display: flex;
- flex-direction: column;
- gap: 2px;
- overflow: hidden;
-}
-
-.shopping-item-name {
- font-weight: 500;
- font-size: 14px;
- color: var(--text-primary);
-}
-
-.shopping-card.bought-item .shopping-item-name {
- text-decoration: line-through;
- color: var(--text-muted);
-}
-
-.shopping-item-price {
- font-size: 11px;
- color: var(--text-secondary);
-}
-
-.shopping-item-actions {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-/* Alerts */
-.alert {
- padding: 10px 14px;
- border-radius: 6px;
- margin-bottom: 16px;
- font-size: 13px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.alert-success {
- background: var(--income-bg);
- border: 1px solid rgba(16, 185, 129, 0.1);
- color: #34d399;
-}
-
-.alert-error {
- background: var(--expense-bg);
- border: 1px solid rgba(244, 63, 94, 0.1);
- color: #fb7185;
-}
-
-/* Headings */
-.page-title {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 24px;
-}
-
-.page-title h1 {
- font-family: var(--font-display);
- font-size: 24px;
- font-weight: 600;
- letter-spacing: -0.4px;
- color: #ffffff;
-}
-
-/* Login container */
-.login-container {
+/* Auth Pages */
+.auth-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
- padding: 16px;
+ background: var(--bg-primary);
}
-.login-card {
+.auth-box {
+ background: var(--bg-card);
+ padding: 2.5rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
width: 100%;
- max-width: 380px;
- padding: 30px;
+ max-width: 400px;
+}
+
+.auth-box h2 {
+ margin-bottom: 1.5rem;
text-align: center;
+}
+
+.auth-box form {
display: flex;
flex-direction: column;
- align-items: center;
}
-.login-icon-large {
- color: var(--primary);
- margin-bottom: 20px;
+.auth-box .toggle-link {
+ margin-top: 1rem;
+ text-align: center;
+ font-size: 0.9rem;
}
-.login-title {
- font-family: var(--font-display);
- font-size: 20px;
- font-weight: 600;
- margin-bottom: 8px;
+.form-group {
+ margin-bottom: 1rem;
}
-
-.login-subtitle {
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
color: var(--text-secondary);
- margin-bottom: 24px;
- font-size: 13px;
-}
-
-.login-instruction {
- background: rgba(255, 255, 255, 0.01);
- border: 1px solid var(--card-border);
- padding: 12px;
- border-radius: 8px;
- font-size: 12px;
- color: var(--text-secondary);
- margin-top: 20px;
- line-height: 1.5;
-}
-
-.login-instruction code {
- color: var(--secondary);
- font-family: monospace;
- background: rgba(0, 0, 0, 0.2);
- padding: 1px 4px;
- border-radius: 3px;
+ font-size: 0.85rem;
}
diff --git a/frontend/src/pages/Auth.jsx b/frontend/src/pages/Auth.jsx
new file mode 100644
index 0000000..24a96ee
--- /dev/null
+++ b/frontend/src/pages/Auth.jsx
@@ -0,0 +1,69 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+export default function Auth() {
+ const [isLogin, setIsLogin] = useState(true);
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [firstName, setFirstName] = useState('');
+ const [error, setError] = useState('');
+ const navigate = useNavigate();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+
+ const url = isLogin ? '/api/auth/login' : '/api/auth/register';
+ const payload = isLogin ? { email, password } : { email, password, first_name: firstName };
+
+ try {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || 'Ошибка авторизации');
+ }
+
+ localStorage.setItem('token', data.token);
+ localStorage.setItem('user', JSON.stringify(data.user));
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ return (
+
+
+
{isLogin ? 'Вход в систему' : 'Регистрация'}
+ {error &&
{error}
}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..e6ce18c
--- /dev/null
+++ b/frontend/src/pages/Dashboard.jsx
@@ -0,0 +1,59 @@
+import React, { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+
+export default function Dashboard() {
+ const [data, setData] = useState(null);
+
+ useEffect(() => {
+ const fetchProfile = async () => {
+ const token = localStorage.getItem('token');
+ try {
+ const res = await fetch('/api/user/profile', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+ if (res.ok) {
+ const json = await res.json();
+ setData(json);
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+ fetchProfile();
+ }, []);
+
+ if (!data) return Загрузка...
;
+
+ return (
+
+
+
Сводка
+
Добро пожаловать, {data.user.first_name || data.user.email}
+
+
+
+
+
Текущий Баланс
+
{data.balance.toFixed(2)} ₽
+
+
+
Всего Доходов
+
+{data.totalIncome.toFixed(2)} ₽
+
+
+
Всего Расходов
+
-{data.totalExpense.toFixed(2)} ₽
+
+
+
+
+
Быстрые действия
+
+ Все Транзакции
+ Мои Долги
+ Регулярные Доходы
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Debts.jsx b/frontend/src/pages/Debts.jsx
new file mode 100644
index 0000000..eecdffd
--- /dev/null
+++ b/frontend/src/pages/Debts.jsx
@@ -0,0 +1,87 @@
+import React, { useEffect, useState } from 'react';
+
+export default function Debts() {
+ const [debts, setDebts] = useState([]);
+ const [personName, setPersonName] = useState('');
+ const [amount, setAmount] = useState('');
+ const [type, setType] = useState('owe');
+ const [dueDate, setDueDate] = useState('');
+
+ const fetchDebts = async () => {
+ const token = localStorage.getItem('token');
+ const res = await fetch('/api/debts', { headers: { 'Authorization': `Bearer ${token}` } });
+ if (res.ok) setDebts(await res.json());
+ };
+
+ useEffect(() => { fetchDebts(); }, []);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const token = localStorage.getItem('token');
+ await fetch('/api/debts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ person_name: personName, amount, type, due_date: dueDate })
+ });
+ setPersonName(''); setAmount(''); fetchDebts();
+ };
+
+ const handlePay = async (id) => {
+ const token = localStorage.getItem('token');
+ await fetch(`/api/debts/${id}/pay`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}` } });
+ fetchDebts();
+ };
+
+ return (
+
+
+
Мои Долги
+
+
+
+
Добавить Долг
+
+
+
+
+
+
+
+ Кто/Кому
+ Тип
+ Сумма
+ Дата возврата
+ Статус
+ Действия
+
+
+
+ {debts.map(d => (
+
+ {d.person_name}
+ {d.type === 'owe' ? Я должен : Мне должны }
+ {d.amount} ₽
+ {d.due_date || 'Без срока'}
+ {d.status === 'paid' ? '✅ Оплачено' : '⏳ Ожидается'}
+
+ {d.status === 'pending' && (
+ handlePay(d.id)}>Отметить оплаченным
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Incomes.jsx b/frontend/src/pages/Incomes.jsx
new file mode 100644
index 0000000..31d1b69
--- /dev/null
+++ b/frontend/src/pages/Incomes.jsx
@@ -0,0 +1,77 @@
+import React, { useEffect, useState } from 'react';
+
+export default function Incomes() {
+ const [incomes, setIncomes] = useState([]);
+ const [sourceName, setSourceName] = useState('');
+ const [amount, setAmount] = useState('');
+ const [dayOfMonth, setDayOfMonth] = useState('1');
+
+ const fetchIncomes = async () => {
+ const token = localStorage.getItem('token');
+ const res = await fetch('/api/incomes', { headers: { 'Authorization': `Bearer ${token}` } });
+ if (res.ok) setIncomes(await res.json());
+ };
+
+ useEffect(() => { fetchIncomes(); }, []);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const token = localStorage.getItem('token');
+ await fetch('/api/incomes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ source_name: sourceName, amount, day_of_month: dayOfMonth })
+ });
+ setSourceName(''); setAmount(''); fetchIncomes();
+ };
+
+ const handleDelete = async (id) => {
+ const token = localStorage.getItem('token');
+ await fetch(`/api/incomes/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
+ fetchIncomes();
+ };
+
+ return (
+
+
+
Регулярные Доходы
+
+
+
+
Добавить Зарплату / Регулярный доход
+
+ Укажите день месяца, и система будет автоматически начислять эту сумму каждый месяц.
+
+
+
+
+
+
+
+
+ Источник
+ Сумма
+ День начисления
+ Действия
+
+
+
+ {incomes.map(inc => (
+
+ {inc.source_name}
+ +{inc.amount} ₽
+ {inc.day_of_month} число
+ handleDelete(inc.id)}>Удалить
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
new file mode 100644
index 0000000..73b3634
--- /dev/null
+++ b/frontend/src/pages/Settings.jsx
@@ -0,0 +1,46 @@
+import React, { useState } from 'react';
+
+export default function Settings() {
+ const [code, setCode] = useState('');
+
+ const generateCode = async () => {
+ const token = localStorage.getItem('token');
+ const res = await fetch('/api/auth/generate-link-code', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setCode(data.code);
+ }
+ };
+
+ return (
+
+
+
Настройки
+
+
+
+
Привязка Telegram
+
+ Чтобы получать уведомления о подписках и долгах, привяжите свой аккаунт к Telegram-боту.
+ Нажмите кнопку ниже, чтобы сгенерировать временный код, и отправьте его боту.
+
+
+
Сгенерировать код
+
+ {code && (
+
+
Ваш код привязки:
+
{code}
+
+ Скопируйте этот код и отправьте боту команду:
+ /link {code}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/Transactions.jsx b/frontend/src/pages/Transactions.jsx
new file mode 100644
index 0000000..9fe2d0f
--- /dev/null
+++ b/frontend/src/pages/Transactions.jsx
@@ -0,0 +1,87 @@
+import React, { useEffect, useState } from 'react';
+
+export default function Transactions() {
+ const [transactions, setTransactions] = useState([]);
+ const [type, setType] = useState('expense');
+ const [amount, setAmount] = useState('');
+ const [category, setCategory] = useState('');
+ const [description, setDescription] = useState('');
+ const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
+
+ const fetchTxs = async () => {
+ const token = localStorage.getItem('token');
+ const res = await fetch('/api/transactions', { headers: { 'Authorization': `Bearer ${token}` } });
+ if (res.ok) setTransactions(await res.json());
+ };
+
+ useEffect(() => { fetchTxs(); }, []);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const token = localStorage.getItem('token');
+ await fetch('/api/transactions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ type, amount, category, description, date })
+ });
+ setAmount(''); setCategory(''); setDescription(''); fetchTxs();
+ };
+
+ const handleDelete = async (id) => {
+ const token = localStorage.getItem('token');
+ await fetch(`/api/transactions/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
+ fetchTxs();
+ };
+
+ return (
+
+
+
Транзакции
+
+
+
+
Добавить Транзакцию
+
+
+
+
+
+
+
+ Дата
+ Тип
+ Категория
+ Описание
+ Сумма
+ Действия
+
+
+
+ {transactions.map(tx => (
+
+ {tx.date}
+ {tx.type === 'income' ? Доход : Расход }
+ {tx.category}
+ {tx.description}
+
+ {tx.type === 'income' ? '+' : '-'}{tx.amount} ₽
+
+ handleDelete(tx.id)}>Удалить
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 8b0f57b..7769dc6 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:5000',
+ changeOrigin: true
+ }
+ }
+ }
})