Skip to content

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=20

Response

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

FatorPesoDescrição
NEW_ACCOUNT0.2Conta com menos de 7 dias
HIGH_AMOUNT0.15Valor acima de 5x a média do usuário
QUICK_DEPOSIT_WITHDRAW0.25Depósito seguido de saque rápido
NEW_IP0.2IP diferente do habitual
NEW_DEVICE0.15Dispositivo não reconhecido
UNUSUAL_HOUR0.05Horário incomum para o usuário
MULTIPLE_ATTEMPTS0.1Múltiplas tentativas recentes
LOW_WAGERING0.15Pouco 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>
  );
}

Documentação Técnica CSGOFlip