openapi: 3.1.0

# Contrato formal da API pública de integração do Mais PDV.
# Fonte da verdade do formato — a página /api-docs/v1 explica o "porquê" de
# cada regra; aqui está o "o quê", em formato que gera client automaticamente.
#
# Mantido à mão junto com specs/SPEC_API_PUBLICA_INTEGRACAO.md. Mudou rota,
# muda aqui.

info:
  title: Mais PDV — API de Integração
  version: "1.0.0"
  description: |
    Sincronize clientes, produtos, contas a receber e a pagar entre o Mais PDV
    e o seu sistema.

    **Antes de começar:**
    - A chave vai no header `Authorization: Bearer …`, nunca na URL.
    - Dinheiro é sempre string decimal de 2 casas (`"149.90"`), nunca número.
    - Campo desconhecido no corpo é recusado com 422 — inclusive dentro do `/batch`.
    - Documentação com exemplos: https://maispdv.com/api-docs/v1
  contact:
    name: Suporte Mais PDV
    url: https://maispdv.com/suporte

servers:
  - url: https://maispdv.com/api/v1

security:
  - BearerAuth: []

tags:
  - name: Identidade
  - name: Catálogo
  - name: Clientes
  - name: Fornecedores
  - name: Financeiro
  - name: Vendas
  - name: Webhooks

