Reconciliação Financeira
O sistema de reconciliação garante a integridade dos dados financeiros, detectando e corrigindo inconsistências entre diferentes fontes de dados.
Visão Geral
Tipos de Reconciliação
1. Reconciliação de Saldo
Compara o saldo cacheado (user.balanceCents) com o saldo real calculado das transações.
typescript
// balance-reconciliation.service.ts
async reconcileUserBalance(userId: bigint): Promise<ReconciliationResult> {
// 1. Buscar saldo do cache
const user = await this.userRepository.findById(userId);
const cachedBalance = user.balanceCents;
// 2. Calcular saldo real das transações (Source of Truth)
const realBalance = await this.transactionRepository.getUserBalance(userId);
// 3. Comparar
const difference = realBalance - cachedBalance;
if (difference !== 0n) {
return {
status: 'DISCREPANCY_FOUND',
userId,
cachedBalance,
realBalance,
difference,
action: 'UPDATE_CACHE',
};
}
return {
status: 'OK',
userId,
cachedBalance,
realBalance,
difference: 0n,
};
}
async reconcileAllUsers(): Promise<BatchReconciliationResult> {
const results: ReconciliationResult[] = [];
const discrepancies: ReconciliationResult[] = [];
// Processar em batches para não sobrecarregar
const batchSize = 100;
let offset = 0;
while (true) {
const users = await this.userRepository.findMany({
skip: offset,
take: batchSize,
select: { id: true },
});
if (users.length === 0) break;
// Processar batch em paralelo
const batchResults = await Promise.all(
users.map((user) => this.reconcileUserBalance(user.id)),
);
results.push(...batchResults);
discrepancies.push(
...batchResults.filter((r) => r.status === 'DISCREPANCY_FOUND'),
);
offset += batchSize;
}
return {
totalUsers: results.length,
discrepanciesFound: discrepancies.length,
discrepancies,
executedAt: new Date(),
};
}2. Reconciliação de Double-Entry
Verifica se todas as transações seguem o padrão de partida dobrada (débito = crédito).
typescript
// double-entry-reconciliation.service.ts
async verifyDoubleEntry(): Promise<DoubleEntryResult> {
// 1. Verificar se todo débito tem crédito correspondente
const orphanDebits = await this.prisma.$queryRaw<Transaction[]>`
SELECT t1.*
FROM "Transaction" t1
WHERE t1.type IN ('DEBIT', 'WITHDRAWAL_PENDING')
AND NOT EXISTS (
SELECT 1 FROM "Transaction" t2
WHERE t2."referenceId" = t1."referenceId"
AND t2."referenceType" = t1."referenceType"
AND t2.type IN ('CREDIT', 'WITHDRAWAL_CREDIT')
AND t2."amountCents" = t1."amountCents"
)
`;
// 2. Verificar soma total (deve ser zero)
const totalSum = await this.prisma.transaction.aggregate({
_sum: {
amountCents: true,
},
});
// Em double-entry, soma de todos os movimentos deve ser 0
// (débitos são negativos, créditos são positivos)
const isBalanced = totalSum._sum.amountCents === 0n;
return {
status: isBalanced && orphanDebits.length === 0 ? 'OK' : 'ERROR',
isBalanced,
totalSum: totalSum._sum.amountCents,
orphanTransactions: orphanDebits.length,
orphanDetails: orphanDebits,
};
}3. Reconciliação com Provedores de Pagamento
Compara transações internas com dados dos provedores externos.
typescript
// payment-reconciliation.service.ts
async reconcileWithProvider(
startDate: Date,
endDate: Date,
): Promise<PaymentReconciliationResult> {
// 1. Buscar depósitos internos no período
const internalDeposits = await this.depositRepository.findByPeriod(
startDate,
endDate,
);
// 2. Buscar transações do provedor
const providerTransactions = await this.paymentProvider.getTransactions(
startDate,
endDate,
);
const discrepancies: PaymentDiscrepancy[] = [];
// 3. Verificar cada depósito interno
for (const deposit of internalDeposits) {
const providerTx = providerTransactions.find(
(tx) => tx.id === deposit.externalTransactionId,
);
if (!providerTx) {
discrepancies.push({
type: 'MISSING_IN_PROVIDER',
depositId: deposit.id,
internalStatus: deposit.status,
amountCents: deposit.amountCents,
});
continue;
}
// Verificar valor
if (providerTx.amountCents !== deposit.amountCents) {
discrepancies.push({
type: 'AMOUNT_MISMATCH',
depositId: deposit.id,
internalAmount: deposit.amountCents,
providerAmount: providerTx.amountCents,
});
}
// Verificar status
if (this.mapProviderStatus(providerTx.status) !== deposit.status) {
discrepancies.push({
type: 'STATUS_MISMATCH',
depositId: deposit.id,
internalStatus: deposit.status,
providerStatus: providerTx.status,
});
}
}
// 4. Verificar transações do provedor não encontradas internamente
for (const providerTx of providerTransactions) {
const internalDeposit = internalDeposits.find(
(d) => d.externalTransactionId === providerTx.id,
);
if (!internalDeposit && providerTx.status === 'COMPLETED') {
discrepancies.push({
type: 'MISSING_INTERNALLY',
externalId: providerTx.id,
providerStatus: providerTx.status,
amountCents: providerTx.amountCents,
});
}
}
return {
period: { startDate, endDate },
internalCount: internalDeposits.length,
providerCount: providerTransactions.length,
discrepancies,
matchedCount: internalDeposits.length - discrepancies.length,
};
}Auto-Correção
Correção de Cache de Saldo
typescript
// auto-fix.service.ts
async fixBalanceCache(userId: bigint): Promise<FixResult> {
const lockKey = `reconciliation:fix:${userId}`;
return await this.redlock.using([lockKey], 30000, async () => {
// 1. Calcular saldo real
const realBalance = await this.transactionRepository.getUserBalance(userId);
// 2. Atualizar cache
await this.prisma.user.update({
where: { id: userId },
data: { balanceCents: realBalance },
});
// 3. Notificar via WebSocket
this.webSocketGateway.emitBalanceUpdate(userId, realBalance);
// 4. Registrar correção
await this.auditService.log({
action: 'BALANCE_CACHE_FIXED',
resourceId: userId,
resourceType: 'USER',
metadata: {
previousCache: await this.getPreviousCacheValue(userId),
newValue: realBalance,
fixedBy: 'RECONCILIATION_SYSTEM',
},
});
return {
status: 'FIXED',
userId,
newBalance: realBalance,
};
});
}
async fixAllDiscrepancies(
discrepancies: ReconciliationResult[],
): Promise<BatchFixResult> {
const fixes: FixResult[] = [];
const errors: FixError[] = [];
for (const discrepancy of discrepancies) {
try {
const fix = await this.fixBalanceCache(discrepancy.userId);
fixes.push(fix);
} catch (error) {
errors.push({
userId: discrepancy.userId,
error: error.message,
});
}
}
return {
totalProcessed: discrepancies.length,
fixed: fixes.length,
errors: errors.length,
errorDetails: errors,
};
}Agendamento
Cron Jobs
typescript
// reconciliation.scheduler.ts
@Injectable()
export class ReconciliationScheduler {
constructor(
private reconciliationService: ReconciliationService,
private alertService: AlertService,
) {}
// Reconciliação de saldo a cada hora
@Cron('0 * * * *')
async hourlyBalanceReconciliation() {
const result = await this.reconciliationService.reconcileAllUsers();
if (result.discrepanciesFound > 0) {
// Corrigir automaticamente
await this.reconciliationService.fixAllDiscrepancies(result.discrepancies);
// Alertar se muitas discrepâncias
if (result.discrepanciesFound > 10) {
await this.alertService.sendAlert({
level: 'WARNING',
title: 'Múltiplas discrepâncias de saldo detectadas',
message: `${result.discrepanciesFound} usuários com saldo inconsistente`,
metadata: result,
});
}
}
}
// Reconciliação double-entry diária
@Cron('0 3 * * *')
async dailyDoubleEntryCheck() {
const result = await this.reconciliationService.verifyDoubleEntry();
if (result.status === 'ERROR') {
await this.alertService.sendCriticalAlert({
title: 'CRÍTICO: Falha na verificação Double-Entry',
message: 'Sistema de transações pode estar corrompido',
metadata: result,
});
}
}
// Reconciliação com provedor de pagamento diária
@Cron('0 4 * * *')
async dailyPaymentReconciliation() {
const yesterday = subDays(new Date(), 1);
const today = new Date();
const result = await this.reconciliationService.reconcileWithProvider(
startOfDay(yesterday),
endOfDay(yesterday),
);
if (result.discrepancies.length > 0) {
await this.alertService.sendAlert({
level: 'WARNING',
title: 'Discrepâncias com provedor de pagamento',
message: `${result.discrepancies.length} transações inconsistentes`,
metadata: result,
});
}
}
}Relatórios
Dashboard de Reconciliação
typescript
// reconciliation-report.service.ts
async generateDailyReport(): Promise<DailyReconciliationReport> {
const today = new Date();
// 1. Estatísticas de saldo
const balanceStats = await this.getBalanceStats();
// 2. Estatísticas de transações
const transactionStats = await this.getTransactionStats(today);
// 3. Discrepâncias encontradas
const discrepancies = await this.getDiscrepanciesHistory(today);
// 4. Correções aplicadas
const fixes = await this.getFixesHistory(today);
return {
date: today,
summary: {
totalUsersChecked: balanceStats.totalUsers,
discrepanciesFound: discrepancies.total,
discrepanciesFixed: fixes.total,
pendingIssues: discrepancies.total - fixes.total,
},
balanceStats: {
totalSystemBalance: balanceStats.totalBalance,
totalCachedBalance: balanceStats.totalCached,
difference: balanceStats.difference,
},
transactionStats: {
totalTransactions: transactionStats.count,
totalVolume: transactionStats.volume,
averageTransaction: transactionStats.average,
},
discrepancies: discrepancies.details,
fixes: fixes.details,
health: this.calculateSystemHealth(balanceStats, discrepancies),
};
}
calculateSystemHealth(
balanceStats: BalanceStats,
discrepancies: DiscrepancyStats,
): SystemHealth {
let score = 100;
const issues: string[] = [];
// Penalizar por discrepâncias
if (discrepancies.total > 0) {
score -= Math.min(discrepancies.total * 2, 30);
issues.push(`${discrepancies.total} discrepâncias de saldo`);
}
// Penalizar por diferença de cache
if (balanceStats.difference !== 0n) {
score -= 20;
issues.push('Cache de saldo global inconsistente');
}
// Penalizar por transações órfãs
if (discrepancies.orphanTransactions > 0) {
score -= 30;
issues.push(`${discrepancies.orphanTransactions} transações órfãs`);
}
return {
score,
status: score >= 90 ? 'HEALTHY' : score >= 70 ? 'WARNING' : 'CRITICAL',
issues,
};
}Exportação de Relatórios
typescript
// report-export.service.ts
async exportReconciliationReport(
startDate: Date,
endDate: Date,
format: 'CSV' | 'JSON' | 'PDF',
): Promise<ExportResult> {
const data = await this.generatePeriodReport(startDate, endDate);
switch (format) {
case 'CSV':
return this.exportToCsv(data);
case 'JSON':
return this.exportToJson(data);
case 'PDF':
return this.exportToPdf(data);
}
}Alertas e Notificações
Níveis de Alerta
typescript
enum AlertLevel {
INFO = 'INFO', // Informativo
WARNING = 'WARNING', // Atenção necessária
ERROR = 'ERROR', // Erro que precisa correção
CRITICAL = 'CRITICAL', // Crítico - ação imediata
}
interface ReconciliationAlert {
level: AlertLevel;
code: string;
title: string;
message: string;
metadata: Record<string, any>;
timestamp: Date;
acknowledged: boolean;
acknowledgedBy?: bigint;
acknowledgedAt?: Date;
}Regras de Alerta
typescript
// alert-rules.config.ts
export const RECONCILIATION_ALERT_RULES = {
// Discrepâncias de saldo
BALANCE_DISCREPANCY: {
threshold: 1, // 1 usuário
level: AlertLevel.INFO,
},
BALANCE_DISCREPANCY_MULTIPLE: {
threshold: 10, // 10+ usuários
level: AlertLevel.WARNING,
},
BALANCE_DISCREPANCY_MASS: {
threshold: 100, // 100+ usuários
level: AlertLevel.CRITICAL,
},
// Double-entry
DOUBLE_ENTRY_ORPHAN: {
threshold: 1,
level: AlertLevel.ERROR,
},
DOUBLE_ENTRY_UNBALANCED: {
threshold: 1,
level: AlertLevel.CRITICAL,
},
// Provedores de pagamento
PAYMENT_DISCREPANCY: {
threshold: 1,
level: AlertLevel.WARNING,
},
PAYMENT_MISSING_INTERNAL: {
threshold: 1, // Pagamento confirmado mas não creditado
level: AlertLevel.CRITICAL,
},
};Endpoints da API
Executar Reconciliação Manual
http
POST /api/admin/reconciliation/run
Authorization: Bearer {adminSessionId}
Content-Type: application/json
{
"type": "BALANCE",
"scope": "ALL",
"autoFix": true
}Consultar Resultados
http
GET /api/admin/reconciliation/results?date=2024-01-15Exportar Relatório
http
GET /api/admin/reconciliation/export?startDate=2024-01-01&endDate=2024-01-31&format=PDFTroubleshooting
Discrepância Persistente
typescript
// Debug de discrepância
async debugDiscrepancy(userId: bigint) {
// 1. Buscar todas as transações do usuário
const transactions = await this.transactionRepository.findByUser(userId);
// 2. Calcular saldo passo a passo
let runningBalance = 0n;
for (const tx of transactions) {
if (tx.debitUserId === userId) {
runningBalance -= tx.amountCents;
}
if (tx.creditUserId === userId) {
runningBalance += tx.amountCents;
}
console.log(`Tx ${tx.id}: ${tx.type} | Balance: ${runningBalance}`);
}
// 3. Comparar com cache
const user = await this.userRepository.findById(userId);
console.log(`Cache: ${user.balanceCents}`);
console.log(`Calculado: ${runningBalance}`);
console.log(`Diferença: ${runningBalance - user.balanceCents}`);
}Transação Órfã
typescript
// Encontrar e corrigir transação órfã
async fixOrphanTransaction(transactionId: bigint) {
const tx = await this.transactionRepository.findById(transactionId);
if (!tx) {
throw new Error('Transação não encontrada');
}
// Verificar se é realmente órfã
const pair = await this.transactionRepository.findPair(
tx.referenceId,
tx.referenceType,
);
if (pair.length === 2) {
console.log('Transação tem par - não é órfã');
return;
}
// Decidir ação baseada no tipo
if (tx.type === TransactionType.CREDIT) {
// Crédito sem débito - potencial problema
console.log('ALERTA: Crédito sem débito correspondente');
// Notificar admin para análise manual
}
if (tx.type === TransactionType.DEBIT) {
// Débito sem crédito - reverter
console.log('Revertendo débito órfão');
await this.transactionService.createTransaction({
creditUserId: tx.debitUserId,
debitUserId: HOUSE_USER_ID,
amountCents: tx.amountCents,
type: TransactionType.REVERSAL,
referenceId: tx.id,
referenceType: 'ORPHAN_FIX',
});
}
}Melhores Práticas
- Executar reconciliação regularmente - Mínimo a cada hora para saldos
- Manter logs detalhados - Toda correção deve ser auditada
- Alertar proativamente - Detectar problemas antes do usuário
- Nunca ignorar discrepâncias - Toda inconsistência tem uma causa
- Validar correções - Após corrigir, verificar se realmente foi resolvido
- Manter histórico - Guardar histórico de reconciliações para análise de padrões
