Skip to content

Double-Entry Bookkeeping

Double-Entry Bookkeeping (Contabilidade de Partida Dupla) é o sistema contábil usado no CSGOFlip para garantir integridade financeira absoluta.

O que é Double-Entry?

É um princípio contábil onde toda transação financeira tem dois lançamentos: um débito e um crédito. A soma de todos os débitos sempre é igual à soma de todos os créditos.

Para toda transação:
  Σ(Débitos) = Σ(Créditos)

Por que usar em Gambling?

1. Impossível Criar Dinheiro do Nada

typescript
// Com double-entry, dinheiro não surge magicamente
// Para dar R$ 100 ao usuário, alguém precisa "pagar":

// Depósito: Dinheiro entra do GATEWAY para o USUÁRIO
await createTransaction({ from: 'GATEWAY', to: userId, amount: 10000 });
// Resultado:
// - Transaction 1: GATEWAY -10000 (DEBIT)
// - Transaction 2: userId +10000 (CREDIT)

// Case Win: Dinheiro sai do HOUSE para o USUÁRIO
await createTransaction({ from: 'HOUSE', to: userId, amount: 5000 });
// Resultado:
// - Transaction 3: HOUSE -5000 (DEBIT)  
// - Transaction 4: userId +5000 (CREDIT)

2. Rastreabilidade Completa

Podemos reconstruir exatamente de onde cada centavo veio:

sql
-- Histórico completo de um usuário
SELECT 
  t.id,
  t.type,
  t.amount_cents,
  t.reason,
  t.related_transaction_id,
  t.created_at
FROM transactions t
WHERE t.user_id = 123
ORDER BY t.created_at;

-- Resultado:
-- id | type   | amount | reason        | related_id | created_at
-- 1  | CREDIT | 10000  | DEPOSIT       | 2          | 2024-01-01 10:00
-- 3  | DEBIT  | -2500  | CASE_OPENING  | 4          | 2024-01-01 10:05
-- 5  | CREDIT | 5000   | CASE_WIN      | 6          | 2024-01-01 10:05
-- 7  | DEBIT  | -1000  | BATTLE_ENTRY  | 8          | 2024-01-01 11:00

3. Detecção de Fraude

Se alguém tentar manipular o banco, os números não fecham:

sql
-- Verificação de integridade
SELECT 
  SUM(CASE WHEN type = 'DEBIT' THEN amount_cents ELSE 0 END) as total_debits,
  SUM(CASE WHEN type = 'CREDIT' THEN amount_cents ELSE 0 END) as total_credits
FROM transactions
WHERE status = 'COMPLETED';

-- Se total_debits + total_credits != 0, há um problema!
-- (débitos são negativos, então a soma deve ser zero)

4. Reconciliação Automática

Podemos verificar que o sistema está correto:

typescript
async verifySystemIntegrity(): Promise<boolean> {
  const result = await this.prisma.$queryRaw`
    SELECT 
      SUM(amount_cents) as net_balance
    FROM transactions
    WHERE status = 'COMPLETED'
  `;

  // Se net_balance != 0, algo está errado
  return result[0].net_balance === 0n;
}

Implementação no CSGOFlip

Estrutura da Transação

prisma
model Transaction {
  id                    BigInt            @id @default(autoincrement())
  userId                BigInt            @map("user_id")
  type                  TransactionType   // DEBIT ou CREDIT
  amountCents           BigInt            @map("amount_cents")
  reason                TransactionReason
  status                TransactionStatus @default(COMPLETED)
  relatedTransactionId  BigInt?           @map("related_transaction_id")
  metadata              Json?
  createdAt             DateTime          @default(now())

  user                  User              @relation(fields: [userId])
  relatedTransaction    Transaction?      @relation("RelatedTransactions")
}

enum TransactionType {
  DEBIT   // Saída de dinheiro (valor negativo)
  CREDIT  // Entrada de dinheiro (valor positivo)
}

enum TransactionReason {
  DEPOSIT
  WITHDRAWAL
  CASE_OPENING
  CASE_WIN
  BATTLE_ENTRY
  BATTLE_WIN
  UPGRADE_COST
  UPGRADE_WIN
  SWAP_OUT
  SWAP_IN
  RAFFLE_TICKET
  RAFFLE_WIN
  ADMIN_ADJUSTMENT
  REFERRAL_BONUS
}

TransactionService

