openapi: 3.1.0
info:
  title: Sipru — API de parceiros ERP
  version: 1.0.0
  summary: Handoff SSO, canal ao vivo e push de produto para a tela do vendedor.
  description: |
    Contrato server-to-server entre o ERP parceiro e o Sipru. Cobre os dois
    modos de integração hoje em produção:

    - **Handoff SSO** (`POST /api/erp/sso/session`): o ERP pede uma URL de
      entrada e o vendedor "sai" do ERP já logado na conta Sipru real dele,
      com a cotação e o cliente anexados ao contexto.
    - **Canal ao vivo** (`POST /api/erp/channel`): além do handoff, abre um
      canal em que o ERP empurra produtos para a tela que o vendedor está
      olhando (`POST /api/erp/channel/{channelId}/product`).

    O caminho de volta — o Sipru avisando o ERP que um item entrou no
    carrinho — está descrito em `webhooks`, e é implementado **pelo parceiro**.

    **Autenticação.** Toda rota exige as duas credenciais juntas:
    `X-Sipru-Partner-Id: <id do parceiro>` e
    `Authorization: Bearer <segredo do parceiro>`. O segredo é emitido em
    `/integracoes/erp-sso` e guardado apenas como hash — ele é exibido uma
    única vez, na emissão ou na rotação.

    **`environment` é sandbox por padrão.** Esse campo decide qual banco do ERP
    recebe a gravação do pedido, e apenas o literal `"prd"` seleciona a base
    real. Qualquer outro valor, incluindo o campo ausente, cai no sandbox —
    deliberadamente, para que um campo esquecido nunca escreva na base de
    produção do parceiro.
  contact:
    name: Sipru
    url: https://docs.sipru.ai
servers:
  - url: https://app.sipru.ai
    description: Produção
  - url: https://dev.sipru.ai
    description: Homologação (DEV)
security:
  - partnerId: []
    partnerSecret: []
tags:
  - name: Handoff SSO
    description: Entrada do vendedor no catálogo Sipru a partir de uma cotação do ERP.
  - name: Canal ao vivo
    description: Canal por cotação em que o ERP empurra produtos para a tela do vendedor.
