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 lockSincronizaçã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 saldosrc/infrastructure/database/repositories/transaction.repository.ts- Query de saldosrc/infrastructure/locks/lock.service.ts- Distributed lockssrc/infrastructure/websocket/websocket.gateway.ts- Sync WebSocket
