Skip to content

Gestão de Saldo

O saldo do usuário é o dado mais crítico do sistema. Esta página documenta como o saldo é calculado, atualizado e sincronizado.

Source of Truth

Regra Absoluta

O saldo REAL é SEMPRE a soma das transações completadas.

A coluna user.balanceCents é apenas um CACHE para consultas rápidas.

typescript
// ✅ CORRETO - Saldo real
const balance = await transactionService.getUserBalance(userId);

// ❌ ERRADO - Cache (pode estar desatualizado)
const balance = user.balanceCents;

Cálculo do Saldo

typescript
// src/infrastructure/database/repositories/transaction.repository.ts

async getUserBalance(userId: bigint): Promise<bigint> {
  const result = await this.prisma.transaction.aggregate({
    where: {
      userId,
      status: 'COMPLETED',
    },
    _sum: {
      amountCents: true,
    },
  });

  return result._sum.amountCents ?? 0n;
}

SQL equivalente:

sql
SELECT COALESCE(SUM(amount_cents), 0) as balance
FROM transactions
WHERE user_id = $1 AND status = 'COMPLETED';

Atualização do Cache

O cache user.balanceCents é atualizado após cada transação:

typescript
// src/application/services/transaction.service.ts

async createTransaction(data: CreateTransactionData): Promise<Transaction> {
  return this.prisma.$transaction(async (tx) => {
    // 1. Cria a transação
    const transaction = await tx.transaction.create({ data });

    // 2. Calcula saldo real
    const newBalance = await this.getUserBalance(data.userId, tx);

    // 3. Atualiza cache
    await tx.user.update({
      where: { id: data.userId },
      data: { balanceCents: newBalance },
    });

    return transaction;
  });
}

Operações de Saldo

Débito (Saída de dinheiro)

typescript
async debit(
  userId: bigint,
  amount: bigint,
  reason: TransactionReason,
): Promise<Transaction> {
  // 1. Adquire lock exclusivo
  const lock = await this.lockService.acquire(`user:balance:${userId}`);

  try {
    // 2. Verifica saldo REAL
    const balance = await this.getUserBalance(userId);

    if (balance < amount) {
      throw new InsufficientBalanceError(balance, amount);
    }

    // 3. Cria transação de débito
    return await this.prisma.$transaction(async (tx) => {
      const transaction = await tx.transaction.create({
        data: {
          userId,
          type: 'DEBIT',
          amountCents: -amount, // Negativo
          reason,
          status: 'COMPLETED',
        },
      });

      // 4. Atualiza cache
      const newBalance = balance - amount;
      await tx.user.update({
        where: { id: userId },
        data: { balanceCents: newBalance },
      });

      return transaction;
    });
  } finally {
    // 5. SEMPRE libera lock
    await lock.release();
  }
}

Crédito (Entrada de dinheiro)

typescript
async credit(
  userId: bigint,
  amount: bigint,
  reason: TransactionReason,
): Promise<Transaction> {
  // Crédito não precisa de lock (não pode falhar por saldo insuficiente)
  return await this.prisma.$transaction(async (tx) => {
    const transaction = await tx.transaction.create({
      data: {
        userId,
        type: 'CREDIT',
        amountCents: amount, // Positivo
        reason,
        status: 'COMPLETED',
      },
    });

    // Atualiza cache
    const newBalance = await this.getUserBalance(userId, tx);
    await tx.user.update({
      where: { id: userId },
      data: { balanceCents: newBalance },
    });

    return transaction;
  });
}

Distributed Locks

Para evitar race conditions, usamos Redlock:

typescript
// src/infrastructure/locks/lock.service.ts

@Injectable()
export class LockService {
  private redlock: Redlock;

  constructor(private readonly redis: RedisService) {
    this.redlock = new Redlock([redis.client], {
      driftFactor: 0.01,
      retryCount: 10,
      retryDelay: 200,
      retryJitter: 200,
    });
  }

  async acquire(resource: string, ttl: number = 5000): Promise<Lock> {
    return this.redlock.acquire([resource], ttl);
  }
}

Por que Locks são Necessários?

Sem lock (PROBLEMA):
T1: Lê saldo = R$ 100
T2: Lê saldo = R$ 100
T1: Verifica R$ 100 >= R$ 80 ✓
T2: Verifica R$ 100 >= R$ 80 ✓
T1: Debita R$ 80, saldo = R$ 20
T2: Debita R$ 80, saldo = -R$ 60 ← BUG!

