openapi: 3.1.0

info:
  title: API de Parceiros - QueroApoiar
  version: 1.0.0
  description: |
    API de integração para partidos políticos parceiros da plataforma QueroApoiar.

    Permite que o partido cadastre candidatos em lote, consulte dados das campanhas e
    acompanhe doações recebidas pelos seus candidatos.

    **Escopo:** esta API é desenhada para integração **B2B de partido** - cada chave
    pertence a um partido e, por padrão, o escopo é "todos candidatos desse partido".

    **Escopo por candidato:** opcionalmente, a chave pode ser restrita a uma lista
    de candidatos específicos (caso de uso: agências e contadores que atendem
    candidatos individuais). A restrição é configurada pela QueroApoiar na criação
    da chave e vale para TODA a API: os endpoints de listagem retornam apenas os
    candidatos/doações do escopo, consultas a CPFs fora do escopo respondem `404`,
    e os webhooks entregam somente eventos dos candidatos autorizados. Para
    solicitar uma chave com escopo restrito, informe os CPFs no pedido de criação.

    ## Autenticação

    Todas as chamadas exigem o cabeçalho `X-API-Key` com a chave fornecida pela
    QueroApoiar. Cada parceiro recebe uma chave própria, vinculada a um escopo
    de leitura/escrita restrito aos candidatos do próprio partido. O backend
    identifica o partido a partir da chave - não é necessário incluí-lo na URL.

    ```
    X-API-Key: {sigla}_{ambiente}_{token}
    Exemplo:   X-API-Key: pt_live_a3f9b2c8d1e4f7a0b3c6d9e2f5a8b1c4
    ```

    O prefixo da chave segue o padrão `{sigla}_{ambiente}_` onde:
    - `{sigla}` é o código do partido (ex `pt`, `psdb`, `republicanos`)
    - `{ambiente}` é `live` (produção) ou `test` (homologação)

    ## Rate Limit

    - **Por chave**: 600 requisições por minuto (padrão, configurável por contrato)
    - **Por IP**: 1.200 requisições por minuto (limite secundário, não configurável)
    - **Bulk**: máximo de 500 candidatos por chamada de `POST /candidatos/lote`
    - **Quota diária**: 5.000 candidatos por chave por dia (somando individuais e lote)
    - Em caso de excesso, a API retorna `429 Too Many Requests` com cabeçalhos
      `Retry-After`, `RateLimit-Limit`, `RateLimit-Remaining` e `RateLimit-Reset`
      (segundos para o reset da janela).

    ## Webhooks (signing)

    Webhooks são opcionais e ficam **amarrados à chave de API** - o `webhookUrl`
    é configurado pelo admin no momento da criação da chave e **não pode ser
    sobrescrito por requisição** (essa decisão evita um vetor de exfiltração de
    PII caso a chave vaze). Para alterar a URL, peça atualização ou rotação
    da chave ao admin.

    ### Eventos suportados

    | `type` | Quando dispara |
    |---|---|
    | `donacao.confirmada` | Doação confirmada pelo gateway (`paid=true, status=authorized`) |
    | `donacao.cancelada` | Doação cancelada/recusada |
    | `donacao.estornada` | Doação estornada (refund/chargeback) |
    | `candidatos_lote.concluido` | Bulk de candidatos terminou de processar |

    O admin configura na chave **quais eventos** essa chave deve receber (filtro
    opcional `webhookEventos`). Vazio = recebe todos os tipos liberados pelas
    permissões.

    ### Formato do callback

    Headers:

    ```
    POST <webhookUrl>
    Content-Type: application/json
    X-QA-Signature: t=<unix_seconds>,v1=<hmac_hex>
    X-QA-Timestamp: <unix_seconds>
    X-QA-Event: <type>
    X-QA-Event-Id: evt_<uuid_hex>
    X-QA-Api-Version: 2026-05
    User-Agent: QueroApoiar-Webhook/1.0
    ```

    Body (envelope):

    ```json
    {
      "id": "evt_a1b2c3d4...",
      "type": "donacao.confirmada",
      "apiVersion": "2026-05",
      "createdAt": "2026-05-09T15:30:00.000Z",
      "data": { ... }
    }
    ```

    ### Validação da assinatura

    1. Reconstrua a mensagem como `${X-QA-Timestamp}.${rawBody}` (sem manipular
       o body - use exatamente os bytes recebidos)
    2. Calcule `HMAC-SHA256(webhookSecret, mensagem)` em hexadecimal
    3. Compare em **tempo constante** com o valor de `v1` no header X-QA-Signature
    4. Rejeite se `|now - X-QA-Timestamp| > 300` (5 minutos) - protege contra replay

    ### Idempotência

    Use `id` do envelope para deduplicar. Se receber o mesmo `id` 2x, descarte
    a 2ª. Retries acontecem em qualquer resposta diferente de 2xx ou timeout
    (10s) - total de 5 tentativas com backoff exponencial: 1min, 5min, 30min,
    2h, 12h.

    ### Ordem dos eventos

    **Não garantida.** Use `data.donacao.status` (e não o `type`) como source
    of truth - em corner cases o gateway pode inverter a ordem (ex: cancelada
    chega antes da confirmada).

    O `webhookSecret` é fornecido pela QueroApoiar quando a chave é criada e
    não aparece em nenhuma resposta da API após a criação.

    ## Fluxo de cadastro

    1. Parceiro envia `POST /candidatos` ou `POST /candidatos/lote`
    2. QueroApoiar cria a conta do candidato com senha temporária
    3. Email automático é enviado ao candidato com link de primeiro acesso
    4. Candidato define a própria senha
    5. Candidato é direcionado para o pagamento da taxa de inscrição
    6. Após pagamento, valida o celular por SMS
    7. Campanha é publicada e passa a receber doações

    O partido não paga pelos candidatos. A taxa de inscrição é responsabilidade do
    próprio candidato.

    ## LGPD e Compliance

    O parceiro reconhece que ao cadastrar candidatos pela API:

    - Atua como **controlador** dos dados na origem (LGPD Art 5 VI)
    - Tem **base legal** para o tratamento (legítimo interesse de viabilização
      de candidatura, LGPD Art 7 IX)
    - Cadastra apenas pessoas com quem tem **vínculo real** (filiação ou
      autorização escrita)
    - Aceita que candidatos sem consentimento possam reportar abuso via
      `POST /api/lgpd/reportarAbuso/exec` (público, sem auth) - após **5
      reports válidos em 7 dias**, a chave é **revogada automaticamente**

    Cotas anti-abuso:
    - Máximo de **5000 candidatos/dia** por chave (configurável pelo admin)
    - Domínios de email descartáveis (mailinator, 10minutemail, etc.) são
      bloqueados para evitar uso da plataforma como spammer
    - Bounce rate alto em emails de boas-vindas pode acionar revisão manual

    ## Sandbox e testes

    Use o ambiente `https://sandbox-api.queroapoiar.com.br` para testar a
    integração antes de mover para produção. A chave de sandbox tem o formato
    `{sigla}_test_{token}` e isola todo o tráfego de teste do banco real.

    ### O que muda em sandbox

    - **Gateways em modo teste:** PIX, cartão e boleto rodam em simulação.
      Nenhum cartão real é cobrado.
    - **E-mail e SMS desligados:** nenhum convite ou notificação sai do sistema.
      Use a resposta dos endpoints para inspecionar o que seria enviado.
    - **Sem aparição pública:** candidatos e campanhas criados em sandbox não
      aparecem em `/campanhas`, ranking, sitemap ou perfil público.
    - **NF e FCC desligados:** nada é emitido na Receita ou no TSE.
    - **Cleanup periódico:** dados em sandbox são descartáveis e podem ser
      apagados sem aviso prévio. Não armazene nada de longo prazo lá.

    ### Cartões de teste

    Use estes números públicos no fluxo de doação por cartão. CVV e validade
    aceitam qualquer valor válido (ex: CVV `123`, validade `12/2030`).

    | Cartão | Bandeira | Resultado | 3DS |
    |---|---|---|---|
    | `4000000000001000` | Visa | Aprovado | Sem challenge |
    | `5200000000001005` | Mastercard | Aprovado | Sem challenge |
    | `4000000000001091` | Visa | Aprovado | Com challenge |
    | `5200000000001096` | Mastercard | Aprovado | Com challenge |
    | `4000000000001109` | Visa | Recusado | Com challenge |
    | `5200000000001104` | Mastercard | Recusado | Com challenge |

    ### CPFs de teste

    Qualquer CPF com dígitos verificadores válidos é aceito em sandbox. A
    base de compliance (CpfBase / TSE) não é consultada no ambiente de teste,
    então CPFs fictícios passam. Para gerar CPFs válidos rapidamente, use
    `https://www.4devs.com.br/gerador_de_cpf`.

    ### Como solicitar chave de sandbox

    Envie e-mail para `parceiros@queroapoiar.com.br` com nome do partido,
    código sugerido (ex: `missao`, `pt`), contato técnico e descrição do
    caso de uso. Retornamos em até 5 dias úteis com a chave `_test_` e este
    documento. A chave de produção (`_live_`) é emitida depois que você
    validar a integração no sandbox.

    ## Suporte

    Em caso de dúvidas, entre em contato com `parceiros@queroapoiar.com.br`.
    Para questões de LGPD/privacidade: `lgpd@queroapoiar.com.br`.

  contact:
    name: Equipe de Parcerias QueroApoiar
    email: parceiros@queroapoiar.com.br
    url: https://queroapoiar.com.br

  license:
    name: Uso restrito - parceiros homologados
    url: https://queroapoiar.com.br/termos-parceiros

