Sistema de Transações
O sistema de transações implementa double-entry bookkeeping para garantir integridade financeira absoluta.
Estrutura da Transação
prisma
model Transaction {
id BigInt @id
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")
}Tipos de Transação
typescript
enum TransactionType {
DEBIT // Saída de dinheiro (valor negativo)
CREDIT // Entrada de dinheiro (valor positivo)
}Razões de Transação
typescript
enum TransactionReason {
// Pagamentos
DEPOSIT // Depósito
WITHDRAWAL // Saque
// Caixas
CASE_OPENING // Custo de abertura
CASE_WIN // Valor do item ganho
// Batalhas
BATTLE_ENTRY // Entrada em batalha
BATTLE_WIN // Prêmio de batalha
// Upgrades
UPGRADE_COST // Custo do upgrade
UPGRADE_WIN // Item ganho no upgrade
// Swaps
SWAP_OUT // Itens enviados no swap
SWAP_IN // Itens recebidos no swap
// Sorteios
RAFFLE_TICKET // Compra de ticket
RAFFLE_WIN // Prêmio de sorteio
// Inventário
ITEM_SOLD // Venda de item
// Admin
ADMIN_ADJUSTMENT // Ajuste manual do admin
REFERRAL_BONUS // Bônus de indicação
}Status da Transação
typescript
enum TransactionStatus {
PENDING // Aguardando processamento
COMPLETED // Concluída
FAILED // Falhou
CANCELLED // Cancelada
}Double-Entry Bookkeeping
Princípio
Toda movimentação financeira cria duas transações pareadas:
Depósito R$ 100:
┌──────────────────────────────────────────────┐
│ ID: 1 | User: GATEWAY | Type: DEBIT | -100 │
│ ID: 2 | User: 123 | Type: CREDIT | +100 │
│ Related: 1 ↔ 2 │
└──────────────────────────────────────────────┘
Soma total: -100 + 100 = 0 ✓Implementação
typescript
// src/application/services/transaction.service.ts
async transfer(
fromUserId: bigint,
toUserId: bigint,
amount: bigint,
reason: TransactionReason,
): Promise<{ debit: Transaction; credit: Transaction }> {
return this.prisma.$transaction(async (tx) => {
// Cria DEBIT (saída)
const debit = await tx.transaction.create({
data: {
userId: fromUserId,
type: 'DEBIT',
amountCents: -amount,
reason,
status: 'COMPLETED',
},
});
// Cria CREDIT (entrada)
const credit = await tx.transaction.create({
data: {
userId: toUserId,
type: 'CREDIT',
amountCents: amount,
reason,
status: 'COMPLETED',
relatedTransactionId: debit.id,
},
});
// Liga DEBIT ao CREDIT
await tx.transaction.update({
where: { id: debit.id },
data: { relatedTransactionId: credit.id },
});
return { debit, credit };
});
}Contas Especiais
O sistema usa contas virtuais para controle:
| Conta | ID | Propósito |
|---|---|---|
HOUSE | 0 | Casa - lucros e perdas do site |
GATEWAY | -1 | Gateway de pagamento externo |
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;Fluxos de Transação
Depósito
typescript
async processDeposit(userId: bigint, amount: bigint) {
// Gateway → Usuário
await this.transfer(
GATEWAY_ACCOUNT_ID,
userId,
amount,
TransactionReason.DEPOSIT,
);
}Abertura de Caixa
typescript
async processCaseOpening(userId: bigint, cost: bigint, winValue: bigint) {
// 1. Usuário paga a caixa → House
await this.transfer(
userId,
HOUSE_ACCOUNT_ID,
cost,
TransactionReason.CASE_OPENING,
);
// 2. House paga o prêmio → Usuário
await this.transfer(
HOUSE_ACCOUNT_ID,
userId,
winValue,
TransactionReason.CASE_WIN,
);
}Batalha
typescript
async processBattle(
participants: bigint[],
entryFee: bigint,
winners: bigint[],
totalPrize: bigint,
) {
// 1. Todos pagam entrada
for (const participantId of participants) {
await this.transfer(
participantId,
HOUSE_ACCOUNT_ID,
entryFee,
TransactionReason.BATTLE_ENTRY,
);
}
// 2. Vencedores recebem prêmio
const prizePerWinner = totalPrize / BigInt(winners.length);
for (const winnerId of winners) {
await this.transfer(
HOUSE_ACCOUNT_ID,
winnerId,
prizePerWinner,
TransactionReason.BATTLE_WIN,
);
}
}Saque
typescript
async processWithdrawal(userId: bigint, amount: bigint) {
// Usuário → Gateway
await this.transfer(
userId,
GATEWAY_ACCOUNT_ID,
amount,
TransactionReason.WITHDRAWAL,
);
}Histórico de Transações
API Endpoint
typescript
@Get('transactions')
async getTransactions(
@CurrentUser() user: User,
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('type') type?: TransactionType,
@Query('reason') reason?: TransactionReason,
) {
return this.transactionRepository.findByUser(user.id, {
page,
limit,
type,
reason,
});
}Response
json
{
"transactions": [
{
"id": "123456789",
"type": "CREDIT",
"amountCents": "10000",
"amountFormatted": "R$ 100,00",
"reason": "DEPOSIT",
"status": "COMPLETED",
"createdAt": "2024-01-15T14:30:00Z"
},
{
"id": "123456790",
"type": "DEBIT",
"amountCents": "-2500",
"amountFormatted": "-R$ 25,00",
"reason": "CASE_OPENING",
"status": "COMPLETED",
"createdAt": "2024-01-15T14:35:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
}
}Verificação de Integridade
Soma Total = Zero
sql
-- Se double-entry está correto, soma deve ser 0
SELECT SUM(amount_cents) as net_balance
FROM transactions
WHERE status = 'COMPLETED';
-- Resultado esperado: 0Pares Correspondem
sql
-- Verifica que todos os pares batem
SELECT t1.id, t1.amount_cents, t2.amount_cents
FROM transactions t1
JOIN transactions t2 ON t1.related_transaction_id = t2.id
WHERE t1.amount_cents + t2.amount_cents != 0;
-- Resultado esperado: 0 rowsAudit Script
typescript
async auditTransactions(): Promise<AuditResult> {
// 1. Verifica soma total
const totalSum = await this.prisma.$queryRaw<{ sum: bigint }[]>`
SELECT COALESCE(SUM(amount_cents), 0) as sum
FROM transactions
WHERE status = 'COMPLETED'
`;
if (totalSum[0].sum !== 0n) {
return { valid: false, error: 'Total sum is not zero' };
}
// 2. Verifica pares
const mismatched = await this.prisma.$queryRaw`
SELECT COUNT(*) as count
FROM transactions t1
JOIN transactions t2 ON t1.related_transaction_id = t2.id
WHERE t1.amount_cents + t2.amount_cents != 0
`;
if (mismatched[0].count > 0) {
return { valid: false, error: 'Mismatched pairs found' };
}
return { valid: true };
}Relatórios
Resumo Diário
typescript
async getDailyReport(date: Date) {
const startOfDay = new Date(date.setHours(0, 0, 0, 0));
const endOfDay = new Date(date.setHours(23, 59, 59, 999));
return this.prisma.transaction.groupBy({
by: ['reason'],
where: {
createdAt: { gte: startOfDay, lte: endOfDay },
status: 'COMPLETED',
},
_sum: { amountCents: true },
_count: true,
});
}Resultado
json
[
{ "reason": "DEPOSIT", "total": 5000000, "count": 500 },
{ "reason": "WITHDRAWAL", "total": -3000000, "count": 100 },
{ "reason": "CASE_OPENING", "total": -2500000, "count": 10000 },
{ "reason": "CASE_WIN", "total": 2300000, "count": 10000 },
{ "reason": "BATTLE_ENTRY", "total": -500000, "count": 200 },
{ "reason": "BATTLE_WIN", "total": 480000, "count": 100 }
]Arquivos Fonte
Principais Arquivos
src/application/services/transaction.service.ts- Serviço principalsrc/infrastructure/database/repositories/transaction.repository.ts- Repositoryprisma/schema.prisma- Model Transaction
