Skip to content

Saques (Withdrawals)

O sistema de saques do CSGOFlip implementa múltiplas camadas de segurança para proteger os usuários contra fraudes e acessos não autorizados.

Visão Geral

Status do Saque

typescript
enum WithdrawalStatus {
  PENDING = 'PENDING',           // Aguardando aprovação
  APPROVED = 'APPROVED',         // Aprovado, processando pagamento
  PROCESSING = 'PROCESSING',     // Pagamento em andamento
  COMPLETED = 'COMPLETED',       // Concluído com sucesso
  REJECTED = 'REJECTED',         // Rejeitado pelo admin
  CANCELLED = 'CANCELLED',       // Cancelado pelo usuário
  FAILED = 'FAILED',             // Falha no processamento
}

Fluxo de Saque

1. Solicitação (Request)

typescript
// withdraw.use-case.ts
async execute(userId: bigint, dto: WithdrawRequestDto): Promise<Withdrawal> {
  const lockKey = `user:${userId}:withdrawal`;
  
  return await this.redlock.using([lockKey], 30000, async () => {
    // 1. Buscar saldo REAL das transações (Source of Truth)
    const realBalance = await this.transactionRepository.getUserBalance(userId);
    
    // 2. Validar saldo suficiente
    if (realBalance < dto.amountCents) {
      throw new InsufficientBalanceException();
    }
    
    // 3. Validar limite mínimo/máximo
    this.validateWithdrawalLimits(dto.amountCents);
    
    // 4. Verificar 2FA se valor > threshold
    if (dto.amountCents >= this.config.twoFactorThreshold) {
      await this.verify2FA(userId, dto.twoFactorCode);
    }
    
    // 5. Criar withdrawal e reservar saldo
    return await this.prisma.$transaction(async (tx) => {
      // Criar registro de saque
      const withdrawal = await tx.withdrawal.create({
        data: {
          userId,
          amountCents: dto.amountCents,
          paymentMethod: dto.paymentMethod,
          paymentDetails: this.encryptPaymentDetails(dto.paymentDetails),
          status: WithdrawalStatus.PENDING,
          requestedAt: new Date(),
        },
      });
      
      // Reservar saldo (débito imediato)
      await this.transactionService.createTransaction(tx, {
        debitUserId: userId,
        creditUserId: HOUSE_USER_ID,
        amountCents: dto.amountCents,
        type: TransactionType.WITHDRAWAL_PENDING,
        referenceId: withdrawal.id,
        referenceType: 'WITHDRAWAL',
      });
      
      return withdrawal;
    });
  });
}

2. Verificação 2FA

typescript
// two-factor.service.ts
async verifyForWithdrawal(userId: bigint, code: string): Promise<void> {
  const user = await this.userRepository.findById(userId);
  
  // 1. Verificar se 2FA está ativado
  if (!user.twoFactorEnabled) {
    throw new TwoFactorNotEnabledException(
      'Ative a autenticação de dois fatores para realizar saques'
    );
  }
  
  // 2. Verificar código TOTP
  const isValid = authenticator.verify({
    token: code,
    secret: this.decrypt(user.twoFactorSecret),
  });
  
  if (!isValid) {
    // Rate limiting para tentativas
    await this.incrementFailedAttempts(userId);
    throw new InvalidTwoFactorCodeException();
  }
  
  // 3. Limpar tentativas em caso de sucesso
  await this.clearFailedAttempts(userId);
}

3. Aprovação pelo Admin

typescript
// approve-withdrawal.use-case.ts
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);
    
    // 1. Validar status
    if (withdrawal.status !== WithdrawalStatus.PENDING) {
      throw new InvalidWithdrawalStatusException();
    }
    
    // 2. Verificar saldo reservado
    const pendingTransaction = await this.transactionRepository.findByReference(
      withdrawalId,
      'WITHDRAWAL',
      TransactionType.WITHDRAWAL_PENDING,
    );
    
    if (!pendingTransaction) {
      throw new WithdrawalTransactionNotFoundException();
    }
    
    // 3. Atualizar status para APPROVED
    return await this.prisma.$transaction(async (tx) => {
      const updated = await tx.withdrawal.update({
        where: { id: withdrawalId },
        data: {
          status: WithdrawalStatus.APPROVED,
          approvedBy: adminId,
          approvedAt: new Date(),
          adminNotes: dto.notes,
        },
      });
      
      // Criar log de auditoria
      await this.auditService.log(tx, {
        action: 'WITHDRAWAL_APPROVED',
        adminId,
        resourceId: withdrawalId,
        resourceType: 'WITHDRAWAL',
        metadata: {
          amountCents: withdrawal.amountCents,
          userId: withdrawal.userId,
        },
      });
      
      return updated;
    });
  });
}

