Saque no nero_payments¶
Implementação de POST /api/wallet/withdraw/request e GET /api/payments/withdrawals/{withdrawal_id} sobre o
POST /v1/withdrawals do Dracma. Referência completa da API: ../saques.md.
1. 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/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 + 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 → 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.
2. Do corpo legado ao corpo do Dracma¶
Pedido do front (legado, Contrato §11): {amount, type: "pix", pix_key, pix_type, accept_terms, currency?}.
| Campo legado | Vai ao Dracma como | Regra |
|---|---|---|
amount (reais) |
amount.amount em centavos, string |
mesma conversão sem float do depósito (deposito.md) |
currency |
amount.currency |
ausente = BRL; outra moeda → recuse |
type |
method |
só pix |
pix_type |
destination.pix.key_type |
cpf, cnpj, email, phone, random. Mapeie legados: evp/aleatoria → random, telefone/celular → phone |
pix_key |
destination.pix.key |
normalize: CPF/CNPJ só dígitos; telefone em +55DDDNUMERO; e-mail em minúsculas; 1–140 caracteres |
accept_terms |
não vai | regra da bet (exija true antes de reservar) |
| — | player.external_ref, document, name |
do cadastro (asserção do portal) |
| — | player.ip |
IP do jogador vindo do Caddy/portal. Sem ele o saque nunca é automático (OWNERSHIP_UNVERIFIED) |
| — | player.device_id |
se a bet tiver |
| — | review_requested |
true quando a pré-checagem local desconfiar (ver seção 4) |
| — | player_context |
opcional e informativo (total_deposited, total_withdrawn, account_created_at, net_gaming_result) |
{
"external_id": "wdr_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6c",
"player": {
"external_ref": "0192a3b4-0000-7000-8000-000000000001",
"document": { "type": "CPF", "number": "12345678909", "country": "BR" },
"name": "Jogador Teste",
"ip": "203.0.113.10"
},
"amount": { "amount": "5000", "currency": "BRL" },
"method": "pix",
"destination": { "pix": { "key_type": "cpf", "key": "12345678909" } },
"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/random são conferidas no DICT pelo Dracma (pode levar
alguns minutos: requested).
3. Resposta do Dracma → resposta ao front¶
| Dracma | Reserva | Front (200 do legado) |
|---|---|---|
201 approved / 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 / 429 (intent gravado) |
mantém | {status: true, withdrawal_id, state: "processing", …} |
422 amount_below_minimum / amount_above_maximum |
devolve | {status: false, message: <mensagem legada de limite>} |
422 document_invalid / document_mismatch |
devolve | {status: false, message: "A chave PIX precisa ser do titular da conta."} |
422 player_blocked / destination_blocked / velocity_exceeded |
devolve | {status: false, message: "Não foi possível solicitar o saque agora."} (genérica: não revele lista) |
422 insufficient_operation_balance |
devolve + alerta financeiro | {status: false, message: "Saque temporariamente indisponível. Tente mais tarde."} |
400 validation_failed |
devolve + alerta (bug do conector) | {status: false, message: <mensagem legada de dados inválidos>} |
400 currency_not_enabled / method_not_enabled |
devolve + alerta | {status: false, message: "Saque indisponível."} |
"Erros de negócio mantêm as mensagens atuais" (§11): use os textos que o front já mostra hoje; a tabela só sugere para os casos novos.
4. Pré-checagem local (só endurece)¶
A Nero pode rodar, antes do POST, uma checagem própria (ex.: DRACMA_LOCAL_AUTO_MULTIPLIER=1, contando também
depósitos anteriores ao Dracma — D3). Ela só pode pedir revisão:
- desconfiou →
review_requested: truee, se quiser,review_reasons: ["texto curto"](aparece no painel comoOP:texto); - nunca "libera" nada: o Dracma decide o automático com os próprios números.
5. under_review e o desfecho¶
under_reviewé decidido no painel do Dracma por revisores da Nero (1 pessoa; 2 comDUAL_APPROVAL_REQUIRED). A bet não tem rota de API para aprovar.- O desfecho chega por webhook:
approved→processing→paid(finaliza) ourejected(devolve). - Motivos em
decision.reasons— mostre ao suporte, nunca ao jogador. Os mais comuns na Nero:FIRST_WITHDRAWAL(primeiro saque, sempre humano),NO_SETTLED_DEPOSIT/AUTO_MULTIPLIER_EXCEEDED(X = 1),OWNERSHIP_UNVERIFIED(faltouplayer.ip),PIX_KEY_CHANGED(chave nova).
6. Cancelar¶
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.
7. Finalizar e devolver (no caminho do webhook)¶
| Estado lido no fetch-back | Ação no nero_ledger (idempotente por external_id) |
|---|---|
paid |
finaliza a reserva; guarda psp_reference (E2E id) |
rejected, failed, cancelled |
devolve a reserva |
returned (depois de paid), recalled |
nada automático: needs_decision + alerta (D7, D18) |
| qualquer outro | nada; reserva mantida |
-- 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;