DEV Community

Roberto de Vargas Neto
Roberto de Vargas Neto

Posted on

Custódia Financeira: Gerenciando Saldo e Carteira com Consistência Eventual

Olá, pessoal!

Continuando a série My Broker B3, chegamos em um dos serviços mais ricos em regras de negócio do ecossistema: o Broker Wallet API.

Se nos posts anteriores construímos a infraestrutura da B3 (sync de preços e matching engine), agora entramos no coração financeiro da corretora. Este serviço é o guardião do dinheiro e dos ativos do investidor — ele precisa ser correto acima de tudo.


🏦 O que é a Wallet API?

A trading-broker-wallet é o serviço de custódia financeira do ecossistema. Sua responsabilidade é reagir aos eventos do ciclo de vida de uma ordem e garantir que o dinheiro e os ativos do usuário sejam gerenciados corretamente em cada etapa.

Ela não recebe comandos diretos de compra — ela escuta eventos Kafka e age de acordo com o status de cada ordem:

Kafka: order-events-v1
         │
    ┌────┴────────────────────────┐
    │                             │
 PENDING                       FILLED / REJECTED
    │                             │
 reserveBalance()          settleOrder() / refundBalance()
    │                             │
 Bloqueia saldo            Liquida ou estorna
Enter fullscreen mode Exit fullscreen mode

🎯 Por que a Wallet é tão crítica?

Imagine o seguinte cenário sem uma custódia bem implementada:

  1. Usuário tem R$ 1.000 disponíveis
  2. Coloca uma ordem de compra de R$ 800
  3. Antes da ordem ser processada, coloca outra ordem de R$ 500
  4. Ambas as ordens são executadas → usuário fica com saldo negativo

A wallet resolve isso com o conceito de saldo bloqueado: no momento em que uma ordem entra como PENDING, o valor é imediatamente reservado. O saldo disponível é sempre balance - blockedBalance.


🛠️ Stack Tecnológica

Tecnologia Uso
Java 21 + Spring Boot 3.5.11 Core do serviço
MySQL + Flyway Persistência operacional e versionamento
Spring Kafka Consumo de eventos de ordem
Spring Data Redis Preços em tempo real para valorização da carteira
SpringDoc OpenAPI Documentação via Swagger UI

🏗️ O Modelo de Dados

Antes de entrar na lógica, é importante entender as três entidades que sustentam o serviço:

Account
├── userId (UNIQUE)
├── balance          ← saldo total (inclui o bloqueado)
├── blockedBalance   ← valor reservado para ordens PENDING
└── currency

Account (1) ──▶ (N) Position
                     ├── ticker
                     ├── quantity
                     └── averagePrice

WalletTransaction (auditoria)
├── orderId
├── userId
├── transactionType  ← RESERVE | SETTLEMENT | REFUND
└── amount
Enter fullscreen mode Exit fullscreen mode

O blockedBalance é a peça central da custódia. O balance nunca desce abaixo de zero — apenas o blockedBalance sobe e desce conforme as ordens avançam no ciclo de vida.


🔄 O Ciclo de Vida de uma Ordem na Wallet

Etapa 1 — PENDING: Reservando o Saldo

Quando uma ordem é criada, ela chega como PENDING. A wallet precisa bloquear imediatamente o valor máximo que pode ser debitado (quantidade × preço limite):

@Transactional
public void reserveBalance(OrderEventDTO event) {
    BigDecimal amountToReserve = event.getPrice().multiply(event.getQuantity());

    Account account = accountRepository.findByUserId(event.getUserId())
            .orElseThrow(() -> new RuntimeException("Account not found for user: " + event.getUserId()));

    // Available = balance - blockedBalance
    BigDecimal availableBalance = account.getAvailableBalance();

    if (availableBalance.compareTo(amountToReserve) < 0) {
        throw new RuntimeException("Insufficient balance to reserve funds.");
    }

    // Only blockedBalance increases — total balance stays intact
    account.setBlockedBalance(account.getBlockedBalance().add(amountToReserve));
    accountRepository.save(account);

    // Audit record
    walletTransactionRepository.save(WalletTransaction.builder()
            .userId(event.getUserId())
            .orderId(event.getOrderId())
            .transactionType(TransactionType.RESERVE)
            .amount(amountToReserve)
            .build());
}
Enter fullscreen mode Exit fullscreen mode

