Замена DePay на FreeKassa и удаление системы проверки чеков
- Создан модуль FreeKassa с обработкой платежей, webhook, IP whitelist, MD5 подписью - Переписан frontend billing.tsx для формы оплаты FreeKassa - Удалены файлы и зависимости DePay (depay.routes.ts, @depay/widgets) - Полностью удалена система проверки чеков операторами: * Удален backend модуль /modules/check/ * Удалена frontend страница checkverification.tsx * Очищены импорты, маршруты, WebSocket события * Удалено поле checkId из Notification схемы * Удалены переводы для чеков - Добавлена поддержка спецсимволов в секретных словах FreeKassa - Добавлена документация PAYMENT_MIGRATION.md
This commit is contained in:
@@ -10,7 +10,7 @@ import authRoutes from './modules/auth/auth.routes';
|
||||
import oauthRoutes from './modules/auth/oauth.routes';
|
||||
import adminRoutes from './modules/admin/admin.routes';
|
||||
import ticketRoutes from './modules/ticket/ticket.routes';
|
||||
import depayRoutes from './modules/payment/depay.routes';
|
||||
import freekassaRoutes from './modules/payment/freekassa.routes';
|
||||
import blogRoutes from './modules/blog/blog.routes';
|
||||
import notificationRoutes from './modules/notification/notification.routes';
|
||||
import userRoutes from './modules/user/user.routes';
|
||||
@@ -329,7 +329,7 @@ app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/auth', authLimiter, oauthRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/ticket', ticketRoutes);
|
||||
app.use('/payment/depay', depayRoutes);
|
||||
app.use('/payment', freekassaRoutes);
|
||||
app.use('/api/blog', blogRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { prisma } from '../../prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { Multer } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Тип расширенного запроса с Multer
|
||||
interface MulterRequest extends Request {
|
||||
file?: Express.Multer.File;
|
||||
}
|
||||
|
||||
// Загрузка чека клиентом (с файлом)
|
||||
export async function uploadCheck(req: MulterRequest, res: Response) {
|
||||
const userId = req.user?.id;
|
||||
const { amount } = req.body;
|
||||
const file = req.file;
|
||||
if (!userId || !amount || !file) return res.status(400).json({ error: 'Данные не заполнены или файл не загружен' });
|
||||
|
||||
// Сохраняем путь к файлу
|
||||
const fileUrl = `/uploads/checks/${file.filename}`;
|
||||
|
||||
const check = await prisma.cryptoPayment.create({
|
||||
data: { userId, amount: Number(amount), fileUrl }
|
||||
});
|
||||
res.json(check);
|
||||
}
|
||||
|
||||
// Получить все чеки (оператор)
|
||||
export async function getChecks(req: Request, res: Response) {
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
if (!isOperator) return res.status(403).json({ error: 'Нет прав' });
|
||||
const checks = await prisma.cryptoPayment.findMany({
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
res.json(checks);
|
||||
}
|
||||
|
||||
// Подтвердить чек и пополнить баланс (только оператор)
|
||||
export async function approveCheck(req: Request, res: Response) {
|
||||
try {
|
||||
const { checkId } = req.body;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
// Проверка прав оператора
|
||||
if (!isOperator) {
|
||||
logger.warn(`[Check] Попытка подтверждения чека #${checkId} не оператором (userId: ${req.user?.id})`);
|
||||
return res.status(403).json({ error: 'Нет прав. Только операторы могут подтверждать чеки' });
|
||||
}
|
||||
|
||||
// Найти чек
|
||||
const check = await prisma.cryptoPayment.findUnique({
|
||||
where: { id: checkId },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({ error: 'Чек не найден' });
|
||||
}
|
||||
|
||||
// Проверка что чек ещё не обработан
|
||||
if (check.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
error: `Чек уже обработан (статус: ${check.status})`
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить статус чека
|
||||
await prisma.cryptoPayment.update({
|
||||
where: { id: checkId },
|
||||
data: { status: 'approved' }
|
||||
});
|
||||
|
||||
// Пополнить баланс пользователя
|
||||
await prisma.user.update({
|
||||
where: { id: check.userId },
|
||||
data: {
|
||||
balance: {
|
||||
increment: check.amount
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Check] Чек #${checkId} подтверждён оператором ${req.user?.id}. Пользователь ${check.user.username} получил ${check.amount} ₽`);
|
||||
res.json({ success: true, message: 'Чек подтверждён, баланс пополнен' });
|
||||
} catch (error) {
|
||||
logger.error('[Check] Ошибка подтверждения чека:', error);
|
||||
res.status(500).json({ error: 'Ошибка подтверждения чека' });
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить чек (только оператор)
|
||||
export async function rejectCheck(req: Request, res: Response) {
|
||||
try {
|
||||
const { checkId, comment } = req.body;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
// Проверка прав оператора
|
||||
if (!isOperator) {
|
||||
logger.warn(`[Check] Попытка отклонения чека #${checkId} не оператором (userId: ${req.user?.id})`);
|
||||
return res.status(403).json({ error: 'Нет прав. Только операторы могут отклонять чеки' });
|
||||
}
|
||||
|
||||
// Найти чек
|
||||
const check = await prisma.cryptoPayment.findUnique({
|
||||
where: { id: checkId },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({ error: 'Чек не найден' });
|
||||
}
|
||||
|
||||
// Проверка что чек ещё не обработан
|
||||
if (check.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
error: `Чек уже обработан (статус: ${check.status})`
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить статус чека
|
||||
await prisma.cryptoPayment.update({
|
||||
where: { id: checkId },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
// Можно добавить поле comment в модель Check для хранения причины отклонения
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[Check] Чек #${checkId} отклонён оператором ${req.user?.id}. Пользователь: ${check.user.username}${comment ? `, причина: ${comment}` : ''}`);
|
||||
res.json({ success: true, message: 'Чек отклонён' });
|
||||
} catch (error) {
|
||||
logger.error('[Check] Ошибка отклонения чека:', error);
|
||||
res.status(500).json({ error: 'Ошибка отклонения чека' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить историю чеков текущего пользователя
|
||||
export async function getUserChecks(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
|
||||
|
||||
const checks = await prisma.cryptoPayment.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50 // Последние 50 чеков
|
||||
});
|
||||
|
||||
res.json({ status: 'success', data: checks });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка получения истории чеков' });
|
||||
}
|
||||
}
|
||||
|
||||
// Просмотреть конкретный чек (изображение)
|
||||
export async function viewCheck(req: Request, res: Response) {
|
||||
try {
|
||||
const checkId = Number(req.params.id);
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
const check = await prisma.cryptoPayment.findUnique({ where: { id: checkId } });
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({ error: 'Чек не найден' });
|
||||
}
|
||||
|
||||
// Проверка прав доступа (только владелец или оператор)
|
||||
if (check.userId !== userId && !isOperator) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому чеку' });
|
||||
}
|
||||
|
||||
res.json({ status: 'success', data: check });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка получения чека' });
|
||||
}
|
||||
}
|
||||
|
||||
// Получить файл изображения чека с авторизацией
|
||||
export async function getCheckFile(req: Request, res: Response) {
|
||||
try {
|
||||
const filename = req.params.filename;
|
||||
const userId = req.user?.id;
|
||||
const isOperator = Number(req.user?.operator) === 1;
|
||||
|
||||
logger.debug(`[CheckFile] Запрос файла ${filename} от пользователя ${userId}, оператор: ${isOperator}`);
|
||||
|
||||
// Операторы имеют доступ ко всем файлам
|
||||
if (!isOperator) {
|
||||
// Для обычных пользователей проверяем владение
|
||||
const check = await prisma.cryptoPayment.findFirst({
|
||||
where: {
|
||||
fileUrl: {
|
||||
contains: filename
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
logger.warn(`[CheckFile] Чек с файлом ${filename} не найден в БД`);
|
||||
return res.status(404).json({ error: 'Файл не найден' });
|
||||
}
|
||||
|
||||
if (check.userId !== userId) {
|
||||
logger.warn(`[CheckFile] Пользователь ${userId} попытался получить доступ к чужому чеку (владелец: ${check.userId})`);
|
||||
return res.status(403).json({ error: 'Нет доступа к этому файлу' });
|
||||
}
|
||||
}
|
||||
|
||||
// Путь к файлу
|
||||
const filePath = path.join(__dirname, '../../../uploads/checks', filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn(`[CheckFile] Файл ${filename} не найден на диске`);
|
||||
return res.status(404).json({ error: 'Файл не найден на сервере' });
|
||||
}
|
||||
|
||||
logger.debug(`[CheckFile] Доступ разрешён, отправка файла ${filename}`);
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
logger.error('[CheckFile] Ошибка получения файла:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения файла' });
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { uploadCheck, getChecks, approveCheck, rejectCheck, getUserChecks, viewCheck, getCheckFile } from './check.controller';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
import multer, { MulterError } from 'multer';
|
||||
import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Настройка Multer для загрузки чеков
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) {
|
||||
const uploadDir = path.join(__dirname, '../../../uploads/checks');
|
||||
// Проверяем и создаём директорию, если её нет
|
||||
try {
|
||||
require('fs').mkdirSync(uploadDir, { recursive: true });
|
||||
} catch (err) {
|
||||
// Игнорируем ошибку, если папка уже существует
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/jpg'
|
||||
];
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB лимит
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
const err: any = new Error('Недопустимый формат файла. Разрешены только изображения: jpg, jpeg, png, gif, webp.');
|
||||
err.code = 'LIMIT_FILE_FORMAT';
|
||||
cb(err, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.post('/upload', upload.single('file'), uploadCheck);
|
||||
router.get('/', getChecks); // Для операторов - все чеки
|
||||
router.get('/my', getUserChecks); // Для пользователей - свои чеки
|
||||
router.get('/file/:filename', getCheckFile); // Получение файла чека с авторизацией
|
||||
router.get('/:id', viewCheck); // Просмотр конкретного чека
|
||||
router.post('/approve', approveCheck);
|
||||
router.post('/reject', rejectCheck);
|
||||
|
||||
export default router;
|
||||
@@ -266,7 +266,6 @@ interface CreateNotificationParams {
|
||||
title: string;
|
||||
message: string;
|
||||
ticketId?: number;
|
||||
checkId?: number;
|
||||
actionUrl?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
@@ -289,7 +288,6 @@ export async function createNotification(params: CreateNotificationParams) {
|
||||
title: params.title,
|
||||
message: params.message,
|
||||
ticketId: params.ticketId,
|
||||
checkId: params.checkId,
|
||||
actionUrl: params.actionUrl,
|
||||
icon: params.icon,
|
||||
color: params.color
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import crypto from 'crypto';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// DePay configuration from environment variables
|
||||
const DEPAY_PUBLIC_KEY = process.env.DEPAY_PUBLIC_KEY || '';
|
||||
const DEPAY_FALLBACK_RATE = parseFloat(process.env.DEPAY_FALLBACK_RATE || '95');
|
||||
|
||||
// Function to get USDT to RUB exchange rate
|
||||
async function getUsdtToRubRate(): Promise<number> {
|
||||
try {
|
||||
// Use CoinGecko API to get USDT price in RUB
|
||||
const response = await fetch(
|
||||
'https://api.coingecko.com/api/v3/simple/price?ids=tether&vs_currencies=rub'
|
||||
);
|
||||
const data = await response.json() as { tether?: { rub?: number } };
|
||||
const rate = data.tether?.rub || DEPAY_FALLBACK_RATE;
|
||||
console.log('[DePay] Current USDT/RUB rate:', rate);
|
||||
return rate;
|
||||
} catch (error) {
|
||||
console.error('[DePay] Error fetching exchange rate:', error);
|
||||
return DEPAY_FALLBACK_RATE;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify webhook signature from DePay
|
||||
function verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string
|
||||
): boolean {
|
||||
try {
|
||||
const verifier = crypto.createVerify('SHA256');
|
||||
verifier.update(payload);
|
||||
const isValid = verifier.verify(DEPAY_PUBLIC_KEY, signature, 'base64');
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.error('[DePay] Signature verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payment/depay/callback
|
||||
* Webhook endpoint called by DePay after successful payment
|
||||
*/
|
||||
router.post('/callback', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('[DePay] Callback received:', JSON.stringify(req.body, null, 2));
|
||||
|
||||
// Verify webhook signature
|
||||
const signature = req.headers['x-signature'] as string;
|
||||
const rawBody = JSON.stringify(req.body);
|
||||
|
||||
if (signature && !verifyWebhookSignature(rawBody, signature)) {
|
||||
console.error('[DePay] Invalid webhook signature');
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
const {
|
||||
transaction,
|
||||
payment,
|
||||
user: paymentUser,
|
||||
} = req.body;
|
||||
|
||||
// Extract payment data
|
||||
const transactionHash = transaction?.id || transaction?.hash;
|
||||
const blockchain = transaction?.blockchain || 'polygon';
|
||||
const tokenAddress = payment?.token;
|
||||
const cryptoAmount = parseFloat(payment?.amount) || 0;
|
||||
const userId = parseInt(paymentUser?.id) || null;
|
||||
|
||||
if (!userId) {
|
||||
console.error('[DePay] No user ID in callback');
|
||||
return res.status(400).json({ error: 'User ID required' });
|
||||
}
|
||||
|
||||
if (!cryptoAmount || cryptoAmount <= 0) {
|
||||
console.error('[DePay] Invalid crypto amount');
|
||||
return res.status(400).json({ error: 'Invalid amount' });
|
||||
}
|
||||
|
||||
// Get current exchange rate
|
||||
const exchangeRate = await getUsdtToRubRate();
|
||||
const amountInRub = cryptoAmount * exchangeRate;
|
||||
|
||||
console.log('[DePay] Payment details:', {
|
||||
userId,
|
||||
cryptoAmount,
|
||||
exchangeRate,
|
||||
amountInRub,
|
||||
transactionHash,
|
||||
});
|
||||
|
||||
// Create crypto payment record
|
||||
const cryptoPayment = await prisma.cryptoPayment.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: amountInRub,
|
||||
cryptoAmount,
|
||||
exchangeRate,
|
||||
status: 'completed',
|
||||
transactionHash,
|
||||
blockchain,
|
||||
token: 'USDT',
|
||||
paymentProvider: 'depay',
|
||||
},
|
||||
});
|
||||
|
||||
// Get user balance before update
|
||||
const userBefore = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { balance: true },
|
||||
});
|
||||
const balanceBefore = userBefore?.balance || 0;
|
||||
const balanceAfter = balanceBefore + amountInRub;
|
||||
|
||||
// Update user balance
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
balance: {
|
||||
increment: amountInRub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create transaction record
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: amountInRub,
|
||||
type: 'deposit',
|
||||
description: `Crypto payment via DePay: ${cryptoAmount} USDT`,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[DePay] ✅ Payment processed successfully:', {
|
||||
paymentId: cryptoPayment.id,
|
||||
amountInRub,
|
||||
});
|
||||
|
||||
// Send success notification
|
||||
try {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'balance',
|
||||
title: 'Balance Topped Up',
|
||||
message: `Your balance has been topped up by ${amountInRub.toFixed(2)} ₽ (${cryptoAmount} USDT)`,
|
||||
isRead: false,
|
||||
},
|
||||
});
|
||||
} catch (notifError) {
|
||||
console.error('[DePay] Error creating notification:', notifError);
|
||||
}
|
||||
|
||||
return res.json({ success: true, paymentId: cryptoPayment.id });
|
||||
} catch (error) {
|
||||
console.error('[DePay] Callback error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /payment/depay/success
|
||||
* Redirect endpoint after successful payment (shown to user)
|
||||
*/
|
||||
router.get('/success', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { transaction, user: userId } = req.query;
|
||||
|
||||
console.log('[DePay] Success redirect:', { transaction, userId });
|
||||
|
||||
// Redirect to frontend with success message
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://ospab.host';
|
||||
return res.redirect(
|
||||
`${frontendUrl}/dashboard/billing?payment=success&tx=${transaction}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[DePay] Success redirect error:', error);
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://ospab.host';
|
||||
return res.redirect(`${frontendUrl}/dashboard/billing?payment=error`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /payment/depay/rate
|
||||
* Get current USDT to RUB exchange rate
|
||||
*/
|
||||
router.get('/rate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rate = await getUsdtToRubRate();
|
||||
return res.json({ rate, currency: 'RUB', crypto: 'USDT' });
|
||||
} catch (error) {
|
||||
console.error('[DePay] Rate fetch error:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch exchange rate' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /payment/depay/history
|
||||
* Get user's crypto payment history (requires authentication)
|
||||
*/
|
||||
router.get('/history', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const payments = await prisma.cryptoPayment.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return res.json({ payments });
|
||||
} catch (error) {
|
||||
console.error('[DePay] History fetch error:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch payment history' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
314
ospabhost/backend/src/modules/payment/freekassa.routes.ts
Normal file
314
ospabhost/backend/src/modules/payment/freekassa.routes.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../../prisma/client';
|
||||
import crypto from 'crypto';
|
||||
import { authMiddleware } from '../auth/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// FreeKassa configuration from environment variables
|
||||
// Remove quotes if present (handles values like "secret" or secret)
|
||||
const FK_MERCHANT_ID = (process.env.FK_MERCHANT_ID || '').replace(/^["']|["']$/g, '');
|
||||
const FK_SECRET_WORD_1 = (process.env.FK_SECRET_WORD_1 || '').replace(/^["']|["']$/g, ''); // For payment form
|
||||
const FK_SECRET_WORD_2 = (process.env.FK_SECRET_WORD_2 || '').replace(/^["']|["']$/g, ''); // For notification verification
|
||||
const FK_ALLOWED_IPS = ['168.119.157.136', '168.119.60.227', '178.154.197.79', '51.250.54.238'];
|
||||
|
||||
/**
|
||||
* Helper: Get client IP address
|
||||
*/
|
||||
function getClientIP(req: Request): string {
|
||||
return (req.headers['x-real-ip'] as string) ||
|
||||
(req.headers['x-forwarded-for'] as string)?.split(',')[0] ||
|
||||
req.socket.remoteAddress ||
|
||||
'';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Generate signature for payment form
|
||||
* Signature format: MD5(merchant_id:amount:secret_word_1:currency:order_id)
|
||||
*/
|
||||
export function generatePaymentSignature(
|
||||
merchantId: string,
|
||||
amount: string,
|
||||
currency: string,
|
||||
orderId: string,
|
||||
secretWord: string
|
||||
): string {
|
||||
const signString = `${merchantId}:${amount}:${secretWord}:${currency}:${orderId}`;
|
||||
return crypto.createHash('md5').update(signString).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Verify notification signature
|
||||
* Signature format: MD5(merchant_id:amount:secret_word_2:order_id)
|
||||
*/
|
||||
function verifyNotificationSignature(
|
||||
merchantId: string,
|
||||
amount: string,
|
||||
orderId: string,
|
||||
signature: string,
|
||||
secretWord: string
|
||||
): boolean {
|
||||
const signString = `${merchantId}:${amount}:${secretWord}:${orderId}`;
|
||||
const expectedSignature = crypto.createHash('md5').update(signString).digest('hex');
|
||||
return expectedSignature === signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payment/frekassa/notification
|
||||
* Webhook endpoint called by FreeKassa after payment
|
||||
*/
|
||||
router.get('/notification', async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('[FreeKassa] Notification received:', req.query);
|
||||
|
||||
// Check IP address
|
||||
const clientIP = getClientIP(req);
|
||||
console.log('[FreeKassa] Client IP:', clientIP);
|
||||
|
||||
if (!FK_ALLOWED_IPS.includes(clientIP)) {
|
||||
console.warn('[FreeKassa] Unauthorized IP:', clientIP);
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
|
||||
// Extract parameters from query
|
||||
const {
|
||||
MERCHANT_ID,
|
||||
AMOUNT,
|
||||
intid,
|
||||
MERCHANT_ORDER_ID,
|
||||
P_EMAIL,
|
||||
P_PHONE,
|
||||
CUR_ID,
|
||||
SIGN,
|
||||
us_user_id
|
||||
} = req.query;
|
||||
|
||||
// Validate required fields
|
||||
if (!MERCHANT_ID || !AMOUNT || !MERCHANT_ORDER_ID || !SIGN) {
|
||||
console.error('[FreeKassa] Missing required fields');
|
||||
return res.status(400).send('Bad Request');
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const isValid = verifyNotificationSignature(
|
||||
String(MERCHANT_ID),
|
||||
String(AMOUNT),
|
||||
String(MERCHANT_ORDER_ID),
|
||||
String(SIGN),
|
||||
FK_SECRET_WORD_2
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.error('[FreeKassa] Invalid signature');
|
||||
return res.status(400).send('Invalid signature');
|
||||
}
|
||||
|
||||
const userId = us_user_id ? parseInt(String(us_user_id)) : null;
|
||||
const amount = parseFloat(String(AMOUNT));
|
||||
const orderId = String(MERCHANT_ORDER_ID);
|
||||
const transactionId = String(intid);
|
||||
|
||||
console.log('[FreeKassa] Valid payment:', { userId, amount, orderId, transactionId });
|
||||
|
||||
// Check if payment already processed
|
||||
const existingPayment = await prisma.cryptoPayment.findFirst({
|
||||
where: { transactionHash: transactionId }
|
||||
});
|
||||
|
||||
if (existingPayment) {
|
||||
console.log('[FreeKassa] Payment already processed:', transactionId);
|
||||
return res.send('YES');
|
||||
}
|
||||
|
||||
// Find user
|
||||
if (!userId) {
|
||||
console.error('[FreeKassa] User ID not provided');
|
||||
return res.status(400).send('User ID required');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error('[FreeKassa] User not found:', userId);
|
||||
return res.status(404).send('User not found');
|
||||
}
|
||||
|
||||
// Create payment record and update balance in transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Create payment record
|
||||
await tx.cryptoPayment.create({
|
||||
data: {
|
||||
userId: userId,
|
||||
amount: amount,
|
||||
cryptoAmount: amount, // For FreeKassa, same as RUB amount
|
||||
exchangeRate: 1, // 1:1 for RUB
|
||||
status: 'completed',
|
||||
transactionHash: transactionId,
|
||||
blockchain: 'freekassa',
|
||||
token: 'RUB',
|
||||
}
|
||||
});
|
||||
|
||||
// Update user balance
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { balance: { increment: amount } }
|
||||
});
|
||||
|
||||
// Create transaction record
|
||||
const newTransaction = await tx.transaction.create({
|
||||
data: {
|
||||
userId: userId,
|
||||
type: 'deposit',
|
||||
amount: amount,
|
||||
description: `Пополнение через FreeKassa (${transactionId})`,
|
||||
balanceBefore: user.balance,
|
||||
balanceAfter: user.balance + amount,
|
||||
}
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await tx.notification.create({
|
||||
data: {
|
||||
userId: userId,
|
||||
title: 'Баланс пополнен',
|
||||
message: `Ваш баланс пополнен на ${amount.toFixed(2)} ₽ через FreeKassa`,
|
||||
type: 'payment_received',
|
||||
isRead: false,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[FreeKassa] Payment processed successfully for user:', userId);
|
||||
|
||||
// Send YES response as required by FreeKassa
|
||||
return res.send('YES');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FreeKassa] Notification error:', error);
|
||||
return res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /payment/freekassa/sucess
|
||||
* Success redirect page after payment
|
||||
*/
|
||||
router.get('/sucess', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { MERCHANT_ORDER_ID, AMOUNT } = req.query;
|
||||
|
||||
console.log('[FreeKassa] Success redirect:', { orderId: MERCHANT_ORDER_ID, amount: AMOUNT });
|
||||
|
||||
// Redirect to dashboard with success message
|
||||
return res.redirect(`https://ospab.host/dashboard/billing?payment=success&order=${MERCHANT_ORDER_ID}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FreeKassa] Success redirect error:', error);
|
||||
return res.redirect('https://ospab.host/dashboard/billing?payment=error');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /payment/freekassa/failure
|
||||
* Failure redirect page after failed payment
|
||||
*/
|
||||
router.get('/failure', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { MERCHANT_ORDER_ID } = req.query;
|
||||
|
||||
console.log('[FreeKassa] Failure redirect:', { orderId: MERCHANT_ORDER_ID });
|
||||
|
||||
return res.redirect(`https://ospab.host/dashboard/billing?payment=error&order=${MERCHANT_ORDER_ID}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FreeKassa] Failure redirect error:', error);
|
||||
return res.redirect('https://ospab.host/dashboard/billing?payment=error');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /payment/freekassa/create-payment
|
||||
* Create FreeKassa payment form data
|
||||
* Protected route - requires authentication
|
||||
*/
|
||||
router.post('/create-payment', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { amount } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
if (!amount || isNaN(amount) || amount < 50) {
|
||||
return res.status(400).json({ error: 'Минимальная сумма 50 ₽' });
|
||||
}
|
||||
|
||||
// Generate unique order ID
|
||||
const orderId = `${userId}_${Date.now()}`;
|
||||
const amountStr = parseFloat(amount).toFixed(2);
|
||||
|
||||
// Generate signature
|
||||
const signature = generatePaymentSignature(
|
||||
FK_MERCHANT_ID,
|
||||
amountStr,
|
||||
'RUB',
|
||||
orderId,
|
||||
FK_SECRET_WORD_1
|
||||
);
|
||||
|
||||
// Get user email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true }
|
||||
});
|
||||
|
||||
// Return payment form data
|
||||
return res.json({
|
||||
merchantId: FK_MERCHANT_ID,
|
||||
amount: amountStr,
|
||||
orderId: orderId,
|
||||
signature: signature,
|
||||
currency: 'RUB',
|
||||
email: user?.email || '',
|
||||
userId: userId,
|
||||
paymentUrl: 'https://pay.fk.money/'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FreeKassa] Create payment error:', error);
|
||||
return res.status(500).json({ error: 'Ошибка создания платежа' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /payment/freekassa/history
|
||||
* Get user's payment history
|
||||
* Protected route - requires authentication
|
||||
*/
|
||||
router.get('/history', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const payments = await prisma.cryptoPayment.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50
|
||||
});
|
||||
|
||||
return res.json({ payments });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FreeKassa] History error:', error);
|
||||
return res.status(500).json({ error: 'Ошибка загрузки истории' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -57,7 +57,6 @@ export type ServerToClientEvents =
|
||||
| { type: 'ticket:response'; ticketId: number; response: TicketResponsePayload }
|
||||
| { type: 'ticket:status'; ticketId: number; status: string }
|
||||
| { type: 'balance:updated'; balance: number; transaction?: TransactionPayload }
|
||||
| { type: 'check:status'; checkId: number; status: string }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user