Skip to content

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

ArquivoResponsabilidade
src/application/services/transaction.service.tsServico principal de transacoes
src/infrastructure/database/repositories/transaction.repository.tsRepository Prisma
src/domain/repositories/transaction.repository.interface.tsInterface do repository
src/infrastructure/locks/lock.service.tsDistributed locks (Redlock)
src/infrastructure/database/prisma.service.tsCliente Prisma
src/infrastructure/snowflake/snowflake.service.tsGerador de IDs Snowflake
src/presentation/controllers/payment.controller.tsController HTTP
src/application/dto/payment.dto.tsDTOs de response

Modelo Prisma (Transaction)

Definido em prisma/schema.prisma, linhas 141-161:

prisma
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

prisma
enum TransactionType {
  DEBIT
  CREDIT
}

enum TransactionStatus {
  PENDING
  COMPLETED
  FAILED
  CANCELLED
}

Campos Detalhados

CampoTipoDescricao
idBigIntSnowflake ID gerado pelo SnowflakeService
typeTransactionTypeDEBIT (saida) ou CREDIT (entrada)
amountCentsBigIntValor em centavos (SEMPRE positivo, o tipo define a direcao)
userIdBigIntUsuario dono desta transacao
relatedUserIdBigInt?Contraparte da transacao (outro usuario ou SYSTEM)
relatedTransactionIdBigInt?ID da transacao par (ligacao bidirecional, constraint UNIQUE)
reasonStringDescricao textual da operacao
metadataJson?Dados adicionais (depositId, method, etc)
statusTransactionStatusEstado da transacao (default: COMPLETED)
createdAtDateTimeTimestamp 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:

prisma
relatedTransaction    Transaction?  @relation("TransactionPair", fields: [relatedTransactionId], references: [id])
oppositeTransaction   Transaction?  @relation("TransactionPair")
  • relatedTransaction: a transacao par apontada por esta transacao
  • oppositeTransaction: a transacao que aponta para esta transacao

Interface do Repository

Definida em src/domain/repositories/transaction.repository.interface.ts:

typescript
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:

typescript
@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:

sql
-- 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 alvo

Exemplo:

  • 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:

FiltroTipoDescricao
typeTransactionTypeFiltrar por DEBIT ou CREDIT
statusTransactionStatusFiltrar por status
startDateDateData inicial (>= createdAt)
endDateDateData final (<= createdAt)
limitnumberQuantidade por pagina (default: 50)
offsetnumberDeslocamento (default: 0)

TransactionService

Servico principal definido em src/application/services/transaction.service.ts.

SYSTEM_USER_ID

typescript
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:

typescript
if (debitUserId !== this.SYSTEM_USER_ID) {
  // Atualiza cache apenas para usuarios reais
}

Dependencias Injetadas

typescript
@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.

typescript
async createDoubleEntry(
  debitUserId: bigint,
  creditUserId: bigint,
  amountCents: bigint,
  reason: string,
  metadata?: any,
  externalTx?: PrismaTransactionClient,
): Promise<{ debit: any; credit: any }>

Parametros:

ParametroTipoObrigatorioDescricao
debitUserIdbigintSimUsuario que sera debitado (dinheiro sai)
creditUserIdbigintSimUsuario que sera creditado (dinheiro entra)
amountCentsbigintSimValor em centavos (deve ser > 0)
reasonstringSimDescricao da operacao
metadataanyNaoDados adicionais (JSON)
externalTxPrismaTransactionClientNaoTransacao Prisma externa (para composicao)

Fluxo Passo a Passo (7 etapas):

Etapa 1 - Validacao do valor:

typescript
if (amountCents <= 0) {
  throw new BadRequestException('Amount must be greater than zero');
}

Etapa 2 - Validacao do usuario de debito:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
@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:

ParametroValorDescricao
driftFactor0.01Fator de drift para compensar clock skew
retryCount10Numero maximo de tentativas para adquirir lock
retryDelay200msTempo entre tentativas
retryJitter200msVariacao aleatoria no delay
automaticExtensionThreshold500msExtensao automatica do TTL

Locks Disponiveis:

LockChave RedisTTLUso
withTransactionLocklock:transaction:create5000mscreateDoubleEntry sem externalTx
withUserBalanceLocklock:user:balance:{userId}10000msOperacoes de saldo por usuario
withWithdrawalLocklock:withdrawal:item:{id}30000msSaques de itens

Metodo: debit

Debita o saldo de um usuario. A contraparte e o SYSTEM.

typescript
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.

typescript
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.

typescript
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' }.

typescript
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:

typescript
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.

typescript
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.

typescript
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

  1. Soma zero: Para qualquer par, DEBIT.amountCents = CREDIT.amountCents
  2. Rastreabilidade: Cada transacao aponta para sua contraparte
  3. Imutabilidade: Transacoes COMPLETED nunca sao alteradas
  4. 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

typescript
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:

#typeuserIdamountCentsreason
1DEBITSYSTEM (0)totalCreditDeposit confirmed #X (+10% bonus)
2CREDITusuariototalCreditDeposit confirmed #X (+10% bonus)

2. Abertura de Caixa

Arquivo: src/application/use-cases/case-opening/open-case.use-case.ts

typescript
await this.transactionService.debit(
  userId,
  caseData.priceCents,
  `Case opening: ${caseData.name}`,
  undefined,
  tx,  // transacao Prisma externa
);

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITusuariopriceCentsCase opening: Snow Case
2CREDITSYSTEM (0)priceCentsCase 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

typescript
if (hasBalance) {
  await this.transactionService.createUpgradeDebit(
    userId,
    balanceUsed,
    `Upgrade attempt for ${targetItem.name}`,
  );
}

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITusuariobalanceUsedUPGRADE: Upgrade attempt for AWP Dragon Lore
2CREDITSYSTEM (0)balanceUsedUPGRADE: 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

