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:003. 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,00typescript
// 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,00Contas Especiais
O sistema tem contas virtuais para controle:
| Conta | ID | Propósito |
|---|---|---|
| HOUSE | 0 | Casa/Site - lucros e perdas |
| GATEWAY | -1 | Gateway de pagamento |
| POOL | -2 | Pool 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ício | Descrição |
|---|---|
| Integridade | Soma sempre zero, impossível criar dinheiro |
| Rastreabilidade | Todo centavo tem origem documentada |
| Auditoria | Relatórios automáticos, fácil compliance |
| Detecção de Fraude | Inconsistências são detectáveis |
| Reconciliação | Verificação automática de integridade |
| Histórico | Reconstrução completa do passado |
Arquivos Fonte Relacionados
Principais Arquivos
src/application/services/transaction.service.ts- Implementação principalsrc/infrastructure/database/repositories/transaction.repository.ts- Queriesprisma/schema.prisma- Model Transactionsrc/application/use-cases/payment/- Use cases de depósito/saque
