полностью изменил структуру

This commit is contained in:
2026-07-23 17:28:23 +05:00
parent 5de29561f2
commit 6a9f00ac75
18 changed files with 1225 additions and 2542 deletions
+142
View File
@@ -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",
+2
View File
@@ -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"
+20 -264
View File
@@ -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'));
+28 -3
View File
@@ -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);
+208 -297
View File
@@ -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);
+91 -82
View File
@@ -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}`);
}
}
}
}