Com lock (CORRETO):
T1: Adquire lock
T1: Lê saldo = R$ 100
T1: Verifica R$ 100 >= R$ 80 ✓
T1: Debita R$ 80, saldo = R$ 20
T1: Libera lock
T2: Adquire lock
T2: Lê saldo = R$ 20
T2: Verifica R$ 20 >= R$ 80 ✗ ERRO
T2: Libera lock

Sincronização WebSocket

Após cada transação, o saldo é enviado via WebSocket:

typescript
// Após criar transação
const newBalance = await this.getUserBalance(userId);
this.websocketGateway.emitBalanceUpdate(userId, newBalance);
typescript
// src/infrastructure/websocket/websocket.gateway.ts

emitBalanceUpdate(userId: bigint, balance: bigint) {
  this.server
    .to(`user:${userId}`)
    .emit('balance:updated', {
      balance: Number(balance) / 100, // Converte para reais
      timestamp: Date.now(),
    });
}

Verificação de Integridade

Script de Reconciliação

typescript
async reconcileUserBalance(userId: bigint): Promise<ReconciliationResult> {
  const user = await this.userRepository.findById(userId);
  const realBalance = await this.transactionRepository.getUserBalance(userId);

  const isDivergent = user.balanceCents !== realBalance;

  if (isDivergent) {
    // Log da divergência
    this.logger.warn({
      message: 'Balance divergence detected',
      userId: userId.toString(),
      cache: user.balanceCents.toString(),
      real: realBalance.toString(),
      diff: (realBalance - user.balanceCents).toString(),
    });

    // Corrige o cache
    await this.userRepository.update(userId, {
      balanceCents: realBalance,
    });

    // Audit log
    await this.auditService.log({
      action: 'BALANCE_RECONCILIATION',
      userId,
      metadata: {
        oldCache: user.balanceCents.toString(),
        newCache: realBalance.toString(),
      },
    });
  }

  return {
    userId,
    wasDivergent: isDivergent,
    cachedBalance: user.balanceCents,
    realBalance,
  };
}

Job de Reconciliação Periódica

typescript
// Roda a cada hora
@Cron('0 * * * *')
async reconcileAllBalances() {
  const users = await this.userRepository.findAll();
  
  let divergentCount = 0;
  
  for (const user of users) {
    const result = await this.reconcileUserBalance(user.id);
    if (result.wasDivergent) {
      divergentCount++;
    }
  }

  this.logger.info(`Reconciliation complete. Divergent: ${divergentCount}/${users.length}`);
}

API de Saldo

GET /api/user/balance

typescript
@Get('balance')
async getBalance(@CurrentUser() user: User) {
  // SEMPRE retorna saldo real, não cache
  const balance = await this.transactionService.getUserBalance(user.id);
  
  return {
    balanceCents: balance.toString(),
    balanceFormatted: this.moneyService.formatBRL(balance),
  };
}

Response:

json
{
  "balanceCents": "12500",
  "balanceFormatted": "R$ 125,00"
}

Formatação de Valores

typescript
// src/application/services/money.service.ts

@Injectable()
export class MoneyService {
  toCents(reais: number): bigint {
    return BigInt(Math.round(reais * 100));
  }

  toReais(cents: bigint): number {
    return Number(cents) / 100;
  }

  formatBRL(cents: bigint): string {
    const reais = this.toReais(cents);
    return new Intl.NumberFormat('pt-BR', {
      style: 'currency',
      currency: 'BRL',
    }).format(reais);
  }
}

Alertas de Saldo

typescript
// Notifica quando saldo fica baixo
async checkLowBalance(userId: bigint) {
  const balance = await this.getUserBalance(userId);
  
  if (balance < 1000n) { // < R$ 10
    await this.notificationService.send(userId, {
      type: 'LOW_BALANCE',
      title: 'Saldo baixo',
      message: 'Seu saldo está abaixo de R$ 10. Faça um depósito para continuar jogando!',
    });
  }
}

Arquivos Fonte

Principais Arquivos

  • src/application/services/transaction.service.ts - Operações de saldo
  • src/infrastructure/database/repositories/transaction.repository.ts - Query de saldo
  • src/infrastructure/locks/lock.service.ts - Distributed locks
  • src/infrastructure/websocket/websocket.gateway.ts - Sync WebSocket

Documentação Técnica CSGOFlip