Skip to content

Source of Truth (Fonte da Verdade)

CONCEITO CRÍTICO

Esta é provavelmente a página mais importante desta documentação. Entender a Source of Truth do saldo é obrigatório para qualquer desenvolvedor do projeto.

O Problema

Em sistemas financeiros, é comum ter o saldo do usuário armazenado em dois lugares:

  1. Coluna na tabela User (user.balanceCents)
  2. Soma das transações (SUM(transactions.amount))

Qual é o saldo "real"? Se os dois divergirem, qual está certo?

A Regra de Ouro

Regra Absoluta

O saldo REAL do usuário é SEMPRE a soma das transações.

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

typescript
// ✅ CORRETO - Sempre use isso para saldo real
const balance = await this.transactionRepository.getUserBalance(userId);

// ❌ ERRADO - NUNCA use para operações financeiras
const user = await this.userRepository.findById(userId);
const balance = user.balanceCents; // PODE ESTAR DESATUALIZADO!

Por que Transações são a Source of Truth?

1. Integridade Garantida

Com double-entry bookkeeping, toda transação tem um par. A soma é matematicamente correta:

sql
-- Saldo = soma de todos os créditos - soma de todos os débitos
SELECT COALESCE(SUM(
  CASE 
    WHEN type = 'CREDIT' THEN amount_cents 
    WHEN type = 'DEBIT' THEN -amount_cents 
  END
), 0) as balance
FROM transactions
WHERE user_id = $1 AND status = 'COMPLETED';

2. Auditoria Completa

Toda movimentação está registrada:

typescript
// Podemos reconstruir o histórico completo
const history = await this.transactionRepository.getUserTransactions(userId);

// Resultado:
// [
//   { type: 'CREDIT', amount: 10000, reason: 'DEPOSIT', date: '2024-01-01' },
//   { type: 'DEBIT', amount: 2500, reason: 'CASE_OPENING', date: '2024-01-01' },
//   { type: 'CREDIT', amount: 5000, reason: 'CASE_WIN', date: '2024-01-01' },
//   { type: 'DEBIT', amount: 1000, reason: 'BATTLE_ENTRY', date: '2024-01-02' },
//   ...
// ]

3. Impossível Criar Dinheiro do Nada

Com transações como fonte, você não pode simplesmente "editar o saldo":

typescript
// ❌ IMPOSSÍVEL criar dinheiro sem transação
await this.prisma.user.update({
  where: { id: userId },
  data: { balanceCents: 1000000 }, // Isso NÃO funciona corretamente!
});

// O saldo real continua sendo a soma das transações
const realBalance = await this.transactionService.getUserBalance(userId);
// realBalance ainda é o valor correto, não 1000000

4. Race Conditions Detectáveis

Se duas operações tentarem debitar simultaneamente:

typescript
// Com cache (user.balanceCents) - PROBLEMÁTICO
// T1: Lê saldo = 100
// T2: Lê saldo = 100
// T1: Verifica 100 >= 80 ✓, debita 80
// T2: Verifica 100 >= 80 ✓, debita 80
// Resultado: -60 (BUG!)

// Com transações + lock - SEGURO
// T1: Adquire lock, lê soma = 100
// T2: Espera lock...
// T1: Verifica 100 >= 80 ✓, cria transação -80, libera lock
// T2: Adquire lock, lê soma = 20
// T2: Verifica 20 >= 80 ✗, erro: saldo insuficiente
// Resultado: 20 (CORRETO!)

Implementação

TransactionRepository

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

@Injectable()
export class TransactionRepository implements ITransactionRepository {
  constructor(private readonly prisma: PrismaService) {}

  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;
  }
}

TransactionService

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

@Injectable()
export class TransactionService {
  constructor(
    @Inject(TRANSACTION_REPOSITORY)
    private readonly transactionRepository: ITransactionRepository,
    private readonly lockService: LockService,
    private readonly prisma: PrismaService,
  ) {}

  async getUserBalance(userId: bigint): Promise<bigint> {
    return this.transactionRepository.getUserBalance(userId);
  }

  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 (das transações)
      const balance = await this.getUserBalance(userId);

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

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

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

        return debit;
      });
    } finally {
      // 4. SEMPRE libera o lock
      await lock.release();
    }
  }

  async credit(
    userId: bigint,
    amount: bigint,
    reason: TransactionReason,
  ): Promise<Transaction> {
    return await this.prisma.$transaction(async (tx) => {
      // Cria crédito
      const credit = await tx.transaction.create({
        data: {
          userId,
          type: 'CREDIT',
          amountCents: amount,
          reason,
          status: 'COMPLETED',
        },
      });

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

      return credit;
    });
  }
}

Quando Usar Cada Um

Use TransactionService.getUserBalance() para:

  • ✅ Verificar saldo antes de débito
  • ✅ Exibir saldo no frontend (via WebSocket/API)
  • ✅ Validações em use cases
  • ✅ Relatórios financeiros
  • ✅ Qualquer operação que envolva dinheiro

Use user.balanceCents (cache) APENAS para:

  • ⚠️ Leaderboards e rankings (onde performance é crítica)
  • ⚠️ Queries de listagem (onde precisão não é crítica)
  • ⚠️ Filtros aproximados

Atenção

Mesmo para leaderboards, considere usar o saldo real se a precisão for importante. O cache pode ter delay de alguns milissegundos.


Exemplo Real: OpenCaseUseCase

❌ ANTES (Errado)