paths:
  /me:
    get:
      tags: [Identidade]
      summary: Confere a chave e devolve a empresa
      description: Primeira chamada de qualquer integração. Se ela responde 200, a autenticação está resolvida.
      responses:
        "200": { description: OK }
        "401": { $ref: "#/components/responses/NaoAutenticado" }

  /catalog/items:
    get:
      tags: [Catálogo]
      summary: Lista produtos e serviços
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - { name: kind, in: query, schema: { type: string, enum: [product, service] } }
        - { name: search, in: query, schema: { type: string } }
        - { name: external_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      tags: [Catálogo]
      summary: Cria produto ou serviço
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CatalogItemInput" }
      responses:
        "201": { description: Criado }
        "422": { $ref: "#/components/responses/Validacao" }

  /catalog/items/{id}:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    get:
      tags: [Catálogo]
      summary: Detalha um item
      responses: { "200": { description: OK }, "404": { $ref: "#/components/responses/NaoEncontrado" } }
    patch:
      tags: [Catálogo]
      summary: Altera um item
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CatalogItemInput" } } }
      responses: { "200": { description: OK } }
    delete:
      tags: [Catálogo]
      summary: Desativa o item
      description: |
        Exclusão **lógica**: o item é desativado (`is_active = false`), não apagado — o
        histórico de vendas depende dele. A resposta traz `"deleted": false` para deixar isso claro.
      responses: { "200": { description: OK } }

  /catalog/items/by-external/{external_id}:
    parameters: [{ $ref: "#/components/parameters/ExternalId" }]
    put:
      tags: [Catálogo]
      summary: Cria ou atualiza pelo id do seu sistema
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CatalogItemInput" } } }
      responses: { "200": { description: Atualizado }, "201": { description: Criado } }

  /catalog/items/batch:
    post:
      tags: [Catálogo]
      summary: Envia até 500 itens de uma vez
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/Lote" } } }
      responses:
        "200": { description: Todos gravados }
        "207": { $ref: "#/components/responses/LoteParcial" }

  /catalog/items/deleted:
    get:
      tags: [Catálogo]
      summary: O que foi excluído
      parameters: [{ name: since, in: query, schema: { type: string, format: date-time } }]
      responses: { "200": { description: OK } }

  /catalog/categories:
    get: { tags: [Catálogo], summary: Lista categorias, responses: { "200": { description: OK } } }
    post:
      tags: [Catálogo]
      summary: Cria categoria
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [name], properties: { name: { type: string } } } } }
      responses: { "201": { description: Criada } }

  /customers:
    get:
      tags: [Clientes]
      summary: Lista clientes
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - { name: search, in: query, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      tags: [Clientes]
      summary: Cria cliente
      description: |
        **Atenção ao nome repetido.** O Mais PDV não aceita dois clientes com o mesmo nome na
        mesma empresa; a resposta 409 traz `conflicting_id`. Prefira `PUT by-external`.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CustomerInput" } } }
      responses:
        "201": { description: Criado }
        "409": { $ref: "#/components/responses/Duplicado" }

  /customers/{id}:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    get: { tags: [Clientes], summary: Detalha, responses: { "200": { description: OK } } }
    patch:
      tags: [Clientes]
      summary: Altera
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CustomerInput" } } } }
      responses: { "200": { description: OK } }
    delete: { tags: [Clientes], summary: Exclui, responses: { "200": { description: OK } } }

  /customers/by-external/{external_id}:
    parameters: [{ $ref: "#/components/parameters/ExternalId" }]
    put:
      tags: [Clientes]
      summary: Cria ou atualiza pelo id do seu sistema
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CustomerInput" } } } }
      responses: { "200": { description: Atualizado }, "201": { description: Criado } }

  /customers/batch:
    post:
      tags: [Clientes]
      summary: Envia até 500 clientes
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/Lote" } } } }
      responses: { "200": { description: OK }, "207": { $ref: "#/components/responses/LoteParcial" } }

  /customers/deleted:
    get:
      tags: [Clientes]
      summary: O que foi excluído
      parameters: [{ name: since, in: query, schema: { type: string, format: date-time } }]
      responses: { "200": { description: OK } }

  /suppliers:
    get: { tags: [Fornecedores], summary: Lista fornecedores, responses: { "200": { description: OK } } }
    post:
      tags: [Fornecedores]
      summary: Cria fornecedor
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SupplierInput" } } } }
      responses: { "201": { description: Criado } }

  /finance/receivables:
    get:
      tags: [Financeiro]
      summary: Lista contas a receber
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - name: status
          in: query
          description: "`overdue` é calculado (pendente com vencimento no passado), não é um status gravado."
          schema: { type: string, enum: [pending, paid, overdue] }
        - { name: due_from, in: query, schema: { type: string, format: date } }
        - { name: due_to, in: query, schema: { type: string, format: date } }
        - { name: customer_id, in: query, schema: { type: integer } }
        - { name: external_id, in: query, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      tags: [Financeiro]
      summary: Lança conta a receber
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ReceivableInput" } } } }
      responses: { "201": { description: Criada }, "422": { $ref: "#/components/responses/Validacao" } }

  /finance/receivables/{id}:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    get: { tags: [Financeiro], summary: Detalha, responses: { "200": { description: OK } } }
    patch:
      tags: [Financeiro]
      summary: Altera
      responses:
        "200": { description: OK }
        "409": { $ref: "#/components/responses/GeridaPeloPdv" }
    delete:
      tags: [Financeiro]
      summary: Exclui
      responses: { "200": { description: OK }, "409": { $ref: "#/components/responses/GeridaPeloPdv" } }

  /finance/receivables/{id}/settle:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    post:
      tags: [Financeiro]
      summary: Dá baixa na conta
      description: |
        **Exige o header `Idempotency-Key`.** A resposta da primeira chamada fica guardada 24h:
        repetir com a mesma chave devolve a mesma resposta sem baixar de novo.

        Não existe baixa parcial: `amount` serve de conferência e, se divergir do valor da conta, a resposta é 422.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Valor único por operação (um uuid resolve).
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                paid_at: { type: string, format: date-time, description: "Data real do pagamento. Define o regime de caixa. Ausente = agora." }
                payment_method: { type: string }
                amount: { type: string, description: "Conferência. Divergiu, 422." }
      responses:
        "200": { description: Baixada (ou já estava baixada) }
        "409": { description: "`idempotency_key_reused` ou `managed_by_pdv`" }
        "422": { $ref: "#/components/responses/Validacao" }

  /finance/payables:
    get: { tags: [Financeiro], summary: Lista contas a pagar, responses: { "200": { description: OK } } }
    post:
      tags: [Financeiro]
      summary: Lança conta a pagar
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PayableInput" } } } }
      responses: { "201": { description: Criada } }

  /finance/payables/{id}/settle:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    post:
      tags: [Financeiro]
      summary: Dá baixa na conta a pagar
      parameters: [{ name: Idempotency-Key, in: header, required: true, schema: { type: string } }]
      responses: { "200": { description: OK } }

  /finance/summary:
    get:
      tags: [Financeiro]
      summary: Resumo do período
      description: "Liquidado é contado por `paid_at` (regime de caixa); em aberto, por vencimento."
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
      responses: { "200": { description: OK } }

  /finance/payment-methods:
    get:
      tags: [Financeiro]
      summary: Formas de pagamento aceitas
      description: "Use a `key`, não o rótulo: mandar \"PIX\" onde esperamos \"pix\" é 422."
      responses: { "200": { description: OK } }

  /sales:
    get:
      tags: [Vendas]
      summary: Lista vendas
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: status, in: query, schema: { type: string } }
        - { name: source, in: query, schema: { type: string, description: "pdv, api, delivery, comanda…" } }
      responses: { "200": { description: OK } }
    post:
      tags: [Vendas]
      summary: Registra uma venda que já aconteceu no seu sistema
      description: |
        Escreve a venda **já concluída**: baixa estoque e entra no faturamento do assinante.

        Regras que evitam a maior parte dos 422:

        - **Só venda quitada.** A soma de `payments` tem que fechar com o total. Sobra só
          é aceita se houver forma em espécie (vira troco). Para saldo em aberto, use
          `POST /v1/finance/receivables`.
        - **Estoque barra a venda inteira.** Faltando um item, nada é gravado e nada é
          debitado (`insufficient_stock`).
        - **Sem promoção e sem cashback**: o preço que você manda é o preço que vale.
        - **Nada de fiscal**: a API não emite NFC-e/NF-e.
        - `sold_at` define o dia no dashboard e no DRE do assinante (regime de caixa).
        - Cada item usa **um** identificador: `catalog_item_id`, `catalog_item_external_id`
          ou `description` (item avulso, que exige `unit_price`).
        - Sem `unit_price`, vale o preço de tabela do catálogo — sem faixa de atacado.

        Reenviou por timeout? Mesma `Idempotency-Key` + mesmo corpo devolve a MESMA venda.
        Perdeu a chave? O `external_id` repetido responde 409 com o id da venda que entrou.
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Um valor único por venda (um uuid resolve)." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SaleInput" }
      responses:
        "201": { description: Venda registrada }
        "409": { description: "`duplicate_resource` (external_id já usado) ou `idempotency_key_reused`" }
        "422": { description: "`validation_failed`, `insufficient_stock` ou `payment_mismatch`" }

  /sales/{id}:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    get:
      tags: [Vendas]
      summary: Detalha a venda com itens e pagamentos
      responses: { "200": { description: OK } }

  /sales/by-external/{external_id}:
    parameters: [{ $ref: "#/components/parameters/ExternalId" }]
    get:
      tags: [Vendas]
      summary: Busca a venda pelo número do pedido no seu sistema
      description: O par do 409 de duplicidade — é aqui que você confirma que o reenvio não era necessário.
      responses:
        "200": { description: OK }
        "404": { description: Não encontrada }

  /sales/payment-types:
    get:
      tags: [Vendas]
      summary: Formas de pagamento desta empresa
      description: |
        `payments[].payment_type_id` é o id daqui — as formas são cadastradas por cada assinante.

        As formas que a API **não** aceita aparecem na lista com `accepts_api: false` e o motivo:
        convênio (é fiado) e os descontos internos do PDV (crédito de troca, promoção, cashback).
      responses: { "200": { description: OK } }

  /sales/{id}/cancel:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    post:
      tags: [Vendas]
      summary: Cancela uma venda registrada pela API
      description: |
        Devolve o estoque e dispara `sale.cancelled`. Venda já cancelada responde 200 com o
        estado atual (reprocessar a fila não é erro).

        Recusa venda que **não** nasceu na API (`managed_by_pdv`) e venda com documento
        fiscal autorizado (`fiscal_document_issued`) — cancelamento fiscal tem prazo legal
        e é feito pelo assinante no sistema.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, minLength: 3, maxLength: 255, description: "Aparece na tela de vendas do assinante." }
      responses:
        "200": { description: Cancelada (ou já estava) }
        "409": { description: "`managed_by_pdv` ou `fiscal_document_issued`" }

  /webhooks:
    get: { tags: [Webhooks], summary: Lista endereços cadastrados, responses: { "200": { description: OK } } }
    post:
      tags: [Webhooks]
      summary: Cadastra endereço
      description: |
        A URL precisa ser HTTPS. O endereço criado por aqui fica amarrado à chave que o criou —
        e por isso **não recebe de volta** os eventos causados por essa mesma chave.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items:
                    type: string
                    enum: [customer.created, customer.updated, catalog_item.created, catalog_item.updated,
                           receivable.created, receivable.settled, payable.settled, sale.completed, sale.cancelled]
      responses: { "201": { description: Criado } }

  /webhooks/{id}/test:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    post:
      tags: [Webhooks]
      summary: Envia uma entrega de teste
      description: Passa pelo mesmo caminho dos eventos reais (fila, assinatura, reentrega).
      responses: { "202": { description: Enfileirada } }

  /webhooks/{id}/deliveries:
    parameters: [{ $ref: "#/components/parameters/Id" }]
    get:
      tags: [Webhooks]
      summary: Últimas tentativas de entrega
      description: Onde descobrir por que não chegou, sem abrir chamado.
      responses: { "200": { description: OK } }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: "Chave gerada em Empresa → Integrações. Ela aparece uma vez só."

  parameters:
    Id: { name: id, in: path, required: true, schema: { type: integer } }
    ExternalId:
      name: external_id
      in: path
      required: true
      description: O identificador do SEU sistema.
      schema: { type: string, maxLength: 120 }
    Limit: { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
    Cursor:
      name: cursor
      in: query
      description: Devolvido em `meta.next_cursor`. Sem OFFSET — em base grande ele custa caro.
      schema: { type: string }
    UpdatedSince:
      name: updated_since
      in: query
      description: |
        ISO-8601. Traz só o que mudou depois desta data.
        Não cobre exclusões — para elas, use `/deleted`.
      schema: { type: string, format: date-time }

  responses:
    NaoAutenticado: { description: "401 — chave ausente, revogada ou expirada" }
    NaoEncontrado: { description: "404 — não existe ou é de outra empresa (nunca dizemos qual)" }
    Validacao: { description: "422 — campo inválido ou desconhecido" }
    Duplicado: { description: "409 — bateu num índice único; `details` traz `field` e `conflicting_id`" }
    GeridaPeloPdv: { description: "409 `managed_by_pdv` — conta gerada por venda no PDV: leitura sim, escrita não" }
    LoteParcial:
      description: "207 — sucesso e falha convivendo; cada erro traz o `index` do item na sua lista"

  schemas:
    Lote:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 500
          description: "`external_id` é obrigatório em cada item: é ele que evita duplicar ao rodar de novo."
          items: { type: object }

    CatalogItemInput:
      type: object
      required: [name, price]
      properties:
        external_id: { type: string, maxLength: 120 }
        name: { type: string }
        kind: { type: string, enum: [product, service], default: product }
        price: { type: string, example: "12.90" }
        cost: { type: string }
        unit: { type: string }
        barcode: { type: string }
        category: { type: string }
        track_stock: { type: boolean, description: "Serviço é sempre false." }
        stock: { type: string }

    CustomerInput:
      type: object
      required: [name]
      properties:
        external_id: { type: string, maxLength: 120 }
        name: { type: string, description: "Único por empresa — nome repetido devolve 409." }
        person_type: { type: string, enum: [F, J] }
        document: { type: string, description: "CPF ou CNPJ, só dígitos (a máscara é removida)." }
        email: { type: string, format: email }
        phone: { type: string }
        birthdate: { type: string, format: date }
        notes: { type: string }
        address:
          type: object
          properties:
            street: { type: string }
            number: { type: string }
            district: { type: string }
            complement: { type: string }
            zip_code: { type: string }
            city: { type: string }
            state: { type: string, maxLength: 2 }

    SupplierInput:
      type: object
      required: [name]
      properties:
        external_id: { type: string }
        name: { type: string }
        document: { type: string }
        email: { type: string }
        phone: { type: string }

    ReceivableInput:
      type: object
      required: [description, amount, due_date]
      properties:
        external_id: { type: string, maxLength: 120 }
        description: { type: string }
        amount: { type: string, example: "149.90" }
        due_date: { type: string, format: date }
        customer_id: { type: integer, description: "Precisa ser um cliente DESTA empresa." }
        category: { type: string }
        payment_method: { type: string }
        paid_at: { type: string, format: date-time, description: "Preenchido = a conta já nasce baixada (importação de histórico)." }
        notes: { type: string }

    PayableInput:
      allOf:
        - $ref: "#/components/schemas/ReceivableInput"
      description: "Igual ao recebível, trocando `customer_id` por `supplier_id`."

    SaleInput:
      type: object
      required: [external_id, items, payments]
      properties:
        external_id:
          type: string
          maxLength: 120
          description: "O número do pedido no SEU sistema. Único por empresa — é ele que impede a venda de entrar duas vezes."
          example: "PED-1001"
        customer_id: { type: integer, description: "Cliente DESTA empresa." }
        customer_external_id: { type: string, maxLength: 120, description: "Alternativa ao `customer_id`. Os dois juntos = 422." }
        seller_id: { type: integer, description: "Usuário desta empresa que vendeu." }
        sold_at:
          type: string
          format: date-time
          description: "Quando a venda aconteceu. Define o dia no dashboard e no DRE. Ausente = agora."
        delivery_fee: { type: string, example: "5.00" }
        notes: { type: string, maxLength: 2000 }
        items:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/SaleItemInput" }
        payments:
          type: array
          minItems: 1
          maxItems: 10
          description: "Uma linha por forma. A soma tem que fechar com o total (troco só com dinheiro em espécie)."
          items: { $ref: "#/components/schemas/SalePaymentInput" }

    SaleItemInput:
      type: object
      required: [quantity]
      description: "Use EXATAMENTE um identificador: `catalog_item_id`, `catalog_item_external_id` ou `description`."
      properties:
        catalog_item_id: { type: integer }
        catalog_item_external_id: { type: string, maxLength: 120 }
        description: { type: string, maxLength: 255, description: "Item avulso, fora do catálogo. Exige `unit_price` e não mexe em estoque." }
        quantity: { type: string, example: "2", description: "Até 3 casas. Produto não fracionado com quantidade quebrada = 422." }
        unit_price: { type: string, example: "10.00", description: "Ausente, vale o preço de tabela do catálogo." }
        notes: { type: string, maxLength: 255 }

    SalePaymentInput:
      type: object
      required: [payment_type_id, amount]
      properties:
        payment_type_id: { type: integer, description: "Id de `GET /v1/sales/payment-types`." }
        amount: { type: string, example: "45.00", description: "O bruto cobrado do cliente — acréscimo da forma já incluso." }
