/** * 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 };