typescript
@Injectable()
export class TransactionService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly lockService: LockService,
  ) {}

  /**
   * Transfere dinheiro entre duas contas
   * Cria par de transações: DEBIT do pagador, CREDIT do recebedor
   */
  async transfer(
    fromUserId: bigint,
    toUserId: bigint,
    amount: bigint,
    reason: TransactionReason,
  ): Promise<{ debit: Transaction; credit: Transaction }> {
    // Lock em ambos os usuários (ordem consistente para evitar deadlock)
    const [lock1, lock2] = fromUserId < toUserId
      ? [`user:balance:${fromUserId}`, `user:balance:${toUserId}`]
      : [`user:balance:${toUserId}`, `user:balance:${fromUserId}`];

    const locks = await this.lockService.acquireMultiple([lock1, lock2]);

    try {
      return await this.prisma.$transaction(async (tx) => {
        // Verifica saldo do pagador
        const balance = await this.getUserBalance(fromUserId, tx);
        if (balance < amount) {
          throw new InsufficientBalanceError(balance, amount);
        }

        // Cria DEBIT (saída do pagador)
        const debit = await tx.transaction.create({
          data: {
            userId: fromUserId,
            type: 'DEBIT',
            amountCents: -amount, // Negativo
            reason,
            status: 'COMPLETED',
          },
        });

        // Cria CREDIT (entrada no recebedor)
        const credit = await tx.transaction.create({
          data: {
            userId: toUserId,
            type: 'CREDIT',
            amountCents: amount, // Positivo
            reason,
            status: 'COMPLETED',
            relatedTransactionId: debit.id, // Liga ao débito
          },
        });

        // Atualiza débito com referência ao crédito
        await tx.transaction.update({
          where: { id: debit.id },
          data: { relatedTransactionId: credit.id },
        });

        // Atualiza caches de saldo
        await this.updateBalanceCache(fromUserId, tx);
        await this.updateBalanceCache(toUserId, tx);

        return { debit, credit };
      });
    } finally {
      await Promise.all(locks.map(l => l.release()));
    }
  }

  /**
   * Debita do usuário (ex: abertura de caixa)
   * HOUSE recebe o dinheiro
   */
  async debit(
    userId: bigint,
    amount: bigint,
    reason: TransactionReason,
  ): Promise<Transaction> {
    return this.transfer(userId, HOUSE_ACCOUNT_ID, amount, reason)
      .then(r => r.debit);
  }

  /**
   * Credita ao usuário (ex: ganho de caixa)
   * HOUSE paga o dinheiro
   */
  async credit(
    userId: bigint,
    amount: bigint,
    reason: TransactionReason,
  ): Promise<Transaction> {
    return this.transfer(HOUSE_ACCOUNT_ID, userId, amount, reason)
      .then(r => r.credit);
  }

  /**
   * Calcula saldo real do usuário (soma das transações)
   */
  async getUserBalance(
    userId: bigint,
    tx?: PrismaTransactionClient,
  ): Promise<bigint> {
    const prisma = tx || this.prisma;
    
    const result = await prisma.transaction.aggregate({
      where: {
        userId,
        status: 'COMPLETED',
      },
      _sum: {
        amountCents: true,
      },
    });

    return result._sum.amountCents ?? 0n;
  }
}

Fluxos de Transação

1. Depósito

GATEWAY (externo) → USUÁRIO

Transações criadas:
1. GATEWAY: DEBIT -10000 (saiu do gateway)
2. USUÁRIO: CREDIT +10000 (entrou na conta)

Saldo do usuário: +R$ 100,00
typescript
// deposit.use-case.ts
async confirmDeposit(depositId: bigint) {
  const deposit = await this.depositRepository.findById(depositId);
  
  await this.transactionService.credit(
    deposit.userId,
    deposit.amountCents,
    TransactionReason.DEPOSIT,
  );
  
  await this.depositRepository.updateStatus(depositId, 'CONFIRMED');
}

2. Abertura de Caixa

USUÁRIO → HOUSE (custo da caixa)
HOUSE → USUÁRIO (valor do item ganho)

Cenário: Caixa custa R$ 25, item ganho vale R$ 50

Transações criadas:
1. USUÁRIO: DEBIT -2500 (pagou a caixa)
2. HOUSE: CREDIT +2500 (recebeu pagamento)
3. HOUSE: DEBIT -5000 (pagou o prêmio)
4. USUÁRIO: CREDIT +5000 (recebeu o prêmio)

Saldo do usuário: +R$ 25,00 (lucro)
Saldo da HOUSE: -R$ 25,00 (prejuízo)
typescript
// open-case.use-case.ts
async execute(userId: bigint, caseId: bigint) {
  const caseData = await this.caseRepository.findById(caseId);
  
  // 1. Cobra o custo da caixa
  await this.transactionService.debit(
    userId,
    caseData.priceCents,
    TransactionReason.CASE_OPENING,
  );
  
  // 2. Calcula resultado (Provably Fair)
  const item = await this.calculateResult(caseId);
  
  // 3. Paga o prêmio
  await this.transactionService.credit(
    userId,
    item.valueCents,
    TransactionReason.CASE_WIN,
  );
  
  return item;
}

3. Batalha

JOGADORES → HOUSE (entradas)
HOUSE → VENCEDORES (prêmios)

Cenário: Batalha 3v3, R$ 100 por jogador

Entradas:
1. PLAYER_1: DEBIT -10000
2. PLAYER_2: DEBIT -10000
3. PLAYER_3: DEBIT -10000
4. PLAYER_4: DEBIT -10000
5. PLAYER_5: DEBIT -10000
6. PLAYER_6: DEBIT -10000
HOUSE: CREDIT +60000

