diff --git a/index.js b/index.js index 889e703..b006f51 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,14 @@ require('fs').mkdirSync(UPLOADS_DIR, { recursive: true }); app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d', immutable: true })); +// Публичные роуты (без auth) +app.get('/api/billing/plans', async (req, res) => { + const { query: q } = require('./src/config/db'); + const { rows: plans } = await q('SELECT * FROM plans WHERE is_active=true ORDER BY sort_order'); + const { rows: costs } = await q('SELECT * FROM credit_costs ORDER BY operation'); + res.json({ plans, costs }); +}); + // Simple internal auth middleware app.use((req, res, next) => { const secret = req.headers['x-internal-secret']; @@ -82,6 +90,7 @@ app.use('/api/channel-stats', channelStatsRoutes); app.use('/api/calendar', calendarRoutes); app.use('/api/metrics', metricsRoutes); app.use('/api/usage', usageRoutes); +app.use('/api/billing', require('./src/routes/billing')); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/routes/billing.js b/src/routes/billing.js new file mode 100644 index 0000000..87acd78 --- /dev/null +++ b/src/routes/billing.js @@ -0,0 +1,82 @@ +const express = require('express'); +const router = express.Router(); +const billing = require('../services/billing'); +const { query } = require('../config/db'); + +function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } + +// GET /api/billing/balance — баланс + план текущего юзера +router.get('/balance', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + try { + await billing.ensureBalance(userId); + const bal = await billing.getBalance(userId); + res.json(bal); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/billing/transactions — история транзакций +router.get('/transactions', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + const limit = Math.min(parseInt(req.query.limit || 50), 200); + const offset = parseInt(req.query.offset || 0); + try { + const txs = await billing.getTransactions(userId, { limit, offset }); + const { rows: [{ total }] } = await query( + 'SELECT count(*)::int as total FROM user_transactions WHERE user_id=$1', [userId] + ); + res.json({ transactions: txs, total, limit, offset }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/billing/plans — все тарифы (публичный) +router.get('/plans', async (req, res) => { + try { + const { rows } = await query( + 'SELECT * FROM plans WHERE is_active=true ORDER BY sort_order' + ); + const { rows: costs } = await query('SELECT * FROM credit_costs ORDER BY operation'); + res.json({ plans: rows, costs }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/billing/admin/credit — начислить кредиты вручную (только admin) +router.post('/admin/credit', async (req, res) => { + const adminId = uid(req); + if (!adminId) return res.status(401).json({ error: 'x-user-id required' }); + const { rows: [admin] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); + if (!admin?.is_admin) return res.status(403).json({ error: 'Forbidden' }); + + const { user_id, amount, description = 'Ручное начисление от администратора' } = req.body; + if (!user_id || !amount) return res.status(400).json({ error: 'user_id и amount обязательны' }); + try { + const result = await billing.credit(user_id, amount, 'bonus', description, { by_admin: adminId }); + res.json(result); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/billing/admin/users — балансы всех пользователей (только admin) +router.get('/admin/users', async (req, res) => { + const adminId = uid(req); + if (!adminId) return res.status(401).json({ error: 'x-user-id required' }); + const { rows: [admin] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); + if (!admin?.is_admin) return res.status(403).json({ error: 'Forbidden' }); + try { + const { rows } = await query(` + SELECT u.id, u.email, u.name, + ub.credits, ub.reset_at, + p.name as plan_name, p.code as plan_code, p.price_rub + FROM users u + LEFT JOIN user_balance ub ON ub.user_id = u.id + LEFT JOIN user_subscriptions us ON us.user_id = u.id + AND us.status='active' AND (us.expires_at IS NULL OR us.expires_at > NOW()) + LEFT JOIN plans p ON p.id = us.plan_id + ORDER BY u.created_at DESC + `); + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +module.exports = router; diff --git a/src/routes/generate.js b/src/routes/generate.js index 0fb1e11..22ae705 100644 --- a/src/routes/generate.js +++ b/src/routes/generate.js @@ -3,18 +3,33 @@ const router = express.Router(); const { query } = require('../config/db'); const channelsSvc = require('../services/channels'); const generationQueue = require('../workers/generation'); +const billing = require('../services/billing'); + +// Маппинг type → billing operation +const BILLING_OP = { post: 'text_post', article: 'article', topics: null }; // POST /api/generate — создать задачу генерации router.post('/', async (req, res) => { try { const { type, topic, channelId, rubric, keywords = [], useCritique = true, customPrompt } = req.body; - const userId = req.headers['x-user-id'] || null; + const userId = req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; if (!type) return res.status(400).json({ error: 'type is required' }); if (!['post', 'article', 'topics'].includes(type)) return res.status(400).json({ error: 'Invalid type' }); if (type !== 'topics' && !topic) return res.status(400).json({ error: 'topic is required' }); if (type === 'post' && !channelId) return res.status(400).json({ error: 'channelId is required for posts' }); + // Проверка и списание кредитов + const billingOp = BILLING_OP[type]; + let billingResult = null; + if (userId && billingOp) { + const ck = await billing.check(userId, billingOp); + if (!ck.allowed) { + return res.status(402).json({ error: ck.reason, code: 'INSUFFICIENT_CREDITS', credits: ck.credits, cost: ck.cost }); + } + billingResult = await billing.spend(userId, billingOp, { channel_id: channelId }); + } + const { rows } = await query( `INSERT INTO generation_jobs (user_id, channel_id, type, topic, rubric, status) VALUES ($1,$2,$3,$4,$5,'pending') RETURNING id`, @@ -24,7 +39,7 @@ router.post('/', async (req, res) => { await generationQueue.add({ jobId, type, topic, channelId, rubric, keywords, useCritique, customPrompt }); - res.json({ jobId, status: 'pending' }); + res.json({ jobId, status: 'pending', credits_after: billingResult?.credits_after ?? null, cost: billingResult?.cost ?? 0 }); } catch (err) { console.error('[Route] POST /generate', err); res.status(500).json({ error: err.message }); @@ -72,9 +87,17 @@ router.post('/post-image', async (req, res) => { const channel = await channelsSvc.getChannel(channelId, userId); if (!channel) return res.status(404).json({ error: 'Channel not found' }); + // Списываем кредиты за картинку + let imgBilling = null; + if (userId) { + const ck = await billing.check(userId, 'image'); + if (!ck.allowed) return res.status(402).json({ error: ck.reason, code: 'INSUFFICIENT_CREDITS', credits: ck.credits, cost: ck.cost }); + imgBilling = await billing.spend(userId, 'image', { channel_id: channelId }); + } + const { generatePostImage } = require('../services/postImages'); const result = await generatePostImage({ post, channel, style: channel.style || {} }); - res.json(result); + res.json({ ...result, credits_after: imgBilling?.credits_after ?? null, cost: imgBilling?.cost ?? 0 }); } catch (err) { console.error('[Route] POST /post-image', err); res.status(500).json({ error: err.message }); diff --git a/src/services/billing.js b/src/services/billing.js new file mode 100644 index 0000000..72cbf16 --- /dev/null +++ b/src/services/billing.js @@ -0,0 +1,214 @@ +/** + * billing.js — сервис управления кредитами пользователей. + * + * Правила: + * • Business план (credits_month = -1) — безлимит, списания не блокируются. + * • При нулевом балансе блокируем ВСЁ (image, text_post, article). + * • autopublish всегда бесплатен (0 кредитов). + */ +const { query } = require('../config/db'); + +// Стоимость операции из credit_costs (кешируем в памяти, перезагружаем каждые 5 мин) +let _costs = null; +let _costsLoadedAt = 0; +async function getCosts() { + if (_costs && Date.now() - _costsLoadedAt < 5 * 60 * 1000) return _costs; + const { rows } = await query('SELECT operation, credits FROM credit_costs'); + _costs = Object.fromEntries(rows.map(r => [r.operation, r.credits])); + _costsLoadedAt = Date.now(); + return _costs; +} + +/** + * Получить баланс пользователя с планом. + */ +async function getBalance(userId) { + const { rows } = await query(` + SELECT ub.credits, ub.credits_monthly_reset, ub.reset_at, + p.code as plan_code, p.name as plan_name, + p.credits_month, p.channels_max, p.price_rub + FROM user_balance ub + LEFT JOIN user_subscriptions us ON us.user_id = ub.user_id + AND us.status = 'active' AND (us.expires_at IS NULL OR us.expires_at > NOW()) + LEFT JOIN plans p ON p.id = us.plan_id + WHERE ub.user_id = $1 + ORDER BY p.price_rub DESC NULLS LAST + LIMIT 1 + `, [userId]); + + if (!rows.length) { + // Новый пользователь — создаём с Free планом + await ensureBalance(userId); + return getBalance(userId); + } + + const row = rows[0]; + const isUnlimited = row.credits_month === -1; + return { + credits: isUnlimited ? Infinity : row.credits, + plan: row.plan_code || 'free', + planName: row.plan_name || 'Free', + isUnlimited, + channelsMax: row.channels_max || 1, + resetAt: row.reset_at, + }; +} + +/** + * Убедиться что баланс существует. Новым — Free план + 50 кредитов. + */ +async function ensureBalance(userId) { + await query(` + INSERT INTO user_balance (user_id, credits, credits_monthly_reset, reset_at) + VALUES ($1, 50, 50, NOW() + INTERVAL '30 days') + ON CONFLICT (user_id) DO NOTHING + `, [userId]); +} + +/** + * Проверить можно ли выполнить операцию (не списывает). + * Возвращает { allowed: bool, credits: int, cost: int, reason?: string } + */ +async function check(userId, operation) { + const costs = await getCosts(); + const cost = costs[operation] ?? 0; + + if (cost === 0) return { allowed: true, credits: 0, cost: 0 }; + + const bal = await getBalance(userId); + if (bal.isUnlimited) return { allowed: true, credits: Infinity, cost }; + + if (bal.credits < cost) { + return { + allowed: false, + credits: bal.credits, + cost, + reason: `Недостаточно кредитов: нужно ${cost}, есть ${bal.credits}`, + }; + } + return { allowed: true, credits: bal.credits, cost }; +} + +/** + * Списать кредиты за операцию. + * Возвращает { ok: bool, credits_after: int, cost: int } или бросает при нехватке. + */ +async function spend(userId, operation, meta = {}) { + const costs = await getCosts(); + const cost = costs[operation] ?? 0; + if (cost === 0) return { ok: true, credits_after: null, cost: 0 }; + + const bal = await getBalance(userId); + if (bal.isUnlimited) { + // Пишем транзакцию но не уменьшаем баланс + await query(` + INSERT INTO user_transactions (user_id, type, amount, balance_after, description, meta) + VALUES ($1, $2, $3, -1, $4, $5) + `, [userId, `spend_${operation}`, -cost, descriptionFor(operation, meta), JSON.stringify(meta)]); + return { ok: true, credits_after: Infinity, cost }; + } + + // Атомарное списание + const { rows } = await query(` + UPDATE user_balance + SET credits = credits - $2, updated_at = NOW() + WHERE user_id = $1 AND credits >= $2 + RETURNING credits + `, [userId, cost]); + + if (!rows.length) { + const cur = await getBalance(userId); + throw Object.assign(new Error(`Недостаточно кредитов: нужно ${cost}, есть ${cur.credits}`), { + code: 'INSUFFICIENT_CREDITS', needed: cost, have: cur.credits, + }); + } + + const balanceAfter = rows[0].credits; + await query(` + INSERT INTO user_transactions (user_id, type, amount, balance_after, description, meta) + VALUES ($1, $2, $3, $4, $5, $6) + `, [userId, `spend_${operation}`, -cost, balanceAfter, descriptionFor(operation, meta), JSON.stringify(meta)]); + + return { ok: true, credits_after: balanceAfter, cost }; +} + +/** + * Начислить кредиты (пополнение, бонус, план). + */ +async function credit(userId, amount, type = 'topup', description = '', meta = {}) { + await ensureBalance(userId); + const { rows } = await query(` + UPDATE user_balance + SET credits = credits + $2, updated_at = NOW() + WHERE user_id = $1 + RETURNING credits + `, [userId, amount]); + + const balanceAfter = rows[0].credits; + await query(` + INSERT INTO user_transactions (user_id, type, amount, balance_after, description, meta) + VALUES ($1, $2, $3, $4, $5, $6) + `, [userId, type, amount, balanceAfter, description, JSON.stringify(meta)]); + + return { credits_after: balanceAfter }; +} + +/** + * Ежемесячный сброс кредитов по тарифу. + * Вызывается cron-джобом раз в сутки — начисляет тем, у кого reset_at прошёл. + */ +async function processMonthlyResets() { + const { rows } = await query(` + SELECT ub.user_id, ub.credits_monthly_reset, p.credits_month, p.code as plan_code + FROM user_balance ub + JOIN user_subscriptions us ON us.user_id = ub.user_id + AND us.status = 'active' AND (us.expires_at IS NULL OR us.expires_at > NOW()) + JOIN plans p ON p.id = us.plan_id + WHERE ub.reset_at <= NOW() AND p.credits_month > 0 + `); + + let processed = 0; + for (const row of rows) { + const newCredits = row.credits_month; + await query(` + UPDATE user_balance + SET credits = $2, credits_monthly_reset = $2, + reset_at = NOW() + INTERVAL '30 days', updated_at = NOW() + WHERE user_id = $1 + `, [row.user_id, newCredits]); + await query(` + INSERT INTO user_transactions (user_id, type, amount, balance_after, description) + VALUES ($1, 'plan_credit', $2, $2, $3) + `, [row.user_id, newCredits, `Ежемесячное пополнение по тарифу ${row.plan_code}`]); + processed++; + } + return processed; +} + +/** + * История транзакций пользователя. + */ +async function getTransactions(userId, { limit = 50, offset = 0 } = {}) { + const { rows } = await query(` + SELECT id, type, amount, balance_after, description, meta, created_at + FROM user_transactions + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + `, [userId, limit, offset]); + return rows; +} + +function descriptionFor(operation, meta) { + const labels = { + image: 'Генерация картинки', + text_post: 'Генерация поста', + article: 'Генерация статьи', + }; + const base = labels[operation] || operation; + if (meta.channel_name) return `${base} — ${meta.channel_name}`; + if (meta.channel_id) return `${base} — канал #${meta.channel_id}`; + return base; +} + +module.exports = { getBalance, check, spend, credit, ensureBalance, getTransactions, processMonthlyResets };