servers:
  - url: https://api.queroapoiar.com.br
    description: Produção
  - url: https://staging.queroapoiar.com.br
    description: Sandbox/Testes (sem cobrança real; páginas web pedem login básico do staging)

security:
  - ApiKeyAuth: []

tags:
  - name: Candidatos
    description: Cadastro e gestão dos candidatos do partido
  - name: Doacoes
    description: Consulta de doações recebidas pelos candidatos
  - name: Jobs
    description: Acompanhamento de operações assíncronas (lote)
  - name: Documentos
    description: Documentos fiscais (FCC do TSE e Notas Fiscais de inscrição/saque)
  - name: Imagens
    description: Upload de foto de perfil do candidato e capa da campanha

paths:

  /api/parceiros/candidatos:
    post:
      tags: [Candidatos]
      summary: Criar candidato individual
      description: |
        Cria um candidato no partido. A campanha nasce pendente de pagamento da
        inscrição.

        **Ativação (fluxo recomendado):** a resposta inclui `linkAtivacao`.
        Redirecione o candidato para esse link e ele conclui tudo numa tela
        enxuta, **nesta ordem**:
          1. Cai logado (sem precisar de senha) e **paga a inscrição primeiro**
             (PIX ou cartão, instantâneos; valor já resolvido pelo partido).
          2. Só **depois do pagamento confirmar**, define a senha de acesso.
          3. Volta ao seu fluxo, se você informar `urlRetorno`.

        Os dados que você já enviou (nome, CPF, e-mail, telefone) vêm
        pré-preenchidos , o candidato só escolhe a forma de pagamento e cria a
        senha.

        Um e-mail de primeiro acesso também é enviado, mas só como **backup**
        para definir a senha caso o candidato não conclua pelo link na hora.

        Esta chamada é síncrona. Para volumes maiores, use o endpoint `/lote`.
      operationId: criarCandidato
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CandidatoInput'
            examples:
              deputadoFederal:
                summary: Deputado federal (cargo de Câmara)
                value:
                  cpf: "12345678900"
                  nome: "Maria Silva Santos"
                  nomeUrna: "Maria do Bairro"
                  email: "maria.silva@email.com.br"
                  telefone: "11999998888"
                  cargo: "deputado_federal"
                  uf: "SP"
                  municipio: "Sao Paulo"
                  numeroCandidato: "12345"
                  tituloEleitor: "123456789012"
                  dataNascimento: "1985-03-15"
              senador:
                summary: Senador (cargo federal; API ainda exige município, use a capital ou domicílio eleitoral)
                value:
                  cpf: "98765432100"
                  nome: "Joao Pereira da Silva"
                  nomeUrna: "Joao Pereira"
                  email: "joao@email.com.br"
                  telefone: "11988887777"
                  cargo: "senador"
                  uf: "RJ"
                  municipio: "Rio de Janeiro"
              prefeito:
                summary: Prefeito (cargo municipal)
                value:
                  cpf: "11122233344"
                  nome: "Ana Carolina Costa"
                  email: "ana@email.com.br"
                  telefone: "31999990000"
                  cargo: "prefeito"
                  uf: "MG"
                  municipio: "Belo Horizonte"
              vereador:
                summary: Vereador (cargo municipal mais comum)
                value:
                  cpf: "55566677788"
                  nome: "Carlos Eduardo Souza"
                  email: "carlos@email.com.br"
                  telefone: "21998887777"
                  cargo: "vereador"
                  uf: "RJ"
                  municipio: "Niteroi"
      responses:
        '201':
          description: Candidato criado com sucesso
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CandidatoCriado'
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '409':
          description: CPF ou email já cadastrado (mensagem unificada para evitar enumeração)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
              example:
                erro: "CADASTRO_DUPLICADO"
                mensagem: "Já existe um cadastro com algum dos dados informados"
        '415':
          $ref: '#/components/responses/Erro415'
        '422':
          $ref: '#/components/responses/Erro422'
        '429':
          $ref: '#/components/responses/Erro429'
        '500':
          $ref: '#/components/responses/Erro500'

    get:
      tags: [Candidatos]
      summary: Listar candidatos do partido
      description: |
        Retorna todos os candidatos do partido com paginação e filtros opcionais.
        Inclui candidatos cadastrados via API e candidatos que se inscreveram
        diretamente pelo site e indicaram o partido como filiação.
      operationId: listarCandidatos
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/Limite'
        - name: cargo
          in: query
          description: Filtrar por cargo
          schema:
            type: string
            enum: [vereador, prefeito, vice_prefeito, deputado_estadual, deputado_distrital, deputado_federal, senador, suplente_senador, governador, vice_governador, presidente, vice_presidente]
        - name: uf
          in: query
          description: Filtrar por estado (sigla, ex SP, RJ)
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: municipio
          in: query
          description: Filtrar por município
          schema:
            type: string
        - name: status
          in: query
          description: Estado da campanha
          schema:
            type: string
            enum: [aguardando_senha, aguardando_pagamento, aguardando_ativacao, ativa]
        - name: criadoApos
          in: query
          description: Filtra candidatos criados após a data informada (ISO 8601)
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Lista de candidatos
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListaCandidatos'
        '401':
          $ref: '#/components/responses/Erro401'
        '429':
          $ref: '#/components/responses/Erro429'

  /api/parceiros/candidatos/lote:
    post:
      tags: [Candidatos]
      summary: Criar candidatos em lote
      description: |
        Cria vários candidatos em uma única chamada. O processamento é feito de
        forma assíncrona em fila. A resposta retorna um `jobId` que pode ser
        consultado em `GET /api/parceiros/jobs/{id}` para acompanhar o progresso.

        Limite de 500 candidatos por chamada. Para volumes maiores, divida em
        múltiplas chamadas.

        Os emails de primeiro acesso são enviados automaticamente conforme cada
        candidato e processado.
      operationId: criarCandidatosLote
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [candidatos]
              properties:
                candidatos:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: '#/components/schemas/CandidatoInput'
      responses:
        '202':
          description: Lote aceito para processamento
          content:
            application/json:
              schema:
                type: object
                required: [jobId, total, status]
                properties:
                  jobId:
                    type: string
                    example: "job_a3f9b2c8d1e4f7a0"
                  total:
                    type: integer
                    example: 432
                  status:
                    type: string
                    enum: [enfileirado]
                  consultarEm:
                    type: string
                    example: "/api/parceiros/jobs/job_a3f9b2c8d1e4f7a0"
                  estimativaTermino:
                    type: string
                    format: date-time
                    example: "2026-09-20T15:42:18Z"
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '413':
          description: Lote excede limite de 500 candidatos
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '429':
          $ref: '#/components/responses/Erro429'
        '503':
          $ref: '#/components/responses/Erro503'

  /api/parceiros/candidatos/{cpf}:
    parameters:
      - $ref: '#/components/parameters/CpfPath'

    get:
      tags: [Candidatos]
      summary: Consultar candidato por CPF
      operationId: consultarCandidato
      responses:
        '200':
          description: Dados do candidato
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CandidatoDetalhe'
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          $ref: '#/components/responses/Erro404'

    patch:
      tags: [Candidatos]
      summary: Atualizar dados do candidato
      description: |
        Atualiza campos do candidato. Apenas os campos enviados são alterados.
        Campos sensíveis (CPF, email) não podem ser alterados via API - nesses
        casos, o candidato deve atualizar pelo painel da plataforma.
      operationId: atualizarCandidato
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CandidatoPatch'
      responses:
        '200':
          description: Candidato atualizado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CandidatoDetalhe'
        '400':
          $ref: '#/components/responses/Erro400'
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/candidatos/{cpf}/doacoes:
    parameters:
      - $ref: '#/components/parameters/CpfPath'
      - $ref: '#/components/parameters/Pagina'
      - $ref: '#/components/parameters/LimiteDoacoes'
      - name: desde
        in: query
        description: |
          Filtra doações criadas a partir deste instante (ISO 8601). Aceita
          apenas a data (`2026-06-02`) ou data e hora (`2026-06-02T14:30:00Z`).
          Recomendado informar o fuso (`Z` para UTC) ao usar hora.
        schema:
          type: string
          format: date-time
          example: '2026-06-02T14:30:00Z'
      - name: ate
        in: query
        description: |
          Filtra doações criadas até este instante (ISO 8601). Aceita apenas a
          data (`2026-06-02`) ou data e hora (`2026-06-02T18:00:00Z`).
        schema:
          type: string
          format: date-time
          example: '2026-06-02T18:00:00Z'
      - name: status
        in: query
        schema:
          type: string
          enum: [pendente, paga, cancelada, estornada]
    get:
      tags: [Doacoes]
      summary: Listar doações recebidas pelo candidato
      operationId: listarDoacoesCandidato
      responses:
        '200':
          description: Lista de doações
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListaDoacoes'
        '400':
          description: |
            Parâmetro inválido. Pode ser `CPF_INVALIDO` (CPF do path),
            `STATUS_INVALIDO` (filtro `status` fora do enum) ou
            `DATA_INVALIDA` (`desde`/`ate` mal formatado).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/doacoes:
    get:
      tags: [Doacoes]
      summary: Listar todas as doações do partido
      description: |
        Retorna doações recebidas por todos os candidatos do partido, paginadas.
        Suporta filtros por candidato, período, status e gateway.
      operationId: listarDoacoes
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/LimiteDoacoes'
        - name: candidatoCpf
          in: query
          description: Filtrar por CPF do candidato
          schema:
            type: string
        - name: desde
          in: query
          description: |
            Filtra doações criadas a partir deste instante (ISO 8601). Aceita
            apenas a data (`2026-06-02`) ou data e hora (`2026-06-02T14:30:00Z`).
            Recomendado informar o fuso (`Z` para UTC) ao usar hora.
          schema:
            type: string
            format: date-time
            example: '2026-06-02T14:30:00Z'
        - name: ate
          in: query
          description: |
            Filtra doações criadas até este instante (ISO 8601). Aceita apenas a
            data (`2026-06-02`) ou data e hora (`2026-06-02T18:00:00Z`).
          schema:
            type: string
            format: date-time
            example: '2026-06-02T18:00:00Z'
        - name: status
          in: query
          schema:
            type: string
            enum: [pendente, paga, cancelada, estornada]
      responses:
        '200':
          description: Lista de doações
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListaDoacoes'
        '401':
          $ref: '#/components/responses/Erro401'

  /api/parceiros/doacoes/resumo:
    get:
      tags: [Doacoes]
      summary: Resumo agregado de doações
      description: |
        Retorna totais consolidados por candidato no período informado.
        Útil para dashboards do partido.
      operationId: resumoDoacoes
      parameters:
        - name: desde
          in: query
          schema:
            type: string
            format: date-time
        - name: ate
          in: query
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Resumo agregado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResumoDoacoes'
        '401':
          $ref: '#/components/responses/Erro401'

  /api/parceiros/jobs/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        example: "job_a3f9b2c8d1e4f7a0"
    get:
      tags: [Jobs]
      summary: Consultar status de um job assíncrono
      operationId: consultarJob
      responses:
        '200':
          description: Status do job
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusJob'
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/candidatos/{cpf}/fccs:
    parameters:
      - $ref: '#/components/parameters/CpfPath'
    get:
      tags: [Documentos]
      summary: Listar FCCs (TSE) do candidato
      description: |
        Cada saque do candidato gera um arquivo FCC no formato ATSEFCC. Esta
        rota retorna todos os FCCs vinculados, com URL pública pra download
        direto.
      operationId: listarFccs
      responses:
        '200':
          description: Lista de FCCs
          content:
            application/json:
              schema:
                type: object
                properties:
                  candidatoCpf: { type: string }
                  candidatoNome: { type: string }
                  total: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        url: { type: string, nullable: true }
                        nomeArquivo: { type: string, nullable: true }
                        withdrawRef: { type: string, nullable: true }
                        dataSolicitacao: { type: string, format: date-time, nullable: true }
                        criadoEm: { type: string, format: date-time }
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/candidatos/{cpf}/notas-fiscais:
    parameters:
      - $ref: '#/components/parameters/CpfPath'
    get:
      tags: [Documentos]
      summary: Listar notas fiscais do candidato (inscrição + saques)
      description: |
        Retorna todas as NFs emitidas relacionadas ao candidato:
        - tipo `inscricao`: NF da taxa de inscrição (R$199 por padrão)
        - tipo `saque`: NF de cada saque que o candidato fez

        URL `pdfUrl` é direta pro provedor de notas fiscais (válida por tempo configurado lá).
      operationId: listarNotasFiscais
      responses:
        '200':
          description: Lista de NFs
          content:
            application/json:
              schema:
                type: object
                properties:
                  candidatoCpf: { type: string }
                  candidatoNome: { type: string }
                  total: { type: integer }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        tipo: { type: string, enum: [inscricao, saque] }
                        pdfUrl: { type: string, nullable: true }
                        xmlUrl: { type: string, nullable: true }
                        numero: { type: string, nullable: true }
                        emitidaEm: { type: string, format: date-time, nullable: true }
                        valor: { type: number, nullable: true }
                        referenciaCriadaEm:
                          type: string
                          format: date-time
                          description: |
                            Data de criação do documento de origem
                            (SubscribeV2 para inscrição, WithdrawV2 para saque).
                            Útil para ordenar a lista quando `emitidaEm` ainda
                            está nulo (NF não emitida).
        '401':
          $ref: '#/components/responses/Erro401'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/candidatos/{cpf}/foto-perfil:
    parameters:
      - $ref: '#/components/parameters/CpfPath'
    post:
      tags: [Imagens]
      summary: Atualizar foto de perfil do candidato
      description: |
        Sobe imagem em base64 como foto principal do candidato. Esta é a foto
        principal do candidato - aparece em listagens e na página dele.

        Validações: jpg/jpeg/png/gif/bmp/webp/svg, max 3MB real (~4MB base64),
        Validação de conteúdo confirma que o arquivo é uma imagem real.
      operationId: uploadFotoPerfil
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [imagemBase64]
              properties:
                imagemBase64:
                  type: string
                  description: Data URL da imagem (data:image/jpeg;base64,...)
                  example: "data:image/jpeg;base64,/9j/4AAQ..."
      responses:
        '200':
          description: Foto atualizada
          content:
            application/json:
              schema:
                type: object
                properties:
                  mensagem: { type: string }
                  mediaId: { type: string }
                  url: { type: string }
                  thumb: { type: string, nullable: true }
        '400':
          $ref: '#/components/responses/Erro400'
        '422':
          description: Imagem inválida
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/candidatos/{cpf}/foto-capa:
    parameters:
      - $ref: '#/components/parameters/CpfPath'
    post:
      tags: [Imagens]
      summary: Atualizar foto de capa da campanha do candidato
      description: |
        Sobe imagem como capa da campanha. É o banner topo da página
        da campanha do candidato. Mesmas validações de tamanho/tipo da foto
        de perfil.
      operationId: uploadFotoCapa
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [imagemBase64]
              properties:
                imagemBase64:
                  type: string
                  description: Data URL da imagem
      responses:
        '200':
          description: Capa atualizada
          content:
            application/json:
              schema:
                type: object
                properties:
                  mensagem: { type: string }
                  mediaId: { type: string }
                  url: { type: string }
                  thumb: { type: string, nullable: true }
        '400':
          $ref: '#/components/responses/Erro400'
        '422':
          description: Imagem inválida
        '401':
          $ref: '#/components/responses/Erro401'
        '403':
          $ref: '#/components/responses/Erro403'
        '404':
          $ref: '#/components/responses/Erro404'

  /api/parceiros/candidatos/{cpf}/sandbox/simular-pagamento:
    post:
      tags: [Candidatos]
      summary: Simular pagamento da inscrição (somente sandbox)
      description: |
        Disponível **apenas no ambiente de teste** (sandbox) e com chave de
        ambiente `test`. Marca a inscrição do candidato como paga, para você
        testar o fluxo de ativação de ponta a ponta sem pagar de verdade , um
        PIX de sandbox não pode ser pago por um banco real. Em produção retorna
        403. Após chamar, a tela de ativação aberta detecta o pagamento (polling)
        e avança para a definição de senha.
      operationId: simularPagamentoSandbox
      parameters:
        - $ref: '#/components/parameters/CpfPath'
      responses:
        '200':
          description: Pagamento de inscrição simulado
          content:
            application/json:
              schema:
                type: object
                properties:
                  sucesso: { type: boolean }
                  mensagem: { type: string }
                  inscricaoPaga: { type: boolean }
        '403':
          description: Indisponível em produção ou chave não é de ambiente de teste
        '404':
          $ref: '#/components/responses/Erro404'

