forked from admin/zeropost-engine
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:
@@ -46,6 +46,16 @@ app.get('/api/billing/plans', async (req, res) => {
|
|||||||
res.json({ plans, costs });
|
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
|
// Simple internal auth middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const secret = req.headers['x-internal-secret'];
|
const secret = req.headers['x-internal-secret'];
|
||||||
|
|||||||
Generated
+34
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@a2seven/yoo-checkout": "^1.1.4",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"bull": "^4.16.5",
|
"bull": "^4.16.5",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
@@ -21,6 +22,29 @@
|
|||||||
"sharp": "^0.34.5"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
@@ -580,6 +604,16 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@a2seven/yoo-checkout": "^1.1.4",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"bull": "^4.16.5",
|
"bull": "^4.16.5",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
|
|||||||
+29
-1
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const billing = require('../services/billing');
|
const billing = require('../services/billing');
|
||||||
|
const yukassa = require('../services/yukassa');
|
||||||
const { query } = require('../config/db');
|
const { query } = require('../config/db');
|
||||||
|
|
||||||
function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; }
|
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;
|
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