mirror of
https://github.com/AmanIbrash-brsh/finance-website.git
synced 2026-11-09 09:35:10 +00:00
feat: initial commit - finance tracker with telegram bot
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
finance.db
|
||||
finance.db-journal
|
||||
finance.db-shm
|
||||
finance.db-wal
|
||||
Generated
+2885
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "finance-tracker-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API and Telegram Bot for personal finance tracker",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"telegraf": "^4.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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.');
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
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()
|
||||
);
|
||||
});
|
||||
|
||||
// /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 }
|
||||
);
|
||||
});
|
||||
|
||||
// /stats command - Show summary of expenses for current month
|
||||
bot.command('stats', 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, '\\+')
|
||||
);
|
||||
});
|
||||
|
||||
// /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;
|
||||
|
||||
const text = ctx.message.text.trim();
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
// 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 = 'Доходы';
|
||||
|
||||
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 продажа книг\`)`
|
||||
);
|
||||
});
|
||||
|
||||
bot.catch((err, ctx) => {
|
||||
console.error(`Error in bot for update ${ctx.update.update_id}:`, err);
|
||||
});
|
||||
|
||||
bot.launch().then(() => {
|
||||
console.log('🤖 Telegram Bot successfully launched!');
|
||||
}).catch((err) => {
|
||||
console.error('Failed to launch bot:', err);
|
||||
});
|
||||
|
||||
// Enable graceful stop
|
||||
process.once('SIGINT', () => bot.stop('SIGINT'));
|
||||
process.once('SIGTERM', () => bot.stop('SIGTERM'));
|
||||
|
||||
return bot;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = process.env.DATABASE_PATH || path.resolve(__dirname, '../finance.db');
|
||||
|
||||
let db;
|
||||
|
||||
export async function initDb() {
|
||||
db = await open({
|
||||
filename: dbPath,
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
// Enable foreign keys
|
||||
await db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create tables
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
auth_token TEXT,
|
||||
currency TEXT DEFAULT 'RUB',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
type TEXT CHECK(type IN ('income', 'expense')),
|
||||
amount REAL NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
period TEXT CHECK(period IN ('monthly', 'yearly')) DEFAULT 'monthly',
|
||||
next_payment_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
active INTEGER DEFAULT 1, -- 0 for inactive, 1 for active
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shopping_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
item_name TEXT NOT NULL,
|
||||
estimated_price REAL DEFAULT 0,
|
||||
priority TEXT CHECK(priority IN ('low', 'medium', 'high')) DEFAULT 'medium',
|
||||
bought INTEGER DEFAULT 0, -- 0 for not bought, 1 for bought
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('Database initialized at', dbPath);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized! Call initDb() first.');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb, getDb } from './db.js';
|
||||
import { initBot } from './bot.js';
|
||||
import { startScheduler } from './scheduler.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
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 (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' };
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// --- API ENDPOINTS ---
|
||||
|
||||
// GET /api/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,
|
||||
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense
|
||||
FROM transactions WHERE user_id = ?`,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
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
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/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
|
||||
);
|
||||
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' });
|
||||
}
|
||||
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) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/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
|
||||
);
|
||||
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' });
|
||||
}
|
||||
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
|
||||
);
|
||||
const newSub = await db.get('SELECT * FROM subscriptions WHERE id = ?', result.lastID);
|
||||
res.status(201).json(newSub);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
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() });
|
||||
});
|
||||
|
||||
// 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.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fatal initialization error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
@@ -0,0 +1,106 @@
|
||||
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);
|
||||
}
|
||||
|
||||
export async function checkSubscriptions(bot) {
|
||||
if (!bot) {
|
||||
console.log('Scheduler: Bot is not initialized. Skipping subscription checks.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🕰️ Running scheduled subscription checks...');
|
||||
const db = getDb();
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Calculate tomorrow's date
|
||||
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
|
||||
);
|
||||
|
||||
for (const sub of upcomingSubs) {
|
||||
try {
|
||||
await bot.telegram.sendMessage(
|
||||
sub.user_id,
|
||||
`🔔 **Напоминание о подписке!**\n\nЗавтра ожидается оплата подписки *${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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Run checks every 12 hours (12 * 60 * 60 * 1000 ms)
|
||||
const intervalMs = 12 * 60 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
checkSubscriptions(bot);
|
||||
}, intervalMs);
|
||||
|
||||
console.log('⏰ Subscription scheduler successfully started (checks every 12 hours).');
|
||||
}
|
||||
Reference in New Issue
Block a user