Ponto importante: o balance total não muda nesta etapa. Apenas o blockedBalance aumenta. O dinheiro ainda está na conta — só está reservado.

Etapa 2a — FILLED: Liquidando a Ordem

A ordem foi executada no Matching Engine. Agora sim o dinheiro sai de verdade. Mas aqui temos uma sutileza: o preço executado pode ser diferente do preço reservado.

Exemplo: usuário colocou ordem de compra com limite de R$ 30,00, mas o mercado executou a R$ 29,50. O valor reservado foi R$ 300 (10 ações × R$ 30), mas o real executado foi R$ 295 (10 × R$ 29,50). Os R$ 5,00 de diferença ficam disponíveis automaticamente.

@Transactional
public void settleOrder(OrderEventDTO event) {
    BigDecimal totalReserved = event.getPrice().multiply(event.getQuantity());
    BigDecimal totalExecuted = event.getExecutedPrice().multiply(event.getQuantity());

    Account account = accountRepository.findByUserId(event.getUserId())
            .orElseThrow(() -> new RuntimeException("Account not found"));

    // Release the initial block
    BigDecimal newBlockedBalance = account.getBlockedBalance().subtract(totalReserved);
    account.setBlockedBalance(newBlockedBalance.compareTo(BigDecimal.ZERO) < 0
            ? BigDecimal.ZERO
            : newBlockedBalance);

    // Debit the actual executed amount from total balance
    account.setBalance(account.getBalance().subtract(totalExecuted));
    accountRepository.save(account);

    // Update asset position
    updatePosition(event, account);
}
Enter fullscreen mode Exit fullscreen mode

Etapa 2b — REJECTED: Estornando o Saldo

A ordem foi rejeitada (preço fora do mercado, ticker não encontrado no Redis, etc.). O dinheiro bloqueado volta a ficar disponível:

@Transactional
public void refundBalance(OrderEventDTO event) {
    BigDecimal amountToRefund = event.getPrice().multiply(event.getQuantity());

    Account account = accountRepository.findByUserId(event.getUserId())
            .orElseThrow(() -> new RuntimeException("Account not found for refund"));

    // Only subtract from blockedBalance — total balance was never touched
    if (account.getBlockedBalance().compareTo(amountToRefund) >= 0) {
        account.setBlockedBalance(account.getBlockedBalance().subtract(amountToRefund));
    } else {
        log.warn("Refund amount exceeds blocked balance for order {}", event.getOrderId());
        account.setBlockedBalance(BigDecimal.ZERO);
    }

    accountRepository.save(account);
}
Enter fullscreen mode Exit fullscreen mode

📊 Cálculo de Preço Médio Ponderado

Quando uma compra é liquidada, precisamos atualizar a posição do usuário naquele ativo. O preço médio ponderado garante que compras em momentos diferentes sejam refletidas corretamente:

Novo Preço Médio = (Custo Atual + Custo Novo) / (Qtd Atual + Qtd Nova)
Enter fullscreen mode Exit fullscreen mode
private void updatePosition(OrderEventDTO event, Account account) {
    Position position = positionRepository
            .findByAccountIdAndTicker(account.getId(), event.getTicker())
            .orElse(Position.builder()
                    .account(account)
                    .ticker(event.getTicker())
                    .quantity(BigDecimal.ZERO)
                    .averagePrice(BigDecimal.ZERO)
                    .build());

    boolean isSell = "SELL".equalsIgnoreCase(event.getSide());

    if (isSell) {
        BigDecimal newQuantity = position.getQuantity().subtract(event.getQuantity());
        if (newQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            positionRepository.delete(position);
            return;
        }
        position.setQuantity(newQuantity);
        // Average price stays the same on sell
    } else {
        // BUY: recalculate weighted average price
        BigDecimal currentCost = position.getAveragePrice().multiply(position.getQuantity());
        BigDecimal newCost = event.getExecutedPrice().multiply(event.getQuantity());
        BigDecimal totalQuantity = position.getQuantity().add(event.getQuantity());
        BigDecimal newAveragePrice = currentCost.add(newCost)
                .divide(totalQuantity, 4, RoundingMode.HALF_UP);

        position.setQuantity(totalQuantity);
        position.setAveragePrice(newAveragePrice);
    }

    positionRepository.save(position);
}
Enter fullscreen mode Exit fullscreen mode