4. Processamento do Pagamento

typescript
// process-withdrawal.use-case.ts
async execute(withdrawalId: bigint): Promise<Withdrawal> {
  const withdrawal = await this.withdrawalRepository.findById(withdrawalId);
  
  // 1. Validar status
  if (withdrawal.status !== WithdrawalStatus.APPROVED) {
    throw new InvalidWithdrawalStatusException();
  }
  
  // 2. Atualizar para PROCESSING
  await this.withdrawalRepository.updateStatus(
    withdrawalId,
    WithdrawalStatus.PROCESSING,
  );
  
  try {
    // 3. Processar pagamento via provider
    const paymentResult = await this.paymentProvider.processWithdrawal({
      amount: withdrawal.amountCents,
      method: withdrawal.paymentMethod,
      details: this.decryptPaymentDetails(withdrawal.paymentDetails),
    });
    
    // 4. Finalizar transação
    return await this.prisma.$transaction(async (tx) => {
      // Converter transação PENDING para COMPLETED
      await this.transactionService.convertPendingToCompleted(
        tx,
        withdrawal.id,
        'WITHDRAWAL',
      );
      
      // Atualizar withdrawal
      return await tx.withdrawal.update({
        where: { id: withdrawalId },
        data: {
          status: WithdrawalStatus.COMPLETED,
          completedAt: new Date(),
          externalTransactionId: paymentResult.transactionId,
        },
      });
    });
    
  } catch (error) {
    // 5. Reverter em caso de falha
    await this.handleWithdrawalFailure(withdrawalId, error);
    throw error;
  }
}

5. Rejeição ou Cancelamento

typescript
// reject-withdrawal.use-case.ts
async execute(
  adminId: bigint,
  withdrawalId: bigint,
  reason: string,
): Promise<Withdrawal> {
  return await this.prisma.$transaction(async (tx) => {
    const withdrawal = await tx.withdrawal.findUnique({
      where: { id: withdrawalId },
    });
    
    // 1. Validar status permite rejeição
    if (!['PENDING', 'APPROVED'].includes(withdrawal.status)) {
      throw new InvalidWithdrawalStatusException();
    }
    
    // 2. Reverter transação (devolver saldo)
    await this.transactionService.reverseTransaction(tx, {
      originalReferenceId: withdrawalId,
      originalReferenceType: 'WITHDRAWAL',
      reason: `Saque rejeitado: ${reason}`,
    });
    
    // 3. Atualizar withdrawal
    const updated = await tx.withdrawal.update({
      where: { id: withdrawalId },
      data: {
        status: WithdrawalStatus.REJECTED,
        rejectedBy: adminId,
        rejectedAt: new Date(),
        rejectionReason: reason,
      },
    });
    
    // 4. Notificar usuário
    await this.notificationService.send(tx, {
      userId: withdrawal.userId,
      type: NotificationType.WITHDRAWAL_REJECTED,
      title: 'Saque Rejeitado',
      message: `Seu saque de R$ ${(withdrawal.amountCents / 100).toFixed(2)} foi rejeitado. Motivo: ${reason}`,
    });
    
    // 5. Atualizar saldo via WebSocket
    const newBalance = await this.transactionRepository.getUserBalance(
      withdrawal.userId,
    );
    this.webSocketGateway.emitBalanceUpdate(withdrawal.userId, newBalance);
    
    return updated;
  });
}

Limites e Validações

Limites de Saque

typescript
// withdrawal.config.ts
export const WITHDRAWAL_CONFIG = {
  // Limites por transação
  minAmountCents: 5000,           // R$ 50,00 mínimo
  maxAmountCents: 10000000,       // R$ 100.000,00 máximo
  
  // Limites diários
  dailyLimitCents: 50000000,      // R$ 500.000,00 por dia
  
  // 2FA obrigatório acima de
  twoFactorThreshold: 100000,     // R$ 1.000,00
  
  // Tempo mínimo entre saques
  cooldownMinutes: 5,
  
  // Verificação extra para novos usuários
  newUserDays: 7,
  newUserMaxCents: 50000,         // R$ 500,00 para novos
};

Validações de Segurança