paths:
  /api/erp/sso/session:
    post:
      operationId: createSsoSession
      tags:
        - Handoff SSO
      summary: Emite a URL de entrada do vendedor
      description: |
        Devolve uma URL de uso único, válida por `ERP_SSO_TOKEN_TTL_SEC`
        (600 s por padrão). **A URL funciona exatamente uma vez**: um segundo
        clique ou um reload da mesma URL responde
        `ERP_SSO_TOKEN_ALREADY_USED`. Para testar de novo, gere uma URL nova —
        esse comportamento é a proteção contra replay, não um defeito.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoSessionRequest'
            examples:
              porCnpjDaLoja:
                summary: Loja identificada pelo CNPJ (companyId resolvido pelo Sipru)
                value:
                  tempQuoteId: TMP-90321
                  quoteId: ORC-2026-1187
                  vendedor:
                    cpf: '12345678909'
                  cliente:
                    cnpj: '04252011000110'
                    razaoSocial: Construtora Alvorada Ltda
                    nomeFantasia: Alvorada Materiais
                  locationId: '07526557000100'
                  pricingPolicy: margem2
                  environment: dev
      responses:
        '200':
          description: URL emitida.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoSessionResponse'
        '400':
          description: |
            Payload inválido, campo obrigatório ausente, ou nenhuma loja
            encontrada para o CNPJ enviado (`code: STORE_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '401':
          $ref: '#/components/responses/NaoAutorizado'
        '404':
          description: CPF não corresponde a nenhum vendedor cadastrado no Sipru.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
              example:
                error: Vendedor não encontrado no Sipru.
                code: VENDEDOR_NOT_FOUND
        '500':
          $ref: '#/components/responses/ErroInterno'
        '503':
          $ref: '#/components/responses/Indisponivel'
  /api/erp/channel:
    post:
      operationId: createChannel
      tags:
        - Canal ao vivo
      summary: Abre um canal ao vivo para a cotação
      description: |
        Faz tudo o que `/api/erp/sso/session` faz e ainda cria um canal
        identificado por `channelId`, com os endpoints para o ERP empurrar
        produtos e para a tela do vendedor consumir os eventos.

        Se `cartCallbackUrl` for informado, ele tem precedência sobre a
        configuração de loja em `/integracoes/cart-erp` para o webhook de
        item adicionado (ver `webhooks`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChannelRequest'
            examples:
              comCallback:
                summary: Canal com callback de carrinho por cotação
                value:
                  tempQuoteId: TMP-90321
                  vendedor:
                    cpf: '12345678909'
                  locationId: '07526557000100'
                  pricingPolicy: margem1
                  environment: dev
                  cartCallbackUrl: https://erp.parceiro.com.br/sipru/carrinho
                  cartCallbackAuth:
                    mode: bearer
                    token: ••••
                  ttlSec: 14400
      responses:
        '201':
          description: Canal criado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelResponse'
        '400':
          description: |
            Payload inválido, campo obrigatório ausente, `cartCallbackUrl` mal
            formada ou sem HTTPS em produção, ou `STORE_NOT_FOUND`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '401':
          $ref: '#/components/responses/NaoAutorizado'
        '404':
          description: Vendedor não encontrado (`VENDEDOR_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '500':
          $ref: '#/components/responses/ErroInterno'
        '503':
          $ref: '#/components/responses/Indisponivel'
  /api/erp/channel/{channelId}/product:
    post:
      operationId: pushProductToChannel
      tags:
        - Canal ao vivo
      summary: Empurra um produto para a tela do vendedor
      description: |
        Resolve o termo no catálogo da loja do canal — com o tenant e a
        política de preço do próprio canal — e publica o resultado na tela que
        o vendedor está olhando, junto com substitutos, cross-sell e upsell.

        A precedência do termo é `productCode` → `ean` → `query`, porque o
        código do parceiro é o mais determinístico e o texto livre é o último
        recurso (cobre o caso de o código não existir no catálogo Sipru).
      parameters:
        - name: channelId
          in: path
          required: true
          description: O `channelId` devolvido por `POST /api/erp/channel`.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChannelProductRequest'
            examples:
              porCodigo:
                summary: Por código do parceiro
                value:
                  productCode: '72411'
                  quantity: 4
              porTextoLivre:
                summary: Por texto livre
                value:
                  query: argamassa ac3 20kg
                  quantity: 1
      responses:
        '200':
          description: Produto publicado na tela do vendedor.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelProductResponse'
        '400':
          description: Payload inválido ou nenhum termo informado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
              example:
                error: Informe productCode, ean ou query.
        '401':
          $ref: '#/components/responses/NaoAutorizado'
        '404':
          description: |
            Canal inexistente ou de outro parceiro (`CHANNEL_NOT_FOUND`), ou
            termo sem correspondência no catálogo (`PRODUCT_NOT_FOUND`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '409':
          description: Canal já encerrado (`CHANNEL_CLOSED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '410':
          description: Canal expirado (`CHANNEL_EXPIRED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Erro'
        '500':
          $ref: '#/components/responses/ErroInterno'
        '503':
          $ref: '#/components/responses/Indisponivel'
webhooks:
  cartItemAdded:
    post:
      operationId: onCartItemAdded
      summary: (parceiro implementa) Item adicionado ao carrinho
      description: |
        Quando o vendedor clica em "adicionar" na tela do Sipru, o Sipru faz
        `POST` no endpoint do parceiro. O destino é resolvido nesta ordem:

        1. `cartCallbackUrl` do canal, quando enviado na criação;
        2. `cartCallbackUrl` cadastrada para a loja em `/integracoes/cart-erp`;
        3. `erpBaseUrl` + `orderEndpointPath` da mesma configuração.

        Sem nenhum dos três, o clique não vira `POST`.

        **Contrato esperado do parceiro:** responder 2xx. O Sipru tenta 2 vezes,
        com timeout de 8 s, e só repete em status transitório (408, 429, 5xx) —
        um 4xx não transitório recusa o item de vez. Deduplique por
        `Idempotency-Key`, que também vem no corpo como `idempotencyKey`.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Igual ao `idempotencyKey` do corpo. Formato `erp_cart_<uuid>`.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CartItemAddedEvent'
      responses:
        2XX:
          description: Item aceito pelo ERP. Qualquer 2xx serve.
components:
  securitySchemes:
    partnerId:
      type: apiKey
      in: header
      name: X-Sipru-Partner-Id
      description: Identificador do parceiro, minúsculo.
    partnerSecret:
      type: http
      scheme: bearer
      description: |
        Segredo do parceiro emitido em `/integracoes/erp-sso`. Guardado apenas
        como hash HMAC; exibido uma única vez na emissão ou rotação.
  responses:
    NaoAutorizado:
      description: Credenciais de parceiro ausentes, inválidas ou revogadas.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
          example:
            error: Credenciais de parceiro inválidas.
    ErroInterno:
      description: Falha inesperada no Sipru.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
    Indisponivel:
      description: |
        Dependência do Sipru indisponível (autenticação de parceiros,
        resolução de loja, ou armazenamento de sessão/canal). É seguro repetir.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Erro'
  schemas:
    Erro:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          description: Mensagem legível, em português.
        code:
          type: string
          description: |
            Código estável, quando existe. É por ele que se deve ramificar —
            a mensagem pode mudar.
          enum:
            - STORE_NOT_FOUND
            - VENDEDOR_NOT_FOUND
            - CHANNEL_NOT_FOUND
            - CHANNEL_CLOSED
            - CHANNEL_EXPIRED
            - PRODUCT_NOT_FOUND
    Cliente:
      type: object
      description: Cliente da cotação. Todos os campos são opcionais.
      properties:
        cnpj:
          type: string
          maxLength: 32
        razaoSocial:
          type: string
          maxLength: 200
        nomeFantasia:
          type: string
          maxLength: 200
    PricingPolicy:
      type: string
      description: |
        Faixa de margem aplicada aos preços exibidos. Valor desconhecido ou
        ausente cai em `margem1`.
      enum:
        - margem1
        - margem2
        - margem3
        - margem4
      default: margem1
    Environment:
      type: string
      description: |
        Banco do ERP que recebe a gravação do pedido. **Só o literal `"prd"`
        seleciona a base real**; qualquer outro valor, ou o campo ausente,
        significa sandbox.
      enum:
        - dev
        - prd
      default: dev
    HandoffBase:
      type: object
      required:
        - tempQuoteId
        - vendedor
        - locationId
      properties:
        tempQuoteId:
          type: string
          maxLength: 120
          description: |
            Identificador da cotação em aberto no ERP. É a chave de
            idempotência da gravação do pedido — uma gravação por cotação.
        quoteId:
          type: string
          maxLength: 120
          description: Número definitivo da cotação/orçamento, quando já existe.
        vendedor:
          type: object
          required:
            - cpf
          properties:
            cpf:
              type: string
              maxLength: 32
              description: |
                CPF do vendedor. É por ele que o Sipru encontra a conta real
                do vendedor — se não houver conta, a chamada falha com
                `VENDEDOR_NOT_FOUND`.
        cliente:
          $ref: '#/components/schemas/Cliente'
        companyId:
          type: string
          maxLength: 120
          description: |
            Tenant no Sipru. Opcional: se omitido, `locationId` é tratado como
            o CNPJ da loja e o Sipru resolve `companyId` e o `locationId` real
            pelo catálogo de lojas.
        locationId:
          type: string
          maxLength: 120
          description: |
            Loja. Com `companyId` presente, é o identificador da loja no
            tenant; sem ele, é o **CNPJ da loja** (string opaca, aceita
            caracteres não numéricos).
        pricingPolicy:
          $ref: '#/components/schemas/PricingPolicy'
        environment:
          $ref: '#/components/schemas/Environment'
    SsoSessionRequest:
      allOf:
        - $ref: '#/components/schemas/HandoffBase'
        - type: object
          properties:
            redirectPath:
              type: string
              description: |
                Caminho interno para onde mandar o vendedor depois do login.
                Precisa começar com `/` e não pode começar com `//` — qualquer
                outro valor é ignorado.
              examples:
                - /erp/v2
    SsoSessionResponse:
      type: object
      required:
        - url
        - expiresAt
        - contextId
      properties:
        url:
          type: string
          format: uri
          description: URL de uso único para abrir no navegador do vendedor.
        expiresAt:
          type: string
          format: date-time
          description: Expiração do token de handoff (ISO 8601, UTC).
        contextId:
          type: string
          format: uuid
          description: Contexto criado para esta cotação.
    ChannelRequest:
      allOf:
        - $ref: '#/components/schemas/HandoffBase'
        - type: object
          properties:
            cartCallbackUrl:
              type: string
              format: uri
              maxLength: 500
              description: |
                Endpoint do parceiro que recebe o webhook `cart_item_added`
                desta cotação. Precisa ser HTTPS em produção (HTTP é aceito
                apenas em DEV, para apontar para host interno na homologação).
            cartCallbackAuth:
              type: object
              description: Credencial estática do callback. OAuth não é suportado aqui.
              properties:
                mode:
                  type: string
                  enum:
                    - none
                    - bearer
                    - api_key
                  default: none
                  description: |
                    `bearer` envia `Authorization: Bearer <token>`; `api_key`
                    envia `X-API-Key: <token>`.
                token:
                  type: string
                  maxLength: 500
            ttlSec:
              type: integer
              minimum: 1
              maximum: 86400
              description: |
                Validade do canal em segundos. Valores acima de 86400 são
                truncados; valor inválido cai no padrão do servidor.
    ChannelResponse:
      type: object
      required:
        - channelId
        - contextId
        - url
        - expiresAt
        - channelExpiresAt
        - endpoints
      properties:
        channelId:
          type: string
          format: uuid
        contextId:
          type: string
          format: uuid
        url:
          type: string
          format: uri
          description: URL de uso único de entrada do vendedor, já apontando para o canal.
        expiresAt:
          type: string
          format: date-time
          description: Expiração do token de handoff — curta, minutos.
        channelExpiresAt:
          type: string
          format: date-time
          description: Expiração do canal — longa, até 24 h.
        endpoints:
          type: object
          properties:
            product:
              type: string
              format: uri
              description: Onde o ERP empurra produtos.
            stream:
              type: string
              format: uri
              description: >-
                SSE consumido pela tela do vendedor (sessão do vendedor, não
                credencial de parceiro).
    ChannelProductRequest:
      type: object
      description: Informe ao menos um entre `productCode`, `ean` e `query`.
      properties:
        productCode:
          type: string
          maxLength: 120
          description: Código do produto no ERP do parceiro.
        ean:
          type: string
          maxLength: 60
        query:
          type: string
          maxLength: 120
          description: Texto livre.
        quantity:
          type: integer
          minimum: 1
          maximum: 9999
          default: 1
          description: Valor inválido ou ausente vira 1; acima de 9999 é truncado.
      anyOf:
        - required:
            - productCode
        - required:
            - ean
        - required:
            - query
    ChannelProductResponse:
      type: object
      required:
        - seq
        - product
        - recommendations
      properties:
        seq:
          type: integer
          description: Sequencial do evento no canal, útil para retomar o stream.
        product:
          type: object
          properties:
            sku:
              type: string
            name:
              type: string
            price:
              type: number
            coinsReward:
              type: number
              description: Sipru Points que o vendedor ganha na venda.
        recommendations:
          type: object
          description: Quantidade de recomendações publicadas junto com o produto.
          properties:
            substitutes:
              type: integer
            crossSell:
              type: integer
            upsell:
              type: integer
    CartItemAddedEvent:
      type: object
      required:
        - idempotencyKey
        - event
        - channelId
        - tempQuoteId
        - vendedor
        - tenant
        - item
        - timestamp
      properties:
        idempotencyKey:
          type: string
          examples:
            - erp_cart_4f1c…
        event:
          type: string
          const: cart_item_added
        channelId:
          type: string
          format: uuid
        tempQuoteId:
          type: string
        quoteId:
          type:
            - string
            - 'null'
        cliente:
          $ref: '#/components/schemas/Cliente'
        vendedor:
          type: object
          properties:
            cpf:
              type: string
            email:
              type:
                - string
                - 'null'
        tenant:
          type: object
          properties:
            companyId:
              type: string
            locationId:
              type: string
        item:
          type: object
          required:
            - productCode
            - name
            - quantity
            - unitPrice
            - coinsReward
          properties:
            productCode:
              type: string
            ean:
              type:
                - string
                - 'null'
            name:
              type: string
            quantity:
              type: number
            unitPrice:
              type: number
            coinsReward:
              type: number
            source:
              type:
                - string
                - 'null'
              description: >-
                De onde o item veio na tela (busca, substituto, cross-sell,
                upsell).
        timestamp:
          type: string
          format: date-time
