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:
+29
-1
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user