feat: Интеграция DePay для криптовалютных платежей (USDT/Polygon)
- Добавлены endpoints для DePay: callback, success, rate, history - Заменена система проверки чеков на CryptoPayment - Переименована модель Check в CryptoPayment в Prisma схеме - Обновлен billing.tsx для работы с DePay виджетом - Все секреты вынесены в .env файлы - Интеграция с CoinGecko API для курса USDT/RUB - Добавлена RSA верификация webhook от DePay
This commit is contained in:
@@ -70,3 +70,15 @@ MINIO_BUCKET_PREFIX=ospab
|
|||||||
MINIO_REGION_DEFAULT=ru-central-1
|
MINIO_REGION_DEFAULT=ru-central-1
|
||||||
|
|
||||||
MINIO_MC_ALIAS=minio
|
MINIO_MC_ALIAS=minio
|
||||||
|
|
||||||
|
# === DePay Crypto Payment Configuration ===
|
||||||
|
DEPAY_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA33QBvp1NDq3vZn8k4I+z
|
||||||
|
e0U90iklattb4C2EHFDXs8Vmssimt63I55KofEV2/e7cJKQVHTrg1OpHFgivTXf8
|
||||||
|
GeFd5Bxx6W+vGHed3YZnVYHj0hP0rqUbweZyvD58EOkmYQ55d2zf03NTf1LmI1K4
|
||||||
|
MrBn+icWm500n4eWNtFta2l5g+/gDLRByLiIn4qobyHIsLr2FVqZiUYcMkx0BepZ
|
||||||
|
nNrI+VGuEyb/i+Eqi58j4x/Y7uoK3NV9lF/DWp95dPU9uCO1sW7Y6NNzKFrN4OOT
|
||||||
|
hURT672kfH2iFkFW2cP7WsRxq1ZU/gW33Wed5kqTEhpOQjSQi83s0heYSAT5gkrY
|
||||||
|
rwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----"
|
||||||
|
DEPAY_FALLBACK_RATE=95
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Rename Check table to CryptoPayment
|
||||||
|
RENAME TABLE `check` TO `crypto_payment`;
|
||||||
|
|
||||||
|
-- Add new columns for crypto payments
|
||||||
|
ALTER TABLE `crypto_payment`
|
||||||
|
ADD COLUMN `transactionHash` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `blockchain` VARCHAR(191) DEFAULT 'polygon',
|
||||||
|
ADD COLUMN `token` VARCHAR(191) DEFAULT 'USDT',
|
||||||
|
ADD COLUMN `cryptoAmount` DECIMAL(20, 8) NULL,
|
||||||
|
ADD COLUMN `exchangeRate` DECIMAL(10, 2) NULL COMMENT 'USDT to RUB rate',
|
||||||
|
ADD COLUMN `paymentProvider` VARCHAR(191) DEFAULT 'depay',
|
||||||
|
MODIFY COLUMN `status` VARCHAR(191) DEFAULT 'pending' COMMENT 'pending, completed, failed',
|
||||||
|
MODIFY COLUMN `fileUrl` VARCHAR(191) NULL COMMENT 'Not used for crypto payments';
|
||||||
|
|
||||||
|
-- Update foreign key name if needed
|
||||||
|
ALTER TABLE `crypto_payment`
|
||||||
|
DROP FOREIGN KEY `check_userId_fkey`,
|
||||||
|
ADD CONSTRAINT `crypto_payment_userId_fkey`
|
||||||
|
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -23,7 +23,7 @@ model User {
|
|||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
tickets Ticket[] @relation("UserTickets")
|
tickets Ticket[] @relation("UserTickets")
|
||||||
responses Response[] @relation("OperatorResponses")
|
responses Response[] @relation("OperatorResponses")
|
||||||
checks Check[] @relation("UserChecks")
|
cryptoPayments CryptoPayment[] @relation("UserCryptoPayments")
|
||||||
balance Float @default(0)
|
balance Float @default(0)
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
@@ -46,16 +46,22 @@ model User {
|
|||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Check {
|
model CryptoPayment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
amount Float
|
amount Float // Amount in RUB credited to balance
|
||||||
status String @default("pending") // pending, approved, rejected
|
cryptoAmount Float? // Amount in USDT paid
|
||||||
fileUrl String
|
exchangeRate Float? // USDT to RUB exchange rate at payment time
|
||||||
createdAt DateTime @default(now())
|
status String @default("pending") // pending, completed, failed
|
||||||
user User @relation("UserChecks", fields: [userId], references: [id])
|
transactionHash String? // Blockchain transaction hash
|
||||||
|
blockchain String @default("polygon") // polygon, ethereum, bsc, etc.
|
||||||
|
token String @default("USDT") // USDT, USDC, ETH, etc.
|
||||||
|
paymentProvider String @default("depay") // depay
|
||||||
|
fileUrl String? // Not used for crypto payments (legacy from Check)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation("UserCryptoPayments", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("check")
|
@@map("crypto_payment")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
|
|||||||
@@ -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 checkRoutes from './modules/check/check.routes';
|
import depayRoutes from './modules/payment/depay.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('/api/check', checkRoutes);
|
app.use('/payment/depay', depayRoutes);
|
||||||
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);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function checkFileAccessMiddleware(req: Request, res: Response, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Для обычных пользователей - проверяем владение чеком
|
// Для обычных пользователей - проверяем владение чеком
|
||||||
const check = await prisma.check.findFirst({
|
const check = await prisma.cryptoPayment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
fileUrl: {
|
fileUrl: {
|
||||||
contains: filename
|
contains: filename
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export async function confirmAccountDeletion(userId: number, code: string): Prom
|
|||||||
logger.log(`Удалено тикетов: ${tickets.count}`);
|
logger.log(`Удалено тикетов: ${tickets.count}`);
|
||||||
|
|
||||||
// 3. Удаляем чеки
|
// 3. Удаляем чеки
|
||||||
const checks = await tx.check.deleteMany({
|
const checks = await tx.cryptoPayment.deleteMany({
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
logger.log(`Удалено чеков: ${checks.count}`);
|
logger.log(`Удалено чеков: ${checks.count}`);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export class AdminController {
|
|||||||
buckets: {
|
buckets: {
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
},
|
},
|
||||||
checks: {
|
cryptoPayments: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 10
|
take: 10
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,7 @@ export class AdminController {
|
|||||||
const safeUser = {
|
const safeUser = {
|
||||||
...user,
|
...user,
|
||||||
balance: typeof user.balance === 'number' ? user.balance : Number(user.balance ?? 0),
|
balance: typeof user.balance === 'number' ? user.balance : Number(user.balance ?? 0),
|
||||||
buckets: user.buckets?.map((bucket: any) => ({
|
buckets: (user as any).buckets?.map((bucket: any) => ({
|
||||||
...bucket,
|
...bucket,
|
||||||
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 0),
|
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 0),
|
||||||
objectCount: typeof bucket.objectCount === 'number' ? bucket.objectCount : Number(bucket.objectCount ?? 0),
|
objectCount: typeof bucket.objectCount === 'number' ? bucket.objectCount : Number(bucket.objectCount ?? 0),
|
||||||
@@ -305,7 +305,7 @@ export class AdminController {
|
|||||||
prisma.storageBucket.count(),
|
prisma.storageBucket.count(),
|
||||||
prisma.storageBucket.count({ where: { public: true } }),
|
prisma.storageBucket.count({ where: { public: true } }),
|
||||||
prisma.user.aggregate({ _sum: { balance: true } }),
|
prisma.user.aggregate({ _sum: { balance: true } }),
|
||||||
prisma.check.count({ where: { status: 'pending' } }),
|
prisma.cryptoPayment.count({ where: { status: 'pending' } }),
|
||||||
prisma.ticket.count({ where: { status: 'open' } }),
|
prisma.ticket.count({ where: { status: 'open' } }),
|
||||||
prisma.storageBucket.aggregate({
|
prisma.storageBucket.aggregate({
|
||||||
_sum: {
|
_sum: {
|
||||||
@@ -441,7 +441,7 @@ export class AdminController {
|
|||||||
await tx.storageBucket.deleteMany({ where: { userId } });
|
await tx.storageBucket.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
await tx.ticket.deleteMany({ where: { userId } });
|
await tx.ticket.deleteMany({ where: { userId } });
|
||||||
await tx.check.deleteMany({ where: { userId } });
|
await tx.cryptoPayment.deleteMany({ where: { userId } });
|
||||||
await tx.transaction.deleteMany({ where: { userId } });
|
await tx.transaction.deleteMany({ where: { userId } });
|
||||||
await tx.post.deleteMany({ where: { authorId: userId } });
|
await tx.post.deleteMany({ where: { authorId: userId } });
|
||||||
await tx.comment.deleteMany({ where: { userId } });
|
await tx.comment.deleteMany({ where: { userId } });
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function uploadCheck(req: MulterRequest, res: Response) {
|
|||||||
// Сохраняем путь к файлу
|
// Сохраняем путь к файлу
|
||||||
const fileUrl = `/uploads/checks/${file.filename}`;
|
const fileUrl = `/uploads/checks/${file.filename}`;
|
||||||
|
|
||||||
const check = await prisma.check.create({
|
const check = await prisma.cryptoPayment.create({
|
||||||
data: { userId, amount: Number(amount), fileUrl }
|
data: { userId, amount: Number(amount), fileUrl }
|
||||||
});
|
});
|
||||||
res.json(check);
|
res.json(check);
|
||||||
@@ -30,7 +30,7 @@ export async function uploadCheck(req: MulterRequest, res: Response) {
|
|||||||
export async function getChecks(req: Request, res: Response) {
|
export async function getChecks(req: Request, res: Response) {
|
||||||
const isOperator = Number(req.user?.operator) === 1;
|
const isOperator = Number(req.user?.operator) === 1;
|
||||||
if (!isOperator) return res.status(403).json({ error: 'Нет прав' });
|
if (!isOperator) return res.status(403).json({ error: 'Нет прав' });
|
||||||
const checks = await prisma.check.findMany({
|
const checks = await prisma.cryptoPayment.findMany({
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
@@ -50,7 +50,7 @@ export async function approveCheck(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Найти чек
|
// Найти чек
|
||||||
const check = await prisma.check.findUnique({
|
const check = await prisma.cryptoPayment.findUnique({
|
||||||
where: { id: checkId },
|
where: { id: checkId },
|
||||||
include: { user: true }
|
include: { user: true }
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ export async function approveCheck(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновить статус чека
|
// Обновить статус чека
|
||||||
await prisma.check.update({
|
await prisma.cryptoPayment.update({
|
||||||
where: { id: checkId },
|
where: { id: checkId },
|
||||||
data: { status: 'approved' }
|
data: { status: 'approved' }
|
||||||
});
|
});
|
||||||
@@ -103,7 +103,7 @@ export async function rejectCheck(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Найти чек
|
// Найти чек
|
||||||
const check = await prisma.check.findUnique({
|
const check = await prisma.cryptoPayment.findUnique({
|
||||||
where: { id: checkId },
|
where: { id: checkId },
|
||||||
include: { user: true }
|
include: { user: true }
|
||||||
});
|
});
|
||||||
@@ -120,7 +120,7 @@ export async function rejectCheck(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновить статус чека
|
// Обновить статус чека
|
||||||
await prisma.check.update({
|
await prisma.cryptoPayment.update({
|
||||||
where: { id: checkId },
|
where: { id: checkId },
|
||||||
data: {
|
data: {
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
@@ -142,7 +142,7 @@ export async function getUserChecks(req: Request, res: Response) {
|
|||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
|
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
|
||||||
|
|
||||||
const checks = await prisma.check.findMany({
|
const checks = await prisma.cryptoPayment.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 50 // Последние 50 чеков
|
take: 50 // Последние 50 чеков
|
||||||
@@ -161,7 +161,7 @@ export async function viewCheck(req: Request, res: Response) {
|
|||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
const isOperator = Number(req.user?.operator) === 1;
|
const isOperator = Number(req.user?.operator) === 1;
|
||||||
|
|
||||||
const check = await prisma.check.findUnique({ where: { id: checkId } });
|
const check = await prisma.cryptoPayment.findUnique({ where: { id: checkId } });
|
||||||
|
|
||||||
if (!check) {
|
if (!check) {
|
||||||
return res.status(404).json({ error: 'Чек не найден' });
|
return res.status(404).json({ error: 'Чек не найден' });
|
||||||
@@ -190,7 +190,7 @@ export async function getCheckFile(req: Request, res: Response) {
|
|||||||
// Операторы имеют доступ ко всем файлам
|
// Операторы имеют доступ ко всем файлам
|
||||||
if (!isOperator) {
|
if (!isOperator) {
|
||||||
// Для обычных пользователей проверяем владение
|
// Для обычных пользователей проверяем владение
|
||||||
const check = await prisma.check.findFirst({
|
const check = await prisma.cryptoPayment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
fileUrl: {
|
fileUrl: {
|
||||||
contains: filename
|
contains: filename
|
||||||
|
|||||||
65
ospabhost/backend/src/modules/payment/crypto.routes.ts
Normal file
65
ospabhost/backend/src/modules/payment/crypto.routes.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { prisma } from '../../prisma/client';
|
||||||
|
import { authMiddleware } from '../auth/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/crypto/payments
|
||||||
|
* Get user's crypto payment history
|
||||||
|
*/
|
||||||
|
router.get('/payments', 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, // Last 50 payments
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, data: payments });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Crypto] Error fetching payments:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch payments';
|
||||||
|
return res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/crypto/payments/:id
|
||||||
|
* Get specific payment details
|
||||||
|
*/
|
||||||
|
router.get('/payments/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const paymentId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await prisma.cryptoPayment.findFirst({
|
||||||
|
where: {
|
||||||
|
id: paymentId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return res.status(404).json({ error: 'Payment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: payment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Crypto] Error fetching payment:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch payment';
|
||||||
|
return res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
231
ospabhost/backend/src/modules/payment/depay.routes.ts
Normal file
231
ospabhost/backend/src/modules/payment/depay.routes.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
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;
|
||||||
@@ -408,7 +408,7 @@ export const exportUserData = async (req: Request, res: Response) => {
|
|||||||
buckets: true,
|
buckets: true,
|
||||||
|
|
||||||
tickets: true,
|
tickets: true,
|
||||||
checks: true,
|
cryptoPayments: true,
|
||||||
transactions: true,
|
transactions: true,
|
||||||
notifications: true,
|
notifications: true,
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ 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
|
||||||
|
|||||||
3840
ospabhost/frontend/package-lock.json
generated
3840
ospabhost/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,51 +1,102 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useContext } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import QRCode from 'react-qr-code';
|
|
||||||
import { API_URL } from '../../config/api';
|
import { API_URL } from '../../config/api';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
|
import AuthContext from '../../context/authcontext';
|
||||||
|
|
||||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
const DEPAY_INTEGRATION_ID = import.meta.env.VITE_DEPAY_INTEGRATION_ID;
|
||||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
|
||||||
|
|
||||||
interface Check {
|
// Declare DePayWidgets on window
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
DePayWidgets?: {
|
||||||
|
Payment: (config: any) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CryptoPayment {
|
||||||
id: number;
|
id: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
cryptoAmount: number | null;
|
||||||
fileUrl: string;
|
exchangeRate: number | null;
|
||||||
|
status: 'pending' | 'completed' | 'failed';
|
||||||
|
transactionHash: string | null;
|
||||||
|
blockchain: string;
|
||||||
|
token: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Billing = () => {
|
const Billing = () => {
|
||||||
const { locale } = useTranslation();
|
const { locale } = useTranslation();
|
||||||
|
const { userData } = useContext(AuthContext);
|
||||||
const isEn = locale === 'en';
|
const isEn = locale === 'en';
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [amount, setAmount] = useState<number>(0);
|
|
||||||
const [balance, setBalance] = useState<number>(0);
|
const [balance, setBalance] = useState<number>(0);
|
||||||
const [checks, setChecks] = useState<Check[]>([]);
|
const [payments, setPayments] = useState<CryptoPayment[]>([]);
|
||||||
const [checkFile, setCheckFile] = useState<File | null>(null);
|
const [exchangeRate, setExchangeRate] = useState<number>(95);
|
||||||
const [uploadLoading, setUploadLoading] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [messageType, setMessageType] = useState<'success' | 'error'>('success');
|
const [messageType, setMessageType] = useState<'success' | 'error'>('success');
|
||||||
const [isPaymentGenerated, setIsPaymentGenerated] = useState(false);
|
|
||||||
|
|
||||||
const quickAmounts = [100, 500, 1000, 3000, 5000];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Load DePay script
|
||||||
|
loadDePayScript();
|
||||||
|
|
||||||
fetchBalance();
|
fetchBalance();
|
||||||
fetchChecks();
|
fetchPayments();
|
||||||
}, []);
|
fetchExchangeRate();
|
||||||
|
|
||||||
// Получить защищённый URL для файла чека
|
// Check for payment success/error from redirect
|
||||||
const getCheckFileUrl = (fileUrl: string): string => {
|
const paymentStatus = searchParams.get('payment');
|
||||||
const filename = fileUrl.split('/').pop();
|
const txHash = searchParams.get('tx');
|
||||||
return `${API_URL}/api/check/file/${filename}`;
|
|
||||||
|
if (paymentStatus === 'success') {
|
||||||
|
showMessage(
|
||||||
|
isEn
|
||||||
|
? `✅ Payment successful! Transaction: ${txHash}`
|
||||||
|
: `✅ Оплата успешна! Транзакция: ${txHash}`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
// Refresh data
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchBalance();
|
||||||
|
fetchPayments();
|
||||||
|
}, 2000);
|
||||||
|
} else if (paymentStatus === 'error') {
|
||||||
|
showMessage(
|
||||||
|
isEn
|
||||||
|
? '❌ Payment failed. Please try again.'
|
||||||
|
: '❌ Ошибка оплаты. Попробуйте снова.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [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] Загрузка баланса...');
|
||||||
const res = await apiClient.get(`${API_URL}/api/user/balance`);
|
const res = await apiClient.get(`${API_URL}/api/user/balance`);
|
||||||
console.log('[Billing] Ответ от сервера:', res.data);
|
|
||||||
const balanceValue = res.data.balance || 0;
|
const balanceValue = res.data.balance || 0;
|
||||||
setBalance(balanceValue);
|
setBalance(balanceValue);
|
||||||
console.log('[Billing] Баланс загружен:', balanceValue);
|
console.log('[Billing] Баланс загружен:', balanceValue);
|
||||||
@@ -54,114 +105,162 @@ const Billing = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchChecks = async () => {
|
const fetchPayments = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[Billing] Загрузка истории чеков...');
|
console.log('[Billing] Загрузка истории платежей...');
|
||||||
const res = await apiClient.get(`${API_URL}/api/check/my`);
|
const res = await apiClient.get(`${API_URL}/api/payment/depay/history`);
|
||||||
const checksData = res.data.data || [];
|
const paymentsData = res.data.payments || [];
|
||||||
setChecks(checksData);
|
setPayments(paymentsData);
|
||||||
console.log('[Billing] История чеков загружена:', checksData.length, 'чеков');
|
console.log('[Billing] История загружена:', paymentsData.length, 'платежей');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Billing] Ошибка загрузки истории чеков:', error);
|
console.error('[Billing] Ошибка загрузки истории:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(''), 5000);
|
setTimeout(() => setMessage(''), 8000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyCard = () => {
|
const handleOpenPaymentWidget = () => {
|
||||||
if (cardNumber) {
|
if (!window.DePayWidgets) {
|
||||||
navigator.clipboard.writeText(cardNumber);
|
showMessage(
|
||||||
showMessage('Номер карты скопирован!', 'success');
|
isEn ? 'Payment widget not loaded yet. Please wait...' : 'Виджет оплаты ещё загружается. Подождите...',
|
||||||
}
|
'error'
|
||||||
};
|
);
|
||||||
|
|
||||||
const handleGeneratePayment = () => {
|
|
||||||
if (amount > 0) {
|
|
||||||
setIsPaymentGenerated(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckUpload = async () => {
|
|
||||||
if (!checkFile || amount <= 0) {
|
|
||||||
console.error('[Billing] Ошибка валидации:', { checkFile: !!checkFile, amount });
|
|
||||||
showMessage('Укажите сумму и выберите файл', 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Billing] Начало загрузки чека:', {
|
if (!userData?.user?.id) {
|
||||||
fileName: checkFile.name,
|
showMessage(
|
||||||
fileSize: checkFile.size,
|
isEn ? 'Please log in to make a payment' : 'Войдите для оплаты',
|
||||||
amount
|
'error'
|
||||||
});
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUploadLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
// Open DePay payment widget
|
||||||
formData.append('file', checkFile);
|
window.DePayWidgets.Payment({
|
||||||
formData.append('amount', String(amount));
|
integration: DEPAY_INTEGRATION_ID,
|
||||||
|
|
||||||
|
// User identifier for callback
|
||||||
|
user: {
|
||||||
|
id: String(userData.user.id),
|
||||||
|
},
|
||||||
|
|
||||||
console.log('[Billing] Отправка запроса на /api/check/upload...');
|
// Callback URLs
|
||||||
const response = await apiClient.post(`${API_URL}/api/check/upload`, formData, {
|
callback: {
|
||||||
headers: {
|
url: `${API_URL}/api/payment/depay/callback`,
|
||||||
'Content-Type': 'multipart/form-data',
|
},
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Billing] ✅ Чек успешно загружен:', response.data);
|
|
||||||
showMessage('✅ Чек успешно отправлен! Ожидайте проверки оператором (обычно до 24 часов)', 'success');
|
|
||||||
|
|
||||||
setCheckFile(null);
|
|
||||||
setAmount(0);
|
|
||||||
setIsPaymentGenerated(false);
|
|
||||||
|
|
||||||
// Обновляем список чеков
|
|
||||||
await fetchChecks();
|
|
||||||
console.log('[Billing] История чеков обновлена');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { response?: { data?: { error?: string; message?: string }; status?: number }; message?: string };
|
console.error('[DePay] Error opening widget:', error);
|
||||||
console.error('[Billing] Ошибка загрузки чека:', {
|
showMessage(
|
||||||
message: err.message,
|
isEn
|
||||||
response: err.response?.data,
|
? 'Failed to open payment widget'
|
||||||
status: err.response?.status,
|
: 'Не удалось открыть виджет оплаты',
|
||||||
});
|
'error'
|
||||||
|
);
|
||||||
const errorMessage = err.response?.data?.error ||
|
|
||||||
err.response?.data?.message ||
|
|
||||||
'Ошибка загрузки чека. Попробуйте снова';
|
|
||||||
showMessage(`${errorMessage}`, 'error');
|
|
||||||
}
|
}
|
||||||
setUploadLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'completed':
|
||||||
return isEn ? 'Approved' : 'Зачислено';
|
return isEn ? 'Completed' : 'Завершён';
|
||||||
case 'rejected':
|
case 'failed':
|
||||||
return isEn ? 'Rejected' : 'Отклонено';
|
return isEn ? 'Failed' : 'Не удался';
|
||||||
default:
|
default:
|
||||||
return isEn ? 'Pending' : 'На проверке';
|
return isEn ? 'Pending' : 'В обработке';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'completed':
|
||||||
return 'text-green-600';
|
return 'text-green-600 dark:text-green-400';
|
||||||
case 'rejected':
|
case 'failed':
|
||||||
return 'text-red-600';
|
return 'text-red-600 dark:text-red-400';
|
||||||
default:
|
default:
|
||||||
return 'text-yellow-600';
|
return 'text-yellow-600 dark:text-yellow-400';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString(isEn ? 'en-US' : 'ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl max-w-4xl mx-auto">
|
<div className="p-4 lg:p-8 bg-white dark:bg-gray-800 rounded-2xl lg:rounded-3xl shadow-xl max-w-4xl mx-auto transition-colors duration-200">
|
||||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">
|
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 dark:text-white mb-4 lg:mb-6">
|
||||||
{isEn ? 'Top Up Balance' : 'Пополнение баланса'}
|
{isEn ? 'Top Up Balance' : 'Пополнение баланса'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -169,217 +268,165 @@ const Billing = () => {
|
|||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-3 border rounded-xl text-sm font-medium ${
|
<div className={`mb-4 p-3 border rounded-xl text-sm font-medium ${
|
||||||
messageType === 'success'
|
messageType === 'success'
|
||||||
? 'bg-green-50 border-green-200 text-green-700'
|
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300'
|
||||||
: 'bg-red-50 border-red-200 text-red-700'
|
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Текущий баланс */}
|
{/* Текущий баланс */}
|
||||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl mb-6">
|
<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 text-gray-600 mb-1">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
<p className="text-sm opacity-90 mb-2">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
||||||
<p className="text-3xl lg:text-4xl font-extrabold text-ospab-primary">{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>
|
||||||
|
|
||||||
{!isPaymentGenerated ? (
|
{/* Курс обмена */}
|
||||||
<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">
|
||||||
{/* Ввод суммы */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="mb-4">
|
<div>
|
||||||
<label htmlFor="amount" className="block text-gray-700 font-semibold mb-2">
|
<p className="text-sm font-semibold text-blue-800 dark:text-blue-300">
|
||||||
{isEn ? 'Top-up amount (₽)' : 'Сумма пополнения (₽)'}
|
{isEn ? 'Exchange Rate' : 'Курс обмена'}
|
||||||
</label>
|
</p>
|
||||||
<input
|
<p className="text-2xl font-bold text-blue-900 dark:text-blue-200">
|
||||||
type="number"
|
1 USDT = {exchangeRate.toFixed(2)} ₽
|
||||||
id="amount"
|
</p>
|
||||||
value={amount || ''}
|
|
||||||
onChange={(e) => setAmount(Number(e.target.value))}
|
|
||||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
|
||||||
min="1"
|
|
||||||
placeholder={isEn ? 'Enter amount' : 'Введите сумму'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
{/* Быстрые суммы */}
|
<p className="text-xs text-blue-600 dark:text-blue-400">
|
||||||
<div className="mb-6">
|
{isEn ? 'Updated every minute' : 'Обновляется каждую минуту'}
|
||||||
<p className="text-sm text-gray-600 mb-2">{isEn ? 'Quick select:' : 'Быстрый выбор:'}</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
|
||||||
{quickAmounts.map((quickAmount) => (
|
|
||||||
<button
|
|
||||||
key={quickAmount}
|
|
||||||
onClick={() => setAmount(quickAmount)}
|
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition ${
|
|
||||||
amount === quickAmount
|
|
||||||
? 'bg-ospab-primary text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{quickAmount} ₽
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleGeneratePayment}
|
|
||||||
disabled={amount <= 0}
|
|
||||||
className="w-full px-5 py-3 rounded-xl text-white font-bold transition-colors bg-ospab-primary hover:bg-ospab-accent disabled:bg-gray-300 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isEn ? 'Proceed to Payment' : 'Перейти к оплате'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div>
|
|
||||||
{/* Инструкция */}
|
{/* Кнопка оплаты через DePay */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
<div className="mb-8">
|
||||||
<p className="font-bold text-blue-800 mb-2">{isEn ? 'Payment Instructions' : 'Инструкция по оплате'}</p>
|
<button
|
||||||
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
|
onClick={handleOpenPaymentWidget}
|
||||||
<li>{isEn ? <>Transfer <strong>₽{amount}</strong> via SBP or to card</> : <>Переведите <strong>₽{amount}</strong> по СБП или на карту</>}</li>
|
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]"
|
||||||
<li>{isEn ? 'Save the payment receipt' : 'Сохраните чек об оплате'}</li>
|
>
|
||||||
<li>{isEn ? 'Upload the receipt below for verification' : 'Загрузите чек ниже для проверки'}</li>
|
<span className="flex items-center justify-center gap-3">
|
||||||
</ol>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{isEn ? 'Top Up with Crypto (USDT)' : 'Пополнить криптовалютой (USDT)'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-3">
|
||||||
|
{isEn
|
||||||
|
? 'Pay with USDT on Polygon network. Fast and secure.'
|
||||||
|
: 'Оплата USDT в сети Polygon. Быстро и безопасно.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Преимущества */}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
{/* QR-код и карта */}
|
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Instant' : 'Мгновенно'}</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{/* QR СБП */}
|
{isEn ? 'Balance updates in seconds' : 'Баланс обновляется за секунды'}
|
||||||
<div className="bg-gray-100 p-4 rounded-xl">
|
</p>
|
||||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Pay via SBP' : 'Оплата по СБП'}</h3>
|
</div>
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
</div>
|
||||||
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE'} size={200} />
|
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
{isEn ? 'Scan QR code in your bank app' : 'Отсканируйте QR-код в приложении банка'}
|
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</p>
|
<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" />
|
||||||
</div>
|
</svg>
|
||||||
|
</div>
|
||||||
{/* Номер карты */}
|
<div>
|
||||||
<div className="bg-gray-100 p-4 rounded-xl">
|
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Secure' : 'Безопасно'}</p>
|
||||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Card Number' : 'Номер карты'}</h3>
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
<p className="text-xl font-mono font-bold text-gray-800 break-all mb-3 bg-white p-4 rounded-lg">
|
{isEn ? 'Blockchain verified' : 'Проверено блокчейном'}
|
||||||
{cardNumber || '0000 0000 0000 0000'}
|
</p>
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleCopyCard}
|
|
||||||
className="w-full px-4 py-2 rounded-lg text-white font-semibold bg-gray-700 hover:bg-gray-800 transition"
|
|
||||||
>
|
|
||||||
{isEn ? 'Copy card number' : 'Скопировать номер карты'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Загрузка чека */}
|
|
||||||
<div className="bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-6 mb-4">
|
|
||||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Upload Receipt' : 'Загрузка чека'}</h3>
|
|
||||||
{checkFile ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-700 mb-2">
|
|
||||||
<strong>{isEn ? 'Selected file:' : 'Выбран файл:'}</strong> <span className="break-all" title={checkFile.name}>{checkFile.name}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 mb-3">
|
|
||||||
{isEn ? 'Size:' : 'Размер:'} {(checkFile.size / 1024 / 1024).toFixed(2)} {isEn ? 'MB' : 'МБ'}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCheckFile(null)}
|
|
||||||
className="px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400 transition"
|
|
||||||
>
|
|
||||||
{isEn ? 'Remove' : 'Удалить'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCheckUpload}
|
|
||||||
disabled={uploadLoading}
|
|
||||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
{uploadLoading ? (isEn ? 'Uploading...' : 'Загрузка...') : (isEn ? 'Submit receipt' : 'Отправить чек')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-600 mb-2">
|
|
||||||
<label className="text-ospab-primary cursor-pointer hover:underline font-semibold">
|
|
||||||
{isEn ? 'Click to select a file' : 'Нажмите, чтобы выбрать файл'}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,application/pdf"
|
|
||||||
onChange={(e) => e.target.files && setCheckFile(e.target.files[0])}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">{isEn ? 'JPG, PNG, PDF (up to 10 MB)' : 'JPG, PNG, PDF (до 10 МБ)'}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsPaymentGenerated(false);
|
|
||||||
setAmount(0);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300 transition"
|
|
||||||
>
|
|
||||||
{isEn ? 'Change amount' : 'Изменить сумму'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* История чеков */}
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">{isEn ? 'Receipt History' : 'История чеков'}</h3>
|
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{checks.length > 0 ? (
|
<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>
|
||||||
|
<h3 className="text-xl font-bold text-gray-800 dark:text-white mb-4">
|
||||||
|
{isEn ? 'Payment History' : 'История платежей'}
|
||||||
|
</h3>
|
||||||
|
{payments.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{checks.map((check) => (
|
{payments.map((payment) => (
|
||||||
<div
|
<div
|
||||||
key={check.id}
|
key={payment.id}
|
||||||
className="bg-gray-50 p-4 rounded-xl flex items-center justify-between"
|
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"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-gray-800">{check.amount} ₽</p>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="font-bold text-gray-800 dark:text-white text-lg">
|
||||||
{new Date(check.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}
|
+{payment.amount.toFixed(2)} ₽
|
||||||
|
</p>
|
||||||
|
<span className={`text-xs font-semibold ${getStatusColor(payment.status)}`}>
|
||||||
|
{getStatusText(payment.status)}
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
{formatDate(payment.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{payment.transactionHash && (
|
||||||
<span className={`font-semibold ${getStatusColor(check.status)}`}>
|
|
||||||
{getStatusText(check.status)}
|
|
||||||
</span>
|
|
||||||
<a
|
<a
|
||||||
href={getCheckFileUrl(check.fileUrl)}
|
href={`https://polygonscan.com/tx/${payment.transactionHash}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-ospab-primary hover:underline text-sm"
|
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline flex items-center gap-1"
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const url = getCheckFileUrl(check.fileUrl);
|
|
||||||
|
|
||||||
// Открываем в новом окне с токеном в заголовке через fetch
|
|
||||||
fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(res => res.blob())
|
|
||||||
.then(blob => {
|
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
|
||||||
window.open(objectUrl, '_blank');
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Ошибка загрузки чека:', err);
|
|
||||||
showMessage(isEn ? 'Failed to load receipt' : 'Не удалось загрузить чек', 'error');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isEn ? 'Receipt' : 'Чек'}
|
{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>
|
</a>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 text-center py-4">{isEn ? 'No receipts yet' : 'История чеков пуста'}</p>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user