Saque no nero_payments

Para: Dev — Conferido em 2026-10-18.

Como implementar POST /api/wallet/withdraw/request e GET /api/payments/withdrawals/{withdrawal_id} sobre o POST /v1/withdrawals do Dracma. A referência da API está em Saques.

O que é

A rota do portal recebe o pedido legado do front, reserva o saldo, grava o intent e chama o Dracma. As particularidades da Nero são o corpo legado (pix_type e pix_key), as mensagens genéricas de bloqueio e a pré-checagem local que só endurece.

Quando usar

Ao implementar ou revisar a rota de saque do nero_payments.

Como funciona

Reserva antes do pedido

sequenceDiagram autonumber participant C as nero_payments participant L as nero_ledger participant D as Dracma C->>L: BEGIN, confere saldo, RESERVA (débito), grava intent wdr_uuidv7 com os bytes do corpo, COMMIT C->>D: POST /v1/withdrawals (Idempotency-Key = external_id) alt resposta conclusiva 2xx D-->>C: 201 approved, requested ou under_review C->>C: grava dracma_id, status, version, decision else 4xx conclusivo D-->>C: 400 ou 422 C->>L: devolve a reserva (uma vez, idempotente por external_id) else timeout, 5xx, 429 D--xC: sem resposta conclusiva C->>C: intent segue pending, reserva MANTIDA, worker repete com a mesma chave end
O que chegou Reserva Por quê
201 (qualquer estado) Mantém O saque existe; o desfecho vem por webhook
400 ou 422 Devolve 4xx não cria objeto
409 duplicate_external_id Mantém; segue pelo existing O saque já existe
409 idempotency_key_reused Mantém; needs_decision e alerta (D12) Bug do conector; ninguém devolve nem reenvia sozinho
Timeout, 5xx, 429, 409 idempotency_in_progress Mantém; repete com a mesma chave Você não sabe se o saque foi criado
Tentativas esgotadas Mantém; GET /v1/withdrawals?external_id=; inconclusivo vira needs_decision Nunca decida às cegas

Por que nunca devolver no timeout: se o POST foi aceito e a resposta se perdeu, o jogador receberia o PIX e o saldo de volta.

Do corpo legado ao corpo do Dracma

Pedido do front (legado): {amount, type: "pix", pix_key, pix_type, accept_terms, currency?}.

Campo legado Vai ao Dracma como Regra
amount (reais) amount.amount na menor unidade, string A mesma conversão sem float do Depósito
currency amount.currency Ausente vale BRL; outra moeda, recuse
type method Só pix
pix_type destination.pix.key_type cpf, cnpj, email, phone, random. Mapeie legados: evp e aleatoria para random; telefone e celular para phone
pix_key destination.pix.key Normalize: CPF e CNPJ só dígitos; telefone em +55DDDNUMERO; e-mail em minúsculas; 1 a 140 caracteres
accept_terms Não vai Regra da bet: exija true antes de reservar
Nenhum player.external_ref, document, name Do cadastro (asserção do portal)
Nenhum player.ip IP do jogador vindo do Caddy e do portal. Sem ele o saque nunca é automático (OWNERSHIP_UNVERIFIED)
Nenhum player.device_id Se a bet tiver
Nenhum review_requested true quando a pré-checagem local desconfiar
Nenhum player_context Opcional e informativo
{
  "external_id": "wdr_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6c",
  "player": {
    "external_ref": "0192a3b4-0000-7000-8000-000000000001",
    "document": { "type": "CPF", "number": "52998224725", "country": "BR" },
    "name": "Jogador Teste",
    "ip": "203.0.113.10"
  },
  "amount": { "amount": "5000", "currency": "BRL" },
  "method": "pix",
  "destination": { "pix": { "key_type": "cpf", "key": "52998224725" } },
  "review_requested": false
}

A chave PIX tem de ser do jogador: chave cpf diferente do CPF do cadastro, ou cnpj para pessoa física, volta 422 document_mismatch. Chaves email, phone e random são conferidas na consulta de titularidade pelo Dracma, o que pode levar alguns minutos (requested).

