{
 "openapi": "3.1.0",
 "info": {
  "title": "VivaPop — Loja do canal próprio",
  "version": "1.0.0",
  "summary": "Catálogo, frete, carrinho e checkout da loja oficial VivaPop (pipoca de cinema).",
  "description": "Fonte de verdade de preço, frete e desconto é SEMPRE este serviço. Nenhum cliente deve calcular valor por conta própria.\n\nPreços são inteiros em centavos (880 = R$ 8,80). O catálogo tem os 36 produtos publicados em https://www.vivapop.com.br, com nome, preço e descrição oficiais.\n\nEstado dos pagamentos (re-medido na borda em 2026-10-29; as duas frases que esta descrição carregava — \"Stripe em sandbox\" e \"Pix não pode ser cobrado\" — estavam vencidas): Stripe está em **produção** (`stripe_modo: \"live\"`, conta `acct_1URtDz1QL0fIO3Tf`, `charges_enabled: true`, chave restrita `rk_live_`) e **cartão, Pix e boleto são cobráveis hoje** — `cartao_disponivel`, `pix_disponivel` e `boleto_disponivel` saem `true`. Mercado Pago segue sem credencial (`mercadopago_modo: \"indisponivel\"`), e isso **não** barra Pix nem boleto: quem decide é o servidor, não o prefixo da chave nem o modo do MP. A regra é não anunciar o que não dá para cobrar: sem credencial, o método some da tela em vez de aparecer e falhar, e POST /checkout/processar responde 422 sem gerar cobrança nenhuma.\n\nO pedido é persistido em banco. GET /pedido/{id}/status consulta de verdade, mas exige o query `cep` (o número do pedido é sequencial e adivinhável) e não devolve dado pessoal — sem nome, e-mail, WhatsApp, endereço ou CPF.\n\nNota fiscal (NF-e modelo 55): existe uma porta pública para o documento de quem comprou — `GET|HEAD /rastreio/{numero}/documento?cep=…`, o mesmo desafio número+CEP do rastreio, devolvendo o DANFE em PDF. Ela **não é uma ferramenta do agente**: o corpo é documento com nome, CPF/CNPJ e endereço de quem comprou. E não confunda porta com entrega: **nada é enviado por e-mail nem WhatsApp** — a credencial da caixa de saída é recusada pelo provedor. O `404` daquela porta é frase única para **toda** recusa possível e não autoriza afirmar nem que a nota existe nem que ela não vai existir. Em 2026-10-29 não havia nenhuma NF-e autorizada em produção nesta loja: para todo pedido, hoje, a resposta é 404.\n\nUm agente com token pode registrar o pedido e entregar ao cliente a página de pagamento hospedada (`criar_pedido` no MCP); sem token, o caminho é o link de sacola /loja/?sacola=SKU:QTD,SKU:QTD, que abre a loja com a sacola pronta e não cria nada.\n\nExiste também um servidor MCP em POST /loja/api/mcp (JSON-RPC 2.0) com as mesmas capacidades; ver https://www.vivapop.com.br/AGENTS.md\n\nOrçamento por endereço de borda: /loja/api/frete, /loja/api/carrinho/simular e /loja/api/mcp admitem 120 requisições por minuto e /loja/api/checkout/processar 30. Acima disso vem 429 com `Retry-After` e corpo JSON explicando o motivo. Catálogo, produto e configuração não têm teto, nem chave.",
  "contact": {
   "name": "VivaPop — SAC",
   "email": "sac@vivapop.com.br",
   "url": "https://www.vivapop.com.br"
  }
 },
 "servers": [
  {"url": "https://www.vivapop.com.br", "description": "Produção (Cloudflare Tunnel)"},
  {"url": "https://www.vivapop.com.br", "description": "Hostname dedicado da loja"}
 ],
 "tags": [
  {"name": "catalogo", "description": "Produtos e categorias oficiais"},
  {"name": "precos", "description": "Frete e precificação autoritativa do carrinho"},
  {"name": "checkout", "description": "Fechamento do pedido"},
  {"name": "meta", "description": "Configuração e integração para agentes"}
 ],
 "paths": {
  "/loja/api/configuracao": {
   "get": {
    "tags": ["meta"],
    "operationId": "getConfiguracao",
    "summary": "Limites comerciais e disponibilidade real de cada gateway",
    "description": "É daqui que a vitrine lê desconto de Pix, parcelas, limite de frete grátis e prazo de postagem. Nenhum cliente deve ter esses números por conta própria. A chave secret jamais é exposta; stripe_publishable_key é null quando não há integração configurada de verdade.",
    "responses": {
     "200": {
      "description": "Configuração atual",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Configuracao"}}}
     }
    }
   }
  },
  "/loja/api/catalogo": {
   "get": {
    "tags": ["catalogo"],
    "operationId": "getCatalogo",
    "summary": "Os 36 produtos oficiais, em listagem compacta",
    "description": "Descrição completa e galeria só em /loja/api/produto/{sku}, para a listagem caber em poucos KB.",
    "responses": {
     "200": {
      "description": "Catálogo",
      "content": {"application/json": {"schema": {
       "type": "object",
       "required": ["categorias", "produtos"],
       "properties": {
        "categorias": {"type": "array", "items": {"$ref": "#/components/schemas/Categoria"}},
        "produtos": {"type": "array", "items": {"$ref": "#/components/schemas/ProdutoResumo"}}
       }
      }}}
     }
    }
   }
  },
  "/loja/api/produto/{sku}": {
   "get": {
    "tags": ["catalogo"],
    "operationId": "getProduto",
    "summary": "Ficha completa de um produto",
    "parameters": [{
     "name": "sku",
     "in": "path",
     "required": true,
     "description": "Sempre maiúsculo. Códigos VIV-* deixaram de existir.",
     "schema": {"type": "string"},
     "example": "VP-KIT-FAMILIA"
    }],
    "responses": {
     "200": {
      "description": "Produto",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProdutoDetalhe"}}}
     },
     "404": {
      "description": "SKU inexistente",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   }
  },
  "/loja/api/frete": {
   "post": {
    "tags": ["precos"],
    "operationId": "postFrete",
    "summary": "PAC e SEDEX para um CEP",
    "description": "Opções de entrega para o CEP. Com `itens` no corpo, sai a **cotação real** das transportadoras que a loja embarca (Correios por contrato e/ou Melhor Envio); sem itens, cai na tabela de estimativa por faixa de CEP. Cada resposta declara de onde veio o número em `fonte` (`cotacao` | `estimativa`) — um preço cotado e um estimado não são a mesma afirmação. O prazo devolvido é o trânsito medido pela transportadora, sem postagem somada: a casa posta no mesmo dia útil após a confirmação do pagamento (`prazo_postagem_dias_uteis: 0`). Não some nada de novo.",
    "requestBody": {
     "required": true,
     "content": {"application/json": {"schema": {
      "type": "object",
      "required": ["cep"],
      "additionalProperties": false,
      "properties": {
       "cep": {"type": "string", "example": "01310-100", "description": "8 dígitos, com ou sem traço"},
       "subtotal_centavos": {"$ref": "#/components/schemas/Centavos", "description": "Necessário para saber se atinge o frete grátis"}
      }
     }}}
    },
    "responses": {
     "429": {"$ref": "#/components/responses/OrcamentoEsgotado"},
     "200": {
      "description": "Opções de frete",
      "content": {"application/json": {"schema": {
       "type": "object",
       "required": ["opcoes", "limite_frete_gratis_centavos", "prazo_postagem_dias_uteis"],
       "properties": {
        "opcoes": {"type": "array", "items": {"$ref": "#/components/schemas/OpcaoFrete"}},
        "fonte": {"type": "string", "enum": ["cotacao", "estimativa"], "description": "De onde veio o preço: cotação real nas transportadoras, ou a tabela de estimativa por faixa de CEP da casa"},
        "cotado_ha_segundos": {"type": "integer", "minimum": 0, "description": "Presente só quando o preço veio de cotação: há quantos segundos esta fila foi cotada nas transportadoras. Ausente na estimativa — não se empresta a uma número inventado o carimbo de frescor de um transportador."},
        "limite_frete_gratis_centavos": {"$ref": "#/components/schemas/Centavos"},
        "prazo_postagem_dias_uteis": {"type": "integer", "example": 0, "description": "Dias úteis de postagem somados ao prazo. 0 = a casa posta no mesmo dia útil após a confirmação do pagamento."}
       }
      }}}
     },
     "400": {
      "description": "CEP inválido",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   }
  },
  "/loja/api/carrinho/simular": {
   "post": {
    "tags": ["precos"],
    "operationId": "postSimular",
    "summary": "Precificação autoritativa de um carrinho",
    "description": "Use antes de apresentar qualquer valor. Sacola vazia é 200 com zeros, não erro. SKU inexistente é 400 — nada é descartado em silêncio. Cupom recusado é 400 com o campo cupom no corpo.",
    "requestBody": {
     "required": true,
     "content": {"application/json": {"schema": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
       "itens": {"type": "array", "items": {"$ref": "#/components/schemas/Item"}},
       "cep": {"type": "string"},
       "cupom": {"type": "string"},
       "frete": {"type": "string", "enum": ["economico", "expresso"], "description": "Pode vir antes do CEP; a preferência vale assim que o CEP chegar."},
       "meio_pagamento": {"type": "string", "enum": ["pix", "cartao", "boleto"]}
      }
     }}}
    },
    "responses": {
     "429": {"$ref": "#/components/responses/OrcamentoEsgotado"},
     "200": {
      "description": "Simulação",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Simulacao"}}}
     },
     "400": {
      "description": "SKU inexistente, cupom recusado, CEP ou opção de frete inválidos",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   }
  },
  "/loja/api/checkout/processar": {
   "post": {
    "tags": ["checkout"],
    "operationId": "postCheckout",
    "summary": "Fecha o pedido",
    "description": "Validação estrita: CPF pelo módulo 11, nome completo com espaço, e-mail, WhatsApp com DDD e endereço completo. Sem gateway configurado responde 422 e nenhuma cobrança é gerada — é o comportamento correto, não uma falha: a loja se recusa a fingir que cobrou.",
    "requestBody": {
     "required": true,
     "content": {"application/json": {"schema": {
      "type": "object",
      "additionalProperties": false,
      "required": ["itens", "nome", "cpf", "email", "whatsapp", "cep", "rua", "numero", "bairro", "cidade", "uf", "meio_pagamento"],
      "properties": {
       "itens": {"type": "array", "minItems": 1, "items": {"$ref": "#/components/schemas/Item"}},
       "nome": {"type": "string", "minLength": 3, "description": "Nome completo: precisa conter um espaço."},
       "cpf": {"type": "string", "description": "11 dígitos, com ou sem máscara. Validado pelo módulo 11."},
       "email": {"type": "string", "format": "email"},
       "whatsapp": {"type": "string", "description": "10 ou 11 dígitos com DDD."},
       "cep": {"type": "string"},
       "rua": {"type": "string"},
       "numero": {"type": "string"},
       "complemento": {"type": "string"},
       "bairro": {"type": "string"},
       "cidade": {"type": "string"},
       "uf": {"type": "string", "minLength": 2, "maxLength": 2},
       "frete": {"type": "string", "enum": ["economico", "expresso"]},
       "cupom": {"type": "string"},
       "meio_pagamento": {"type": "string", "enum": ["pix", "cartao", "boleto"]},
       "parcelas": {"type": "integer", "minimum": 1, "maximum": 6},
       "payment_method": {"type": "string", "description": "pm_… do Stripe, obtido no navegador via Stripe Elements. O número do cartão nunca passa por esta API."}
      }
     }}}
    },
    "responses": {
     "429": {"$ref": "#/components/responses/OrcamentoEsgotado"},
     "200": {
      "description": "Pedido aceito e cobrança emitida",
      "content": {"application/json": {"schema": {
       "type": "object",
       "required": ["ok", "pedido_id", "total_centavos", "pagamento"],
       "properties": {
        "ok": {"type": "boolean", "enum": [true]},
        "pedido_id": {"type": "string"},
        "total_centavos": {"$ref": "#/components/schemas/Centavos"},
        "pagamento": {
         "type": "object",
         "additionalProperties": true,
         "description": "Depende do meio e do gateway. **Cartão (Stripe):** `client_secret`, `payment_intent_id`. **Pix pela Stripe:** `meio:\"pix\"`, `payment_intent_id`, `client_secret`, `copia_e_cola` (payload EMV de 76 caracteres), `qr_code_url` (PNG em qr.stripe.com — é URL, não base64), `qr_code_svg_url`, `instrucoes_url`, `expira_em_timestamp`. **Pix pelo Mercado Pago:** `copia_e_cola` e `qr_code_base64`. **Boleto pela Stripe:** `meio:\"boleto\"`, `linha_digitavel`, `url_pdf`, `url_voucher`, `expira_em_timestamp`. Pix e boleto voltam com `status:\"requires_action\"` — o pagamento acontece fora da página, e o pedido só vira pago quando POST /checkout/confirmar-pagamento (ou o webhook) consulta a operadora."
        }
       }
      }}}
     },
     "400": {
      "description": "Dado de cliente ou endereço inválido, SKU inexistente",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     },
     "422": {
      "description": "Gateway indisponível ou recusou. Nenhuma cobrança foi gerada.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   }
  },
  "/loja/api/checkout/confirmar-pagamento": {
   "post": {
    "tags": ["checkout"],
    "operationId": "postConfirmarPagamento",
    "summary": "Confirma no servidor um pagamento que o navegador acabou de concluir",
    "description": "Chamada pela vitrine depois que `confirmCardPayment` volta com `status: \"succeeded\"`. **O corpo não é fonte de verdade**: o servidor recebe o id do PaymentIntent e vai consultá-lo na Stripe com a chave secret, conferindo ainda se `metadata.pedido_id` é a sessão declarada. Só então marca o pedido como pago.\n\nExiste porque o webhook é o único outro caminho e depende de `VIVAPOP_STRIPE_WEBHOOK_SECRET` — sem ele, o dinheiro era capturado e o pedido ficava em `aguardando_pagamento` para sempre (medido em sandbox: PaymentIntent `succeeded` com `amount_received` 1440 e `pago_em` vazio). Idempotente: `marcar_pago` tem `situacao <> $1` no WHERE, então chamar duas vezes, ou depois do webhook, não muda nada na segunda.\n\nFalhar aqui não desfaz a compra — a Stripe já confirmou no navegador. A vitrine registra `checkout_error` na telemetria e mostra o pedido como confirmado; o webhook cobre depois.",
    "requestBody": {
     "required": true,
     "content": {"application/json": {
      "schema": {
       "type": "object",
       "additionalProperties": false,
       "required": ["payment_intent_id", "pedido_id"],
       "properties": {
        "payment_intent_id": {"type": "string", "pattern": "^pi_[A-Za-z0-9_]{3,125}$", "description": "Id do PaymentIntent devolvido por POST /checkout/processar."},
        "pedido_id": {"type": "string", "description": "O `pedido_id` (id da sessão de checkout) que POST /checkout/processar devolveu. Tem de casar com `metadata.pedido_id` do PaymentIntent."}
       }
      }
     }}
    },
    "responses": {
     "200": {
      "description": "Consulta feita. `pago: true` significa dinheiro recebido E pedido marcado como pago. `pago: false` com `status` cru significa que o pagamento ainda não concluiu (3DS em andamento, cliente abandonou o desafio) — não é erro.",
      "content": {"application/json": {"schema": {
       "type": "object",
       "required": ["ok", "pago"],
       "properties": {
        "ok": {"type": "boolean"},
        "pago": {"type": "boolean"},
        "status": {"type": "string", "description": "Status do PaymentIntent na Stripe: succeeded, requires_action, processing, …"},
        "payment_intent_id": {"type": "string"},
        "valor_centavos": {"$ref": "#/components/schemas/Centavos"},
        "efeito": {"type": "string", "enum": ["marcado", "ja_pago"], "description": "`marcado` = esta chamada mudou a situação; `ja_pago` = já estava pago (retentativa, ou o webhook chegou antes)."}
       }
      }}}
     },
     "400": {
      "description": "Faltou campo, ou o id não tem formato de PaymentIntent.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     },
     "404": {
      "description": "A Stripe não conhece esse PaymentIntent nesta conta.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     },
     "409": {
      "description": "O PaymentIntent existe e está pago, mas `metadata.pedido_id` não é a sessão declarada. Nenhuma linha foi alterada.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     },
     "502": {
      "description": "Não foi possível consultar a operadora. Como não se sabe, não se afirma: `pago` vem `false`. Atenção ao integrar: a borda Cloudflare substitui o corpo de qualquer 5xx pela própria página `error code: 5xx`, então a mensagem em JSON só chega ao cliente nos 4xx.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   }
  },
  "/loja/api/pedido/{id}/status": {
   "get": {
    "tags": ["checkout"],
    "operationId": "getStatusPedido",
    "summary": "Situação de um pedido (exige o CEP de entrega)",
    "description": "Consulta pública de rastreio. **Exige `cep`**: o número do pedido é sequencial e adivinhável (`VP-<ano>-<seq 6>`), então sem um segundo fator qualquer pessoa conseguiria enumerar os pedidos de todo mundo. O CEP é comparado só com dígitos — `01310-100`, `01310100` e `01310%2D100` são o mesmo CEP.\n\nCEP errado devolve o MESMO 404 de pedido inexistente, de propósito: se a resposta distinguisse os dois casos, a rota virava um oráculo de \"esse número existe?\".\n\nA resposta **não contém dado pessoal**: sem nome, sem e-mail, sem WhatsApp, sem endereço e sem CPF. A tela de rastreio mostra número, situação, código de rastreio, transportadora e prazo — nunca precisou de mais que isso. Endereço completo é dado autenticado e mora no painel interno, atrás de sessão.\n\nHistórico que não pode voltar: antes de 2026-10-18 esta rota inventava a resposta — devolvia sempre 200 com status \"Aprovado na Fábrica\", uma chave de NF-e fictícia e um `rastreio_codigo` fabricado a partir do que o cliente tivesse digitado, para qualquer entrada, inclusive lixo.",
    "parameters": [
     {"name": "id", "in": "path", "required": true, "description": "Número do pedido (`VP-2026-000123`) ou o id do gateway (`pi_…`).", "schema": {"type": "string"}},
     {"name": "cep", "in": "query", "required": true, "description": "CEP de entrega do pedido. Aceita com ou sem hífen; só os dígitos são comparados.", "schema": {"type": "string", "example": "01310-100"}}
    ],
    "responses": {
     "200": {
      "description": "Pedido encontrado e CEP confere",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PedidoStatus"}}}
     },
     "400": {
      "description": "Id vazio, ou `cep` ausente/inválido. O corpo traz `campo: \"cep\"` quando é o CEP.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     },
     "404": {
      "description": "Pedido não encontrado OU CEP que não confere — as duas respostas são idênticas.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   }
  },
  "/rastreio/{numero}/documento": {
   "get": {
    "tags": ["checkout"],
    "operationId": "getNotaDocumento",
    "summary": "DANFE em PDF do próprio comprador (exige número do pedido + CEP de entrega)",
    "description": "O documento fiscal **de quem compra**, em PDF (DANFE), gerado neste serviço a partir da nota autorizada — não é o XML, e o XML não sai por porta nenhuma desta casa.\n\n**Esta rota não é uma ferramenta do agente. É a porta do comprador.** O corpo é um documento com nome, CPF/CNPJ e endereço de quem comprou. Um agente pode **verificar** se o documento existe (HEAD, ou GET com o CEP que o próprio cliente informou na conversa); não coleta o PDF, não junta números, não varre faixa de pedido, não retransmite o arquivo nem o conteúdo dele a terceiros. Não existe uso legítimo desta rota com um CEP que não seja o da pessoa pedindo o próprio documento.\n\nO desafio é o mesmo da rota de status: `numero` (ou `gateway_pedido_id`) **+** `cep`, comparado dígito a dígito. E ela não está sob `/api/` de propósito: o gate de sessão morre lá, e esta porta não tem sessão nem Bearer.\n\n**Quando responde 200, e só quando:** existe linha em `loja.nota_fiscal` para aquele pedido com `estado='autorizada'`, `ambiente='producao'`, chave de 44 dígitos com dígito verificador conferido, protocolo e instante de autorização. Nota de **homologação** nunca é entregue a quem compra — carimbada seria entregar teste, limpa seria falsificá-la.\n\n**O que um 404 não quer dizer.** A frase é única (`{\"erro\":\"pedido não encontrado\"}`) e **toda** recusa cabe nela: pedido inexistente, CEP divergente, nota ainda não lavrada, nota lavrada só em homologação, banco sem a camada de nota, cadastro do emitente incompleto. As três últimas têm status próprio (`503`/`409`) na rota do painel; na do cliente, não. Então 404 **não autoriza** dizer \"a loja não emite nota\" nem \"esse pedido não vai ter nota\" — autoriza, no máximo, \"ainda não há documento para esta consulta\". Na data deste contrato (2026-10-29) não existe nenhuma NF-e autorizada em produção nesta loja: 404 é a resposta esperada de todo pedido.\n\n**Nada é enviado a ninguém.** A loja não manda e-mail nem WhatsApp com a nota, com o rastreio nem com a confirmação: a credencial da caixa de saída é recusada pelo provedor. Entrega, aqui, é a pessoa abrir esta porta.\n\nResposta: `no-store`, sem `ETag` (validador de cache em recurso `no-store` é contradição) e sem gzip (PDF já vem comprimido por dentro). `X-VivaPop-Documento-Sha256` traz o digest do arquivo, para conferência sem confiar nesta resposta.",
    "parameters": [
     {"name": "numero", "in": "path", "required": true, "description": "Número do pedido (`VP-2026-000123`) ou o id do gateway (`pi_…`) — os dois são aceitos; o segundo é inadivinhável.", "schema": {"type": "string"}},
     {"name": "cep", "in": "query", "required": true, "description": "CEP de entrega, com ou sem hífen (só os dígitos são comparados). Sem `cep` a rota falha **fechado** no mesmo 404: não existe consulta só pelo número.", "schema": {"type": "string", "example": "01310-100"}}
    ],
    "responses": {
     "200": {
      "description": "PDF do DANFE. Vem com `X-VivaPop-Documento-Sha256` (digest do arquivo) e `X-VivaPop-Pedido` (número do pedido).",
      "headers": {
       "X-VivaPop-Documento-Sha256": {"description": "SHA-256 do PDF entregue, em hexadecimal.", "schema": {"type": "string"}},
       "X-VivaPop-Pedido": {"description": "O número do pedido a que o documento pertence.", "schema": {"type": "string"}}
      },
      "content": {"application/pdf": {"schema": {"type": "string", "format": "binary"}}}
     },
     "404": {
      "description": "Frase única, sempre `{\"erro\":\"pedido não encontrado\"}`: **toda** recusa cabe aqui — pedido inexistente, CEP divergente, nota não lavrada, nota só de homologação, banco sem a camada, cadastro do emitente incompleto. Não é oráculo de existência, e não é afirmação de que a nota não vai existir.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     },
     "405": {
      "description": "Qualquer método que não seja GET ou HEAD. O `Allow` da resposta diz `GET, HEAD`.",
      "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Erro"}}}
     }
    }
   },
   "head": {
    "tags": ["checkout"],
    "operationId": "headNotaDocumento",
    "summary": "Pergunta se já existe documento para (número + CEP) — mesma rota, sem corpo",
    "description": "É a pergunta \"existe?\", e é a forma barata de respondê-la: baixar um PDF para saber se ele existe custaria banda à toa. Mesmos cabeçalhos do GET, corpo vazio, mesmos 200/404 com a mesma semântica — inclusive a de que **404 não afirma ausência de nota, apenas ausência de documento para esta consulta**.\n\nUso correto: confirmar com o cliente o número e o CEP **dele**, chamar isto uma vez, e só então dizer que o documento abre na loja. Nunca anunciar documento antes da resposta. E não é rota para ficar consultando em ciclo: gera um PDF por chamada, não tem teto por endereço, e o `no-store` diz que nada ali é reaproveitável.",
    "parameters": [
     {"name": "numero", "in": "path", "required": true, "description": "Número do pedido ou `gateway_pedido_id`.", "schema": {"type": "string"}},
     {"name": "cep", "in": "query", "required": true, "description": "CEP de entrega, com ou sem hífen.", "schema": {"type": "string", "example": "01310-100"}}
    ],
    "responses": {
     "200": {"description": "Existe documento para este (número, CEP). `Content-Type: application/pdf` e o digest em `X-VivaPop-Documento-Sha256`, sem corpo."},
     "404": {"description": "Nenhum documento para esta consulta — que é a resposta de todo pedido enquanto não houver nota autorizada em produção. Veja na descrição do GET o que mais cabe dentro deste 404."}
    }
   }
  },
  "/loja/api/mcp": {
   "post": {
    "tags": ["meta"],
    "operationId": "postMcp",
    "summary": "Servidor MCP (JSON-RPC 2.0) para agentes",
    "description": "Transporte Streamable HTTP respondendo com corpo JSON único, sem SSE. Sem sessão. Métodos: initialize, ping, tools/list, tools/call; notifications/* recebem 202 sem corpo. Erro de argumento volta como result com isError true, para o modelo conseguir ler a mensagem.\n\nTools públicas, sem credencial: listar_categorias, buscar_produtos, detalhe_produto, calcular_frete, simular_pedido, criar_link_de_pedido — é catálogo e preço públicos, os mesmos que qualquer navegador recebe.\n\nTools com token de agente: criar_pedido (escopo `criacao:pedido`) persiste o pedido e devolve a página de pagamento hospedada na Stripe; consultar_pedido (escopo `consulta:pedido`) lê o estado real e, se o pedido ainda estiver aguardando, pergunta à Stripe com a chave secret do servidor. `Authorization: Bearer vpa_…` no cabeçalho, nunca no corpo. Sem o escopo a chamada é recusada e nada é escrito. Mesmo com criar_pedido o agente não cobra ninguém: quem paga é o cliente, na página do provedor. Guarde o número do pedido que a ferramenta devolve: a URL de sucesso da página hosted carrega ele (`/loja/?pagamento=retornou&pedido=VP-…`) e é por ele que a loja oferece ao cliente a consulta de estado, que ainda pede o CEP de entrega.",
  "security": [ {}, { "tokenDeAgente": [] } ],
    "requestBody": {
     "required": true,
     "content": {"application/json": {"schema": {
      "type": "object",
      "required": ["jsonrpc", "method"],
      "properties": {
       "jsonrpc": {"type": "string", "enum": ["2.0"]},
       "id": {"oneOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]},
       "method": {"type": "string", "example": "tools/call"},
       "params": {"type": "object", "additionalProperties": true}
      }
     }}}
    },
    "responses": {
     "429": {"$ref": "#/components/responses/OrcamentoEsgotado"},
     "200": {
      "description": "Resultado JSON-RPC",
      "content": {"application/json": {"schema": {
       "type": "object",
       "properties": {
        "jsonrpc": {"type": "string", "enum": ["2.0"]},
        "result": {"type": "object", "additionalProperties": true},
        "error": {
         "type": "object",
         "properties": {"code": {"type": "integer"}, "message": {"type": "string"}}
        }
       }
      }}}
     },
     "202": {"description": "Notificação aceita; sem corpo"},
     "400": {"description": "JSON inválido (-32700) ou método não suportado (-32601)"},
     "405": {"description": "Use POST"}
    }
   }
  }
 },
 "components": {
  "responses": {
   "OrcamentoEsgotado": {
     "description": "Orçamento por endereço de borda esgotado no minuto corrente: frete, simulação e MCP admitem 120 requisições/minuto, checkout 30. Não é falha da loja nem do provedor — é o teto de chamadas de saída a transportadora (até quatro por cotação, na conta da casa) e de criação de cobrança. Catálogo, produto e configuração não têm teto.",
     "headers": {
      "Retry-After": {"description": "Segundos até a janela liberar de novo.", "schema": {"type": "integer", "minimum": 1}}
     },
     "content": {"application/json": {"schema": {
      "type": "object",
      "required": ["ok", "erro", "espera_segundos"],
      "properties": {
       "ok": {"type": "boolean", "enum": [false]},
       "erro": {"type": "string", "example": "Muitas requisições deste endereço no último minuto."},
       "espera_segundos": {"type": "integer", "example": 54},
       "ajuda": {"type": "string"}
      }
     }}}
    }
   },
  "securitySchemes": {
   "tokenDeAgente": {
    "type": "http",
    "scheme": "bearer",
    "bearerFormat": "vpa_…",
    "description": "Token de agente da loja, com escopos. Só as duas tools que registram pedido o usam; catálogo, frete, simulação e o checkout da vitrine continuam sem credencial. O banco guarda apenas o SHA-256 do token; a revogação é uma marcação em `loja.agente`, e o texto claro é impresso uma única vez, na emissão por `vivapop-migracao --criar-agente`."
   }
  },
  "schemas": {
   "Centavos": {
    "type": "integer",
    "format": "int64",
    "minimum": 0,
    "description": "Valor em centavos de real. 880 = R$ 8,80."
   },
   "Item": {
    "type": "object",
    "additionalProperties": false,
    "required": ["sku", "quantidade"],
    "properties": {
     "sku": {"type": "string", "pattern": "^VP-[A-Z0-9-]+$", "example": "VP-CAR-070"},
     "quantidade": {"type": "integer", "minimum": 1, "maximum": 99, "example": 2}
    }
   },
   "Erro": {
    "type": "object",
    "required": ["erro"],
    "properties": {
     "erro": {"type": "string"},
     "cupom": {"type": "string", "description": "Presente quando o erro é cupom recusado — o cliente deve limpar o código."},
     "campo": {"type": "string", "description": "Presente quando falta um parâmetro específico. `cep` na consulta de rastreio sem CEP."},
     "pedido_id": {"type": "string"}
    }
   },
   "ItemPedido": {
    "type": "object",
    "additionalProperties": false,
    "description": "Linha de um pedido já gravado. Nome e preço unitário ficam CONGELADOS no item: o catálogo pode mudar depois da compra e o que vale é o que o cliente viu ao pagar.",
    "required": ["sku", "nome", "quantidade", "preco_unitario_centavos", "total_centavos"],
    "properties": {
     "sku": {"type": "string", "example": "VP-CAR-070"},
     "nome": {"type": "string", "example": "Pipoca Pronta de Caramelo 70g"},
     "quantidade": {"type": "integer", "minimum": 1},
     "preco_unitario_centavos": {"$ref": "#/components/schemas/Centavos"},
     "total_centavos": {"$ref": "#/components/schemas/Centavos"}
    }
   },
   "PedidoStatus": {
    "type": "object",
    "additionalProperties": false,
    "description": "Situação de um pedido. **Nenhum campo de dado pessoal**: sem nome, e-mail, WhatsApp, endereço ou CPF. A rota é pública e a tela de rastreio não usa nada disso — endereço completo é dado autenticado e mora no painel interno.",
    "required": ["pedido_numero", "status", "meio_pagamento", "subtotal_centavos", "total_centavos", "rastreio_codigo", "criado_em", "itens", "transportadora"],
    "properties": {
     "pedido_numero": {"type": "string", "example": "VP-2026-000123"},
     "status": {
      "type": "string",
      "enum": ["aguardando_pagamento", "pago", "pagamento_falhou", "expedido", "entregue", "cancelado"],
      "description": "Vocabulário fechado. `aguardando_pagamento` é cobrança criada e NÃO confirmada — não é venda concluída, e não pode ser apresentada ao cliente como pedido pago."
     },
     "meio_pagamento": {"type": "string", "example": "cartao"},
     "gateway": {"type": ["string", "null"], "example": "stripe"},
     "subtotal_centavos": {"$ref": "#/components/schemas/Centavos"},
     "desconto_cupom_centavos": {"oneOf": [{"$ref": "#/components/schemas/Centavos"}, {"type": "null"}]},
     "frete_centavos": {"oneOf": [{"$ref": "#/components/schemas/Centavos"}, {"type": "null"}]},
     "desconto_pix_centavos": {"oneOf": [{"$ref": "#/components/schemas/Centavos"}, {"type": "null"}]},
     "total_centavos": {"$ref": "#/components/schemas/Centavos"},
     "frete_opcao": {"type": ["string", "null"], "enum": ["economico", "expresso", null]},
     "frete_prazo_dias_uteis": {
      "type": ["integer", "null"],
      "description": "Prazo em dias úteis, já com a postagem da casa somada — que hoje é zero: a loja posta no mesmo dia útil, então o número é o trânsito. Sem `itens` no corpo é estimativa por faixa de CEP e não data garantida; com `itens` é cotação real e `prazo_fonte` diz qual dos dois."
     },
     "rastreio_codigo": {"type": "string", "description": "Vazio enquanto o pedido não for postado."},
     "pago_em": {"type": "string", "description": "Vazio enquanto o pagamento não confirmar."},
     "criado_em": {"type": "string", "description": "Timestamp do Postgres em texto, no fuso do servidor (UTC-3).", "example": "2026-10-18 12:43:13.98248-03"},
     "itens": {"type": "array", "items": {"$ref": "#/components/schemas/ItemPedido"}},
     "transportadora": {"type": "string", "example": "Correios"}
    }
   },
   "Configuracao": {
    "type": "object",
    "additionalProperties": false,
    "required": ["stripe_publishable_key", "stripe_modo", "mercadopago_modo", "cartao_disponivel", "pix_disponivel", "boleto_disponivel", "parcelas_max", "parcelamento_ativo", "pix_desconto_pct", "limite_frete_gratis_centavos", "prazo_postagem_dias_uteis", "url_retorno_pagamento", "google_sso_disponivel", "apple_sso_disponivel", "whatsapp"],
    "properties": {
     "stripe_publishable_key": {"oneOf": [{"type": "string"}, {"type": "null"}]},
     "stripe_modo": {"type": "string", "enum": ["live", "sandbox", "indisponivel"]},
     "mercadopago_modo": {"type": "string", "enum": ["live", "sandbox", "indisponivel"], "description": "Estado do Mercado Pago. **Não use isto para decidir se Pix ou boleto são cobráveis** — eles saem pela Stripe quando ela está configurada. Use `pix_disponivel` e `boleto_disponivel`."},
     "cartao_disponivel": {"type": "boolean"},
     "pix_disponivel": {"type": "boolean", "description": "A loja consegue cobrar Pix agora. Decidido pelo servidor: Stripe quando há credencial (e `VIVAPOP_PIX_BOLETO_GATEWAY` não manda para o Mercado Pago), senão Mercado Pago."},
     "boleto_disponivel": {"type": "boolean", "description": "A loja consegue emitir boleto agora. Mesma regra de `pix_disponivel`."},
     "parcelas_max": {"type": "integer", "example": 6, "description": "Teto de parcelas. Só faz sentido anunciar se `parcelamento_ativo` for true."},
     "parcelamento_ativo": {
      "type": "boolean",
      "description": "O parcelamento funciona DE VERDADE, ponta a ponta. Hoje é `false`: `installments` não é enviado à Stripe em lugar nenhum (nem na criação do PaymentIntent, nem no `confirmCardPayment` da vitrine), então o cliente que escolhesse 6x seria cobrado à vista. Enquanto for `false`, não anuncie \"Nx sem juros\" nem mostre seletor de parcelas. Para ligar: ver `checkout::PARCELAMENTO_VERIFICADO`."
     },
     "pix_desconto_pct": {"type": "integer", "example": 5},
     "limite_frete_gratis_centavos": {"$ref": "#/components/schemas/Centavos"},
     "prazo_postagem_dias_uteis": {"type": "integer", "example": 0, "description": "Dias úteis de postagem somados ao prazo. 0 = a casa posta no mesmo dia útil após a confirmação do pagamento."},
     "url_retorno_pagamento": {"type": "string", "format": "uri", "example": "https://www.vivapop.com.br/loja/?pagamento=retornou", "description": "`return_url` que a Stripe exige ao confirmar um PaymentIntent criado com `automatic_payment_methods[enabled]=true`. O navegador usa este valor em `confirmCardPayment` (cartão e carteira); o servidor usa o mesmo em Pix e boleto. Fonte única de propósito — a URL vem de `VIVAPOP_URL_PUBLICA`. Sem ela a confirmação é recusada com `invalid_request_error`."},
     "google_sso_disponivel": {"type": "boolean", "description": "Há credencial OIDC do Google no servidor: o botão \"entrar com Google\" funciona e quem entra tem histórico, endereços e a nota fiscal na própria conta. **Não** é porta para o agente — a sessão é de navegador, em cookie `HttpOnly`, e não abre nenhuma rota `/api/*`. Esta chave sempre saiu na resposta viva (medido 2026-10-29) e faltava neste schema, que declara `additionalProperties: false`: um cliente validando a resposta contra este documento rejeitava uma resposta correta. A lição é a do `testes::o_openapi_declara_cada_campo_que_o_serializador_de_frete_emite`, aplicada à outra rota — o contrato se confere contra o servidor, não se copia de memória."},
     "apple_sso_disponivel": {"type": "boolean", "description": "Há credencial de Sign in with Apple no servidor (Services ID, Team ID, Key ID, chave .p8 e redirect URI) e a chave é legível: o botão \"entrar com Apple\" funciona. Sem ele, o botão não aparece — um botão que leva a 503 é pior que botão nenhum."},
     "whatsapp": {"type": "string", "example": "5511973666464"}
    }
   },
   "Categoria": {
    "type": "object",
    "additionalProperties": false,
    "required": ["id", "nome", "descricao", "slug_oficial"],
    "properties": {
     "id": {"type": "string", "enum": ["pronta", "kits", "cinema", "milho"]},
     "nome": {"type": "string", "example": "Pipoca Pronta"},
     "descricao": {"type": "string"},
     "slug_oficial": {"type": "string", "description": "Coleção correspondente em vivapop.com.br."}
    }
   },
   "ProdutoResumo": {
    "type": "object",
    "additionalProperties": false,
    "required": ["sku", "slug", "nome", "categoria_id", "peso_gramas", "unidades", "preco_centavos", "imagem_url", "url_oficial", "disponivel"],
    "properties": {
     "sku": {"type": "string", "example": "VP-CAR-070"},
     "slug": {"type": "string", "description": "Slug em vivapop.com.br — chave de conferência com o oficial.", "example": "pipoca-pronta-de-caramelo-70g"},
     "nome": {"type": "string", "description": "Nome exato publicado na vitrine oficial.", "example": "Pipoca Pronta de Caramelo 70g"},
     "categoria_id": {"type": "string", "enum": ["pronta", "kits", "cinema", "milho"]},
     "resumo": {"type": "string", "description": "Primeira linha da descrição oficial."},
     "peso_gramas": {"type": "integer", "example": 70},
     "unidades": {"type": "integer", "description": "Pacotes na caixa; 1 para produto avulso.", "example": 1},
     "preco_centavos": {"$ref": "#/components/schemas/Centavos"},
     "preco_promocional_centavos": {
      "oneOf": [{"$ref": "#/components/schemas/Centavos"}, {"type": "null"}],
      "description": "null em todos hoje: o site oficial publica discount 0 em todos os produtos."
     },
     "imagem_url": {"type": "string", "example": "/estatico/loja/img/produtos/pipoca-pronta-de-caramelo-70g.jpeg"},
     "url_oficial": {"type": "string", "format": "uri"},
     "disponivel": {"type": "boolean"}
    }
   },
   "ProdutoDetalhe": {
    "allOf": [
     {"$ref": "#/components/schemas/ProdutoResumo"},
     {
      "type": "object",
      "properties": {
       "descricao": {"type": "string", "description": "Descrição oficial integral, com quebras de linha."},
       "imagens": {"type": "array", "items": {"type": "string"}, "description": "Galeria; o primeiro é a foto principal."},
       "ref_oficial": {"type": "integer", "format": "int64", "description": "SKU numérico do produto na plataforma Dooca."}
      }
     }
    ]
   },
   "OpcaoFrete": {
    "type": "object",
    "additionalProperties": false,
    "required": ["id", "nome", "transportadora", "prazo_dias_uteis", "valor_centavos", "gratis"],
    "properties": {
     "id": {"type": "string", "enum": ["economico", "expresso", "pac", "sedex", "loggi-express"], "description": "`economico` e `expresso` são os dois slots posicionais — o mais barato que não é o expresso, e o expresso. Os demais nomeiam o serviço cotado. Todo id listado é aceito em `frete`; uma resposta real só contém os que a loja embarca."},
     "nome": {"type": "string", "example": "PAC"},
     "transportadora": {"type": "string", "example": "Correios"},
     "prazo_dias_uteis": {"type": "integer", "description": "Prazo prometido: trânsito da transportadora + postagem da casa. A postagem é 0 desde 2026-10-24 (postamos no mesmo dia útil), então hoje este número É o trânsito medido."},
     "transito_dias_uteis": {"type": ["integer", "null"], "description": "A metade do prazo que é da estrada, medida pela transportadora. `null` quando a opção saiu da tabela de estimativa: ninguém mediu rota, e somar uma metade inventada à nossa postagem é exatamente o que este campo existe para impedir."},
     "valor_centavos": {"$ref": "#/components/schemas/Centavos"},
     "gratis": {"type": "boolean"},
     "descricao": {"type": "string"},
     "prazo_fonte": {"type": "string", "enum": ["cotacao", "estimativa"], "description": "De onde veio o PRAZO — pode ser diferente da `fonte` do preço na mesma opção: o SEDEX tem preço cotado no contrato e prazo estimado."},
     "gateway": {"type": ["string", "null"], "enum": ["correios", "melhor_envio", null], "description": "Onde a etiqueta é comprada. `null` = tabela de estimativa, sem fornecedor do outro lado."},
     "valor_fornecedor_centavos": {"$ref": "#/components/schemas/Centavos", "description": "O que a transportadora cobra da casa, antes da política de frete grátis. É o número do P&L quando `gratis` é true."},
     "servico_id": {"type": ["integer", "null"], "description": "O `service` numérico do provedor (Melhor Envio) que a expedição tem de receber para comprar a etiqueta — distinto de `id`, que é slot do cardápio da loja. `null` = a opção saiu da tabela de estimativa (nenhum provedor do outro lado) ou veio do contrato direto dos Correios, cujo `co_produto` não é service do agregador. Zero nunca é ausência."}
    }
   },
   "Simulacao": {
    "type": "object",
    "additionalProperties": false,
    "description": "Precificação autoritativa. Use estes números, nunca os seus.",
    "properties": {
     "id": {"type": "string"},
     "itens": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "sku": {"type": "string"},
        "nome": {"type": "string"},
        "preco_unitario_centavos": {"$ref": "#/components/schemas/Centavos"},
        "quantidade": {"type": "integer"},
        "total_centavos": {"$ref": "#/components/schemas/Centavos"},
        "peso_gramas": {"type": "integer"},
        "imagem_url": {"type": "string"}
       }
      }
     },
     "total_itens": {"type": "integer"},
     "subtotal_centavos": {"$ref": "#/components/schemas/Centavos"},
     "desconto_cupom_centavos": {"$ref": "#/components/schemas/Centavos"},
     "frete_centavos": {"$ref": "#/components/schemas/Centavos"},
     "total_centavos": {"$ref": "#/components/schemas/Centavos"},
     "desconto_pix_centavos": {"$ref": "#/components/schemas/Centavos"},
     "total_final_centavos": {"$ref": "#/components/schemas/Centavos", "description": "O que o cliente paga após eventuais descontos aplicáveis."},
     "total_peso_gramas": {"type": "integer"},
     "cupom": {"oneOf": [{"type": "string"}, {"type": "null"}]},
     "cep": {"oneOf": [{"type": "string"}, {"type": "null"}]},
     "frete_selecionado": {"oneOf": [{"$ref": "#/components/schemas/OpcaoFrete"}, {"type": "null"}]},
     "opcoes_frete": {"type": "array", "items": {"$ref": "#/components/schemas/OpcaoFrete"}, "description": "Vazio enquanto não houver CEP."},
     "limite_frete_gratis_centavos": {"$ref": "#/components/schemas/Centavos"},
     "frete_gratis": {
      "type": "object",
      "properties": {
       "falta_centavos": {"$ref": "#/components/schemas/Centavos"},
       "progresso_pct": {"type": "number"}
      }
     }
    }
   }
  }
 },
 "x-descoberta": {
  "llms_txt": "https://www.vivapop.com.br/llms.txt",
  "agentes_md": "https://www.vivapop.com.br/AGENTS.md",
  "mcp": "https://www.vivapop.com.br/loja/api/mcp"
 }
}
