feat: YuKassa payment integration

- services/yukassa.js: createPayment, handleWebhook
  createPayment → создаёт платёж + сохраняет в payment_orders
  handleWebhook → activates plan + charges credits on payment.succeeded
- routes/billing.js: POST /checkout, POST /webhook (публичный)
- DB: payment_orders table
- index.js: /api/billing/webhook публичный (до auth middleware)
This commit is contained in:
Ник (Claude)
2026-06-11 18:44:20 +03:00
parent 2e60a6e316
commit 9baa0f0959
5 changed files with 218 additions and 1 deletions
+10
View File
@@ -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'];
+34
View File
@@ -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",
+1
View File
@@ -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",
+28
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
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 });
}
});
+144
View File
@@ -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 };