Gestão de Saques
O módulo de saques permite aos administradores revisar, aprovar ou rejeitar solicitações de saque dos usuários.
Fila de Saques
Visão Geral
Endpoint
http
GET /api/admin/withdrawals?status=PENDING&page=1&limit=20Response
json
{
"withdrawals": [
{
"id": "wd123",
"user": {
"id": "user456",
"username": "player1",
"avatarUrl": "https://...",
"status": "ACTIVE",
"totalDeposited": 50000,
"totalWithdrawn": 25000,
"accountAge": 45
},
"amountCents": 10000,
"paymentMethod": "PIX",
"paymentDetails": {
"pixKeyType": "EMAIL",
"pixKey": "***@***.com"
},
"status": "PENDING",
"riskScore": 0.15,
"riskFactors": [],
"requestedAt": "2024-01-15T10:30:00Z",
"notes": null
}
],
"total": 15,
"summary": {
"pendingCount": 15,
"pendingValue": 150000,
"approvedToday": 5,
"approvedValueToday": 75000,
"rejectedToday": 2,
"avgProcessingTime": "2h 15min"
}
}Análise de Risco
Cada saque recebe uma análise de risco automática:
typescript
// withdrawal-risk.service.ts
interface RiskAnalysis {
score: number; // 0 a 1
level: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
factors: RiskFactor[];
recommendation: 'APPROVE' | 'REVIEW' | 'REJECT';
}
interface RiskFactor {
factor: string;
weight: number;
description: string;
}Fatores de Risco
| Fator | Peso | Descrição |
|---|---|---|
NEW_ACCOUNT | 0.2 | Conta com menos de 7 dias |
HIGH_AMOUNT | 0.15 | Valor acima de 5x a média do usuário |
QUICK_DEPOSIT_WITHDRAW | 0.25 | Depósito seguido de saque rápido |
NEW_IP | 0.2 | IP diferente do habitual |
NEW_DEVICE | 0.15 | Dispositivo não reconhecido |
UNUSUAL_HOUR | 0.05 | Horário incomum para o usuário |
MULTIPLE_ATTEMPTS | 0.1 | Múltiplas tentativas recentes |
LOW_WAGERING | 0.15 | Pouco jogo em relação ao depósito |
Implementação
typescript
// withdrawal-risk.service.ts
@Injectable()
export class WithdrawalRiskService {
async analyze(withdrawal: Withdrawal): Promise<RiskAnalysis> {
const factors: RiskFactor[] = [];
let totalScore = 0;
const user = await this.userRepository.findById(withdrawal.userId);
const stats = await this.getUserStats(withdrawal.userId);
// 1. Conta nova
const accountAge = differenceInDays(new Date(), user.createdAt);
if (accountAge < 7) {
factors.push({
factor: 'NEW_ACCOUNT',
weight: 0.2,
description: `Conta criada há ${accountAge} dias`,
});
totalScore += 0.2;
}
// 2. Valor alto
const avgWithdrawal = stats.avgWithdrawalAmount;
if (withdrawal.amountCents > avgWithdrawal * 5) {
factors.push({
factor: 'HIGH_AMOUNT',
weight: 0.15,
description: `Valor 5x maior que a média`,
});
totalScore += 0.15;
}
// 3. Depósito recente + saque rápido
const recentDeposit = await this.getRecentDeposit(withdrawal.userId, 60);
if (
recentDeposit &&
withdrawal.amountCents >= recentDeposit.amountCents * 0.9
) {
factors.push({
factor: 'QUICK_DEPOSIT_WITHDRAW',
weight: 0.25,
description: 'Depósito há menos de 1h seguido de saque',
});
totalScore += 0.25;
}
// 4. Baixa aposta em relação ao depósito
const wagerRatio = stats.totalWagered / stats.totalDeposited;
if (wagerRatio < 0.5) {
factors.push({
factor: 'LOW_WAGERING',
weight: 0.15,
description: `Apostou apenas ${(wagerRatio * 100).toFixed(0)}% do depositado`,
});
totalScore += 0.15;
}
// ... outros fatores
// Determinar nível e recomendação
const level = this.getLevel(totalScore);
const recommendation = this.getRecommendation(totalScore, factors);
return {
score: Math.min(totalScore, 1),
level,
factors,
recommendation,
};
}
private getLevel(score: number): RiskLevel {
if (score < 0.3) return 'LOW';
if (score < 0.5) return 'MEDIUM';
if (score < 0.8) return 'HIGH';
return 'CRITICAL';
}
private getRecommendation(
score: number,
factors: RiskFactor[],
): Recommendation {
if (score >= 0.8) return 'REJECT';
if (score >= 0.5) return 'REVIEW';
return 'APPROVE';
}
}Aprovar Saque
Endpoint
http
POST /api/admin/withdrawals/:id/approve
Content-Type: application/json
{
"notes": "Verificado - usuário legítimo"
}Implementação
typescript
// approve-withdrawal.use-case.ts
@Injectable()
export class ApproveWithdrawalUseCase {
async execute(
adminId: bigint,
withdrawalId: bigint,
dto: ApproveWithdrawalDto,
): Promise<Withdrawal> {
const lockKey = `withdrawal:${withdrawalId}:process`;
return await this.redlock.using([lockKey], 60000, async () => {
const withdrawal = await this.withdrawalRepository.findById(withdrawalId);
if (!withdrawal) {
throw new WithdrawalNotFoundException();
}
if (withdrawal.status !== WithdrawalStatus.PENDING) {
throw new InvalidWithdrawalStatusException(
`Saque não está pendente (status: ${withdrawal.status})`,
);
}
return await this.prisma.$transaction(async (tx) => {
// 1. Atualizar status
const updated = await tx.withdrawal.update({
where: { id: withdrawalId },
data: {
status: WithdrawalStatus.APPROVED,
approvedBy: adminId,
approvedAt: new Date(),
adminNotes: dto.notes,
},
});
// 2. Registrar auditoria
await this.auditService.log(tx, {
action: AdminAction.WITHDRAWAL_APPROVED,
adminId,
resourceId: withdrawalId,
resourceType: 'WITHDRAWAL',
metadata: {
amountCents: withdrawal.amountCents,
userId: withdrawal.userId,
notes: dto.notes,
},
});
// 3. Agendar processamento do pagamento
await this.paymentQueue.add('process-withdrawal', {
withdrawalId,
});
return updated;
});
});
}
}Rejeitar Saque
Endpoint
http
POST /api/admin/withdrawals/:id/reject
Content-Type: application/json
{
"reason": "Atividade suspeita detectada",
"notifyUser": true
}Implementação
typescript
// reject-withdrawal.use-case.ts
@Injectable()
export class RejectWithdrawalUseCase {
async execute(
adminId: bigint,
withdrawalId: bigint,
dto: RejectWithdrawalDto,
): Promise<Withdrawal> {
const withdrawal = await this.withdrawalRepository.findById(withdrawalId);
if (!withdrawal) {
throw new WithdrawalNotFoundException();
}
if (!['PENDING', 'APPROVED'].includes(withdrawal.status)) {
throw new InvalidWithdrawalStatusException();
}
return await this.prisma.$transaction(async (tx) => {
// 1. Reverter transação (devolver saldo)
await this.transactionService.reverseTransaction(tx, {
originalReferenceId: withdrawalId,
originalReferenceType: 'WITHDRAWAL',
reason: dto.reason,
});
// 2. Atualizar status
const updated = await tx.withdrawal.update({
where: { id: withdrawalId },
data: {
status: WithdrawalStatus.REJECTED,
rejectedBy: adminId,
rejectedAt: new Date(),
rejectionReason: dto.reason,
},
});
// 3. Registrar auditoria
await this.auditService.log(tx, {
action: AdminAction.WITHDRAWAL_REJECTED,
adminId,
resourceId: withdrawalId,
resourceType: 'WITHDRAWAL',
metadata: {
amountCents: withdrawal.amountCents,
userId: withdrawal.userId,
reason: dto.reason,
},
});
// 4. Notificar usuário
if (dto.notifyUser) {
await this.notificationService.send(withdrawal.userId, {
type: NotificationType.WITHDRAWAL_REJECTED,
title: 'Saque Rejeitado',
message: `Seu saque de R$ ${(withdrawal.amountCents / 100).toFixed(2)} foi rejeitado. Motivo: ${dto.reason}`,
});
// Atualizar saldo via WebSocket
const newBalance = await this.transactionService.getUserBalance(
withdrawal.userId,
);
this.webSocketGateway.emitBalanceUpdate(withdrawal.userId, newBalance);
}
return updated;
});
}
}Aprovação em Massa
Endpoint
http
POST /api/admin/withdrawals/batch-approve
Content-Type: application/json
{
"withdrawalIds": ["wd123", "wd456", "wd789"],
"notes": "Aprovação em lote - baixo risco"
}Implementação
typescript
// batch-approve-withdrawals.use-case.ts
@Injectable()
export class BatchApproveWithdrawalsUseCase {
async execute(
adminId: bigint,
dto: BatchApproveDto,
): Promise<BatchResult> {
const results: BatchResultItem[] = [];
for (const withdrawalId of dto.withdrawalIds) {
try {
await this.approveWithdrawalUseCase.execute(adminId, withdrawalId, {
notes: dto.notes,
});
results.push({
id: withdrawalId,
success: true,
});
} catch (error) {
results.push({
id: withdrawalId,
success: false,
error: error.message,
});
}
}
return {
total: dto.withdrawalIds.length,
successful: results.filter((r) => r.success).length,
failed: results.filter((r) => !r.success).length,
results,
};
}
}Frontend - Fila de Saques
tsx
// app/(dashboard)/withdrawals/page.tsx
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { CheckCircle, XCircle, AlertTriangle, Clock } from 'lucide-react';
export default function WithdrawalsPage() {
const [selectedWithdrawal, setSelectedWithdrawal] = useState<Withdrawal | null>(null);
const [rejectReason, setRejectReason] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['withdrawals', 'pending'],
queryFn: () => fetchWithdrawals({ status: 'PENDING' }),
refetchInterval: 30000, // Atualizar a cada 30s
});
const approveMutation = useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
approveWithdrawal(id, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['withdrawals'] });
setSelectedWithdrawal(null);
},
});
const rejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
rejectWithdrawal(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['withdrawals'] });
setSelectedWithdrawal(null);
setRejectReason('');
},
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Saques Pendentes</h1>
{/* Summary Cards */}
<div className="flex gap-4">
<Card className="w-40">
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{data?.summary.pendingCount}</p>
<p className="text-sm text-muted-foreground">Pendentes</p>
</CardContent>
</Card>
<Card className="w-40">
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">
R$ {((data?.summary.pendingValue || 0) / 100).toFixed(0)}
</p>
<p className="text-sm text-muted-foreground">Valor Total</p>
</CardContent>
</Card>
</div>
</div>
{/* Table */}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Usuário</TableHead>
<TableHead>Valor</TableHead>
<TableHead>Método</TableHead>
<TableHead>Risco</TableHead>
<TableHead>Solicitado</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.withdrawals.map((withdrawal) => (
<TableRow key={withdrawal.id}>
<TableCell>
<div className="flex items-center gap-2">
<img
src={withdrawal.user.avatarUrl}
alt={withdrawal.user.username}
className="h-8 w-8 rounded-full"
/>
<div>
<p className="font-medium">{withdrawal.user.username}</p>
<p className="text-xs text-muted-foreground">
Conta há {withdrawal.user.accountAge} dias
</p>
</div>
</div>
</TableCell>
<TableCell>
<span className="font-medium">
R$ {(withdrawal.amountCents / 100).toFixed(2)}
</span>
</TableCell>
<TableCell>
<Badge variant="outline">{withdrawal.paymentMethod}</Badge>
</TableCell>
<TableCell>
<RiskBadge score={withdrawal.riskScore} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
{formatDistanceToNow(new Date(withdrawal.requestedAt))}
</div>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="default"
onClick={() => approveMutation.mutate({ id: withdrawal.id })}
disabled={approveMutation.isPending}
>
<CheckCircle className="h-4 w-4 mr-1" />
Aprovar
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setSelectedWithdrawal(withdrawal)}
>
<XCircle className="h-4 w-4 mr-1" />
Rejeitar
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Reject Dialog */}
<Dialog
open={!!selectedWithdrawal}
onOpenChange={() => setSelectedWithdrawal(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rejeitar Saque</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p>
Saque de R$ {((selectedWithdrawal?.amountCents || 0) / 100).toFixed(2)} por{' '}
<strong>{selectedWithdrawal?.user.username}</strong>
</p>
<Textarea
placeholder="Motivo da rejeição..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedWithdrawal(null)}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() =>
rejectMutation.mutate({
id: selectedWithdrawal!.id,
reason: rejectReason,
})
}
disabled={!rejectReason || rejectMutation.isPending}
>
Confirmar Rejeição
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function RiskBadge({ score }: { score: number }) {
const level = score < 0.3 ? 'LOW' : score < 0.5 ? 'MEDIUM' : score < 0.8 ? 'HIGH' : 'CRITICAL';
const variants: Record<string, string> = {
LOW: 'bg-green-100 text-green-800',
MEDIUM: 'bg-yellow-100 text-yellow-800',
HIGH: 'bg-orange-100 text-orange-800',
CRITICAL: 'bg-red-100 text-red-800',
};
return (
<Badge className={variants[level]}>
{level === 'HIGH' || level === 'CRITICAL' ? (
<AlertTriangle className="h-3 w-3 mr-1" />
) : null}
{level} ({(score * 100).toFixed(0)}%)
</Badge>
);
}