Resultados (Team 1 vence):
7. HOUSE: DEBIT -20000
8. PLAYER_1: CREDIT +20000 (1/3 do prêmio)
9. HOUSE: DEBIT -20000
10. PLAYER_2: CREDIT +20000 (1/3 do prêmio)
11. HOUSE: DEBIT -20000
12. PLAYER_3: CREDIT +20000 (1/3 do prêmio)

Saldo final:
- PLAYER_1: +R$ 100,00 (lucro)
- PLAYER_2: +R$ 100,00 (lucro)
- PLAYER_3: +R$ 100,00 (lucro)
- PLAYER_4: -R$ 100,00 (perda)
- PLAYER_5: -R$ 100,00 (perda)
- PLAYER_6: -R$ 100,00 (perda)
- HOUSE: R$ 0,00 (neutro - sem rake neste exemplo)

4. Saque

USUÁRIO → GATEWAY (externo)

Transações criadas:
1. USUÁRIO: DEBIT -10000 (saiu da conta)
2. GATEWAY: CREDIT +10000 (enviado ao gateway)

Saldo do usuário: -R$ 100,00

Contas Especiais

O sistema tem contas virtuais para controle:

ContaIDPropósito
HOUSE0Casa/Site - lucros e perdas
GATEWAY-1Gateway de pagamento
POOL-2Pool de itens para settlement
typescript
export const HOUSE_ACCOUNT_ID = 0n;
export const GATEWAY_ACCOUNT_ID = -1n;
export const POOL_ACCOUNT_ID = -2n;

Verificação de Integridade

Script de Auditoria

typescript
async auditTransactions() {
  // 1. Verifica que soma geral é zero
  const totalSum = await this.prisma.transaction.aggregate({
    where: { status: 'COMPLETED' },
    _sum: { amountCents: true },
  });

  if (totalSum._sum.amountCents !== 0n) {
    throw new IntegrityError('Total sum is not zero!');
  }

  // 2. Verifica que toda transação tem par
  const orphans = await this.prisma.$queryRaw`
    SELECT id, user_id, amount_cents, reason
    FROM transactions
    WHERE status = 'COMPLETED'
      AND related_transaction_id IS NULL
      AND reason NOT IN ('ADMIN_ADJUSTMENT')
  `;

  if (orphans.length > 0) {
    throw new IntegrityError(`Found ${orphans.length} orphan transactions`);
  }

  // 3. Verifica que pares batem
  const mismatched = await this.prisma.$queryRaw`
    SELECT t1.id, t1.amount_cents, t2.id as related_id, t2.amount_cents as related_amount
    FROM transactions t1
    JOIN transactions t2 ON t1.related_transaction_id = t2.id
    WHERE t1.amount_cents + t2.amount_cents != 0
  `;

  if (mismatched.length > 0) {
    throw new IntegrityError(`Found ${mismatched.length} mismatched pairs`);
  }

  return { status: 'OK', totalTransactions: totalSum._count };
}

Relatório Diário

typescript
async dailyReport(date: Date) {
  const startOfDay = new Date(date.setHours(0, 0, 0, 0));
  const endOfDay = new Date(date.setHours(23, 59, 59, 999));

  const report = await this.prisma.transaction.groupBy({
    by: ['reason'],
    where: {
      createdAt: { gte: startOfDay, lte: endOfDay },
      status: 'COMPLETED',
    },
    _sum: { amountCents: true },
    _count: true,
  });

  return report.map(r => ({
    reason: r.reason,
    total: r._sum.amountCents,
    count: r._count,
  }));
}

// Exemplo de saída:
// [
//   { reason: 'DEPOSIT', total: 50000000, count: 500 },
//   { reason: 'WITHDRAWAL', total: -30000000, count: 100 },
//   { reason: 'CASE_OPENING', total: -25000000, count: 10000 },
//   { reason: 'CASE_WIN', total: 23000000, count: 10000 },
//   { reason: 'BATTLE_ENTRY', total: -5000000, count: 200 },
//   { reason: 'BATTLE_WIN', total: 4800000, count: 100 },
// ]

Benefícios Resumidos

BenefícioDescrição
IntegridadeSoma sempre zero, impossível criar dinheiro
RastreabilidadeTodo centavo tem origem documentada
AuditoriaRelatórios automáticos, fácil compliance
Detecção de FraudeInconsistências são detectáveis
ReconciliaçãoVerificação automática de integridade
HistóricoReconstrução completa do passado

Arquivos Fonte Relacionados

Principais Arquivos

  • src/application/services/transaction.service.ts - Implementação principal
  • src/infrastructure/database/repositories/transaction.repository.ts - Queries
  • prisma/schema.prisma - Model Transaction
  • src/application/use-cases/payment/ - Use cases de depósito/saque

Documentação Técnica CSGOFlip