typescript
// withdrawal-validation.service.ts
async validateWithdrawal(
  userId: bigint,
  amountCents: bigint,
): Promise<ValidationResult> {
  const validations: ValidationCheck[] = [];
  
  // 1. Verificar conta ativa
  const user = await this.userRepository.findById(userId);
  if (user.status !== UserStatus.ACTIVE) {
    validations.push({
      passed: false,
      code: 'ACCOUNT_NOT_ACTIVE',
      message: 'Conta não está ativa',
    });
  }
  
  // 2. Verificar 2FA ativado (obrigatório para saques)
  if (!user.twoFactorEnabled) {
    validations.push({
      passed: false,
      code: '2FA_REQUIRED',
      message: 'Autenticação de dois fatores é obrigatória',
    });
  }
  
  // 3. Verificar limite diário
  const todayWithdrawals = await this.withdrawalRepository.getTodayTotal(userId);
  if (todayWithdrawals + amountCents > WITHDRAWAL_CONFIG.dailyLimitCents) {
    validations.push({
      passed: false,
      code: 'DAILY_LIMIT_EXCEEDED',
      message: 'Limite diário de saques excedido',
    });
  }
  
  // 4. Verificar cooldown
  const lastWithdrawal = await this.withdrawalRepository.getLastByUser(userId);
  if (lastWithdrawal) {
    const minutesSinceLast = differenceInMinutes(
      new Date(),
      lastWithdrawal.requestedAt,
    );
    if (minutesSinceLast < WITHDRAWAL_CONFIG.cooldownMinutes) {
      validations.push({
        passed: false,
        code: 'COOLDOWN_ACTIVE',
        message: `Aguarde ${WITHDRAWAL_CONFIG.cooldownMinutes - minutesSinceLast} minutos`,
      });
    }
  }
  
  // 5. Verificar usuário novo
  const accountAge = differenceInDays(new Date(), user.createdAt);
  if (
    accountAge < WITHDRAWAL_CONFIG.newUserDays &&
    amountCents > WITHDRAWAL_CONFIG.newUserMaxCents
  ) {
    validations.push({
      passed: false,
      code: 'NEW_USER_LIMIT',
      message: `Contas com menos de ${WITHDRAWAL_CONFIG.newUserDays} dias têm limite de R$ ${WITHDRAWAL_CONFIG.newUserMaxCents / 100}`,
    });
  }
  
  // 6. Análise de risco
  const riskScore = await this.fraudDetectionService.analyzeWithdrawal(
    userId,
    amountCents,
  );
  if (riskScore > 0.8) {
    validations.push({
      passed: false,
      code: 'HIGH_RISK',
      message: 'Saque bloqueado para análise de segurança',
      requiresManualReview: true,
    });
  }
  
  return {
    valid: validations.every((v) => v.passed),
    validations,
  };
}

Detecção de Fraude

Análise de Risco

typescript
// fraud-detection.service.ts
async analyzeWithdrawal(userId: bigint, amountCents: bigint): Promise<number> {
  let riskScore = 0;
  const factors: RiskFactor[] = [];
  
  // 1. Valor muito alto para o padrão do usuário
  const avgWithdrawal = await this.getAverageWithdrawal(userId);
  if (amountCents > avgWithdrawal * 5) {
    riskScore += 0.2;
    factors.push({ factor: 'HIGH_AMOUNT_VARIANCE', weight: 0.2 });
  }
  
  // 2. Múltiplas tentativas recentes
  const recentAttempts = await this.getRecentWithdrawalAttempts(userId, 24);
  if (recentAttempts > 3) {
    riskScore += 0.15;
    factors.push({ factor: 'MULTIPLE_ATTEMPTS', weight: 0.15 });
  }
  
  // 3. IP diferente do usual
  const currentIp = this.requestContext.ip;
  const usualIps = await this.getUserUsualIps(userId);
  if (!usualIps.includes(currentIp)) {
    riskScore += 0.25;
    factors.push({ factor: 'NEW_IP_ADDRESS', weight: 0.25 });
  }
  
  // 4. Horário incomum
  const hour = new Date().getHours();
  const usualHours = await this.getUserUsualHours(userId);
  if (!usualHours.includes(hour)) {
    riskScore += 0.1;
    factors.push({ factor: 'UNUSUAL_HOUR', weight: 0.1 });
  }
  
  // 5. Depósito recente seguido de saque total
  const recentDeposit = await this.getRecentDeposit(userId, 60); // 60 min
  if (recentDeposit && amountCents >= recentDeposit.amountCents * 0.9) {
    riskScore += 0.3;
    factors.push({ factor: 'QUICK_DEPOSIT_WITHDRAW', weight: 0.3 });
  }
  
  // 6. Dispositivo não reconhecido
  const deviceFingerprint = this.requestContext.deviceFingerprint;
  const knownDevices = await this.getUserDevices(userId);
  if (!knownDevices.includes(deviceFingerprint)) {
    riskScore += 0.2;
    factors.push({ factor: 'NEW_DEVICE', weight: 0.2 });
  }
  
  // Logar análise para auditoria
  await this.logRiskAnalysis(userId, 'WITHDRAWAL', riskScore, factors);
  
  return Math.min(riskScore, 1); // Cap em 1.0
}

