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:
2026-01-10 23:23:38 +03:00
parent 501e858c06
commit 95780564a6
15 changed files with 4448 additions and 384 deletions

View File

@@ -70,3 +70,15 @@ MINIO_BUCKET_PREFIX=ospab
MINIO_REGION_DEFAULT=ru-central-1
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

View File

@@ -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;

View File

@@ -23,7 +23,7 @@ model User {
isAdmin Boolean @default(false)
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
cryptoPayments CryptoPayment[] @relation("UserCryptoPayments")
balance Float @default(0)
notifications Notification[]
pushSubscriptions PushSubscription[]
@@ -46,16 +46,22 @@ model User {
@@map("user")
}
model Check {
id Int @id @default(autoincrement())
userId Int
amount Float
status String @default("pending") // pending, approved, rejected
fileUrl String
createdAt DateTime @default(now())
user User @relation("UserChecks", fields: [userId], references: [id])
model CryptoPayment {
id Int @id @default(autoincrement())
userId Int
amount Float // Amount in RUB credited to balance
cryptoAmount Float? // Amount in USDT paid
exchangeRate Float? // USDT to RUB exchange rate at payment time
status String @default("pending") // pending, completed, failed
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 {

View File

@@ -10,7 +10,7 @@ import authRoutes from './modules/auth/auth.routes';
import oauthRoutes from './modules/auth/oauth.routes';
import adminRoutes from './modules/admin/admin.routes';
import ticketRoutes from './modules/ticket/ticket.routes';
import checkRoutes from './modules/check/check.routes';
import depayRoutes from './modules/payment/depay.routes';
import blogRoutes from './modules/blog/blog.routes';
import notificationRoutes from './modules/notification/notification.routes';
import userRoutes from './modules/user/user.routes';
@@ -329,7 +329,7 @@ app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/auth', authLimiter, oauthRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/ticket', ticketRoutes);
app.use('/api/check', checkRoutes);
app.use('/payment/depay', depayRoutes);
app.use('/api/blog', blogRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/user', userRoutes);

View File

@@ -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: {
fileUrl: {
contains: filename

View File

@@ -290,7 +290,7 @@ export async function confirmAccountDeletion(userId: number, code: string): Prom
logger.log(`Удалено тикетов: ${tickets.count}`);
// 3. Удаляем чеки
const checks = await tx.check.deleteMany({
const checks = await tx.cryptoPayment.deleteMany({
where: { userId }
});
logger.log(`Удалено чеков: ${checks.count}`);

View File

@@ -89,7 +89,7 @@ export class AdminController {
buckets: {
orderBy: { createdAt: 'desc' }
},
checks: {
cryptoPayments: {
orderBy: { createdAt: 'desc' },
take: 10
},
@@ -111,7 +111,7 @@ export class AdminController {
const safeUser = {
...user,
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,
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 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({ where: { public: 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.storageBucket.aggregate({
_sum: {
@@ -441,7 +441,7 @@ export class AdminController {
await tx.storageBucket.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.post.deleteMany({ where: { authorId: userId } });
await tx.comment.deleteMany({ where: { userId } });

View File

@@ -20,7 +20,7 @@ export async function uploadCheck(req: MulterRequest, res: Response) {
// Сохраняем путь к файлу
const fileUrl = `/uploads/checks/${file.filename}`;
const check = await prisma.check.create({
const check = await prisma.cryptoPayment.create({
data: { userId, amount: Number(amount), fileUrl }
});
res.json(check);
@@ -30,7 +30,7 @@ export async function uploadCheck(req: MulterRequest, res: Response) {
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.check.findMany({
const checks = await prisma.cryptoPayment.findMany({
include: { user: true },
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 },
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 },
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 },
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 },
data: {
status: 'rejected',
@@ -142,7 +142,7 @@ export async function getUserChecks(req: Request, res: Response) {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизован' });
const checks = await prisma.check.findMany({
const checks = await prisma.cryptoPayment.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50 // Последние 50 чеков
@@ -161,7 +161,7 @@ export async function viewCheck(req: Request, res: Response) {
const userId = req.user?.id;
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) {
return res.status(404).json({ error: 'Чек не найден' });
@@ -190,7 +190,7 @@ export async function getCheckFile(req: Request, res: Response) {
// Операторы имеют доступ ко всем файлам
if (!isOperator) {
// Для обычных пользователей проверяем владение
const check = await prisma.check.findFirst({
const check = await prisma.cryptoPayment.findFirst({
where: {
fileUrl: {
contains: filename

View 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;

View 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;

View File

@@ -408,7 +408,7 @@ export const exportUserData = async (req: Request, res: Response) => {
buckets: true,
tickets: true,
checks: true,
cryptoPayments: true,
transactions: true,
notifications: true,
apiKeys: {