Resposta do Dracma e resposta ao front

Dracma Reserva Front (200 do legado)
201 approved ou requested Mantém {status: true, withdrawal_id, state: "processing", message: "Saque em processamento."}
201 under_review Mantém {status: true, withdrawal_id, state: "under_review", message: "Saque em análise."}
Timeout, 5xx ou 429 (intent gravado) Mantém {status: true, withdrawal_id, state: "processing", ...}
422 amount_below_minimum ou amount_above_maximum Devolve {status: false, message: <mensagem legada de limite>}
422 document_invalid ou document_mismatch Devolve {status: false, message: "A chave PIX precisa ser do titular da conta."}
422 player_blocked, destination_blocked ou velocity_exceeded Devolve {status: false, message: "Não foi possível solicitar o saque agora."} (genérica: não revele a lista)
422 insufficient_operation_balance Devolve e alerta o financeiro {status: false, message: "Saque temporariamente indisponível. Tente mais tarde."}
400 validation_failed Devolve e alerta (bug do conector) {status: false, message: <mensagem legada de dados inválidos>}
400 currency_not_enabled ou method_not_enabled Devolve e alerta {status: false, message: "Saque indisponível."}

"Erros de negócio mantêm as mensagens atuais": use os textos que o front já mostra hoje; a tabela só sugere para os casos novos.

Pré-checagem local: só endurece

A Nero roda, antes do POST, uma checagem própria que conta também os depósitos anteriores ao Dracma (D3). Ela só pode pedir revisão:

  • Se desconfiou, envia review_requested: true e, se quiser, review_reasons: ["texto curto"] (aparece no painel com o prefixo OP:).
  • Nunca "libera" nada: o Dracma decide o automático com os próprios números.

under_review e o desfecho

  • under_review é decidido no painel do Dracma por revisores da Nero (uma pessoa; duas quando o motivo é DUAL_APPROVAL_REQUIRED). A bet não tem rota de API para aprovar.
  • O desfecho chega por webhook: approved, processing, paid (finaliza) ou rejected (devolve).
  • Os motivos em decision.reasons são para o suporte, nunca para o jogador. Os mais comuns na Nero: FIRST_WITHDRAWAL (primeiro saque), NO_SETTLED_DEPOSIT e AUTO_MULTIPLIER_EXCEEDED (X = 1), OWNERSHIP_UNVERIFIED (faltou player.ip), PIX_KEY_CHANGED (chave nova).

Cancelar, finalizar e devolver

POST /v1/withdrawals/{id}/cancel (chave determinística cnl_<external_id>) funciona em requested, under_review e em approved antes do envio. Depois, 409 invalid_state_transition: espere o desfecho. Só devolva a reserva ao ler cancelled no fetch-back.

No caminho do webhook, o efeito no nero_ledger (idempotente por external_id) é:

Estado lido no fetch-back Ação
paid Finaliza a reserva; guarda o psp_reference (E2E id)
rejected, failed, cancelled Devolve a reserva
returned (depois de paid) ou recalled Nada automático: needs_decision e alerta (D7, D18)
Qualquer outro Nada; a reserva continua
-- devolução idempotente: no máximo uma por saque
INSERT INTO lancamento (origem, external_id, jogador_id, valor_minor, tipo)
VALUES ('dracma', $1, $2, $3, 'devolucao_reserva_saque')
ON CONFLICT (origem, external_id, tipo) DO NOTHING;

Exemplos

O primeiro saque de teste, com a aprovação simulada, está em Primeiros passos no sandbox.

Erros e casos de borda

  • Saque sem player.ip: vai sempre para revisão; verifique que o portal repassa o IP vindo do Caddy.
  • Chave PIX nova do jogador: o motivo PIX_KEY_CHANGED manda para revisão; avise o jogador de que a análise pode demorar.
  • Muitos saques do mesmo jogador na hora: 429 rate_limited; mostre a mensagem genérica e não repita em laço.

Veja também