Exemplo prático:

  • Compra 1: 10 ações de PETR4 a R$ 30,00 → preço médio = R$ 30,00
  • Compra 2: 5 ações de PETR4 a R$ 33,00 → preço médio = (300 + 165) / 15 = R$ 31,00

🐛 Bugs Críticos Corrigidos

Durante a revisão do código, identifiquei problemas que causariam falhas silenciosas em produção:

1. NPE garantido no primeiro depósito
Conta nova criada sem inicializar blockedBalance. A primeira chamada a getAvailableBalance() explodiria com NullPointerException.

// ❌ Antes
Account.builder()
    .balance(BigDecimal.ZERO)
    // blockedBalance ausente → NPE

// ✅ Depois
Account.builder()
    .balance(BigDecimal.ZERO)
    .blockedBalance(BigDecimal.ZERO)
Enter fullscreen mode Exit fullscreen mode

2. Saque validava saldo total, não disponível
Permitia sacar dinheiro bloqueado em ordens pendentes:

// ❌ Antes — permite sacar fundos reservados
if (account.getBalance().compareTo(amount) < 0)

// ✅ Depois — valida apenas o disponível
if (account.getAvailableBalance().compareTo(amount) < 0)
Enter fullscreen mode Exit fullscreen mode

3. SELL aumentava posição em vez de diminuir
O método updatePosition() sempre somava quantidade, ignorando completamente o campo side. Uma venda de 10 ações adicionava 10 à posição.

4. Consumer Kafka engolia exceções silenciosamente
Erros eram logados mas o offset era confirmado — a mensagem era descartada sem retry. Agora o consumer relança a exceção para o Kafka aplicar a política de retry corretamente.


🔒 Idempotência

Para evitar duplicidade em caso de reprocessamento Kafka, adicionamos uma constraint de banco:

-- V4 migration
ALTER TABLE wallet_transactions
    ADD CONSTRAINT uq_order_transaction UNIQUE (order_id, transaction_type);
Enter fullscreen mode Exit fullscreen mode

Se o mesmo evento PENDING chegar duas vezes, o segundo insert vai falhar com violação de constraint — evitando que o saldo seja bloqueado duas vezes para a mesma ordem.


🌐 API REST

Método Endpoint Descrição
POST /api/v1/wallet/{userId}/deposit Depositar fundos
POST /api/v1/wallet/{userId}/withdraw Sacar fundos disponíveis
GET /api/v1/wallet/{userId}/summary Resumo: saldo + posições + patrimônio total
GET /api/v1/wallet/{userId}/positions Listar posições em ativos
GET /api/v1/wallet/{userId}/transactions Histórico de transações

📄 Swagger UI: http://localhost:8085/swagger-ui.html


✅ Validando a Execução

Com a aplicação rodando localmente:

  • ✅ Flyway aplicou as 4 migrations com sucesso
  • ✅ Hibernate validou o schema
  • ✅ Kafka consumer conectado e subscrito ao tópico order-events-v1
  • ✅ Consumer group trading-broker-wallet sincronizado
  • ✅ Swagger UI com 5 endpoints documentados em http://localhost:8085/swagger-ui.html

🚀 O que vem a seguir?

Com a Wallet pronta para reagir aos eventos de ordem, o próximo passo é construir o trading-broker-order — o maestro que vai orquestrar o ciclo de vida completo de uma ordem:

  1. Receber a intenção de compra/venda via REST
  2. Validar o ticker na trading-broker-asset
  3. Salvar a ordem como PENDING e publicar no Kafka
  4. Enviar a ordem ao Matching Engine via RabbitMQ
  5. Consumir o resultado e atualizar o status final

Quando esse serviço estiver pronto, teremos o primeiro fluxo ponta a ponta funcionando: do clique do usuário até o saldo atualizado na carteira.

Ficou com alguma dúvida sobre a lógica de custódia ou sobre o ciclo PENDING → FILLED → REJECTED? Deixe nos comentários!


🔎 Sobre a série

⬅️ Post Anterior: O Coração da B3: Construindo o Matching Engine com RabbitMQ e Redis

📘 Índice da Série: Guia da Série


Links:

Top comments (0)