Métodos de Pagamento

PIX

typescript
// pix-payment.provider.ts
async processWithdrawal(data: WithdrawalData): Promise<PaymentResult> {
  // 1. Validar chave PIX
  const pixKey = data.details.pixKey;
  const pixKeyType = this.detectPixKeyType(pixKey);
  
  if (!this.validatePixKey(pixKey, pixKeyType)) {
    throw new InvalidPixKeyException();
  }
  
  // 2. Consultar dados do recebedor
  const receiverInfo = await this.pixProvider.consultKey(pixKey);
  
  // 3. Validar CPF/CNPJ do recebedor (anti-fraude)
  if (data.details.cpf && receiverInfo.cpf !== data.details.cpf) {
    throw new PixKeyOwnerMismatchException();
  }
  
  // 4. Iniciar transferência
  const transfer = await this.pixProvider.transfer({
    amount: data.amount / 100, // Converter centavos para reais
    pixKey,
    pixKeyType,
    description: `Saque CSGOFlip #${data.withdrawalId}`,
    endToEndId: this.generateE2EId(),
  });
  
  return {
    success: true,
    transactionId: transfer.endToEndId,
    status: 'COMPLETED',
  };
}

Endpoints da API

Solicitar Saque

http
POST /api/withdrawals/request
Authorization: Bearer {sessionId}
Content-Type: application/json

{
  "amountCents": 50000,
  "paymentMethod": "PIX",
  "paymentDetails": {
    "pixKey": "user@email.com",
    "pixKeyType": "EMAIL"
  },
  "twoFactorCode": "123456"
}

Response:

json
{
  "id": "8234567890123456789",
  "amountCents": 50000,
  "status": "PENDING",
  "paymentMethod": "PIX",
  "requestedAt": "2024-01-15T10:30:00Z",
  "estimatedProcessingTime": "24h"
}

Listar Saques

http
GET /api/withdrawals?status=PENDING&page=1&limit=10

Cancelar Saque

http
POST /api/withdrawals/{id}/cancel
Authorization: Bearer {sessionId}

Admin Panel

Fila de Aprovação

O painel admin exibe saques pendentes com:

  • Dados do usuário (histórico, risco)
  • Análise de fraude (score, fatores)
  • Histórico de depósitos/saques
  • Botões de aprovar/rejeitar

Aprovação em Massa

http
POST /api/admin/withdrawals/batch-approve
Content-Type: application/json

{
  "withdrawalIds": ["123", "456", "789"],
  "adminNotes": "Aprovação em lote - baixo risco"
}

Auditoria

Toda operação de saque é registrada:

typescript
interface WithdrawalAuditLog {
  id: bigint;
  withdrawalId: bigint;
  action: 'REQUESTED' | 'APPROVED' | 'REJECTED' | 'COMPLETED' | 'FAILED';
  performedBy: bigint;        // userId ou adminId
  performedByType: 'USER' | 'ADMIN' | 'SYSTEM';
  ipAddress: string;
  userAgent: string;
  metadata: {
    amountCents: bigint;
    previousStatus?: string;
    newStatus: string;
    reason?: string;
    riskScore?: number;
  };
  createdAt: Date;
}

Troubleshooting

Saque Travado

typescript
// Verificar status da transação
const withdrawal = await withdrawalRepository.findById(id);
console.log('Status:', withdrawal.status);

// Verificar transação de reserva
const reservation = await transactionRepository.findByReference(
  id,
  'WITHDRAWAL',
);
console.log('Transação:', reservation);

// Verificar locks ativos
const lockExists = await redis.exists(`withdrawal:${id}:process`);
console.log('Lock ativo:', lockExists);

Saldo Não Devolvido

typescript
// Verificar se reversão foi executada
const transactions = await transactionRepository.findByReference(
  withdrawalId,
  'WITHDRAWAL',
);

const hasReversal = transactions.some(
  t => t.type === TransactionType.WITHDRAWAL_REVERSAL
);

if (!hasReversal) {
  // Executar reversão manual
  await transactionService.reverseTransaction(prisma, {
    originalReferenceId: withdrawalId,
    originalReferenceType: 'WITHDRAWAL',
    reason: 'Reversão manual - saque rejeitado',
  });
}

Documentação Técnica CSGOFlip