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:
- Coluna na tabela User (
user.balanceCents) - 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.
// ✅ 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:
-- 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:
// 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":
// ❌ 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 10000004. Race Conditions Detectáveis
Se duas operações tentarem debitar simultaneamente:
// 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
// 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
// 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)
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:
- Usa
user.balanceCentspara verificar saldo - Manipula cache diretamente
- Emite saldo desatualizado para WebSocket
- Não usa lock (race condition possível)
✅ DEPOIS (Correto)
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:
- Usa
transactionService.getUserBalance()- saldo real - Usa
transactionService.debit()- com lock automático - Cria transações para débito e crédito
- Emite saldo real para WebSocket
Sincronização do Cache
O cache (user.balanceCents) é atualizado automaticamente pelo TransactionService:
// 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:
// 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:
// 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
-- 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
// 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
| Aspecto | user.balanceCents | transactionRepository.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 Truthsrc/infrastructure/database/repositories/transaction.repository.ts- Query de saldosrc/application/use-cases/case-opening/open-case.use-case.ts- Exemplo de uso corretosrc/infrastructure/websocket/websocket.gateway.ts- Emissão de saldo