typescript
await this.transactionService.debit(userId, totalValueCents, `Battle entry fee`);

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITcriadortotalValueCentsBattle entry fee
2CREDITSYSTEM (0)totalValueCentsBattle entry fee

5. Entrada em Batalha

Arquivo: src/application/use-cases/battle/join-battle.use-case.ts

typescript
await this.transactionService.debit(
  userId,
  battle.totalValueCents,
  `Battle entry fee - Battle #${battleId}`,
);

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITjogadortotalValueCentsBattle entry fee - Battle #123
2CREDITSYSTEM (0)totalValueCentsBattle entry fee - Battle #123

6. Venda de Item Individual

Arquivo: src/application/use-cases/inventory/sell-inventory-item.use-case.ts

typescript
await this.transactionService.credit(
  userId,
  inventoryItem.item.valueCentsBrl,
  `Sold item: ${inventoryItem.item.name}`,
);

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITSYSTEM (0)valueCentsBrlSold item: AK-47 Redline
2CREDITusuariovalueCentsBrlSold 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

typescript
await this.transactionService.credit(
  userId,
  totalValueCents,
  `Sold ${inventoryItems.length} items`,
);

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITSYSTEM (0)totalValueCentsSold 5 items
2CREDITusuariototalValueCentsSold 5 items

8. Venda de Todos os Itens

Arquivo: src/application/use-cases/inventory/sell-all-items.use-case.ts

typescript
await this.transactionService.credit(
  userId,
  totalValueCents,
  `Sold all ${inventoryItems.length} items`,
);

9. Saque (Withdrawal)

Arquivo: src/application/services/withdrawal.service.ts

typescript
await this.transactionService.debit(
  userId,
  withdrawal.amountCents,
  `Withdrawal #${withdrawalId}`,
  {
    withdrawalId: withdrawalId.toString(),
  },
);

Transacoes geradas:

#typeuserIdamountCentsreason
1DEBITusuarioamountCentsWithdrawal #123
2CREDITSYSTEM (0)amountCentsWithdrawal #123

10. Cancelamento de Batalha (Admin)

Arquivo: src/application/use-cases/admin/cancel-battle.use-case.ts

typescript
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

typescript
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

typescript
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

typescript
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 lock

O lock transaction:create serializa TODAS as operacoes financeiras, garantindo que:

  1. Apenas uma transacao e processada por vez
  2. A verificacao de saldo e o debito sao atomicos
  3. Nao e possivel ter saldo negativo

Composicao com externalTx

Quando um use case precisa debitar E fazer outras operacoes atomicamente, ele passa o externalTx:

typescript
// 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:

typescript
// 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.

typescript
@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:

typescript
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:

typescript
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=abc123

Exemplo de Response (200):

json
{
  "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:

sql
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: 0

Isso 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:

sql
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 rows

3. Ligacao Bidirecional

Toda transacao que aponta para outra deve ser apontada de volta:

sql
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 rows

4. Cache vs Verdade

Verificar se user.balanceCents esta consistente com as transacoes:

sql
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 rows

Se 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

SistemaMetodoReason
DepositocreditDeposit confirmed #{id} (+{bonusPercentage}% bonus)
Deposito (Admin)creditDEPOSIT_CONFIRMED
Abertura de CaixadebitCase opening: {caseName}
UpgradecreateUpgradeDebitUPGRADE: Upgrade attempt for {itemName}
Batalha (criar)debitBattle entry fee
Batalha (entrar)debitBattle entry fee - Battle #{id}
Batalha (cancelar)creditBATTLE_CANCELLED_REFUND
Venda de ItemcreditSold item: {itemName}
Venda de MultiploscreditSold {count} items
Venda de TodoscreditSold all {count} items
SaquedebitWithdrawal #{id}
Saque (rejeitar)creditWITHDRAWAL_REFUND
Admin Ajuste (+)creditAdmin adjustment: {reason} (by admin {id})
Admin Ajuste (-)debitAdmin 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 passed

Cenarios de Erro

Saldo Insuficiente:

[TransactionService] Current balance: 5000n Required: 10000n
[TransactionService] INSUFFICIENT BALANCE in transaction service!
BadRequestException: Insufficient balance

Usuario Nao Encontrado:

[TransactionService] Finding debit user: 9999n
BadRequestException: Debit user not found

Lock Timeout:

Se o Redlock nao conseguir adquirir o lock em 10 tentativas (2000ms total):

Redlock error: Error: Exceeded retryCount

Verificando Saldo Real

Para verificar o saldo real de um usuario no banco:

sql
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:

sql
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

  1. NUNCA use user.balanceCents para operacoes financeiras - use TransactionService.getUserBalance()
  2. NUNCA crie transacoes avulsas - use sempre createDoubleEntry (ou seus wrappers debit/credit/transfer)
  3. amountCents e SEMPRE positivo - o type (DEBIT/CREDIT) define a direcao
  4. SYSTEM_USER_ID = BigInt(0) - contraparte para todas as operacoes casa vs usuario
  5. Cache e recalculado, nao incrementado - elimina drift acumulativo
  6. Lock e obrigatorio para operacoes sem externalTx - previne race conditions
  7. Transacoes sao COMPLETED imediatamente - nao ha fluxo PENDING -> COMPLETED
  8. Ligacao bidirecional e obrigatoria - ambas as transacoes apontam uma para a outra
  9. Valores em BRL (centavos) - todo o sistema financeiro opera em reais
  10. Snowflake IDs - BigInt de 64 bits com timestamp embutido, epoch 1704067200000

Documentação Técnica - ProCases