diff --git a/index.js b/index.js index b006f51..7b09756 100644 --- a/index.js +++ b/index.js @@ -46,6 +46,16 @@ app.get('/api/billing/plans', async (req, res) => { res.json({ plans, costs }); }); +// ЮKassa webhook — публичный, без internal secret +app.post('/api/billing/webhook', + express.json({ type: '*/*' }), + require('./src/routes/billing').handle || ((req, res, next) => { + require('./src/services/yukassa').handleWebhook(req.body) + .then(r => res.json({ ok: true, ...r })) + .catch(err => res.status(500).json({ error: err.message })); + }) +); + // Simple internal auth middleware app.use((req, res, next) => { const secret = req.headers['x-internal-secret']; diff --git a/package-lock.json b/package-lock.json index fcffe59..8a1cdde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@a2seven/yoo-checkout": "^1.1.4", "axios": "^1.16.1", "bull": "^4.16.5", "cheerio": "^1.2.0", @@ -21,6 +22,29 @@ "sharp": "^0.34.5" } }, + "node_modules/@a2seven/yoo-checkout": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@a2seven/yoo-checkout/-/yoo-checkout-1.1.4.tgz", + "integrity": "sha512-Xn8E6fJKVUpSqTTEgigoi0Woyvmc1/UuHukwpQUTEtxD2l4skTEeqPlhd0J4xum6lQ/0eGzeJP+/MgpkE9Ak9g==", + "license": "MIT", + "dependencies": { + "@types/axios": "^0.14.0", + "axios": "^0.21.1", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@a2seven/yoo-checkout/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -580,6 +604,16 @@ "win32" ] }, + "node_modules/@types/axios": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", + "integrity": "sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==", + "deprecated": "This is a stub types definition. axios provides its own type definitions, so you do not need this installed.", + "license": "MIT", + "dependencies": { + "axios": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index 67e1a0f..562046c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "author": "", "license": "ISC", "dependencies": { + "@a2seven/yoo-checkout": "^1.1.4", "axios": "^1.16.1", "bull": "^4.16.5", "cheerio": "^1.2.0", diff --git a/src/routes/billing.js b/src/routes/billing.js index 87acd78..27ed70a 100644 --- a/src/routes/billing.js +++ b/src/routes/billing.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); -const billing = require('../services/billing'); +const billing = require('../services/billing'); +const yukassa = require('../services/yukassa'); const { query } = require('../config/db'); function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } @@ -80,3 +81,30 @@ router.get('/admin/users', async (req, res) => { }); module.exports = router; + +// POST /api/billing/checkout — создать платёж ЮKassa +router.post('/checkout', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + const { plan_code } = req.body; + if (!plan_code) return res.status(400).json({ error: 'plan_code required' }); + try { + const { rows: [user] } = await query('SELECT email FROM users WHERE id=$1', [userId]); + const result = await yukassa.createPayment({ userId, planCode: plan_code, userEmail: user?.email }); + res.json(result); + } catch (err) { + console.error('[billing] checkout:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/billing/webhook — вебхук ЮKassa (без auth, проверка по IP/подписи) +router.post('/webhook', express.json({ type: '*/*' }), async (req, res) => { + try { + const result = await yukassa.handleWebhook(req.body); + res.json({ ok: true, ...result }); + } catch (err) { + console.error('[billing] webhook error:', err.message); + res.status(500).json({ error: err.message }); + } +}); diff --git a/src/services/yukassa.js b/src/services/yukassa.js new file mode 100644 index 0000000..714a5e3 --- /dev/null +++ b/src/services/yukassa.js @@ -0,0 +1,144 @@ +/** + * yukassa.js — интеграция с ЮKassa для оплаты подписок. + * + * Флоу: + * 1. POST /api/billing/checkout — создаём платёж, редиректим на ЮKassa + * 2. ЮKassa вызывает webhook POST /api/billing/webhook + * 3. При succeeded → активируем подписку + начисляем кредиты + * + * Настройка в app_settings: + * YUKASSA_SHOP_ID — ID магазина + * YUKASSA_SECRET — секретный ключ + * YUKASSA_RETURN_URL — URL после оплаты (напр. https://app.zeropost.ru/billing?paid=1) + */ +const { YooCheckout } = require('@a2seven/yoo-checkout'); +const { query } = require('../config/db'); +const billing = require('./billing'); + +function getClient() { + const shopId = process.env.YUKASSA_SHOP_ID; + const secret = process.env.YUKASSA_SECRET; + if (!shopId || !secret) throw new Error('YUKASSA_SHOP_ID и YUKASSA_SECRET не настроены'); + return new YooCheckout({ shopId, secretKey: secret }); +} + +/** + * Создать платёж ЮKassa для подписки на план. + * Возвращает { paymentId, confirmationUrl } + */ +async function createPayment({ userId, planCode, userEmail }) { + const { rows: [plan] } = await query('SELECT * FROM plans WHERE code=$1 AND is_active=true', [planCode]); + if (!plan) throw new Error(`План "${planCode}" не найден`); + if (plan.price_rub === 0) throw new Error('Free план не требует оплаты'); + + const returnUrl = process.env.YUKASSA_RETURN_URL || 'https://app.zeropost.ru/billing?paid=1'; + const client = getClient(); + + const idempotenceKey = `${userId}-${planCode}-${Date.now()}`; + const payment = await client.createPayment({ + amount: { value: String(plan.price_rub) + '.00', currency: 'RUB' }, + payment_method_data: { type: 'bank_card' }, + confirmation: { type: 'redirect', return_url: returnUrl }, + capture: true, + description: `${plan.name} — ZeroPost`, + metadata: { user_id: String(userId), plan_code: planCode }, + receipt: { + customer: { email: userEmail }, + items: [{ + description: `Тариф ${plan.name} (1 месяц)`, + quantity: '1.00', + amount: { value: String(plan.price_rub) + '.00', currency: 'RUB' }, + vat_code: 1, + payment_mode: 'full_payment', + payment_subject: 'service', + }], + }, + }, idempotenceKey); + + // Сохраняем pending платёж + await query(` + INSERT INTO payment_orders (user_id, plan_code, yukassa_payment_id, amount_rub, status) + VALUES ($1, $2, $3, $4, 'pending') + ON CONFLICT (yukassa_payment_id) DO NOTHING + `, [userId, planCode, payment.id, plan.price_rub]); + + return { + paymentId: payment.id, + confirmationUrl: payment.confirmation?.confirmation_url, + }; +} + +/** + * Обработать webhook от ЮKassa. + * event.type: 'payment.succeeded' | 'payment.canceled' | 'refund.succeeded' + */ +async function handleWebhook(event) { + if (event.type !== 'payment.succeeded') { + console.log('[YuKassa] webhook:', event.type, '— пропускаем'); + return { handled: false }; + } + + const payment = event.object; + const meta = payment.metadata || {}; + const userId = parseInt(meta.user_id); + const planCode = meta.plan_code; + + if (!userId || !planCode) { + console.warn('[YuKassa] webhook succeeded: нет user_id/plan_code в metadata'); + return { handled: false }; + } + + // Проверяем дубль + const { rows: [order] } = await query( + 'SELECT * FROM payment_orders WHERE yukassa_payment_id=$1', [payment.id] + ); + if (order?.status === 'succeeded') { + console.log('[YuKassa] дубль webhook для', payment.id); + return { handled: true, duplicate: true }; + } + + // Обновляем статус платежа + await query( + 'UPDATE payment_orders SET status=$1, updated_at=NOW() WHERE yukassa_payment_id=$2', + ['succeeded', payment.id] + ); + + // Активируем подписку + const { rows: [plan] } = await query('SELECT * FROM plans WHERE code=$1', [planCode]); + if (!plan) { console.error('[YuKassa] план не найден:', planCode); return { handled: false }; } + + // Деактивируем старую подписку + await query( + "UPDATE user_subscriptions SET status='cancelled' WHERE user_id=$1 AND status='active'", + [userId] + ); + + // Создаём новую + const expiresAt = new Date(Date.now() + 32 * 24 * 60 * 60 * 1000); // +32 дня + await query(` + INSERT INTO user_subscriptions (user_id, plan_id, status, expires_at, yukassa_sub_id) + VALUES ($1, $2, 'active', $3, $4) + `, [userId, plan.id, expiresAt, payment.id]); + + // Начисляем кредиты по новому тарифу + const creditsToAdd = plan.credits_month === -1 ? 0 : plan.credits_month; + if (creditsToAdd > 0) { + // Сбрасываем до кредитов нового плана + await query(` + UPDATE user_balance + SET credits = $2, credits_monthly_reset = $2, + reset_at = $3, updated_at = NOW() + WHERE user_id = $1 + `, [userId, creditsToAdd, expiresAt]); + + await query(` + INSERT INTO user_transactions (user_id, type, amount, balance_after, description) + VALUES ($1, 'plan_credit', $2, $2, $3) + `, [userId, creditsToAdd, `Активация тарифа ${plan.name}`]); + } + + console.log(`[YuKassa] ✓ userId=${userId} план=${planCode} кредиты=${creditsToAdd}`); + return { handled: true, userId, planCode }; +} + +module.exports = { createPayment, handleWebhook };