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=10Cancelar 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',
});
}