components:

  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Chave de API fornecida pela QueroApoiar ao parceiro

  parameters:
    Pagina:
      name: pagina
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    Limite:
      name: limite
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    LimiteDoacoes:
      name: limite
      in: query
      description: Quantidade de doações por página. Máximo 500.
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 50
    CpfPath:
      name: cpf
      in: path
      required: true
      description: CPF do candidato (apenas números)
      schema:
        type: string
        pattern: '^\d{11}$'
      example: "12345678900"

  schemas:

    RedesSociais:
      type: object
      description: |
        Links ou @ das redes sociais da campanha. Todas as chaves são
        opcionais; cada valor aceita até 300 caracteres.
      properties:
        instagram:
          type: string
          maxLength: 300
          example: "@maria.candidata"
        facebook:
          type: string
          maxLength: 300
        twitter:
          type: string
          maxLength: 300
        youtube:
          type: string
          maxLength: 300
        tiktok:
          type: string
          maxLength: 300
        linkedin:
          type: string
          maxLength: 300
        site:
          type: string
          maxLength: 300
          example: "https://mariacandidata.com.br"

    CandidatoInput:
      type: object
      required: [cpf, nome, email, telefone, cargo, uf, municipio]
      properties:
        cpf:
          type: string
          pattern: '^\d{11}$'
          description: CPF apenas com números (11 dígitos)
          example: "12345678900"
        nome:
          type: string
          minLength: 3
          maxLength: 120
          description: Nome completo conforme RG
          example: "Maria Silva Santos"
        nomeUrna:
          type: string
          maxLength: 30
          description: Nome de urna (caso já registrado no TSE)
          example: "Maria do Bairro"
        email:
          type: string
          format: email
          description: Email pessoal do candidato
        telefone:
          type: string
          pattern: '^\d{10,11}$'
          description: Telefone celular com DDD (apenas números)
          example: "11999998888"
        cargo:
          type: string
          enum:
            - vereador
            - prefeito
            - vice_prefeito
            - deputado_estadual
            - deputado_distrital
            - deputado_federal
            - senador
            - suplente_senador
            - governador
            - vice_governador
            - presidente
            - vice_presidente
        uf:
          type: string
          minLength: 2
          maxLength: 2
          description: Sigla do estado (ex SP, RJ, MG)
        municipio:
          type: string
          description: Município (obrigatório para vereador e prefeito)
        numeroCandidato:
          type: string
          description: Número de urna (caso já definido)
        tituloEleitor:
          type: string
          pattern: '^\d{12}$'
          description: Número do título de eleitor
        dataNascimento:
          type: string
          format: date
          example: "1985-03-15"
        genero:
          type: string
          enum: [M, F, Masculino, Feminino]
          description: |
            Gênero do candidato. Usado para personalizar o tratamento no email de
            boas-vindas (Bem-vindo / Bem-vinda). Opcional, mas recomendado: se
            ausente, é inferido pelo cargo com heurística que pode errar.
          example: "F"
        slug:
          type: string
          pattern: '^[a-z0-9][a-z0-9-]{1,49}$'
          description: |
            Slug customizado da URL pública da campanha (`queroapoiar.com.br/{slug}`).
            Opcional. 3-50 chars, apenas letras minúsculas, números e hífen.

            Se omitido, é gerado automaticamente a partir de `nomeUrna`/`nome`
            com sufixo `-{partidoCodigo}` ou `-{partidoCodigo}-{N}` em caso de colisão.

            Se fornecido e já estiver ocupado, retorna **409 SLUG_OCUPADO** (não
            adiciona sufixo silencioso — parceiro decide qual outro slug usar).
            Slugs reservados (ex: `admin`, `painel`, `api`) retornam 422 SLUG_RESERVADO.
          example: "joao-silva"
        cupom:
          type: string
          description: |
            Código de cupom reservado pelo parceiro. Opcional. A chave precisa ter
            permissão `cupons.aplicar` e o código tem que estar na whitelist
            `cuponsPermitidos` da chave (ambos configurados pelo admin do
            QueroApoiar). Quando aceito, o cupom é gravado em no cadastro do candidato
            e pré-aplicado quando o candidato abrir o checkout. Veja a seção
            "Cupons reservados" da documentação para os 8 códigos de erro
            possíveis (CUPOM_PERMISSAO_NEGADA, CUPOM_NAO_PERMITIDO,
            CUPOM_INEXISTENTE, CUPOM_INATIVO, CUPOM_ESGOTADO, CUPOM_AINDA_NAO_VALIDO,
            CUPOM_EXPIRADO, CUPOM_TIPO_INVALIDO).
          example: "MISSAO-VIP-2026"
        meta:
          type: number
          minimum: 1
          maximum: 100000000
          description: Meta de arrecadação da campanha, em reais. Opcional.
          example: 25000
        descricao:
          type: string
          maxLength: 5000
          description: Texto de descrição da campanha. Opcional.
          example: "Campanha por mais saúde e educação no nosso bairro."
        redesSociais:
          $ref: '#/components/schemas/RedesSociais'
        urlRetorno:
          type: string
          format: uri
          description: |
            URL (http/https) para onde redirecionar o candidato ao concluir a
            ativação (pagar a inscrição e definir a senha), de volta ao seu fluxo.
            Opcional. Se omitida, o candidato segue para o painel QueroApoiar.
            Esquemas diferentes de http/https são rejeitados (422 URL_RETORNO_INVALIDA).
          example: "https://app.meupartido.org.br/onboarding/concluido"

    CandidatoPatch:
      type: object
      description: |
        Atualização parcial. Apenas os campos enviados são alterados. Em
        redesSociais, as chaves não enviadas são preservadas (não apaga).
      properties:
        nomeUrna:
          type: string
        telefone:
          type: string
        cargo:
          type: string
        uf:
          type: string
        municipio:
          type: string
        numeroCandidato:
          type: string
        tituloEleitor:
          type: string
        meta:
          type: number
          minimum: 1
          maximum: 100000000
          description: Meta de arrecadação da campanha, em reais.
        descricao:
          type: string
          maxLength: 5000
          description: Texto de descrição da campanha.
        redesSociais:
          $ref: '#/components/schemas/RedesSociais'

    CandidatoCriado:
      type: object
      properties:
        id:
          type: string
          example: "67a3b8e4f1d29c5e88a0d234"
        cpf:
          type: string
        nome:
          type: string
        email:
          type: string
        cargo:
          type: string
        status:
          type: string
          enum: [aguardando_senha]
          example: "aguardando_senha"
        emailEnfileirado:
          type: boolean
          description: |
            True se o email de boas-vindas foi enfileirado com sucesso. NÃO
            garante entrega - o worker SES processa em seguida com retry.
            Acompanhe via webhooks ou consulta de status.
        urlPublicaCampanha:
          type: string
          description: URL da página pública da campanha (ainda não publicada)
          example: "https://queroapoiar.com.br/maria-do-bairro"
        linkAtivacao:
          type: string
          description: |
            Link pronto para você redirecionar o candidato e concluir a ativação:
            ele cai logado numa tela enxuta para pagar a inscrição (PIX ou cartão,
            instantâneos; valor já resolvido pelo partido) e, ao confirmar o
            pagamento, definir a senha. Use no seu fluxo (redirect ou botão).
            Contém um código de uso restrito com validade de 24h.
          example: "https://queroapoiar.com.br/ativar-inscricao?c=<codigo>&u=<userId>"
        cupomReservado:
          type: string
          nullable: true
          description: |
            Cupom efetivamente reservado para este candidato (em UPPERCASE).
            Aparece apenas se a request enviou `cupom` válido. Esse mesmo código
            será aplicado automaticamente no checkout do candidato.
          example: "MISSAO-VIP-2026"
        criadoEm:
          type: string
          format: date-time

    CandidatoDetalhe:
      allOf:
        - $ref: '#/components/schemas/CandidatoCriado'
        - type: object
          properties:
            telefone:
              type: string
            municipio:
              type: string
            uf:
              type: string
            numeroCandidato:
              type: string
            tituloEleitor:
              type: string
            partido:
              type: string
              description: Nome do partido vinculado à chave de API
            inscricaoPaga:
              type: boolean
            celularValidado:
              type: boolean
            emailVerificado:
              type: boolean
            campanhaPublicada:
              type: boolean
            meta:
              type: number
              nullable: true
              description: |
                Meta de arrecadação da campanha, em reais. `null` quando ainda
                não foi definida. Use o PATCH para definir ou atualizar.
              example: 50000
            descricao:
              type: string
              nullable: true
              description: |
                Texto de descrição da campanha. `null` quando ainda não foi
                definido. Use o PATCH para definir ou atualizar.
            redesSociais:
              allOf:
                - $ref: '#/components/schemas/RedesSociais'
              description: |
                Redes sociais já definidas para a campanha. Objeto vazio (`{}`)
                quando nenhuma foi definida; traz apenas as chaves preenchidas.
                Use o PATCH para definir ou atualizar.
            criadoViaApi:
              type: boolean
              description: True se criado via esta API (em oposição ao cadastro direto pelo site)
            totalArrecadado:
              type: number
              example: 12500.00
            qtdDoacoes:
              type: integer
              example: 87

    ListaCandidatos:
      type: object
      properties:
        total:
          type: integer
        pagina:
          type: integer
        limite:
          type: integer
        items:
          type: array
          items:
            $ref: '#/components/schemas/CandidatoDetalhe'

    Doador:
      type: object
      properties:
        nome:
          type: string
        cpf:
          type: string
        email:
          type: string

    Doacao:
      type: object
      properties:
        id:
          type: string
        data:
          type: string
          format: date-time
        valor:
          type: number
          example: 100.00
        formaPagamento:
          type: string
          enum: [pix, cartao, boleto]
        status:
          type: string
          enum: [pendente, paga, cancelada, estornada]
        candidatoCpf:
          type: string
          description: CPF do candidato dono da vaquinha (NÃO é o CPF do doador)
        candidatoNome:
          type: string
          description: Nome do candidato dono da vaquinha
        candidatoVaquinhaRef:
          type: string
          description: |
            ID da campanha (VaquinhaV2) que recebeu a doação. Útil para
            agrupar múltiplas doações do mesmo candidato sem reprocessar
            o CPF.
        doador:
          $ref: '#/components/schemas/Doador'

    ListaDoacoes:
      type: object
      properties:
        total:
          type: integer
        pagina:
          type: integer
        limite:
          type: integer
        somaValores:
          type: number
        items:
          type: array
          items:
            $ref: '#/components/schemas/Doacao'

    ResumoDoacoes:
      type: object
      properties:
        periodo:
          type: object
          properties:
            desde:
              type: string
              format: date-time
            ate:
              type: string
              format: date-time
        totalGeral:
          type: object
          properties:
            arrecadado:
              type: number
            qtdDoacoes:
              type: integer
            ticketMedio:
              type: number
        porCandidato:
          type: array
          items:
            type: object
            properties:
              candidatoCpf:
                type: string
              candidatoNome:
                type: string
              arrecadado:
                type: number
              qtdDoacoes:
                type: integer
              ticketMedio:
                type: number

    StatusJob:
      type: object
      properties:
        id:
          type: string
        tipo:
          type: string
          example: "candidatos_lote"
        status:
          type: string
          enum: [enfileirado, processando, concluido, falhou]
        total:
          type: integer
        processados:
          type: integer
        sucessos:
          type: integer
        erros:
          type: integer
        criadoEm:
          type: string
          format: date-time
        atualizadoEm:
          type: string
          format: date-time
        concluidoEm:
          type: string
          format: date-time
          nullable: true
        erroProcessamento:
          type: string
          nullable: true
          description: |
            Mensagem genérica quando o job falhou (`status: falhou`). Não
            ecoa o erro bruto do worker para evitar vazar PII; consulte
            os logs internos via suporte se precisar de detalhe.
          example: "Erro no processamento - consulte logs internos"
        resultados:
          type: array
          description: |
            Lista de resultados individuais. Disponível apenas quando status =
            concluído ou falhou.
          items:
            type: object
            properties:
              indice:
                type: integer
              cpf:
                type: string
              status:
                type: string
                enum: [criado, erro]
              candidatoId:
                type: string
                nullable: true
              erro:
                type: string
                nullable: true

    Erro:
      type: object
      required: [erro, mensagem]
      properties:
        erro:
          type: string
          description: Código do erro em formato CONSTANT_CASE
          example: "CAMPO_OBRIGATORIO"
        mensagem:
          type: string
          description: Mensagem em português, segura para exibir ao usuário final
        detalhes:
          type: object
          description: Detalhes adicionais específicos do erro
          additionalProperties: true

  responses:
    Erro400:
      description: |
        Requisição inválida. Possíveis códigos:
        - `CAMPO_OBRIGATORIO` — algum campo obrigatório está ausente
        - `CPF_INVALIDO` — CPF do path não tem 11 dígitos
        - `STATUS_INVALIDO` — filtro `status` fora do enum aceito
        - `DATA_INVALIDA` — `desde`/`ate` mal formatado
        - `WEBHOOK_INVALIDO` — `webhookUrl` da chave aponta pra IP privado/loopback
        - `ID_OBRIGATORIO` — id do job ausente
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "CAMPO_OBRIGATORIO"
            mensagem: "O campo cpf é obrigatório"
            detalhes:
              campo: "cpf"
    Erro401:
      description: Não autenticado ou chave inválida
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "CHAVE_INVALIDA"
            mensagem: "Chave de API inválida ou revogada"
    Erro404:
      description: |
        Recurso não encontrado. Possíveis códigos:
        - `CANDIDATO_NAO_ENCONTRADO` — CPF não pertence ao seu partido (escopo da chave)
        - `JOB_NAO_ENCONTRADO` — id do job não existe ou já expirou (TTL: 7d sucesso, 30d falha)
        - `ROTA_NAO_ENCONTRADA` — método/path não mapeado em `/api/parceiros/*`
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "CANDIDATO_NAO_ENCONTRADO"
            mensagem: "Não foi encontrado candidato com este CPF no partido"
    Erro422:
      description: |
        Validação semântica falhou. Códigos retornados pela validação do
        candidato (todos vêm do `partyCandidateService.validateInput`):

        | Código | Quando ocorre |
        |---|---|
        | `CPF_INVALIDO` | CPF com dígitos verificadores inválidos |
        | `EMAIL_INVALIDO` | Formato de email não passa no regex defensivo |
        | `EMAIL_DOMINIO_BLOQUEADO` | Domínio descartável (mailinator, 10minutemail etc.) |
        | `TELEFONE_INVALIDO` | Não tem 10/11 dígitos ou DDD fora de 11–99 |
        | `NOME_INVALIDO` | Fora do range 3–120 chars |
        | `NOME_URNA_INVALIDO` | Maior que 30 chars |
        | `CARGO_INVALIDO` | Fora dos 13 valores aceitos (campo `detalhes.valores` lista todos) |
        | `UF_INVALIDA` | Sigla não está na lista IBGE |
        | `MUNICIPIO_INVALIDO` | Fora do range 2–80 chars |
        | `NUMERO_CANDIDATO_INVALIDO` | Fora de 2 a 5 dígitos (regex `^[0-9]{2,5}` âncora fim) |
        | `TITULO_ELEITOR_INVALIDO` | Não tem exatamente 12 dígitos |
        | `DATA_NASCIMENTO_INVALIDA` | Data inválida ou ano fora de 1900–hoje |
        | `SLUG_INVALIDO` | Fora de `^[a-z0-9][a-z0-9-]{1,49}` (âncora fim, total 2 a 50 chars) |
        | `SLUG_RESERVADO` | Slug está na lista interna de reservados (admin, painel, etc.) |
        | `CUPOM_INEXISTENTE` / `CUPOM_INATIVO` / `CUPOM_ESGOTADO` / `CUPOM_AINDA_NAO_VALIDO` / `CUPOM_EXPIRADO` / `CUPOM_TIPO_INVALIDO` | Validações de cupom (ver seção Cupons) |
        | `FORMATO_INVALIDO` / `UPLOAD_FALHOU` | Upload de imagem (não é data URL ou conteúdo corrompido) |
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "CPF_INVALIDO"
            mensagem: "O CPF informado não é válido"
    Erro403:
      description: |
        Permissão negada. Possíveis códigos:
        - `PERMISSAO_NEGADA` — chave sem a permissão necessária pro recurso
        - `IP_NAO_AUTORIZADO` — IP de origem fora da whitelist da chave
        - `OWNERSHIP_INVALIDA` — vínculo entre candidato e partido não confirmado (defesa em profundidade)
        - `CUPOM_PERMISSAO_NEGADA` — chave sem `cupons.aplicar` (ver seção Cupons)
        - `CUPOM_NAO_PERMITIDO` — cupom fora da `cuponsPermitidos` da chave
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "PERMISSAO_NEGADA"
            mensagem: "Esta chave não tem permissão para esta operação"
    Erro415:
      description: Content-Type não suportado (use application/json em POST/PATCH)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "TIPO_NAO_SUPORTADO"
            mensagem: "Content-Type deve ser application/json"
    Erro429:
      description: |
        Limite excedido. Pode vir de duas fontes:
        - `RATE_LIMIT_EXCEDIDO` — janela de requisições por minuto (600 por chave / 1.200 por IP)
        - `QUOTA_DIARIA_EXCEDIDA` — quota diária de candidatos cadastrados (5.000/dia por chave); reseta às 00h UTC
      headers:
        Retry-After:
          schema:
            type: integer
          description: Segundos para aguardar antes de tentar novamente
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "RATE_LIMIT_EXCEDIDO"
            mensagem: "Limite de requisições excedido. Tente novamente em alguns segundos."
            detalhes:
              limite: 600
              janelaSegundos: 60
    Erro500:
      description: |
        Erro interno do servidor. Códigos possíveis:
        - `ERRO_INTERNO` — falha inesperada (também reportada via Sentry); tente novamente
        - `URL_UNICA_NAO_GERADA` — falha ao gerar slug único pra campanha após 50 tentativas (caso patológico)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "ERRO_INTERNO"
            mensagem: "Erro interno ao processar a requisição"
    Erro503:
      description: |
        Dependência interna indisponível temporariamente. Códigos:
        - `FILA_INDISPONIVEL` — sistema de fila (Redis/BullMQ) não respondendo;
          vem com `Retry-After: 60`. Reenviar o lote após 1 min costuma resolver.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            erro: "FILA_INDISPONIVEL"
            mensagem: "Sistema de fila temporariamente indisponível, tente novamente em 1 minuto"
