Замена 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:
275
PAYMENT_MIGRATION.md
Normal file
275
PAYMENT_MIGRATION.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Миграция оплаты: DePay → FreeKassa
|
||||||
|
|
||||||
|
## ✅ Выполненные изменения
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **Создан новый модуль FreeKassa** (`backend/src/modules/payment/freekassa.routes.ts`)
|
||||||
|
- ✅ Endpoint уведомлений: `GET /payment/freekassa/notification`
|
||||||
|
- ✅ Успешная оплата: `GET /payment/freekassa/sucess`
|
||||||
|
- ✅ Неудачная оплата: `GET /payment/freekassa/failure`
|
||||||
|
- ✅ Создание платежа: `POST /payment/freekassa/create-payment`
|
||||||
|
- ✅ История платежей: `GET /payment/freekassa/history`
|
||||||
|
- ✅ IP whitelist проверка (4 IP FreeKassa)
|
||||||
|
- ✅ MD5 подпись для формы и уведомлений
|
||||||
|
|
||||||
|
2. **Обновлен main router** (`backend/src/index.ts`)
|
||||||
|
- ✅ Заменен импорт: `depayRoutes` → `freekassaRoutes`
|
||||||
|
- ✅ Изменен маршрут: `/payment/depay` → `/payment`
|
||||||
|
|
||||||
|
3. **Обновлен .env** (`backend/.env`)
|
||||||
|
- ✅ Удалены переменные DePay: `DEPAY_PUBLIC_KEY`, `DEPAY_FALLBACK_RATE`
|
||||||
|
- ✅ Добавлены переменные FreeKassa (требуют заполнения):
|
||||||
|
```env
|
||||||
|
FK_MERCHANT_ID=YOUR_MERCHANT_ID
|
||||||
|
FK_SECRET_WORD_1=YOUR_SECRET_WORD_1
|
||||||
|
FK_SECRET_WORD_2=YOUR_SECRET_WORD_2
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Удален старый модуль DePay**
|
||||||
|
- ✅ Удален файл: `backend/src/modules/payment/depay.routes.ts`
|
||||||
|
|
||||||
|
5. **Обновлена схема Prisma** (`backend/prisma/schema.prisma`)
|
||||||
|
- ✅ Удалено поле `checkId` из модели `Notification`
|
||||||
|
- ⚠️ **Требуется миграция БД** (см. раздел "Развертывание")
|
||||||
|
|
||||||
|
6. **Очищены ссылки на систему чеков**
|
||||||
|
- ✅ Удалено событие `check:status` из WebSocket events
|
||||||
|
- ✅ Удалено поле `checkId` из notification controller
|
||||||
|
- ✅ Удален модуль `backend/src/modules/check/`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **Переписан компонент оплаты** (`frontend/src/pages/dashboard/billing.tsx`)
|
||||||
|
- ✅ Удален DePay виджет
|
||||||
|
- ✅ Добавлена форма для редиректа на FreeKassa
|
||||||
|
- ✅ Динамическое создание формы с подписью
|
||||||
|
- ✅ Открытие оплаты в новой вкладке
|
||||||
|
|
||||||
|
2. **Обновлен package.json** (`frontend/package.json`)
|
||||||
|
- ✅ Удалена зависимость: `@depay/widgets`
|
||||||
|
|
||||||
|
3. **Обновлен .env** (`frontend/.env`)
|
||||||
|
- ✅ Удалена переменная: `VITE_DEPAY_INTEGRATION_ID`
|
||||||
|
|
||||||
|
4. **Удалена система проверки чеков**
|
||||||
|
- ✅ Удален файл: `frontend/src/pages/dashboard/checkverification.tsx`
|
||||||
|
- ✅ Удален импорт и маршрут из `mainpage.tsx`
|
||||||
|
- ✅ Удалена вкладка из панели администратора
|
||||||
|
- ✅ Удалено событие `check:status` из WebSocket
|
||||||
|
- ✅ Удалено поле `checkId` из типов уведомлений
|
||||||
|
- ✅ Удалены переводы для чеков (en.ts, ru.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Развертывание
|
||||||
|
|
||||||
|
### 1. Заполнить credentials FreeKassa
|
||||||
|
|
||||||
|
Откройте [merchant.freekassa.com](https://merchant.freekassa.com) → **Настройки** → **Магазины**
|
||||||
|
|
||||||
|
В файле `backend/.env` замените:
|
||||||
|
```env
|
||||||
|
FK_MERCHANT_ID=12345 # ID вашего магазина
|
||||||
|
FK_SECRET_WORD_1=your_secret_1 # Секретное слово 1 (для формы)
|
||||||
|
FK_SECRET_WORD_2=your_secret_2 # Секретное слово 2 (для уведомлений)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настроить URL в FreeKassa панели
|
||||||
|
|
||||||
|
В настройках магазина на FreeKassa укажите:
|
||||||
|
|
||||||
|
- **Result URL** (уведомления): `https://ospab.host/payment/freekassa/notification`
|
||||||
|
- **Success URL**: `https://ospab.host/payment/freekassa/sucess`
|
||||||
|
- **Failure URL**: `https://ospab.host/payment/freekassa/failure`
|
||||||
|
|
||||||
|
⚠️ **Важно**: В скриншоте была опечатка `/frekassa/` вместо `/freekassa/`. Убедитесь, что используете правильный путь!
|
||||||
|
|
||||||
|
### 3. Применить миграцию БД
|
||||||
|
|
||||||
|
После обновления `schema.prisma` нужно создать миграцию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npx prisma migrate dev --name remove_checkid_field
|
||||||
|
```
|
||||||
|
|
||||||
|
Или для production:
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Переустановить зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend
|
||||||
|
cd ospabhost/frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Backend (если нужно)
|
||||||
|
cd ../backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Пересобрать проекты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Перезапустить сервисы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Если используется PM2
|
||||||
|
pm2 restart all
|
||||||
|
|
||||||
|
# Или через systemd
|
||||||
|
sudo systemctl restart ospab-backend
|
||||||
|
sudo systemctl restart ospab-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
### 1. Проверка создания платежа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.ospab.host/payment/freekassa/create-payment \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"amount": 100}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый ответ:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"merchantId": "12345",
|
||||||
|
"amount": 100,
|
||||||
|
"orderId": "user123_1234567890",
|
||||||
|
"signature": "abc123...",
|
||||||
|
"currency": "RUB",
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Проверка уведомлений (webhook)
|
||||||
|
|
||||||
|
FreeKassa отправит GET запрос на `/payment/freekassa/notification` с параметрами:
|
||||||
|
- `MERCHANT_ID` - ID магазина
|
||||||
|
- `AMOUNT` - сумма платежа
|
||||||
|
- `intid` - ID транзакции FreeKassa
|
||||||
|
- `MERCHANT_ORDER_ID` - ваш order ID
|
||||||
|
- `SIGN` - MD5 подпись
|
||||||
|
|
||||||
|
Backend проверит:
|
||||||
|
1. ✅ IP адрес отправителя (whitelist)
|
||||||
|
2. ✅ MD5 подпись (через SECRET_WORD_2)
|
||||||
|
3. ✅ Уникальность платежа (не был обработан ранее)
|
||||||
|
4. ✅ Создаст запись в CryptoPayment
|
||||||
|
5. ✅ Увеличит баланс пользователя
|
||||||
|
6. ✅ Создаст транзакцию
|
||||||
|
7. ✅ Отправит уведомление
|
||||||
|
8. ✅ Вернет "YES"
|
||||||
|
|
||||||
|
### 3. Ручное тестирование
|
||||||
|
|
||||||
|
1. Войдите в личный кабинет
|
||||||
|
2. Перейдите в раздел "Billing"
|
||||||
|
3. Введите сумму (минимум 50 ₽)
|
||||||
|
4. Нажмите "Пополнить"
|
||||||
|
5. Откроется форма FreeKassa в новой вкладке
|
||||||
|
6. Выберите способ оплаты и оплатите
|
||||||
|
7. После оплаты FreeKassa отправит уведомление на backend
|
||||||
|
8. Баланс должен автоматически обновиться
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Важные замечания
|
||||||
|
|
||||||
|
### Безопасность
|
||||||
|
|
||||||
|
1. **IP Whitelist**: Backend проверяет, что уведомления приходят только с IP FreeKassa:
|
||||||
|
- `168.119.157.136`
|
||||||
|
- `168.119.60.227`
|
||||||
|
- `178.154.197.79`
|
||||||
|
- `51.250.54.238`
|
||||||
|
|
||||||
|
2. **MD5 подпись**: Все уведомления проверяются на валидность подписи
|
||||||
|
|
||||||
|
3. **Идемпотентность**: Повторная обработка одного платежа блокируется
|
||||||
|
|
||||||
|
### Отличия от DePay
|
||||||
|
|
||||||
|
| Параметр | DePay | FreeKassa |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| Тип интеграции | Виджет (JavaScript) | Форма (POST redirect) |
|
||||||
|
| Валюта | Криптовалюта | RUB |
|
||||||
|
| Открытие | Модальное окно | Новая вкладка |
|
||||||
|
| Callback | WebSocket | GET webhook |
|
||||||
|
| Подпись | RSA | MD5 |
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
Используется существующая таблица `CryptoPayment`:
|
||||||
|
- `blockchain` = `'freekassa'`
|
||||||
|
- `token` = `'RUB'`
|
||||||
|
- `status` = `'completed'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Удаленные функции
|
||||||
|
|
||||||
|
### Система проверки чеков операторами
|
||||||
|
|
||||||
|
✅ **Полностью удалена**:
|
||||||
|
- Backend модуль `/modules/check/`
|
||||||
|
- Frontend страница `checkverification.tsx`
|
||||||
|
- Маршруты и импорты в mainpage
|
||||||
|
- WebSocket события `check:status`
|
||||||
|
- Поле `checkId` в уведомлениях
|
||||||
|
- Переводы для чеков
|
||||||
|
- Вкладка в админ-панели
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Логи для отладки
|
||||||
|
|
||||||
|
Backend логирует все этапы обработки платежа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Посмотреть логи PM2
|
||||||
|
pm2 logs ospab-backend --lines 100
|
||||||
|
|
||||||
|
# Grep для FreeKassa
|
||||||
|
pm2 logs | grep FreeKassa
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые логи:
|
||||||
|
- `[FreeKassa] Notification received` - получено уведомление
|
||||||
|
- `[FreeKassa] IP check failed` - неверный IP
|
||||||
|
- `[FreeKassa] Invalid signature` - неверная подпись
|
||||||
|
- `[FreeKassa] Payment already exists` - дубликат
|
||||||
|
- `[FreeKassa] Payment processed successfully` - успешно обработано
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Поддержка
|
||||||
|
|
||||||
|
Если возникли проблемы:
|
||||||
|
|
||||||
|
1. Проверьте логи backend: `pm2 logs ospab-backend`
|
||||||
|
2. Проверьте настройки FreeKassa (URL, секретные слова)
|
||||||
|
3. Убедитесь, что миграция БД применена
|
||||||
|
4. Проверьте, что переменные окружения заполнены
|
||||||
|
|
||||||
|
**Документация FreeKassa**: [wiki.freekassa.com](https://wiki.freekassa.com/)
|
||||||
@@ -71,14 +71,8 @@ MINIO_REGION_DEFAULT=ru-central-1
|
|||||||
|
|
||||||
MINIO_MC_ALIAS=minio
|
MINIO_MC_ALIAS=minio
|
||||||
|
|
||||||
# === DePay Crypto Payment Configuration ===
|
# === FreeKassa Payment Configuration ===
|
||||||
DEPAY_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
|
# Получите эти данные в личном кабинете FreeKassa: https://merchant.freekassa.com/settings
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA33QBvp1NDq3vZn8k4I+z
|
FK_MERCHANT_ID="69144"
|
||||||
e0U90iklattb4C2EHFDXs8Vmssimt63I55KofEV2/e7cJKQVHTrg1OpHFgivTXf8
|
FK_SECRET_WORD_1="Qg.JACIVa}(UsmK"
|
||||||
GeFd5Bxx6W+vGHed3YZnVYHj0hP0rqUbweZyvD58EOkmYQ55d2zf03NTf1LmI1K4
|
FK_SECRET_WORD_2="UB+9D_(&pyI1319"
|
||||||
MrBn+icWm500n4eWNtFta2l5g+/gDLRByLiIn4qobyHIsLr2FVqZiUYcMkx0BepZ
|
|
||||||
nNrI+VGuEyb/i+Eqi58j4x/Y7uoK3NV9lF/DWp95dPU9uCO1sW7Y6NNzKFrN4OOT
|
|
||||||
hURT672kfH2iFkFW2cP7WsRxq1ZU/gW33Wed5kqTEhpOQjSQi83s0heYSAT5gkrY
|
|
||||||
rwIDAQAB
|
|
||||||
-----END PUBLIC KEY-----"
|
|
||||||
DEPAY_FALLBACK_RATE=95
|
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ model Notification {
|
|||||||
|
|
||||||
// Связанные сущности (опционально)
|
// Связанные сущности (опционально)
|
||||||
ticketId Int?
|
ticketId Int?
|
||||||
checkId Int?
|
|
||||||
|
|
||||||
// Метаданные
|
// Метаданные
|
||||||
actionUrl String? // URL для перехода при клике
|
actionUrl String? // URL для перехода при клике
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import authRoutes from './modules/auth/auth.routes';
|
|||||||
import oauthRoutes from './modules/auth/oauth.routes';
|
import oauthRoutes from './modules/auth/oauth.routes';
|
||||||
import adminRoutes from './modules/admin/admin.routes';
|
import adminRoutes from './modules/admin/admin.routes';
|
||||||
import ticketRoutes from './modules/ticket/ticket.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 blogRoutes from './modules/blog/blog.routes';
|
||||||
import notificationRoutes from './modules/notification/notification.routes';
|
import notificationRoutes from './modules/notification/notification.routes';
|
||||||
import userRoutes from './modules/user/user.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/auth', authLimiter, oauthRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/ticket', ticketRoutes);
|
app.use('/api/ticket', ticketRoutes);
|
||||||
app.use('/payment/depay', depayRoutes);
|
app.use('/payment', freekassaRoutes);
|
||||||
app.use('/api/blog', blogRoutes);
|
app.use('/api/blog', blogRoutes);
|
||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
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;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
ticketId?: number;
|
ticketId?: number;
|
||||||
checkId?: number;
|
|
||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -289,7 +288,6 @@ export async function createNotification(params: CreateNotificationParams) {
|
|||||||
title: params.title,
|
title: params.title,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
ticketId: params.ticketId,
|
ticketId: params.ticketId,
|
||||||
checkId: params.checkId,
|
|
||||||
actionUrl: params.actionUrl,
|
actionUrl: params.actionUrl,
|
||||||
icon: params.icon,
|
icon: params.icon,
|
||||||
color: params.color
|
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:response'; ticketId: number; response: TicketResponsePayload }
|
||||||
| { type: 'ticket:status'; ticketId: number; status: string }
|
| { type: 'ticket:status'; ticketId: number; status: string }
|
||||||
| { type: 'balance:updated'; balance: number; transaction?: TransactionPayload }
|
| { type: 'balance:updated'; balance: number; transaction?: TransactionPayload }
|
||||||
| { type: 'check:status'; checkId: number; status: string }
|
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,3 @@ VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB7306voAK0Pjx8O
|
|||||||
# API URLs
|
# API URLs
|
||||||
VITE_API_URL=https://api.ospab.host
|
VITE_API_URL=https://api.ospab.host
|
||||||
VITE_SOCKET_URL=wss://api.ospab.host
|
VITE_SOCKET_URL=wss://api.ospab.host
|
||||||
|
|
||||||
# DePay Crypto Payment
|
|
||||||
VITE_DEPAY_INTEGRATION_ID=60f35b39-15e6-4900-9c6d-eb4e4213d5b9
|
|
||||||
|
|||||||
3794
ospabhost/frontend/package-lock.json
generated
3794
ospabhost/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@depay/widgets": "^13.0.40",
|
|
||||||
"@marsidev/react-turnstile": "^1.3.1",
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ type ServerToClientEvent =
|
|||||||
| { type: 'ticket:response'; ticketId: number; response: AnyObject }
|
| { type: 'ticket:response'; ticketId: number; response: AnyObject }
|
||||||
| { type: 'ticket:status'; ticketId: number; status: string }
|
| { type: 'ticket:status'; ticketId: number; status: string }
|
||||||
| { type: 'balance:updated'; newBalance: number }
|
| { type: 'balance:updated'; newBalance: number }
|
||||||
| { type: 'check:status'; checkId: number; status: string }
|
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -298,10 +298,6 @@ export const en: TranslationKeys = {
|
|||||||
description: 'Description',
|
description: 'Description',
|
||||||
amount: 'Amount',
|
amount: 'Amount',
|
||||||
noTransactions: 'No transactions',
|
noTransactions: 'No transactions',
|
||||||
uploadCheck: 'Upload Receipt',
|
|
||||||
checkPending: 'Pending Review',
|
|
||||||
checkApproved: 'Approved',
|
|
||||||
checkRejected: 'Rejected',
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
|
|||||||
@@ -296,10 +296,7 @@ export const ru = {
|
|||||||
description: 'Описание',
|
description: 'Описание',
|
||||||
amount: 'Сумма',
|
amount: 'Сумма',
|
||||||
noTransactions: 'Нет транзакций',
|
noTransactions: 'Нет транзакций',
|
||||||
uploadCheck: 'Загрузить чек',
|
|
||||||
checkPending: 'На проверке',
|
|
||||||
checkApproved: 'Одобрен',
|
|
||||||
checkRejected: 'Отклонён',
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Настройки',
|
title: 'Настройки',
|
||||||
|
|||||||
@@ -5,18 +5,7 @@ import { API_URL } from '../../config/api';
|
|||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import AuthContext from '../../context/authcontext';
|
import AuthContext from '../../context/authcontext';
|
||||||
|
|
||||||
const DEPAY_INTEGRATION_ID = import.meta.env.VITE_DEPAY_INTEGRATION_ID;
|
interface Payment {
|
||||||
|
|
||||||
// Declare DePayWidgets on window
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
DePayWidgets?: {
|
|
||||||
Payment: (config: any) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CryptoPayment {
|
|
||||||
id: number;
|
id: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
cryptoAmount: number | null;
|
cryptoAmount: number | null;
|
||||||
@@ -35,29 +24,25 @@ const Billing = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [balance, setBalance] = useState<number>(0);
|
const [balance, setBalance] = useState<number>(0);
|
||||||
const [payments, setPayments] = useState<CryptoPayment[]>([]);
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
const [exchangeRate, setExchangeRate] = useState<number>(95);
|
|
||||||
const [amount, setAmount] = useState<string>('500'); // Default 500 RUB
|
const [amount, setAmount] = useState<string>('500'); // Default 500 RUB
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [messageType, setMessageType] = useState<'success' | 'error'>('success');
|
const [messageType, setMessageType] = useState<'success' | 'error'>('success');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load DePay script
|
|
||||||
loadDePayScript();
|
|
||||||
|
|
||||||
fetchBalance();
|
fetchBalance();
|
||||||
fetchPayments();
|
fetchPayments();
|
||||||
fetchExchangeRate();
|
|
||||||
|
|
||||||
// Check for payment success/error from redirect
|
// Check for payment success/error from redirect
|
||||||
const paymentStatus = searchParams.get('payment');
|
const paymentStatus = searchParams.get('payment');
|
||||||
const txHash = searchParams.get('tx');
|
const orderId = searchParams.get('order');
|
||||||
|
|
||||||
if (paymentStatus === 'success') {
|
if (paymentStatus === 'success') {
|
||||||
showMessage(
|
showMessage(
|
||||||
isEn
|
isEn
|
||||||
? `✅ Payment successful! Transaction: ${txHash}`
|
? `✅ Payment successful! Order: ${orderId}`
|
||||||
: `✅ Оплата успешна! Транзакция: ${txHash}`,
|
: `✅ Оплата успешна! Заказ: ${orderId}`,
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
// Refresh data
|
// Refresh data
|
||||||
@@ -75,25 +60,6 @@ const Billing = () => {
|
|||||||
}
|
}
|
||||||
}, [searchParams, isEn]);
|
}, [searchParams, isEn]);
|
||||||
|
|
||||||
const loadDePayScript = () => {
|
|
||||||
if (window.DePayWidgets) return;
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://integrate.depay.com/widgets/v12.js';
|
|
||||||
script.async = true;
|
|
||||||
script.onload = () => {
|
|
||||||
console.log('[DePay] Widget script loaded');
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
console.error('[DePay] Failed to load widget script');
|
|
||||||
showMessage(
|
|
||||||
isEn ? 'Failed to load payment widget' : 'Не удалось загрузить платёжный виджет',
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
document.body.appendChild(script);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBalance = async () => {
|
const fetchBalance = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[Billing] Загрузка баланса...');
|
console.log('[Billing] Загрузка баланса...');
|
||||||
@@ -109,7 +75,7 @@ const Billing = () => {
|
|||||||
const fetchPayments = async () => {
|
const fetchPayments = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[Billing] Загрузка истории платежей...');
|
console.log('[Billing] Загрузка истории платежей...');
|
||||||
const res = await apiClient.get(`${API_URL}/api/payment/depay/history`);
|
const res = await apiClient.get(`${API_URL}/payment/freekassa/history`);
|
||||||
const paymentsData = res.data.payments || [];
|
const paymentsData = res.data.payments || [];
|
||||||
setPayments(paymentsData);
|
setPayments(paymentsData);
|
||||||
console.log('[Billing] История загружена:', paymentsData.length, 'платежей');
|
console.log('[Billing] История загружена:', paymentsData.length, 'платежей');
|
||||||
@@ -118,31 +84,14 @@ const Billing = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchExchangeRate = async () => {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get(`${API_URL}/api/payment/depay/rate`);
|
|
||||||
const rate = res.data.rate || 95;
|
|
||||||
setExchangeRate(rate);
|
|
||||||
console.log('[Billing] Курс USDT/RUB:', rate);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Billing] Ошибка получения курса:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||||
setMessage(msg);
|
setMessage(msg);
|
||||||
setMessageType(type);
|
setMessageType(type);
|
||||||
setTimeout(() => setMessage(''), 8000);
|
setTimeout(() => setMessage(''), 8000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenPaymentWidget = () => {
|
const handleSubmitPayment = async (e: React.FormEvent) => {
|
||||||
if (!window.DePayWidgets) {
|
e.preventDefault();
|
||||||
showMessage(
|
|
||||||
isEn ? 'Payment widget not loaded yet. Please wait...' : 'Виджет оплаты ещё загружается. Подождите...',
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userData?.user?.id) {
|
if (!userData?.user?.id) {
|
||||||
showMessage(
|
showMessage(
|
||||||
@@ -161,87 +110,65 @@ const Billing = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountInUSDT = (amountInRub / exchangeRate).toFixed(2);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Open DePay payment widget
|
// Create payment order
|
||||||
window.DePayWidgets.Payment({
|
const res = await apiClient.post(`${API_URL}/payment/freekassa/create-payment`, {
|
||||||
integration: DEPAY_INTEGRATION_ID,
|
amount: amountInRub
|
||||||
|
|
||||||
// Amount to pay in USDT
|
|
||||||
amount: {
|
|
||||||
token: 'USDT',
|
|
||||||
blockchain: 'polygon',
|
|
||||||
amount: amountInUSDT,
|
|
||||||
},
|
|
||||||
|
|
||||||
// User identifier for callback
|
|
||||||
user: {
|
|
||||||
id: String(userData.user.id),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Callback URLs
|
|
||||||
callback: {
|
|
||||||
url: `${API_URL}/api/payment/depay/callback`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Success redirect
|
|
||||||
success: {
|
|
||||||
url: `${API_URL}/payment/depay/success`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Styling
|
|
||||||
style: {
|
|
||||||
colors: {
|
|
||||||
primary: '#6366f1', // ospab primary color
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
sent: (transaction: any) => {
|
|
||||||
console.log('[DePay] Payment sent:', transaction);
|
|
||||||
showMessage(
|
|
||||||
isEn
|
|
||||||
? 'Payment sent! Waiting for confirmation...'
|
|
||||||
: 'Оплата отправлена! Ожидаем подтверждение...',
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmed: (transaction: any) => {
|
|
||||||
console.log('[DePay] Payment confirmed:', transaction);
|
|
||||||
showMessage(
|
|
||||||
isEn
|
|
||||||
? '✅ Payment confirmed! Your balance will be updated shortly.'
|
|
||||||
: '✅ Оплата подтверждена! Баланс будет обновлён.',
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh balance and payments after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
fetchBalance();
|
|
||||||
fetchPayments();
|
|
||||||
}, 3000);
|
|
||||||
},
|
|
||||||
|
|
||||||
failed: (error: any) => {
|
|
||||||
console.error('[DePay] Payment failed:', error);
|
|
||||||
showMessage(
|
|
||||||
isEn
|
|
||||||
? '❌ Payment failed. Please try again.'
|
|
||||||
: '❌ Ошибка оплаты. Попробуйте снова.',
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('[DePay] Error opening widget:', error);
|
const { merchantId, amount: orderAmount, orderId, signature, currency, email, userId, paymentUrl } = res.data;
|
||||||
|
|
||||||
|
console.log('[FreeKassa] Payment data received:', res.data);
|
||||||
|
|
||||||
|
// Create form and submit to FreeKassa
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'GET';
|
||||||
|
form.action = paymentUrl;
|
||||||
|
form.target = '_blank'; // Open in new tab
|
||||||
|
|
||||||
|
// Add hidden fields
|
||||||
|
const fields = {
|
||||||
|
m: merchantId,
|
||||||
|
oa: orderAmount,
|
||||||
|
o: orderId,
|
||||||
|
s: signature,
|
||||||
|
currency: currency,
|
||||||
|
em: email,
|
||||||
|
us_user_id: userId.toString(),
|
||||||
|
lang: isEn ? 'en' : 'ru'
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(fields).forEach(([key, value]) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = key;
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
|
||||||
showMessage(
|
showMessage(
|
||||||
isEn
|
isEn
|
||||||
? 'Failed to open payment widget'
|
? 'Redirecting to payment page...'
|
||||||
: 'Не удалось открыть виджет оплаты',
|
: 'Перенаправление на страницу оплаты...',
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[FreeKassa] Payment creation error:', error);
|
||||||
|
showMessage(
|
||||||
|
isEn
|
||||||
|
? 'Failed to create payment. Please try again.'
|
||||||
|
: 'Не удалось создать платёж. Попробуйте снова.',
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,183 +225,98 @@ const Billing = () => {
|
|||||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 dark:from-indigo-600 dark:to-purple-700 p-6 lg:p-8 rounded-xl lg:rounded-2xl mb-6 text-white shadow-lg">
|
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 dark:from-indigo-600 dark:to-purple-700 p-6 lg:p-8 rounded-xl lg:rounded-2xl mb-6 text-white shadow-lg">
|
||||||
<p className="text-sm opacity-90 mb-2">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
<p className="text-sm opacity-90 mb-2">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
||||||
<p className="text-4xl lg:text-5xl font-extrabold">{balance.toFixed(2)} ₽</p>
|
<p className="text-4xl lg:text-5xl font-extrabold">{balance.toFixed(2)} ₽</p>
|
||||||
<p className="text-xs opacity-75 mt-2">
|
|
||||||
≈ ${(balance / exchangeRate).toFixed(2)} USDT
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Курс обмена */}
|
{/* Форма пополнения */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6">
|
<form onSubmit={handleSubmitPayment} className="mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-xl border border-gray-200 dark:border-gray-600 mb-6">
|
||||||
<div>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
<p className="text-sm font-semibold text-blue-800 dark:text-blue-300">
|
{isEn ? 'Amount to top up (RUB)' : 'Сумма пополнения (₽)'}
|
||||||
{isEn ? 'Exchange Rate' : 'Курс обмена'}
|
</label>
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-900 dark:text-blue-200">
|
|
||||||
1 USDT = {exchangeRate.toFixed(2)} ₽
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-xs text-blue-600 dark:text-blue-400">
|
|
||||||
{isEn ? 'Updated every minute' : 'Обновляется каждую минуту'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Форма ввода суммы */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{isEn ? 'Top-up amount (RUB)' : 'Сумма пополнения (₽)'}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
min="50"
|
||||||
|
step="0.01"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
min="50"
|
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
step="50"
|
placeholder="500"
|
||||||
className="w-full px-4 py-3 pr-20 rounded-xl border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-800 transition-all text-lg font-semibold"
|
required
|
||||||
placeholder={isEn ? 'Enter amount' : 'Введите сумму'}
|
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 font-semibold">
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
₽
|
{isEn ? 'Minimum amount: 50 RUB' : 'Минимальная сумма: 50 ₽'}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{isEn ? 'Minimum: 50 RUB' : 'Минимум: 50 ₽'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold text-indigo-600 dark:text-indigo-400">
|
|
||||||
≈ {(parseFloat(amount || '0') / exchangeRate).toFixed(4)} USDT
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка оплаты через DePay */}
|
{/* Кнопка оплаты через FreeKassa */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenPaymentWidget}
|
type="submit"
|
||||||
className="w-full px-6 py-4 rounded-xl text-white font-bold text-lg transition-all duration-200 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 shadow-lg hover:shadow-xl transform hover:scale-[1.02] active:scale-[0.98]"
|
disabled={isSubmitting}
|
||||||
>
|
className="w-full px-6 py-4 rounded-xl text-white font-bold text-lg transition-all duration-200 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 shadow-lg hover:shadow-xl transform hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
<span className="flex items-center justify-center gap-3">
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span className="flex items-center justify-center gap-3">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
{isEn ? 'Top Up with Crypto (USDT)' : 'Пополнить криптовалютой (USDT)'}
|
</svg>
|
||||||
</span>
|
{isSubmitting
|
||||||
</button>
|
? (isEn ? 'Processing...' : 'Обработка...')
|
||||||
|
: (isEn ? 'Top Up via FreeKassa' : 'Пополнить через FreeKassa')
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-3">
|
}
|
||||||
{isEn
|
</span>
|
||||||
? 'Pay with USDT on Polygon network. Fast and secure.'
|
</button>
|
||||||
: 'Оплата USDT в сети Polygon. Быстро и безопасно.'}
|
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
{isEn
|
||||||
</div>
|
? 'Secure payment via FreeKassa. Multiple payment methods available.'
|
||||||
|
: 'Безопасная оплата через FreeKassa. Доступны различные способы оплаты.'
|
||||||
{/* Преимущества */}
|
}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
|
</p>
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Instant' : 'Мгновенно'}</p>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{isEn ? 'Balance updates in seconds' : 'Баланс обновляется за секунды'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Secure' : 'Безопасно'}</p>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{isEn ? 'Blockchain verified' : 'Проверено блокчейном'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Low Fees' : 'Низкие комиссии'}</p>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{isEn ? 'Minimal network fees' : 'Минимальные комиссии сети'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* История платежей */}
|
{/* История платежей */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white mb-4">
|
<h3 className="text-xl font-bold text-gray-800 dark:text-white mb-4">
|
||||||
{isEn ? 'Payment History' : 'История платежей'}
|
{isEn ? 'Payment History' : 'История платежей'}
|
||||||
</h3>
|
</h3>
|
||||||
{payments.length > 0 ? (
|
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<svg className="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{isEn ? 'No payment history yet' : 'История платежей пуста'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{payments.map((payment) => (
|
{payments.map((payment) => (
|
||||||
<div
|
<div
|
||||||
key={payment.id}
|
key={payment.id}
|
||||||
className="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-xl flex flex-col md:flex-row md:items-center justify-between gap-3 transition-colors duration-200"
|
className="bg-gray-50 dark:bg-gray-700 p-4 rounded-xl border border-gray-200 dark:border-gray-600 hover:shadow-md transition-shadow"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div>
|
||||||
<p className="font-bold text-gray-800 dark:text-white text-lg">
|
<p className="font-semibold text-lg text-gray-900 dark:text-white">
|
||||||
+{payment.amount.toFixed(2)} ₽
|
+{payment.amount.toFixed(2)} ₽
|
||||||
</p>
|
</p>
|
||||||
<span className={`text-xs font-semibold ${getStatusColor(payment.status)}`}>
|
<span className={`text-xs font-semibold ${getStatusColor(payment.status)}`}>
|
||||||
{getStatusText(payment.status)}
|
{getStatusText(payment.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{payment.cryptoAmount?.toFixed(4)} {payment.token}
|
|
||||||
{payment.exchangeRate && (
|
|
||||||
<span className="ml-2 text-xs">
|
|
||||||
(@ {payment.exchangeRate.toFixed(2)} ₽)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
{formatDate(payment.createdAt)}
|
{formatDate(payment.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{payment.transactionHash && (
|
{payment.transactionHash && (
|
||||||
<a
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2">
|
||||||
href={`https://polygonscan.com/tx/${payment.transactionHash}`}
|
{isEn ? 'Transaction ID:' : 'ID транзакции:'} {payment.transactionHash}
|
||||||
target="_blank"
|
</p>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{isEn ? 'View on Explorer' : 'Посмотреть в Explorer'}
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 bg-gray-50 dark:bg-gray-700/30 rounded-xl">
|
|
||||||
<svg className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
|
||||||
{isEn ? 'No payment history yet' : 'История платежей пуста'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import apiClient from '../../utils/apiClient';
|
|
||||||
import { API_URL } from '../../config/api';
|
|
||||||
|
|
||||||
interface IUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ICheck {
|
|
||||||
id: number;
|
|
||||||
userId: number;
|
|
||||||
amount: number;
|
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
|
||||||
fileUrl: string;
|
|
||||||
createdAt: string;
|
|
||||||
user?: IUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CheckVerification: React.FC = () => {
|
|
||||||
const [checks, setChecks] = useState<ICheck[]>([]);
|
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
|
|
||||||
// Получить защищённый URL для файла чека
|
|
||||||
const getCheckFileUrl = (fileUrl: string): string => {
|
|
||||||
const filename = fileUrl.split('/').pop();
|
|
||||||
return `${API_URL}/api/check/file/${filename}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Открыть изображение чека в новом окне с авторизацией
|
|
||||||
const openCheckImage = async (fileUrl: string) => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const url = getCheckFileUrl(fileUrl);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Ошибка загрузки изображения');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
window.open(objectUrl, '_blank');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[CheckVerification] Ошибка загрузки изображения:', error);
|
|
||||||
alert('Не удалось загрузить изображение чека');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchChecks = async (): Promise<void> => {
|
|
||||||
console.log('[CheckVerification] Загрузка чеков для проверки...');
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get<ICheck[]>(`${API_URL}/api/check`);
|
|
||||||
setChecks(res.data);
|
|
||||||
console.log('[CheckVerification] Загружено чеков:', res.data.length);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[CheckVerification] Ошибка загрузки чеков:', err);
|
|
||||||
setError('Ошибка загрузки чеков');
|
|
||||||
setChecks([]);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
fetchChecks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAction = async (checkId: number, action: 'approve' | 'reject'): Promise<void> => {
|
|
||||||
console.log(`[CheckVerification] ${action === 'approve' ? 'Подтверждение' : 'Отклонение'} чека #${checkId}`);
|
|
||||||
setActionLoading(checkId);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiClient.post(`${API_URL}/api/check/${action}`, { checkId });
|
|
||||||
|
|
||||||
console.log(`[CheckVerification] Чек #${checkId} ${action === 'approve' ? 'подтверждён' : 'отклонён'}`);
|
|
||||||
|
|
||||||
setChecks((prevChecks: ICheck[]) =>
|
|
||||||
prevChecks.map((c: ICheck) =>
|
|
||||||
c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Если подтверждение — обновить баланс пользователя
|
|
||||||
if (action === 'approve') {
|
|
||||||
try {
|
|
||||||
console.log('[CheckVerification] Обновление данных пользователя...');
|
|
||||||
const userRes = await apiClient.get(`${API_URL}/api/auth/me`);
|
|
||||||
|
|
||||||
// Глобально обновить userData через типизированное событие (для Dashboard)
|
|
||||||
window.dispatchEvent(new CustomEvent<import('./types').UserData>('userDataUpdate', {
|
|
||||||
detail: {
|
|
||||||
user: userRes.data.user,
|
|
||||||
balance: userRes.data.user.balance ?? 0,
|
|
||||||
tickets: userRes.data.user.tickets ?? [],
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
console.log('[CheckVerification] Данные пользователя обновлены');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[CheckVerification] Ошибка обновления userData:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[CheckVerification] Ошибка ${action === 'approve' ? 'подтверждения' : 'отклонения'}:`, err);
|
|
||||||
setError('Ошибка действия');
|
|
||||||
}
|
|
||||||
setActionLoading(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
|
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Проверка чеков</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-lg text-gray-500">Загрузка чеков...</p>
|
|
||||||
) : error ? (
|
|
||||||
<p className="text-lg text-red-500">{error}</p>
|
|
||||||
) : checks.length === 0 ? (
|
|
||||||
<p className="text-lg text-gray-500">Нет чеков для проверки.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{checks.map((check: ICheck) => (
|
|
||||||
<div key={check.id} className="border rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between bg-gray-50">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="mb-2">
|
|
||||||
<span className="font-bold text-gray-800">Пользователь:</span> <span className="text-gray-700">{check.user?.username || check.user?.email}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2">
|
|
||||||
<span className="font-bold text-gray-800">Сумма:</span> <span className="text-gray-700">₽{check.amount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2">
|
|
||||||
<span className="font-bold text-gray-800">Статус:</span> <span className={`font-bold ${check.status === 'pending' ? 'text-yellow-600' : check.status === 'approved' ? 'text-green-600' : 'text-red-600'}`}>{check.status === 'pending' ? 'На проверке' : check.status === 'approved' ? 'Подтверждён' : 'Отклонён'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2">
|
|
||||||
<span className="font-bold text-gray-800">Дата:</span> <span className="text-gray-700">{new Date(check.createdAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-2 md:ml-8">
|
|
||||||
<button
|
|
||||||
onClick={() => openCheckImage(check.fileUrl)}
|
|
||||||
className="block mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<div className="w-32 h-32 flex items-center justify-center bg-gray-200 rounded-xl border">
|
|
||||||
<span className="text-gray-600 text-sm text-center px-2">
|
|
||||||
Нажмите для просмотра чека
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{check.status === 'pending' && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleAction(check.id, 'approve')}
|
|
||||||
disabled={actionLoading === check.id}
|
|
||||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-full font-bold mb-2"
|
|
||||||
>
|
|
||||||
{actionLoading === check.id ? 'Подтверждение...' : 'Подтвердить'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleAction(check.id, 'reject')}
|
|
||||||
disabled={actionLoading === check.id}
|
|
||||||
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-bold"
|
|
||||||
>
|
|
||||||
{actionLoading === check.id ? 'Отклонение...' : 'Отклонить'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CheckVerification;
|
|
||||||
@@ -12,7 +12,6 @@ import TicketsPage from './tickets/index';
|
|||||||
import Billing from './billing';
|
import Billing from './billing';
|
||||||
import Settings from './settings';
|
import Settings from './settings';
|
||||||
import NotificationsPage from './notifications';
|
import NotificationsPage from './notifications';
|
||||||
import CheckVerification from './checkverification';
|
|
||||||
import Checkout from './checkout';
|
import Checkout from './checkout';
|
||||||
import StoragePage from './storage';
|
import StoragePage from './storage';
|
||||||
import StorageBucketPage from './storage-bucket';
|
import StorageBucketPage from './storage-bucket';
|
||||||
@@ -92,9 +91,6 @@ const Dashboard = () => {
|
|||||||
{ key: 'settings', label: isEn ? 'Settings' : 'Настройки', to: '/dashboard/settings' },
|
{ key: 'settings', label: isEn ? 'Settings' : 'Настройки', to: '/dashboard/settings' },
|
||||||
{ key: 'notifications', label: isEn ? 'Notifications' : 'Уведомления', to: '/dashboard/notifications' },
|
{ key: 'notifications', label: isEn ? 'Notifications' : 'Уведомления', to: '/dashboard/notifications' },
|
||||||
];
|
];
|
||||||
const adminTabs = [
|
|
||||||
{ key: 'checkverification', label: isEn ? 'Check Verification' : 'Проверка чеков', to: '/dashboard/checkverification' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const superAdminTabs = [
|
const superAdminTabs = [
|
||||||
{ key: 'admin', label: isEn ? 'Admin Panel' : 'Админ-панель', to: '/dashboard/admin' },
|
{ key: 'admin', label: isEn ? 'Admin Panel' : 'Админ-панель', to: '/dashboard/admin' },
|
||||||
@@ -159,27 +155,6 @@ const Dashboard = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{isOperator && (
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 px-4">
|
|
||||||
{isEn ? 'Admin Panel' : 'Админ панель'}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{adminTabs.map(tab => (
|
|
||||||
<Link
|
|
||||||
key={tab.key}
|
|
||||||
to={tab.to}
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
|
||||||
activeTab === tab.key ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p className="text-xs font-semibold text-red-500 dark:text-red-400 uppercase tracking-wider mb-3 px-4">
|
<p className="text-xs font-semibold text-red-500 dark:text-red-400 uppercase tracking-wider mb-3 px-4">
|
||||||
@@ -253,7 +228,7 @@ const Dashboard = () => {
|
|||||||
<div className="flex-1 flex flex-col w-full lg:w-auto">
|
<div className="flex-1 flex flex-col w-full lg:w-auto">
|
||||||
<div className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4 pt-16 lg:pt-4">
|
<div className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4 pt-16 lg:pt-4">
|
||||||
<h1 className="text-xl lg:text-2xl font-bold text-gray-900 capitalize break-words">
|
<h1 className="text-xl lg:text-2xl font-bold text-gray-900 capitalize break-words">
|
||||||
{tabs.concat(adminTabs).concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}
|
{tabs.concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs lg:text-sm text-gray-600 mt-1">
|
<p className="text-xs lg:text-sm text-gray-600 mt-1">
|
||||||
{new Date().toLocaleDateString(isEn ? 'en-US' : 'ru-RU', {
|
{new Date().toLocaleDateString(isEn ? 'en-US' : 'ru-RU', {
|
||||||
@@ -282,11 +257,6 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
{isOperator && (
|
|
||||||
<>
|
|
||||||
<Route path="checkverification" element={<CheckVerification />} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Route path="admin" element={<AdminPanel />} />
|
<Route path="admin" element={<AdminPanel />} />
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ const RegisterPage = () => {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
required
|
required
|
||||||
className="mt-1 mr-2 h-4 w-4 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded"
|
className="mt-0.5 mr-3 h-5 w-5 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded cursor-pointer"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface Notification {
|
|||||||
message: string;
|
message: string;
|
||||||
serverId?: number;
|
serverId?: number;
|
||||||
ticketId?: number;
|
ticketId?: number;
|
||||||
checkId?: number;
|
|
||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user