typescript
async execute(userId: bigint, caseId: bigint) {
  // Busca usuário
  const user = await this.userRepository.findById(userId);
  
  // Verifica saldo (ERRADO - usando cache!)
  if (user.balanceCents < caseData.priceCents) {
    throw new InsufficientBalanceError();
  }
  
  // Abre a caixa...
  const result = await this.openCase(userId, caseId);
  
  // Atualiza saldo (ERRADO - manipulação direta do cache!)
  await this.userRepository.update(userId, {
    balanceCents: user.balanceCents - caseData.priceCents + result.item.valueCents,
  });
  
  // Emite para WebSocket (ERRADO - usando cache desatualizado!)
  this.websocket.emitBalanceUpdate(userId, user.balanceCents);
  
  return result;
}

Problemas:

  1. Usa user.balanceCents para verificar saldo
  2. Manipula cache diretamente
  3. Emite saldo desatualizado para WebSocket
  4. Não usa lock (race condition possível)

✅ DEPOIS (Correto)

typescript
async execute(userId: bigint, caseId: bigint) {
  // Busca caixa
  const caseData = await this.caseRepository.findById(caseId);
  
  // Verifica saldo REAL (das transações)
  const balance = await this.transactionService.getUserBalance(userId);
  
  if (balance < caseData.priceCents) {
    throw new InsufficientBalanceError(balance, caseData.priceCents);
  }
  
  // Debita usando TransactionService (com lock)
  await this.transactionService.debit(
    userId,
    caseData.priceCents,
    TransactionReason.CASE_OPENING,
  );
  
  // Abre a caixa (Provably Fair)
  const result = await this.performCaseOpening(userId, caseId);
  
  // Credita o item ganho
  await this.transactionService.credit(
    userId,
    result.item.valueCents,
    TransactionReason.CASE_WIN,
  );
  
  // Busca saldo REAL atualizado para WebSocket
  const newBalance = await this.transactionService.getUserBalance(userId);
  this.websocket.emitBalanceUpdate(userId, newBalance);
  
  return result;
}

Melhorias:

  1. Usa transactionService.getUserBalance() - saldo real
  2. Usa transactionService.debit() - com lock automático
  3. Cria transações para débito e crédito
  4. Emite saldo real para WebSocket

Sincronização do Cache

O cache (user.balanceCents) é atualizado automaticamente pelo TransactionService:

typescript
// Após cada transação, atualiza o cache
const newBalance = await this.transactionRepository.getUserBalance(userId);
await tx.user.update({
  where: { id: userId },
  data: { balanceCents: newBalance },
});

E se o cache ficar dessincronizado?

Em caso de bug ou falha, pode haver divergência. Para corrigir:

typescript
// Script de reconciliação
async reconcileUserBalance(userId: bigint) {
  const realBalance = await this.transactionRepository.getUserBalance(userId);
  const user = await this.userRepository.findById(userId);

  if (user.balanceCents !== realBalance) {
    console.warn(`Divergência detectada para user ${userId}:`);
    console.warn(`  Cache: ${user.balanceCents}`);
    console.warn(`  Real: ${realBalance}`);

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

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

WebSocket e Source of Truth

O frontend recebe atualizações de saldo via WebSocket:

typescript
// Backend - Após qualquer transação
const realBalance = await this.transactionService.getUserBalance(userId);
this.websocketGateway.emitBalanceUpdate(userId, realBalance);

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

Consistência

O frontend sempre recebe o saldo calculado das transações, nunca o cache.


Debugging

Verificar Divergência

sql
-- Query para encontrar usuários com saldo divergente
SELECT 
  u.id,
  u.username,
  u.balance_cents as cache,
  COALESCE(SUM(
    CASE 
      WHEN t.type = 'CREDIT' THEN t.amount_cents 
      WHEN t.type = 'DEBIT' THEN t.amount_cents -- já é negativo
    END
  ), 0) as real_balance
FROM users u
LEFT JOIN transactions t ON t.user_id = u.id AND t.status = 'COMPLETED'
GROUP BY u.id
HAVING u.balance_cents != COALESCE(SUM(
  CASE 
    WHEN t.type = 'CREDIT' THEN t.amount_cents 
    WHEN t.type = 'DEBIT' THEN t.amount_cents
  END
), 0);

Logs para Debug

typescript
// Em caso de problema, adicione logs
async getUserBalance(userId: bigint): Promise<bigint> {
  const cacheBalance = await this.userRepository.findById(userId)
    .then(u => u?.balanceCents ?? 0n);
  
  const realBalance = await this.transactionRepository.getUserBalance(userId);

  if (cacheBalance !== realBalance) {
    this.logger.warn({
      message: 'Balance divergence detected',
      userId: userId.toString(),
      cache: cacheBalance.toString(),
      real: realBalance.toString(),
      diff: (realBalance - cacheBalance).toString(),
    });
  }

  return realBalance; // SEMPRE retorna o real
}

Resumo

Aspectouser.balanceCentstransactionRepository.getUserBalance()
É a verdade?❌ Não, é cache✅ Sim, é a fonte
Usar para débito?❌ NUNCA✅ SEMPRE
Usar para crédito?❌ NUNCA✅ SEMPRE
Usar para display?⚠️ Com cuidado✅ SEMPRE
Pode divergir?⚠️ Sim, temporariamente❌ Não, é calculado
Auditável?❌ Não✅ Sim

Arquivos Fonte Relacionados

Principais Arquivos

  • src/application/services/transaction.service.ts - Source of Truth
  • src/infrastructure/database/repositories/transaction.repository.ts - Query de saldo
  • src/application/use-cases/case-opening/open-case.use-case.ts - Exemplo de uso correto
  • src/infrastructure/websocket/websocket.gateway.ts - Emissão de saldo

Documentação Técnica CSGOFlip