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: true e, se quiser, review_reasons: ["texto curto"] (aparece no painel como OP: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 com 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).
  • 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 (faltou player.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;