Sistema de Transacoes (Double-Entry Bookkeeping)
Documentacao completa do sistema de transacoes financeiras com double-entry bookkeeping. Este sistema e a fonte unica de verdade (Source of Truth) para todos os saldos do sistema. Todos os valores monetarios sao armazenados e trafegados em centavos (BIGINT).
Arquivos Fonte
| Arquivo | Responsabilidade |
|---|---|
src/application/services/transaction.service.ts | Servico principal de transacoes |
src/infrastructure/database/repositories/transaction.repository.ts | Repository Prisma |
src/domain/repositories/transaction.repository.interface.ts | Interface do repository |
src/infrastructure/locks/lock.service.ts | Distributed locks (Redlock) |
src/infrastructure/database/prisma.service.ts | Cliente Prisma |
src/infrastructure/snowflake/snowflake.service.ts | Gerador de IDs Snowflake |
src/presentation/controllers/payment.controller.ts | Controller HTTP |
src/application/dto/payment.dto.ts | DTOs de response |
Modelo Prisma (Transaction)
Definido em prisma/schema.prisma, linhas 141-161:
model Transaction {
id BigInt @id
type TransactionType
amountCents BigInt @map("amount_cents")
userId BigInt @map("user_id")
relatedUserId BigInt? @map("related_user_id")
relatedTransactionId BigInt? @unique @map("related_transaction_id")
reason String
metadata Json?
status TransactionStatus @default(COMPLETED)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
relatedTransaction Transaction? @relation("TransactionPair", fields: [relatedTransactionId], references: [id])
oppositeTransaction Transaction? @relation("TransactionPair")
@@map("transactions")
@@index([userId, createdAt])
@@index([type, createdAt])
@@index([status])
}Enums Relacionados
enum TransactionType {
DEBIT
CREDIT
}
enum TransactionStatus {
PENDING
COMPLETED
FAILED
CANCELLED
}Campos Detalhados
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID gerado pelo SnowflakeService |
type | TransactionType | DEBIT (saida) ou CREDIT (entrada) |
amountCents | BigInt | Valor em centavos (SEMPRE positivo, o tipo define a direcao) |
userId | BigInt | Usuario dono desta transacao |
relatedUserId | BigInt? | Contraparte da transacao (outro usuario ou SYSTEM) |
relatedTransactionId | BigInt? | ID da transacao par (ligacao bidirecional, constraint UNIQUE) |
reason | String | Descricao textual da operacao |
metadata | Json? | Dados adicionais (depositId, method, etc) |
status | TransactionStatus | Estado da transacao (default: COMPLETED) |
createdAt | DateTime | Timestamp de criacao |
Indices
@@index([userId, createdAt])- Historico de transacoes por usuario@@index([type, createdAt])- Filtro por tipo com ordenacao@@index([status])- Filtro por status
Constraint Importante
relatedTransactionId tem @unique, garantindo que cada transacao so pode ser ligada a UMA outra transacao par. Isso impede inconsistencias no double-entry.
Relacao Bidirecional (TransactionPair)
A relacao TransactionPair cria um vinculo bidirecional entre duas transacoes:
relatedTransaction Transaction? @relation("TransactionPair", fields: [relatedTransactionId], references: [id])
oppositeTransaction Transaction? @relation("TransactionPair")relatedTransaction: a transacao par apontada por esta transacaooppositeTransaction: a transacao que aponta para esta transacao
Interface do Repository
Definida em src/domain/repositories/transaction.repository.interface.ts:
import { Transaction, TransactionType, TransactionStatus } from '@prisma/client';
export interface ITransactionRepository {
create(data: CreateTransactionData): Promise<Transaction>;
findById(id: bigint): Promise<Transaction | null>;
findByUserId(
userId: bigint,
filters?: TransactionFilters,
): Promise<{ transactions: Transaction[]; total: number }>;
getUserBalance(userId: bigint): Promise<bigint>;
}
export interface CreateTransactionData {
type: TransactionType;
amountCents: bigint;
userId: bigint;
relatedUserId?: bigint;
relatedTransactionId?: bigint;
reason: string;
metadata?: any;
status?: TransactionStatus;
}
export interface TransactionFilters {
type?: TransactionType;
status?: TransactionStatus;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
}Implementacao do Repository
Definido em src/infrastructure/database/repositories/transaction.repository.ts:
@Injectable()
export class TransactionRepository implements ITransactionRepository {
constructor(
private prisma: PrismaService,
private snowflake: SnowflakeService,
) {}
async create(data: CreateTransactionData): Promise<Transaction> {
return this.prisma.transaction.create({
data: {
id: this.snowflake.generate(),
type: data.type,
amountCents: data.amountCents,
userId: data.userId,
relatedUserId: data.relatedUserId,
relatedTransactionId: data.relatedTransactionId,
reason: data.reason,
metadata: data.metadata,
status: data.status,
},
});
}
async findById(id: bigint): Promise<Transaction | null> {
return this.prisma.transaction.findUnique({
where: { id },
});
}
async findByUserId(
userId: bigint,
filters?: TransactionFilters,
): Promise<{ transactions: Transaction[]; total: number }> {
const where: any = { userId };
if (filters?.type) {
where.type = filters.type;
}
if (filters?.status) {
where.status = filters.status;
}
if (filters?.startDate || filters?.endDate) {
where.createdAt = {};
if (filters.startDate) {
where.createdAt.gte = filters.startDate;
}
if (filters.endDate) {
where.createdAt.lte = filters.endDate;
}
}
const [transactions, total] = await Promise.all([
this.prisma.transaction.findMany({
where,
orderBy: { createdAt: 'desc' },
take: filters?.limit || 50,
skip: filters?.offset || 0,
}),
this.prisma.transaction.count({ where }),
]);
return { transactions, total };
}
async getUserBalance(userId: bigint): Promise<bigint> {
const result = await this.prisma.transaction.groupBy({
by: ['type'],
where: {
userId,
status: 'COMPLETED',
},
_sum: {
amountCents: true,
},
});
let balance = BigInt(0);
for (const item of result) {
if (item.type === 'CREDIT') {
balance += BigInt(item._sum.amountCents || 0);
} else if (item.type === 'DEBIT') {
balance -= BigInt(item._sum.amountCents || 0);
}
}
return balance;
}
}getUserBalance - Calculo Detalhado
O calculo do saldo e feito agregando TODAS as transacoes do usuario com status COMPLETED:
-- Query equivalente gerada pelo Prisma
SELECT type, SUM(amount_cents) as _sum_amount_cents
FROM transactions
WHERE user_id = {userId} AND status = 'COMPLETED'
GROUP BY type;A logica em TypeScript:
Balance = SUM(todas as transacoes CREDIT) - SUM(todas as transacoes DEBIT)
WHERE status = 'COMPLETED' AND userId = usuario alvoExemplo:
- CREDIT: 10000 (deposito) + 5000 (venda item) = 15000
- DEBIT: 2500 (abertura caixa) + 1000 (upgrade) = 3500
- Balance = 15000 - 3500 = 11500 centavos (R$115,00)
Filtros de Consulta
O findByUserId suporta filtros opcionais:
| Filtro | Tipo | Descricao |
|---|---|---|
type | TransactionType | Filtrar por DEBIT ou CREDIT |
status | TransactionStatus | Filtrar por status |
startDate | Date | Data inicial (>= createdAt) |
endDate | Date | Data final (<= createdAt) |
limit | number | Quantidade por pagina (default: 50) |
offset | number | Deslocamento (default: 0) |
TransactionService
Servico principal definido em src/application/services/transaction.service.ts.
SYSTEM_USER_ID
private readonly SYSTEM_USER_ID = BigInt(0);O SYSTEM_USER_ID (BigInt(0)) e a contraparte para TODAS as operacoes onde o "outro lado" e a casa/sistema. Ele funciona como a conta da "casa" no double-entry bookkeeping.
Quando um usuario recebe credito (deposito, venda de item, etc), o SYSTEM e debitado. Quando um usuario e debitado (abertura de caixa, upgrade, etc), o SYSTEM e creditado.
O saldo do SYSTEM nao e atualizado no cache (user.balanceCents) pois nao e um usuario real:
if (debitUserId !== this.SYSTEM_USER_ID) {
// Atualiza cache apenas para usuarios reais
}Dependencias Injetadas
@Injectable()
export class TransactionService {
private readonly SYSTEM_USER_ID = BigInt(0);
constructor(
private transactionRepository: TransactionRepository,
private userRepository: UserRepository,
private lockService: LockService,
private prisma: PrismaService,
private snowflake: SnowflakeService,
) {}
}Metodo: createDoubleEntry
Metodo central que cria um par de transacoes (DEBIT + CREDIT) de forma atomica.
async createDoubleEntry(
debitUserId: bigint,
creditUserId: bigint,
amountCents: bigint,
reason: string,
metadata?: any,
externalTx?: PrismaTransactionClient,
): Promise<{ debit: any; credit: any }>Parametros:
| Parametro | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
debitUserId | bigint | Sim | Usuario que sera debitado (dinheiro sai) |
creditUserId | bigint | Sim | Usuario que sera creditado (dinheiro entra) |
amountCents | bigint | Sim | Valor em centavos (deve ser > 0) |
reason | string | Sim | Descricao da operacao |
metadata | any | Nao | Dados adicionais (JSON) |
externalTx | PrismaTransactionClient | Nao | Transacao Prisma externa (para composicao) |
Fluxo Passo a Passo (7 etapas):
Etapa 1 - Validacao do valor:
if (amountCents <= 0) {
throw new BadRequestException('Amount must be greater than zero');
}Etapa 2 - Validacao do usuario de debito:
const debitUser = await tx.user.findUnique({ where: { id: debitUserId } });
if (!debitUser) {
throw new BadRequestException('Debit user not found');
}O usuario de debito DEVE existir no banco. Nota: o SYSTEM_USER_ID (0) tambem precisa existir como registro na tabela User.
Etapa 3 - Verificacao de saldo:
const currentBalance = await this.transactionRepository.getUserBalance(debitUserId);
if (currentBalance < amountCents) {
throw new BadRequestException('Insufficient balance');
}O saldo e verificado a partir das transacoes (Source of Truth), NAO do cache user.balanceCents. Se o saldo for insuficiente, a operacao e rejeitada imediatamente.
Etapa 4 - Criacao da transacao DEBIT:
const debitTransaction = await tx.transaction.create({
data: {
id: this.snowflake.generate(),
type: 'DEBIT',
amountCents,
userId: debitUserId,
relatedUserId: creditUserId,
reason,
metadata,
status: 'COMPLETED',
},
});O DEBIT e criado com status: 'COMPLETED' imediatamente (nao passa por PENDING).
Etapa 5 - Criacao da transacao CREDIT:
const creditTransaction = await tx.transaction.create({
data: {
id: this.snowflake.generate(),
type: 'CREDIT',
amountCents,
userId: creditUserId,
relatedUserId: debitUserId,
relatedTransactionId: debitTransaction.id,
reason,
metadata,
status: 'COMPLETED',
},
});O CREDIT ja nasce linkado ao DEBIT via relatedTransactionId: debitTransaction.id.
Etapa 6 - Ligacao bidirecional:
await tx.transaction.update({
where: { id: debitTransaction.id },
data: { relatedTransactionId: creditTransaction.id },
});Agora ambas as transacoes apontam uma para a outra:
- DEBIT.relatedTransactionId -> CREDIT.id
- CREDIT.relatedTransactionId -> DEBIT.id
Etapa 7 - Atualizacao do cache de saldo:
Para cada usuario real (nao SYSTEM), recalcula o saldo a partir das transacoes e atualiza o campo user.balanceCents:
if (debitUserId !== this.SYSTEM_USER_ID) {
const debitTransactions = await tx.transaction.groupBy({
by: ['type'],
where: { userId: debitUserId, status: 'COMPLETED' },
_sum: { amountCents: true },
});
let debitBalance = BigInt(0);
for (const item of debitTransactions) {
if (item.type === 'CREDIT') {
debitBalance += BigInt(item._sum.amountCents || 0);
} else if (item.type === 'DEBIT') {
debitBalance -= BigInt(item._sum.amountCents || 0);
}
}
await tx.user.update({
where: { id: debitUserId },
data: { balanceCents: debitBalance },
});
}
if (creditUserId !== this.SYSTEM_USER_ID) {
const creditTransactions = await tx.transaction.groupBy({
by: ['type'],
where: { userId: creditUserId, status: 'COMPLETED' },
_sum: { amountCents: true },
});
let creditBalance = BigInt(0);
for (const item of creditTransactions) {
if (item.type === 'CREDIT') {
creditBalance += BigInt(item._sum.amountCents || 0);
} else if (item.type === 'DEBIT') {
creditBalance -= BigInt(item._sum.amountCents || 0);
}
}
await tx.user.update({
where: { id: creditUserId },
data: { balanceCents: creditBalance },
});
}O cache user.balanceCents e recalculado COMPLETAMENTE a cada transacao (nao e incrementado/decrementado). Isso garante consistencia mesmo que alguma transacao anterior tenha falhado.
IMPORTANTE: O cache e pular para SYSTEM_USER_ID (BigInt(0)) pois nao existe necessidade de saldo para a conta do sistema.
Retorno:
return {
debit: debitTransaction,
credit: creditTransaction,
};Controle de Concorrencia
O createDoubleEntry usa DOIS mecanismos de protecao contra race conditions:
1. Distributed Lock (Redlock):
Quando chamado SEM externalTx:
if (externalTx) {
return executeTransaction(externalTx);
}
return this.lockService.withTransactionLock(async () => {
return this.prisma.$transaction(async (tx) => {
return executeTransaction(tx);
});
});O withTransactionLock adquire um lock distribuido via Redis (Redlock) com chave lock:transaction:create e TTL de 5000ms.
2. Database Transaction (Prisma):
prisma.$transaction() garante atomicidade no nivel do banco de dados. Se qualquer etapa falhar, todas as alteracoes sao revertidas (rollback).
Composicao com Transacao Externa:
Quando externalTx e fornecido, o metodo reutiliza a transacao Prisma externa ao inves de criar uma nova. Isso permite compor multiplas operacoes atomicamente:
// Exemplo: abertura de caixa debita E adiciona ao inventario na mesma transacao
await this.prisma.$transaction(async (tx) => {
await this.transactionService.createDoubleEntry(
userId, SYSTEM_USER_ID, casePrice, 'Case opening', undefined, tx
);
await this.inventoryRepository.addItem(userId, itemId, tx);
});Quando externalTx e fornecido, o lock distribuido NAO e adquirido (o chamador e responsavel por isso).
LockService (Redlock)
Definido em src/infrastructure/locks/lock.service.ts:
@Injectable()
export class LockService implements OnModuleInit {
private redlock: Redlock;
onModuleInit() {
const client = this.redisService.getClient();
this.redlock = new Redlock([client], {
driftFactor: 0.01,
retryCount: 10,
retryDelay: 200,
retryJitter: 200,
automaticExtensionThreshold: 500,
});
}
async withTransactionLock<T>(callback: () => Promise<T>): Promise<T> {
return this.withLock('transaction:create', callback, 5000);
}
async withUserBalanceLock<T>(userId: bigint, callback: () => Promise<T>): Promise<T> {
return this.withLock(`user:balance:${userId}`, callback, 10000);
}
async withLock<T>(resource: string, callback: () => Promise<T>, ttl: number = 5000): Promise<T> {
const lock = await this.acquireLock(resource, ttl);
try {
return await callback();
} finally {
await this.releaseLock(lock);
}
}
}Configuracoes do Redlock:
| Parametro | Valor | Descricao |
|---|---|---|
driftFactor | 0.01 | Fator de drift para compensar clock skew |
retryCount | 10 | Numero maximo de tentativas para adquirir lock |
retryDelay | 200ms | Tempo entre tentativas |
retryJitter | 200ms | Variacao aleatoria no delay |
automaticExtensionThreshold | 500ms | Extensao automatica do TTL |
Locks Disponiveis:
| Lock | Chave Redis | TTL | Uso |
|---|---|---|---|
withTransactionLock | lock:transaction:create | 5000ms | createDoubleEntry sem externalTx |
withUserBalanceLock | lock:user:balance:{userId} | 10000ms | Operacoes de saldo por usuario |
withWithdrawalLock | lock:withdrawal:item:{id} | 30000ms | Saques de itens |
Metodo: debit
Debita o saldo de um usuario. A contraparte e o SYSTEM.
async debit(
userId: bigint,
amountCents: bigint,
reason: string,
metadata?: any,
externalTx?: PrismaTransactionClient,
): Promise<any> {
return this.createDoubleEntry(
userId, // debitUserId: quem paga
this.SYSTEM_USER_ID, // creditUserId: casa recebe
amountCents,
reason,
metadata,
externalTx,
);
}Resultado no banco:
Transaction 1: { type: DEBIT, userId: {user}, relatedUserId: 0, amountCents: X }
Transaction 2: { type: CREDIT, userId: 0(SYSTEM), relatedUserId: {user}, amountCents: X }O saldo do usuario DIMINUI. O saldo do SYSTEM AUMENTA (mas nao e cacheado).
Metodo: credit
Credita saldo para um usuario. O SYSTEM e debitado.
async credit(
userId: bigint,
amountCents: bigint,
reason: string,
metadata?: any,
externalTx?: PrismaTransactionClient,
): Promise<any> {
return this.createDoubleEntry(
this.SYSTEM_USER_ID, // debitUserId: casa paga
userId, // creditUserId: usuario recebe
amountCents,
reason,
metadata,
externalTx,
);
}Resultado no banco:
Transaction 1: { type: DEBIT, userId: 0(SYSTEM), relatedUserId: {user}, amountCents: X }
Transaction 2: { type: CREDIT, userId: {user}, relatedUserId: 0, amountCents: X }O saldo do usuario AUMENTA. O saldo do SYSTEM DIMINUI (mas nao e cacheado).
Metodo: transfer
Transfere saldo entre dois usuarios reais.
async transfer(
fromUserId: bigint,
toUserId: bigint,
amountCents: bigint,
reason: string,
metadata?: any,
externalTx?: PrismaTransactionClient,
): Promise<any> {
return this.createDoubleEntry(fromUserId, toUserId, amountCents, reason, metadata, externalTx);
}Resultado no banco:
Transaction 1: { type: DEBIT, userId: {from}, relatedUserId: {to}, amountCents: X }
Transaction 2: { type: CREDIT, userId: {to}, relatedUserId: {from}, amountCents: X }O saldo de from DIMINUI. O saldo de to AUMENTA. Ambos os caches sao atualizados.
Metodo: createUpgradeDebit
Debita o saldo de um usuario para uma operacao de upgrade. Adiciona prefixo "UPGRADE:" ao reason e metadata { type: 'upgrade' }.
async createUpgradeDebit(
userId: bigint,
amountCents: bigint,
description: string,
externalTx?: PrismaTransactionClient,
): Promise<any> {
return this.debit(
userId,
amountCents,
`UPGRADE: ${description}`,
{ type: 'upgrade' },
externalTx,
);
}Exemplo de chamada:
await this.transactionService.createUpgradeDebit(
userId,
balanceUsed,
`Upgrade attempt for ${targetItem.name}`,
);Reason resultante: "UPGRADE: Upgrade attempt for AWP | Dragon Lore"
Metodo: getUserBalance
Delega para o repository. Retorna o saldo real do usuario baseado nas transacoes.
async getUserBalance(userId: bigint): Promise<bigint> {
return this.transactionRepository.getUserBalance(userId);
}REGRA CRITICA: Este metodo e a UNICA fonte de verdade para saldo. user.balanceCents e apenas um CACHE.
Metodo: getUserTransactions
Lista transacoes de um usuario com filtros e paginacao.
async getUserTransactions(userId: bigint, filters?: any) {
return this.transactionRepository.findByUserId(userId, filters);
}Retorna { transactions: Transaction[], total: number }.
Double-Entry Bookkeeping - Conceito
Principio Fundamental
Toda movimentacao financeira cria EXATAMENTE 2 transacoes (um par DEBIT + CREDIT) que sao linkadas bidirecionalmente via relatedTransactionId.
Deposito de R$110,00 (R$100 + 10% bonus):
+------------------------------------------------------------+
| ID: 1001 | User: SYSTEM(0) | Type: DEBIT | 11000 centavos |
| ID: 1002 | User: 42 | Type: CREDIT | 11000 centavos |
| Linked: 1001.relatedTransactionId = 1002 |
| 1002.relatedTransactionId = 1001 |
+------------------------------------------------------------+
Abertura de caixa R$25,00:
+------------------------------------------------------------+
| ID: 1003 | User: 42 | Type: DEBIT | 2500 centavos |
| ID: 1004 | User: SYSTEM(0) | Type: CREDIT | 2500 centavos |
| Linked: 1003.relatedTransactionId = 1004 |
| 1004.relatedTransactionId = 1003 |
+------------------------------------------------------------+Garantias
- Soma zero: Para qualquer par,
DEBIT.amountCents = CREDIT.amountCents - Rastreabilidade: Cada transacao aponta para sua contraparte
- Imutabilidade: Transacoes COMPLETED nunca sao alteradas
- Atomicidade: O par e criado dentro de uma unica transacao de banco
Uso em Cada Sistema
1. Deposito
Arquivo: src/application/services/deposit.service.ts
await this.transactionService.credit(
deposit.userId,
totalCredit, // amountCents + bonus 10%
`Deposit confirmed #${depositId} (+${bonusPercentage}% bonus)`,
{
depositId: depositId.toString(),
method: deposit.method,
bonusPercentage,
bonusAmountCents: bonusAmount.toString(),
},
);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | SYSTEM (0) | totalCredit | Deposit confirmed #X (+10% bonus) |
| 2 | CREDIT | usuario | totalCredit | Deposit confirmed #X (+10% bonus) |
2. Abertura de Caixa
Arquivo: src/application/use-cases/case-opening/open-case.use-case.ts
await this.transactionService.debit(
userId,
caseData.priceCents,
`Case opening: ${caseData.name}`,
undefined,
tx, // transacao Prisma externa
);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | usuario | priceCents | Case opening: Snow Case |
| 2 | CREDIT | SYSTEM (0) | priceCents | Case opening: Snow Case |
Nota: usa externalTx para compor com outras operacoes (adicionar item ao inventario, etc).
3. Upgrade
Arquivo: src/application/use-cases/upgrade/perform-upgrade.use-case.ts
if (hasBalance) {
await this.transactionService.createUpgradeDebit(
userId,
balanceUsed,
`Upgrade attempt for ${targetItem.name}`,
);
}Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | usuario | balanceUsed | UPGRADE: Upgrade attempt for AWP Dragon Lore |
| 2 | CREDIT | SYSTEM (0) | balanceUsed | UPGRADE: Upgrade attempt for AWP Dragon Lore |
O createUpgradeDebit adiciona prefixo "UPGRADE: " e metadata { type: 'upgrade' }.
4. Criacao de Batalha
Arquivo: src/application/use-cases/battle/create-battle.use-case.ts
await this.transactionService.debit(userId, totalValueCents, `Battle entry fee`);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | criador | totalValueCents | Battle entry fee |
| 2 | CREDIT | SYSTEM (0) | totalValueCents | Battle entry fee |
5. Entrada em Batalha
Arquivo: src/application/use-cases/battle/join-battle.use-case.ts
await this.transactionService.debit(
userId,
battle.totalValueCents,
`Battle entry fee - Battle #${battleId}`,
);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | jogador | totalValueCents | Battle entry fee - Battle #123 |
| 2 | CREDIT | SYSTEM (0) | totalValueCents | Battle entry fee - Battle #123 |
6. Venda de Item Individual
Arquivo: src/application/use-cases/inventory/sell-inventory-item.use-case.ts
await this.transactionService.credit(
userId,
inventoryItem.item.valueCentsBrl,
`Sold item: ${inventoryItem.item.name}`,
);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | SYSTEM (0) | valueCentsBrl | Sold item: AK-47 Redline |
| 2 | CREDIT | usuario | valueCentsBrl | Sold item: AK-47 Redline |
Nota: usa valueCentsBrl (BRL), NAO valueCents (USD).
7. Venda de Multiplos Itens
Arquivo: src/application/use-cases/inventory/sell-multiple-items.use-case.ts
await this.transactionService.credit(
userId,
totalValueCents,
`Sold ${inventoryItems.length} items`,
);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | SYSTEM (0) | totalValueCents | Sold 5 items |
| 2 | CREDIT | usuario | totalValueCents | Sold 5 items |
8. Venda de Todos os Itens
Arquivo: src/application/use-cases/inventory/sell-all-items.use-case.ts
await this.transactionService.credit(
userId,
totalValueCents,
`Sold all ${inventoryItems.length} items`,
);9. Saque (Withdrawal)
Arquivo: src/application/services/withdrawal.service.ts
await this.transactionService.debit(
userId,
withdrawal.amountCents,
`Withdrawal #${withdrawalId}`,
{
withdrawalId: withdrawalId.toString(),
},
);Transacoes geradas:
| # | type | userId | amountCents | reason |
|---|---|---|---|---|
| 1 | DEBIT | usuario | amountCents | Withdrawal #123 |
| 2 | CREDIT | SYSTEM (0) | amountCents | Withdrawal #123 |
10. Cancelamento de Batalha (Admin)
Arquivo: src/application/use-cases/admin/cancel-battle.use-case.ts
await this.transactionService.credit(
player.userId,
BigInt(totalCaseCost),
'BATTLE_CANCELLED_REFUND',
{
battleId: battle.id.toString(),
},
);Devolve o valor de entrada para cada jogador da batalha cancelada.
11. Rejeicao de Saque (Admin)
Arquivo: src/application/use-cases/admin/reject-withdrawal.use-case.ts
await this.transactionService.credit(
withdrawal.userId,
withdrawal.amountCents,
'WITHDRAWAL_REFUND',
{ withdrawalId: withdrawal.id.toString(), reason, adminUserId: adminUserId.toString() },
);Devolve o saldo ao usuario quando o admin rejeita um saque.
12. Confirmacao Manual de Deposito (Admin)
Arquivo: src/application/use-cases/admin/confirm-deposit.use-case.ts
await this.transactionService.credit(
deposit.userId,
deposit.amountCents,
'DEPOSIT_CONFIRMED',
{ depositId: deposit.id.toString(), method: deposit.method, adminUserId: adminUserId.toString() },
);Nota: a confirmacao manual por admin NAO aplica bonus.
13. Ajuste de Saldo (Admin)
Arquivo: src/application/use-cases/admin/update-user-balance.use-case.ts
if (amountCents > BigInt(0)) {
await this.transactionService.credit(
userId,
amountCents,
`Admin adjustment: ${reason} (by admin ${adminUserId})`,
);
} else if (amountCents < BigInt(0)) {
const positiveAmount = amountCents * BigInt(-1);
await this.transactionService.debit(
userId,
positiveAmount,
`Admin adjustment: ${reason} (by admin ${adminUserId})`,
);
}Permite ajustes positivos (credit) e negativos (debit) pelo admin.
Prevencao de Race Conditions
Cenario: Dois Debitos Simultaneos
Sem protecao, dois pedidos simultaneos poderiam debitar mais do que o saldo disponivel:
Saldo: R$100,00
Thread 1: Verifica saldo -> R$100 >= R$80 -> OK
Thread 2: Verifica saldo -> R$100 >= R$60 -> OK
Thread 1: Debita R$80 -> Saldo: R$20
Thread 2: Debita R$60 -> Saldo: -R$40 *** SALDO NEGATIVO ***Solucao: Lock + Transaction
Saldo: R$100,00
Thread 1: Adquire lock 'transaction:create'
Thread 1: Verifica saldo -> R$100 >= R$80 -> OK
Thread 1: Debita R$80 -> Saldo: R$20
Thread 1: Libera lock
Thread 2: Adquire lock 'transaction:create'
Thread 2: Verifica saldo -> R$20 >= R$60 -> FALHA
Thread 2: Lanca 'Insufficient balance'
Thread 2: Libera lockO lock transaction:create serializa TODAS as operacoes financeiras, garantindo que:
- Apenas uma transacao e processada por vez
- A verificacao de saldo e o debito sao atomicos
- Nao e possivel ter saldo negativo
Composicao com externalTx
Quando um use case precisa debitar E fazer outras operacoes atomicamente, ele passa o externalTx:
// open-case.use-case.ts
return this.lockService.withTransactionLock(async () => {
return this.prisma.$transaction(async (tx) => {
// Debito E adicao ao inventario sao atomicos
await this.transactionService.debit(userId, price, reason, undefined, tx);
await this.inventoryRepository.addItem(userId, itemId, tx);
});
});Neste caso, o lock E a transacao sao gerenciados pelo chamador, e o createDoubleEntry apenas executa dentro da transacao fornecida.
Source of Truth: Transacoes vs Cache
Regra
+-------------------+ +------------------+
| Transacoes | | user.balanceCents |
| (VERDADE) | | (CACHE) |
+-------------------+ +------------------+
| SUM(CREDIT) - | | Atualizado apos |
| SUM(DEBIT) |---->| cada transacao |
| WHERE COMPLETED | | pelo Transaction |
+-------------------+ | Service |
+------------------+Onde Usar Cada Um
TransactionService.getUserBalance() (VERDADE):
- Verificar saldo antes de debito/credito
- Exibir saldo no frontend (via WebSocket)
- Validacoes de saldo em use cases
- Relatorios financeiros
user.balanceCents (CACHE):
- Apenas queries de leitura otimizadas (leaderboards, rankings)
- NUNCA para operacoes que envolvam dinheiro
- Se houver divergencia, o TransactionRepository e a verdade
Atualizacao do Cache
O cache e atualizado DENTRO da mesma transacao de banco que cria o par DEBIT/CREDIT:
// Dentro de createDoubleEntry, dentro do prisma.$transaction:
const debitTransactions = await tx.transaction.groupBy({
by: ['type'],
where: { userId: debitUserId, status: 'COMPLETED' },
_sum: { amountCents: true },
});
let debitBalance = BigInt(0);
for (const item of debitTransactions) {
if (item.type === 'CREDIT') {
debitBalance += BigInt(item._sum.amountCents || 0);
} else if (item.type === 'DEBIT') {
debitBalance -= BigInt(item._sum.amountCents || 0);
}
}
await tx.user.update({
where: { id: debitUserId },
data: { balanceCents: debitBalance },
});O cache e RECALCULADO por completo (nao incrementado), eliminando risco de drift.
Controller HTTP
GET /payment/transactions
Lista transacoes do usuario autenticado.
@Get('transactions')
@ApiOperation({ summary: 'Get user transactions' })
@ApiResponse({ status: 200, type: [TransactionResponseDto] })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getTransactions(
@CurrentUser() user: User,
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
): Promise<{ transactions: TransactionResponseDto[]; total: number }>Response DTO:
export class TransactionResponseDto {
id: string;
type: string; // "DEBIT" ou "CREDIT"
amountCents: string; // Centavos como string
amount: string; // Valor formatado (ex: "100.00")
reason: string;
status: string;
createdAt: string;
}Mapeamento no Controller:
return {
transactions: result.transactions.map((tx) => ({
id: tx.id.toString(),
type: tx.type,
amountCents: tx.amountCents.toString(),
amount: this.moneyService.toReais(tx.amountCents).toFixed(2),
reason: tx.reason,
status: tx.status,
createdAt: tx.createdAt.toISOString(),
})),
total: result.total,
};Exemplo de Request:
GET /payment/transactions?limit=20&offset=0
Cookie: sessionId=abc123Exemplo de Response (200):
{
"transactions": [
{
"id": "7142591820701696",
"type": "CREDIT",
"amountCents": "11000",
"amount": "110.00",
"reason": "Deposit confirmed #7142591820701500 (+10% bonus)",
"status": "COMPLETED",
"createdAt": "2025-01-15T14:31:00.000Z"
},
{
"id": "7142591820701698",
"type": "DEBIT",
"amountCents": "2500",
"amount": "25.00",
"reason": "Case opening: Snow Case",
"status": "COMPLETED",
"createdAt": "2025-01-15T14:35:00.000Z"
},
{
"id": "7142591820701700",
"type": "CREDIT",
"amountCents": "5000",
"amount": "50.00",
"reason": "Sold item: AK-47 Redline",
"status": "COMPLETED",
"createdAt": "2025-01-15T14:40:00.000Z"
}
],
"total": 3
}Nota: o amountCents e SEMPRE positivo. O type (DEBIT/CREDIT) define a direcao.
Verificacao de Integridade
1. Soma Total Deve Ser Zero
Se o double-entry esta correto, a soma de todas as transacoes de TODOS os usuarios (incluindo SYSTEM) deve ser zero:
SELECT
SUM(CASE WHEN type = 'CREDIT' THEN amount_cents ELSE 0 END) -
SUM(CASE WHEN type = 'DEBIT' THEN amount_cents ELSE 0 END) as net
FROM transactions
WHERE status = 'COMPLETED';
-- Resultado esperado: 0Isso acontece porque cada CREDIT tem um DEBIT correspondente com o mesmo valor.
2. Pares Devem Corresponder
Toda transacao deve ter sua contraparte com o mesmo valor:
SELECT t1.id, t1.amount_cents as t1_amount, t2.amount_cents as t2_amount
FROM transactions t1
JOIN transactions t2 ON t1.related_transaction_id = t2.id
WHERE t1.amount_cents != t2.amount_cents;
-- Resultado esperado: 0 rows3. Ligacao Bidirecional
Toda transacao que aponta para outra deve ser apontada de volta:
SELECT t1.id
FROM transactions t1
JOIN transactions t2 ON t1.related_transaction_id = t2.id
WHERE t2.related_transaction_id != t1.id;
-- Resultado esperado: 0 rows4. Cache vs Verdade
Verificar se user.balanceCents esta consistente com as transacoes:
SELECT
u.id,
u.balance_cents as cached,
COALESCE(SUM(CASE WHEN t.type = 'CREDIT' THEN t.amount_cents ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t.amount_cents ELSE 0 END), 0) as real
FROM users u
LEFT JOIN transactions t ON t.user_id = u.id AND t.status = 'COMPLETED'
WHERE u.id != 0 -- exclui SYSTEM
GROUP BY u.id, u.balance_cents
HAVING u.balance_cents != (
COALESCE(SUM(CASE WHEN t.type = 'CREDIT' THEN t.amount_cents ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t.amount_cents ELSE 0 END), 0)
);
-- Resultado esperado: 0 rowsSe encontrar divergencias, o valor correto e o das transacoes.
Diagrama de Fluxo - createDoubleEntry
+-------------------+
| createDoubleEntry |
+-------------------+
|
v
+-------------------+
| amountCents > 0? |---NO--> BadRequestException('Amount must be greater than zero')
+-------------------+
| YES
v
+-------------------+
| externalTx? |---YES--> executeTransaction(externalTx)
+-------------------+ (sem lock proprio)
| NO
v
+-------------------+
| withTransactionLock|
| (Redlock 5000ms) |
+-------------------+
|
v
+-------------------+
| prisma.$transaction|
+-------------------+
|
v
+-------------------+
| 1. Find debit user|---NOT FOUND--> BadRequestException('Debit user not found')
+-------------------+
| FOUND
v
+-------------------+
| 2. getUserBalance |
| (from txns) |
+-------------------+
|
v
+-------------------+
| 3. balance >= |---NO--> BadRequestException('Insufficient balance')
| amountCents? |
+-------------------+
| YES
v
+-------------------+
| 4. Create DEBIT |
| transaction |
+-------------------+
|
v
+-------------------+
| 5. Create CREDIT |
| transaction |
| (linked to DEBIT)|
+-------------------+
|
v
+-------------------+
| 6. Update DEBIT |
| relatedTxId |
| (link to CREDIT)|
+-------------------+
|
v
+-------------------+
| 7a. Update cache |
| debitUser |
| (if not SYSTEM)|
+-------------------+
|
v
+-------------------+
| 7b. Update cache |
| creditUser |
| (if not SYSTEM)|
+-------------------+
|
v
+-------------------+
| Return {debit, |
| credit} |
+-------------------+Resumo de Reasons Utilizados
| Sistema | Metodo | Reason |
|---|---|---|
| Deposito | credit | Deposit confirmed #{id} (+{bonusPercentage}% bonus) |
| Deposito (Admin) | credit | DEPOSIT_CONFIRMED |
| Abertura de Caixa | debit | Case opening: {caseName} |
| Upgrade | createUpgradeDebit | UPGRADE: Upgrade attempt for {itemName} |
| Batalha (criar) | debit | Battle entry fee |
| Batalha (entrar) | debit | Battle entry fee - Battle #{id} |
| Batalha (cancelar) | credit | BATTLE_CANCELLED_REFUND |
| Venda de Item | credit | Sold item: {itemName} |
| Venda de Multiplos | credit | Sold {count} items |
| Venda de Todos | credit | Sold all {count} items |
| Saque | debit | Withdrawal #{id} |
| Saque (rejeitar) | credit | WITHDRAWAL_REFUND |
| Admin Ajuste (+) | credit | Admin adjustment: {reason} (by admin {id}) |
| Admin Ajuste (-) | debit | Admin adjustment: {reason} (by admin {id}) |
Debugging
Logs do TransactionService
O createDoubleEntry possui logs detalhados:
[TransactionService] createDoubleEntry - Amount: 10000n From: 42n To: 0n
[TransactionService] Finding debit user: 42n
[TransactionService] Debit user found
[TransactionService] Getting current balance...
[TransactionService] Current balance: 25000n Required: 10000n
[TransactionService] Balance check passedCenarios de Erro
Saldo Insuficiente:
[TransactionService] Current balance: 5000n Required: 10000n
[TransactionService] INSUFFICIENT BALANCE in transaction service!
BadRequestException: Insufficient balanceUsuario Nao Encontrado:
[TransactionService] Finding debit user: 9999n
BadRequestException: Debit user not foundLock Timeout:
Se o Redlock nao conseguir adquirir o lock em 10 tentativas (2000ms total):
Redlock error: Error: Exceeded retryCountVerificando Saldo Real
Para verificar o saldo real de um usuario no banco:
SELECT
type,
SUM(amount_cents) as total
FROM transactions
WHERE user_id = 42 AND status = 'COMPLETED'
GROUP BY type;Verificando Par de Transacoes
Para verificar se um par esta correto:
SELECT
t1.id as debit_id,
t1.amount_cents as debit_amount,
t1.user_id as debit_user,
t2.id as credit_id,
t2.amount_cents as credit_amount,
t2.user_id as credit_user
FROM transactions t1
JOIN transactions t2 ON t1.related_transaction_id = t2.id
WHERE t1.type = 'DEBIT'
ORDER BY t1.created_at DESC
LIMIT 10;Regras Criticas
- NUNCA use
user.balanceCentspara operacoes financeiras - useTransactionService.getUserBalance() - NUNCA crie transacoes avulsas - use sempre
createDoubleEntry(ou seus wrappersdebit/credit/transfer) amountCentse SEMPRE positivo - otype(DEBIT/CREDIT) define a direcao- SYSTEM_USER_ID = BigInt(0) - contraparte para todas as operacoes casa vs usuario
- Cache e recalculado, nao incrementado - elimina drift acumulativo
- Lock e obrigatorio para operacoes sem externalTx - previne race conditions
- Transacoes sao COMPLETED imediatamente - nao ha fluxo PENDING -> COMPLETED
- Ligacao bidirecional e obrigatoria - ambas as transacoes apontam uma para a outra
- Valores em BRL (centavos) - todo o sistema financeiro opera em reais
- Snowflake IDs - BigInt de 64 bits com timestamp embutido, epoch 1704067200000
