# Conta Azul CLI — referência completa Gerado por tools/generate-docs.php. Contém a referência de todos os comandos. Para a superfície em forma de dados, use commands.json. ============================================================================== # Configuração Estes quatro comandos são os únicos do CLI que **não chamam a API**. Eles leem e escrevem o arquivo de ambiente local, e por isso continuam registrados quando o bootstrap falha por falta de credencial — que é exatamente o estado em que `ca config init` precisa funcionar. Vale então uma ressalva sobre a marca: no resto desta referência, ✅ quer dizer "exercitado contra a API de produção". Aqui não existe API para exercitar, e a marca quer dizer "coberto por testes e pelo smoke test do PHAR". Nenhuma requisição HTTP sai desta página. **Onde o CLI procura o arquivo.** Um único arquivo é lido — o primeiro candidato que existir vence, e **nada é mesclado**: | Ordem | Candidato | Observação | |---|---|---| | 1 | o arquivo indicado por `CA_CLI_ENV_FILE` | Escape hatch explícito, por invocação. Se a variável estiver definida e o arquivo não puder ser lido, é **erro duro** — nunca um pulo silencioso para o próximo candidato. | | 2 | `/.env` | O caminho histórico, que continua valendo num clone. **Pulado dentro de um PHAR**, onde ele resolveria para um `phar://…/.env` que ninguém pode criar. | | 3 | `~/.config/conta-azul-cli/.env` | O arquivo do usuário, no mesmo diretório em que já moram `tokens.json` e o log. É o que torna um `ca` global utilizável. | Mesclar candidatos tornaria `ca config set` ambíguo — gravou em qual arquivo? — e transformaria um arquivo esquecido num override parcial silencioso. Por isso a regra cabe numa frase, e por isso `ca config path` imprime a decisão inteira. `getcwd() . '/.env'` **não** é candidato, de propósito: rodar o `ca` de dentro de qualquer projeto alheio que tenha um `.env` faria o CLI absorver as variáveis daquele projeto — credenciais de terceiros, em silêncio. `CA_CLI_ENV_FILE` cobre a mesma necessidade de forma explícita, por invocação, e aparece em `ca config path`. Uma variável já exportada no ambiente continua ganhando do arquivo. A precedência completa está em [Configuração](../guia/configuracao.md). ## `config path` ✅ { #config-path } Sem parâmetros. Responde `arquivo` — o caminho que venceu, ou `null` quando nenhum candidato existe — e `candidatos`, a lista inteira na ordem de precedência, com o `status` de cada um: `usado`, `não existe`, `não definido`, `pulado: dentro do PHAR`, `pulado: diretório home não resolvido` ou `ignorado: um candidato anterior venceu`. É o primeiro comando a rodar quando o CLI não parece enxergar uma credencial que você tem certeza de ter gravado: ele responde **qual arquivo está valendo** antes de qualquer palpite. ## `config init` ✅ { #config-init } | Parâmetro | Obrig. | Descrição | |---|---|---| | `--force` | não | Sobrescreve o arquivo do usuário. Sem ela, um arquivo existente faz o comando falhar, em vez de descartar o que está lá | Escreve o modelo embutido em `~/.config/conta-azul-cli/.env`, criando o diretório com `0700` e o arquivo com `0600`. Responde com o caminho gravado e o próximo passo. **`init` e `set` têm alvos diferentes, e isso é deliberado.** `config init` grava **sempre** no arquivo do usuário, mesmo que outro candidato esteja valendo agora — a razão de ele existir é criar o arquivo que torna o CLI utilizável fora do diretório do projeto. Já `config set` grava no arquivo **em vigor**, seja ele qual for. Num clone que tenha `.env` na raiz, portanto, `config init` cria um segundo arquivo que só passa a valer quando o primeiro sair do caminho — e `config path` mostra exatamente isso. O modelo é uma constante compilada no binário, não uma cópia de `.env.example` lida do disco. `.env.example` mora no repositório e não dentro do PHAR: lê-lo de disco funcionaria num clone e falharia em todo o resto, reproduzindo a classe exata de bug que o search path existe para corrigir. ## `config set` ✅ { #config-set } | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Nome da variável (ex.: `CA_CLIENT_ID`). Aceita minúsculas; o arquivo sempre recebe o nome canônico | | `` | **sim** | Valor a gravar. Vazio equivale a não definir a variável | Insere ou substitui a variável no arquivo em vigor, **preservando comentários, linhas em branco e a ordem** do arquivo, e reaplicando `0600` a cada escrita. Só as variáveis que o CLI realmente lê são aceitas; qualquer outra é recusada na hora, com a lista das válidas. Sem essa validação, uma chave digitada errado — `CA_CLIENT_IDD`, por exemplo — viraria uma linha no arquivo que nunca faz efeito, e o sintoma reapareceria depois como credencial ausente. `CA_CLI_ENV_FILE` **não** está na lista, e não poderia estar: ela *escolhe* o arquivo de ambiente em vez de morar dentro de um. Defina-a no shell. Valor vazio equivale a não definir a variável — o CLI trata variável vazia como ausente e cai no default compilado. A resposta traz `arquivo` e `chave`, **nunca o valor**: este é o caminho normal pelo qual um client secret chega ao arquivo, e a saída do comando termina no scrollback do terminal e no log do CI. ## `config show` ✅ { #config-show } Sem parâmetros. Lista as treze variáveis conhecidas com o valor efetivo e a coluna `origem`: `ambiente` para uma variável exportada no shell, `arquivo` para uma definida no arquivo em vigor, `default` para o valor compilado no CLI. `(ausente)` marca o que não tem nem valor nem default. **A saída nunca contém segredo.** `CA_CLIENT_SECRET` e `CA_BOOTSTRAP_REFRESH_TOKEN` aparecem apenas como `(definido)` ou `(ausente)` — sem máscara parcial e sem opção para revelar. Um prefixo ainda é material aproveitável, e meio segredo num relatório de bug é um segredo num relatório de bug. É essa ausência de saída de emergência que torna a saída deste comando **segura de colar numa issue**. O arquivo é reinterpretado aqui em vez de lido de volta por `getenv()`: a essa altura o Dotenv já rodou, e `getenv()` não distingue mais um valor que veio do arquivo de um que você exportou — que é justamente o que a coluna `origem` existe para dizer. ============================================================================== # Autenticação ## `auth login` ✅ { #auth-login } Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Na primeira vez emite o certificado em `~/.config/conta-azul-cli/certs/` e instala a CA no trust store do usuário. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. O refresh é automático e invisível — não existe comando para isso. Renovação preventiva a menos de 60 s da expiração, e reativa uma vez em caso de `401`. ## `auth logout` ✅ { #auth-logout } Sem parâmetros. Remove as credenciais locais. ============================================================================== # Categorias ## `categoria list` ✅ { #categoria-list } `GET /v1/categorias` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | Retorna `{itens_totais, itens[]}`. Cada item traz `id`, `nome`, `tipo` (`RECEITA`/`DESPESA`), `categoria_pai` e `entrada_dre`. ## `categoria configuracao-padrao` ✅ { #categoria-configuracao-padrao } `GET /v1/categorias/configuracao-padrao` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--sugestao-padrao` / `--no-sugestao-padrao` | não | `--sugestao-padrao` | Inclui (ou omite) a sugestão padrão de categoria em cada item | | `--no-sugestao-padrao` | não | — | Negate the "--sugestao-padrao" option | Retorna uma lista de de-para entre operação financeira (`tipo_operacao`, ex: `FRETES_RECEBIDOS`, `JUROS_PAGOS`) e a categoria configurada para ela (`id_categoria`, `nome_categoria`). Com `--no-sugestao-padrao`, o campo `sugestao_padrao` de cada item vem `null`. ## `categoria dre` ✅ { #categoria-dre } `GET /v1/financeiro/categorias-dre` Sem parâmetros. Retorna `{itens[]}` com a estrutura hierárquica da DRE (Demonstração do Resultado do Exercício): cada item traz `descricao`, `codigo`, `subitens[]` e `categorias_financeiras[]` associadas. ============================================================================== # Centros de custo ## `centro-de-custo list` ✅ { #centro-de-custo-list } `GET /v1/centro-de-custo` — note o **singular** no path. | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | Cada item traz `id`, `codigo`, `nome`, `ativo`. ## `centro-de-custo create` ✅ { #centro-de-custo-create } `POST /v1/centro-de-custo` — **escrita síncrona** (`200`), sem protocolo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON do centro de custo (`nome` obrigatório; `codigo` opcional) | Retorna `{id, codigo, nome, ativo}`. `codigo` omitido volta `null` — os registros criados pela interface trazem `""`, não `null`. > **Não existe como desfazer.** A API publica só `GET` e `POST` para este > recurso: `DELETE`, `PUT` e `PATCH` em `/v1/centro-de-custo/{id}` respondem > o `404` genérico de rota inexistente. Centro de custo criado por engano só > sai pela interface web. ============================================================================== # Contas financeiras ## `conta-financeira list` ✅ { #conta-financeira-list } `GET /v1/conta-financeira` — **singular**. | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | Cada item traz `id`, `nome`, `banco`, `tipo`, `ativo`, `conta_padrao`. ## `conta-financeira saldo` ✅ { #conta-financeira-saldo } `GET /v1/conta-financeira/{id}/saldo-atual` | Parâmetro | Obrig. | Descrição | |---|---|---| | `--id` | **sim** | ID da conta financeira (de `conta-financeira list`) | Retorna `{"saldo_atual": 5931.64}`. É opção `--id`, não argumento posicional. ============================================================================== # Transferências ## `transferencia list` ✅ { #transferencia-list } `GET /v1/financeiro/transferencias` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-inicio` | não¹ | 1º dia do mês corrente | Início do intervalo (`YYYY-MM-DD`) | | `--data-fim` | não¹ | último dia do mês corrente | Fim do intervalo (`YYYY-MM-DD`) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | ¹ A API exige o intervalo; o CLI supre com o mês corrente e avisa em stderr. > As datas vão em `YYYY-MM-DD` puro, diferente de `financeiro alteracoes` (mesmo domínio `/financeiro/`), que exige o instante ISO 8601 completo. Cada item traz `id`, `descricao`, `valor`, `data`, e os blocos `origem`/`destino` com `data`, `composicao_valor` (`valor_bruto`, `juros`, `multa`, `valor_liquido`, `desconto`, `taxa`) e `conta_financeira` (`id`, `nome`, `instituicao_bancaria`). ============================================================================== # Contas a receber ## `conta-a-receber list` ✅ { #conta-a-receber-list } `GET /v1/financeiro/eventos-financeiros/contas-a-receber/buscar` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-vencimento-de` | não¹ | 1º dia do mês corrente | Vencimento inicial (`YYYY-MM-DD`) | | `--data-vencimento-ate` | não¹ | último dia do mês corrente | Vencimento final (`YYYY-MM-DD`) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | ¹ A API exige o intervalo; o CLI supre com o mês corrente e avisa em stderr. > **O `id` de cada item é o da PARCELA, não do evento financeiro.** É esse valor que `parcela get` e `parcela baixar` consomem. O id do evento vem aninhado em `evento.id` na resposta de `parcela get`. Cada item traz `id`, `status` (`ACQUITTED`, `OVERDUE`, …), `status_traduzido`, `total`, `pago`, `nao_pago`, `data_vencimento`, `data_competencia`, `descricao`, `categorias[]`, `centros_de_custo[]`, `cliente`. A resposta ainda inclui um bloco `totais` com somatórios por situação. ## `conta-a-receber create` ✅ { #conta-a-receber-create } `POST /v1/financeiro/eventos-financeiros/contas-a-receber` — **escrita assíncrona**. | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--json` | **sim** | — | Payload JSON, repassado à API **verbatim** | | `--poll-timeout` | não | `60` | Segundos aguardando a confirmação assíncrona | | `--no-wait` | não | — | Retorna o protocolo na hora, sem aguardar | O CLI não valida o conteúdo de `--json`; o schema é o da API. **Payload mínimo aceito** — descoberto exercitando, porque a documentação oficial erra em dois pontos (ver adiante): ```json { "descricao": "…", "data_competencia": "2026-08-19", "valor": 1.00, "rateio": [{"id_categoria": "", "valor": 1.00, "rateio_centro_custo": []}], "condicao_pagamento": {"parcelas": [{ "descricao": "…", "data_vencimento": "2026-09-30", "detalhe_valor": {"multa":0,"juros":0,"valor_bruto":1.00,"valor_liquido":1.00,"desconto":0,"taxa":0} }]} } ``` Armadilhas confirmadas: - **Este endpoint acusa todos os campos faltantes de uma vez**, diferente do resto da API, que revela um por `400`. O primeiro `400` já lista `competenceDate`, `valor`, `condicao_pagamento` e `rateio` — em *camelCase*, com o nome do campo Java, não o nome JSON (`data_competencia`). - **`condicao_pagamento` não é o que a leitura devolve.** Na escrita é `{parcelas: [...]}`, uma lista; `parcela get` devolve `{quantidade_parcelas, montante_fixo}`, um resumo. Mandar o formato de leitura dá `paymentCondition.installments: deve ter no mínimo uma parcela`. - **A composição de valor da parcela é `detalhe_valor` na escrita** e `valor_composicao` em toda leitura. `composicao_valor` — o nome que as Baixas usam — **não** funciona aqui. - **A documentação oficial marca `observacao`, `contato` e `conta_financeira` como obrigatórios; não são.** O payload acima é aceito sem os três. - **`contato` é obrigatório na prática se você for gerar cobrança.** Sem ele `cobranca create` recusa com "Existem parcelas associadas a eventos financeiros sem identificação do pagador" — e, como não há `PUT`/`PATCH` de evento financeiro, não dá para adicionar o pagador depois. Decida antes de criar. - **A resposta é `200`, não o `202` documentado**, e traz `{protocolo, status, data_criacao}` com `status` `PENDING`. > **Não existe como desfazer.** A API não publica `DELETE` nem `GET` de > evento financeiro (`/v1/financeiro/eventos-financeiros/{id}` responde o > `404` genérico de rota inexistente em ambos). Conta a receber criada por > engano só sai pela interface web. ============================================================================== # Cobranças Gera cobrança (boleto, PIX ou link de pagamento) para a parcela de uma conta a receber. Spec OpenAPI próprio (`charge-apis-openapi`), separado do núcleo Financeiro. ## `cobranca create` ✅ { #cobranca-create } `POST /v1/financeiro/eventos-financeiros/contas-a-receber/gerar-cobranca` — **escrita síncrona** (`200`), sem protocolo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON da cobrança (`conta_bancaria`, `descricao_fatura`, `id_parcela`, `data_vencimento` e `tipo` — `LINK_PAGAMENTO`, `PIX_COBRANCA` ou `BOLETO` — obrigatórios) | O CLI não valida o conteúdo de `--json`; o schema é o da API. Os cinco campos documentados conferem. Retorna `{id, url, status}`, mas: - **`url` vem `null` na criação**, com `status` `AGUARDANDO_CONFIRMACAO`. O link só existe depois que o provedor confirma — use `cobranca get` para buscá-lo, não a resposta da criação. - **`conta_bancaria` precisa ser conta de banco.** Apontar para uma `CAIXINHA` devolve `400` "O tipo de conta selecionado não é válido para a criação de cobrança". - **A parcela precisa ter pagador.** Evento financeiro criado sem `contato` recusa com "Existem parcelas associadas a eventos financeiros sem identificação do pagador", e não há como acrescentá-lo depois. - **Payload incompleto responde `500`, não `400`** — e o CLI traduz `500` em escrita para `ambiguous`, mandando reconciliar. Na verificação de 2026-08-19 nada tinha sido criado: `parcela get` devolveu `solicitacoes_cobrancas: []`. É o mesmo problema de classificação de `contrato get`/`delete`/`encerrar`. - Cliente sem CPF/e-mail leva a cobrança a `INVALIDO` alguns segundos depois de criada. O endpoint funcionou; quem recusou foi o provedor. ## `cobranca get` ✅ { #cobranca-get } `GET /v1/financeiro/eventos-financeiros/contas-a-receber/cobranca/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da cobrança | Retorna `{id, url, status}`. Id inexistente responde `404` **com corpo vazio** — a mensagem do envelope de erro fica em branco. As cobranças de uma parcela também aparecem em `parcela get` → `solicitacoes_cobrancas[]`, com mais campos (`status_solicitacao_cobranca`, `tipo_solicitacao_cobranca`, `valor_composicao`). ## `cobranca delete` ✅ { #cobranca-delete } `DELETE /v1/financeiro/eventos-financeiros/contas-a-receber/cobranca/{id}` — recomendado só para cobrança gerada incorretamente ou a invalidar antes do pagamento. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da cobrança | Responde **`200` com corpo vazio** (renderizado como `[]`), não `204` — a documentação acertou. Até 2026-08-19 isso **quebrava o CLI**: o caso de corpo vazio dependia do status ser `204`, então `toArray()` estourava numa exceção que escapava do tratamento de erro, e um delete bem-sucedido imprimia a linha de uso do Symfony e saía com código `1`. Corrigido. **A exclusão é lógica e assíncrona.** A cobrança passa a `EM_CANCELAMENTO` e assenta em `CANCELADO`; `cobranca get` continua respondendo `200`. Logo depois do `DELETE` há uma janela em que o `get` devolve `404` — é transitório, não indica exclusão permanente. Como em `servico delete`, o teste confiável é o **status**, não o `404`. ============================================================================== # Contas a pagar ## `conta-a-pagar list` ✅ { #conta-a-pagar-list } `GET /v1/financeiro/eventos-financeiros/contas-a-pagar/buscar` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-vencimento-de` | não¹ | 1º dia do mês corrente | Vencimento inicial (`YYYY-MM-DD`) | | `--data-vencimento-ate` | não¹ | último dia do mês corrente | Vencimento final (`YYYY-MM-DD`) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | ¹ A API exige o intervalo; o CLI supre com o mês corrente e avisa em stderr. > O `id` de cada item é o da **parcela**, mesma regra de `conta-a-receber list`. Formato de resposta igual ao de contas a receber, trocando `cliente` por fornecedor. ## `conta-a-pagar create` ✅ { #conta-a-pagar-create } `POST /v1/financeiro/eventos-financeiros/contas-a-pagar` — **escrita assíncrona**. | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--json` | **sim** | — | Payload JSON, repassado à API **verbatim** | | `--poll-timeout` | não | `60` | Segundos aguardando a confirmação assíncrona | | `--no-wait` | não | — | Retorna o protocolo na hora, sem aguardar | Mesmo payload de `conta-a-receber create` (inclusive `detalhe_valor` e o `condicao_pagamento.parcelas`), trocando a categoria do rateio por uma de `tipo` `DESPESA`. Vale a mesma advertência: **a API não publica `DELETE` de evento financeiro**, então conta a pagar criada por engano só sai pela interface web. ============================================================================== # Parcelas ## `parcela get` ✅ { #parcela-get } `GET /v1/financeiro/eventos-financeiros/parcelas/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. ID da parcela — o `id` devolvido pelas buscas de contas | Retorna a parcela com o evento financeiro aninhado em `evento`, incluindo `evento.id`, `condicao_pagamento` e `rateio[]`. ## `parcela update` ✅ { #parcela-update } `PATCH /v1/financeiro/eventos-financeiros/parcelas/{id}` — **escrita síncrona** (`200`). | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da parcela | | `--json` | **sim** | Payload JSON da parcela; `versao` (a versão atual) é obrigatório | Atualiza `nota`, `descricao`, `vencimento`, `composicao_valor`, `data_pagamento_esperado`, `metodo_pagamento`, `perda`, `nsu`, `pagamento_agendado` e `id_conta_financeira`. Devolve a parcela atualizada, já com a `versao` nova. **Controle de concorrência otimista:** sem `versao` a resposta é **`409`**, não `400` — mesma regra de `baixa update`. > **Este endpoint não dá baixa.** Ele atualiza a parcela. Até 2026-08-19 o > CLI o chamava como se quitasse (`parcela baixar`, mandando `{valor, data}`) > e, exercitado, ele respondeu `200` sem registrar pagamento nenhum: nenhum > dos dois campos existe no schema, e a API descarta campo desconhecido em > silêncio também **no corpo da escrita**, não só na query. O `409` por falta > de `versao` escondia isso — o comando nunca chegava a "funcionar" errado. ## `parcela baixar` ✅ { #parcela-baixar } `POST /v1/financeiro/eventos-financeiros/parcelas/{id}/baixa` — **escrita síncrona** (`200`). Atalho para `baixa create`, montando o payload mínimo a partir de três opções. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da parcela | | `--valor` | **sim** | Valor da baixa (ex: `100.50`) | | `--data` | **sim** | Data da baixa (`YYYY-MM-DD`) | | `--conta-financeira` | **sim** | Uuid da conta financeira que recebe a baixa | síncrona** (`200`). Atalho para `baixa create`, montando o payload mínimo a partir de três opções. > O subrecurso `/baixa` **existe** — a nota anterior, de que a baixa seria um > `PATCH` na parcela, estava errada. `--poll-timeout` e `--no-wait` saíram: > a escrita é síncrona e nunca devolveu protocolo. ## `parcela list` ✅ { #parcela-list } `GET /v1/financeiro/eventos-financeiros/{id_evento}/parcelas` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. ID do evento financeiro (`evento.id` aninhado na resposta de `parcela get`) | Sem paginação: a API devolve **um array puro**, não um envelope — não há `itens` nem contador. Cada item tem o mesmo formato de `parcela get`. > O `id` do evento sai de `parcela get` → `evento.id`, ou do > `evento_financeiro_id` que `protocolo get` devolve depois de uma criação. ============================================================================== # Baixas Recurso dedicado de baixa (quitação) de uma parcela — mais rico que o `PATCH` direto de `parcela baixar`: registra data, valor, juros, multa, desconto e método de pagamento. Uma parcela pode ter mais de uma baixa (pagamento parcial). Spec OpenAPI próprio (`acquittance-apis-openapi`), separado do núcleo Financeiro. ## `baixa create` ✅ { #baixa-create } `POST /v1/financeiro/eventos-financeiros/parcelas/{id}/baixa` — **escrita síncrona** (`200`), sem protocolo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da parcela | | `--json` | **sim** | Payload JSON da baixa (`data_pagamento`, `conta_financeira` e `composicao_valor` — objeto com `valor_bruto` obrigatório e `multa`/`juros`/`desconto`/`taxa` opcionais — obrigatórios) | O CLI não valida o conteúdo de `--json`; o schema é o da API. Os três campos documentados conferem — este spec acertou. ```json {"data_pagamento":"2026-08-19","conta_financeira":"","composicao_valor":{"valor_bruto":0.50}} ``` - **`composicao_valor` aqui, `detalhe_valor` em `conta-a-receber create`.** Os dois specs nomeiam o mesmo objeto de formas diferentes na escrita, e ambos voltam como `valor_composicao` na leitura. - **`conta_financeira` é um uuid na escrita e um objeto completo na leitura** (`baixa get`, `baixa list`, `parcela get`). - **Pagamento parcial funciona:** duas baixas de `0,50` numa parcela de `1,00` levam a parcela de `PENDENTE` a `RECEBIDO_PARCIAL` e depois a `QUITADO`. Cada baixa incrementa a `versao` da parcela. - **Faltando campo obrigatório, a resposta é um `400` que embrulha uma página HTML** ("Unexpected character ('<'…) … Internal Server Error"), não uma mensagem de validação. Leia como "falta alguma coisa", sem pista de o quê. ## `baixa list` ✅ { #baixa-list } `GET /v1/financeiro/eventos-financeiros/parcelas/{id}/baixa` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da parcela | Sem paginação: **array puro**, sem envelope nem contador. As mesmas baixas também vêm aninhadas em `parcela get` → `baixas[]`. ## `baixa get` ✅ { #baixa-get } `GET /v1/financeiro/eventos-financeiros/parcelas/baixa/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da baixa | Traz `valor_composicao` (não `composicao_valor`) e `conta_financeira` como objeto. Baixa excluída responde `404` **com corpo vazio**. ## `baixa update` ✅ { #baixa-update } `PATCH /v1/financeiro/eventos-financeiros/parcelas/baixa/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da baixa | | `--json` | **sim** | Payload JSON com as mudanças; campo `versao` (a versão atual da baixa) é obrigatório | **Controle de concorrência otimista:** a API exige a `versao` atual no payload e a incrementa após o sucesso, para evitar que duas atualizações concorrentes se sobrescrevam silenciosamente. > Sem `versao`, a resposta é **`409 Conflict`**, não `400` — "Versão > informada para o recurso é inválida". A resposta de sucesso usa os nomes > de **escrita** (`composicao_valor`, `conta_financeira` como uuid), não os > de leitura. ## `baixa delete` ✅ { #baixa-delete } `DELETE /v1/financeiro/eventos-financeiros/parcelas/baixa/{id}` — use com cautela: impacta o saldo e o histórico financeiro da parcela associada. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da baixa | Responde **`200` com corpo vazio** (renderizado como `[]`), não `204` — a documentação acertou, e o mesmo bug de corpo vazio descrito em `cobranca delete` também atingia este comando. Corrigido em 2026-08-19. **A exclusão é permanente e desfaz a quitação.** `baixa get` passa a `404`, e a parcela volta ao estado anterior: `QUITADO` → `RECEBIDO_PARCIAL` → `PENDENTE` conforme as baixas somem, com `valor_pago` voltando a `0`. O saldo da conta financeira volta junto — na verificação, a conta saiu de `35004,34` e voltou a `35003,34` ao excluir a baixa de `1,00`. **É assim que se desfaz uma baixa: não existe "estornar".** ============================================================================== # Eventos financeiros ## `financeiro alteracoes` ✅ { #financeiro-alteracoes } `GET /v1/financeiro/eventos-financeiros/alteracoes` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-inicio` | não¹ | início do mês corrente | ISO 8601 **sem timezone** | | `--data-fim` | não¹ | fim do mês corrente | ISO 8601 **sem timezone** | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | Feed de alterações no período — o caminho para reconciliar escritas que terminaram em `ambiguous`. ¹ A API exige o intervalo; o CLI supre com o mês corrente e avisa em stderr. Retorna `{itens_totais, itens[]}`, onde cada item traz apenas o `id` do evento alterado. Use `parcela get` para hidratar. ## `financeiro saldo-inicial` ✅ { #financeiro-saldo-inicial } `GET /v1/financeiro/eventos-financeiros/saldo-inicial` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-inicio` | não¹ | início do mês corrente | ISO 8601 **sem timezone** | | `--data-fim` | não¹ | fim do mês corrente | ISO 8601 **sem timezone** | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | Saldos iniciais das contas financeiras no período. ¹ A API exige o intervalo; o CLI supre com o mês corrente e avisa em stderr. Retorna `{itens_totais, itens[]}`, com `{tipo, id_conta_financeira, data_competencia, saldo_inicial}` em cada item. - **O intervalo é limitado a 365 dias.** Acima disso a resposta é `400` ("O intervalo entre as datas excede o limite máximo permitido de 365 dias"). Exatos 365 passam. **`financeiro alteracoes` tem o mesmo teto**, e nenhum dos dois documentava isso. O default do CLI (mês corrente) fica bem abaixo, então o comando sem argumentos funciona. - **A janela não filtra por `data_competencia`.** A consulta de 2020 devolve um item com `data_competencia` de 2024; janelas diferentes devolvem conjuntos distintos, então o filtro existe — só não é sobre o campo que a resposta mostra. - Descarta em silêncio parâmetro desconhecido, como as outras listagens (comprovado com `zzz_bogus=abc`: o total não muda). Paginação funciona e aceita `tamanho_pagina` até `1000`. ============================================================================== # Protocolos ## `protocolo get` ✅ { #protocolo-get } `GET /v1/protocolo/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. O `protocolo` devolvido por uma escrita | Consulta o status de uma escrita assíncrona. Não tem opções próprias. É como se retoma uma escrita disparada com no-wait, ou uma que terminou em `poll_timeout_known_id` ou `poll_drop_known_id`. Retorna `{id, resposta, status, evento_financeiro_id}`: ```json {"id":"60a7011c-…","resposta":"O evento financeiro foi criado no contas a receber da Conta Azul.", "status":"SUCCESS","evento_financeiro_id":"eef5550b-…"} ``` > **O id do protocolo vem em `id`, e o da entidade criada em > `evento_financeiro_id`** — não há um `data` embrulhando o objeto criado. > Para chegar na parcela criada: `protocolo get` → `evento_financeiro_id` → > `parcela list `. > > **A escrita que gera o protocolo devolve a chave `protocolo`**, não > `protocol_id` nem `protocolId`. Até 2026-08-19 o CLI procurava > `protocolId`, não achava, e por isso **nunca fazia polling**: toda escrita > assíncrona devolvia o envelope `PENDING` cru, e `--no-wait` e > `--poll-timeout` não tinham efeito observável. Corrigido. ============================================================================== # Contratos **Verificado contra produção em 2026-08-19** — os seis comandos, com dois contratos de teste criados, um encerrado e ambos excluídos. Os seis parâmetros de `contrato list` passaram na receita completa (baseline, `zzz_bogus=abc`, valor discriminante). Três avisos antes das tabelas: > **1. Criar um contrato cria vendas na hora.** Um contrato mensal com > `tipo_expiracao: NUNCA` e `data_fim` a um ano gerou **25 vendas agendadas** > de uma vez, com `data_ultima_emissao` em 2028 — o `NUNCA` ignora o > `data_fim` e agenda dois anos à frente. O mesmo contrato com > `tipo_expiracao: DATA` e janela de um mês gerou 2. Escolha a janela antes > de testar em produção. > > **2. `contrato delete` não é exclusão permanente**, ao contrário do que > este documento afirmava. O `GET` continua respondendo `200`, com > `status: DELETADO`; o que some é a listagem. As vendas associadas **são** > canceladas de verdade (a contagem de vendas voltou ao valor de antes). > > **3. Um uuid válido mas inexistente devolve `500`, não `404`** — em > `contrato get`, `contrato delete` e `contrato encerrar`. Nas duas escritas > o CLI classifica isso como `ambiguous` e sugere reconciliar, quando na > prática não havia nada para aplicar. Um id malformado, esse sim, devolve > `400`. ## `contrato list` ✅ { #contrato-list } `GET /v1/contratos` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-inicio` | não¹ | 1º dia do mês corrente | Início do intervalo (`YYYY-MM-DD`) | | `--data-fim` | não¹ | último dia do mês corrente | Fim do intervalo (`YYYY-MM-DD`) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `10` | Itens por página (aceita até `1000`) | | `--busca-textual` | não | — | Busca textual: casa **nome do cliente e número do contrato** | | `--cliente-id` | não | — | Filtra pelo ID do cliente | | `--campo-ordenado-ascendente` | não | — | `DATA_INICIO` ou `DATA_FIM`; se informado, ignora `--campo-ordenado-descendente` | | `--campo-ordenado-descendente` | não | — | `DATA_INICIO` ou `DATA_FIM` | ¹ A API exige o intervalo — **os dois lados**. Sem nenhum, responde `400` ("Data de início da recorrência não pode ser nula"); só com um lado, cobra o outro. O CLI supre com o mês corrente e avisa em stderr, comportamento confirmado exercitando. Retorna **`{itens_totais, itens[]}`** — a chave é `itens`, não `items` como este documento dizia. Cada item traz `{id, cliente, status, proximo_vencimento, total_proximo_vencimento, data_inicio, numero, conta_financeira, termos, tipo_pagamento, total}`; é um recorte diferente do que `contrato get` devolve. `--busca-textual` não busca um "nome do contrato" — esse campo não existe no recurso. Casa o nome do cliente e o número: `busca_textual=2` devolveu só o contrato de número 2. Ordenação inválida devolve `400` listando `[DATA_INICIO, DATA_FIM]`, que é como se prova que o parâmetro é lido e não descartado; mandando as duas ordenações juntas, a ascendente vence. ## `contrato create` ✅ { #contrato-create } `POST /v1/contratos` — **escrita síncrona**, diferente das escritas financeiras: não devolve protocolo, o `id` do contrato já vem na resposta. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON do contrato, repassado à API **verbatim** | O CLI não valida o conteúdo de `--json`. A lista de obrigatórios que este documento trazia (`id_cliente`, `termos`, `condicao_pagamento`, `itens`) é verdadeira mas inútil: a API cobra **onze campos**, descobertos um `400` de cada vez. | Campo | Onde | Formato | |---|---|---| | `id_cliente` | raiz | uuid da pessoa | | `tipo_frequencia` | `termos` | `MENSAL` ou `ANUAL` | | `tipo_expiracao` | `termos` | `DATA` ou `NUNCA` | | `data_inicio` | `termos` | `YYYY-MM-DD` | | `data_fim` | `termos` | `YYYY-MM-DD` — **exigido mesmo com `tipo_expiracao: NUNCA`** | | `intervalo_frequencia` | `termos` | inteiro | | `dia_emissao_venda` | `termos` | inteiro (dia do mês) | | `numero` | **`termos`** | inteiro — veja abaixo | | `tipo_pagamento` | `condicao_pagamento` | enum longo (`BOLETO_BANCARIO`, `PIX_PAGAMENTO_INSTANTANEO`, `DINHEIRO`, …) | | `dia_vencimento` | `condicao_pagamento` | inteiro | | `primeira_data_vencimento` | `condicao_pagamento` | `YYYY-MM-DD` | | `itens` | **raiz** | array de `{id, quantidade, valor}` | Dois desses lugares custam tempo se você supuser errado: - **`numero` fica dentro de `termos`.** Na raiz — e como `numero_contrato`, `num_contrato`, `numero_do_contrato`, `contrato_numero` ou `number` — a API repete "O número do contrato é obrigatório" sem dizer onde ela quer. - **`itens` fica na raiz**, não em `termos`. Dentro de `termos` a resposta é "Lista de itens da recorrência não pode ser vazia". Retorna **`{id, id_legado}`** — não há `id_venda`, ao contrário do que este documento afirmava. **`observacoes` volta em outro lugar.** O que você manda na raiz como `observacoes` aparece no `GET` em `condicao_pagamento.observacoes_pagamento`; a `observacoes` da raiz fica sempre vazia na leitura, e uma `observacoes_pagamento` mandada dentro de `condicao_pagamento` é descartada. Mesma classe de troca de `orcamento create`. Payload mínimo que passou: ```json { "id_cliente": "11111111-1111-4111-8111-111111111111", "termos": { "tipo_frequencia": "MENSAL", "tipo_expiracao": "DATA", "data_inicio": "2026-09-01", "data_fim": "2026-10-01", "intervalo_frequencia": 1, "dia_emissao_venda": 1, "numero": 1 }, "condicao_pagamento": { "tipo_pagamento": "BOLETO_BANCARIO", "dia_vencimento": 10, "primeira_data_vencimento": "2026-09-10" }, "itens": [{ "id": "22222222-2222-4222-8222-222222222222", "quantidade": 1, "valor": 10 }] } ``` A `descricao` de um item **propaga para as vendas geradas**, o que a torna o lugar prático para marcar um contrato de teste. ## `contrato proximo-numero` ✅ { #contrato-proximo-numero } `GET /v1/contratos/proximo-numero` Sem parâmetros. Retorna o próximo número de contrato disponível como um inteiro solto, não um objeto — diferente de todos os outros comandos de leitura. O contador acompanha as exclusões: numa conta sem contratos devolveu `1`, subiu para `2` depois do contrato de número 1 e voltou a `1` quando os contratos de teste foram excluídos. Ao contrário de `venda`, esse número **não** é atribuído sozinho — é você quem manda `termos.numero` no `create`. ## `contrato get` ✅ { #contrato-get } `GET /v1/contratos/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid do contrato | Devolve bem mais do que a listagem: `{id, id_ultima_venda_confirmada, id_proxima_venda_agendada, cliente, vendedor, termos, data_proximo_vencimento, data_proxima_emissao, data_ultima_emissao, status, configuracao_recorrencia, condicao_pagamento, local_prestacao_servico, composicao_valor, observacoes}`. `status` é `ATIVO`, `INATIVO` (depois de `encerrar`) ou `DELETADO` (depois de `delete`). Uuid inexistente devolve `500`; id malformado, `400`. ## `contrato delete` ✅ { #contrato-delete } `DELETE /v1/contratos/{id}` — exclusão **lógica**, apesar do nome. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid do contrato | Resposta `204 No Content` — sem corpo, que o CLI imprime como `[]`. O contrato sai da listagem mas continua legível por `contrato get`, com `status: DELETADO` e `id_proxima_venda_agendada` zerado. **As vendas associadas são canceladas de verdade**: um contrato que havia gerado 25 vendas agendadas devolveu a contagem global de vendas ao valor anterior depois do `delete`. Funciona também em contrato já encerrado. Contratos em reajuste de valor não podem ser removidos. ## `contrato encerrar` ✅ { #contrato-encerrar } `POST /v1/contratos/{id}/encerrar` — sem corpo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid do contrato | Resposta `204 No Content` — sem corpo, que o CLI imprime como `[]`. Desativa o contrato: `status` passa de `ATIVO` para `INATIVO` e `id_proxima_venda_agendada` é zerado, mas o contrato **permanece na listagem** — é o que separa `encerrar` de `delete`. A chamada é idempotente: repetida num contrato já `INATIVO`, responde `204` de novo. Contratos em reajuste de valor não podem ser encerrados. ============================================================================== # Pessoas / Fornecedores Os payloads de criação e atualização seguem o schema da API e são enviados sem transformação. Use `--json` com um objeto JSON. > **Os enums vão acentuados e capitalizados como na interface**, não em > `SCREAMING_SNAKE_CASE`: `tipo_pessoa` aceita `Física`, `Jurídica` ou > `Estrangeira`; `tipo_perfil` aceita `Cliente`, `Fornecedor` ou > `Transportadora`. Enviar `FISICA` ou `CLIENTE` devolve **400**. ## `pessoa list` ✅ { #pessoa-list } `GET /v1/pessoas` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--tipo-ordenacao` | não | — | Campo de ordenação | | `--ordem-ordenacao` | não | — | Direção da ordenação | | `--busca` | não | — | Busca por nome ou documento | | `--ids` | não | — | IDs das pessoas | | `--documentos` | não | — | Documentos das pessoas | | `--paises` | não | — | Países das pessoas | | `--cidades` | não | — | Cidades das pessoas | | `--ufs` | não | — | UFs das pessoas | | `--codigos-pessoa` | não | — | Códigos das pessoas | | `--emails` | não | — | Emails das pessoas | | `--tipos-pessoa` | não | — | Tipos de pessoa | | `--nomes` | não | — | Nomes das pessoas | | `--telefones` | não | — | Telefones das pessoas | | `--data-criacao-inicio` | não | — | Data inicial de criação | | `--data-criacao-fim` | não | — | Data final de criação | | `--data-alteracao-de` | não | — | Data inicial de alteração | | `--data-alteracao-ate` | não | — | Data final de alteração | | `--tipo-perfil` | não | — | Perfil da pessoa | | `--com-endereco` | não | — | Retorna apenas pessoas com endereço | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | Retorna `{totalItems, items[]}` — **camelCase e em inglês**, diferente do `{itens_totais, itens[]}` dos comandos financeiros. Quando nada casa, `items` vem `null`, não `[]`. > **Os dois pares de data não usam o mesmo formato**, e trocá-los devolve 400: > > | Filtro | Formato | Exemplo | > |---|---|---| > | `--data-criacao-inicio` / `--data-criacao-fim` | `YYYY-MM-DD` | `2026-08-19` | > | `--data-alteracao-de` / `--data-alteracao-ate` | ISO 8601 **sem timezone** | `2026-08-19T00:00:00` | ## `pessoa create` ✅ { #pessoa-create } `POST /v1/pessoas` | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Objeto JSON da pessoa | O mínimo aceito é `nome`, `tipo_pessoa` e `perfis`: ```json {"nome":"…","tipo_pessoa":"Física","perfis":[{"tipo_perfil":"Cliente"}]} ``` Retorna `{id, tipo_pessoa, nome, ativo, origem, perfis, estrangeiro}`. A criação é bem mais permissiva que `pessoa update` — nem documento, nem endereço, nem contato são exigidos aqui. ## `pessoa get`, `pessoa legado` ✅ { #pessoa-get } - `pessoa get` — `GET /v1/pessoas/{id}` - `pessoa legado` — `GET /v1/pessoas/legado/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | ID atual ou legado da pessoa | > `pessoa legado` consome o **`uuid_legado`** (o uuid que `pessoa list` > devolve nesse campo, e que `pessoa get` aninha em `pessoas_legado[].uuid`), > não o `id_legado` inteiro do mesmo item. ## `pessoa update`, `pessoa patch` ✅ { #pessoa-update } - `pessoa update` — `PUT /v1/pessoas/{id}` - `pessoa patch` — `PATCH /v1/pessoas/{id}` `PUT` substitui o cadastro inteiro; `PATCH` atualiza apenas os campos enviados. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | ID da pessoa | | `--json` | **sim** | Objeto JSON da atualização | `pessoa patch` responde **204 No Content** (renderizado como `[]`) e aplica só o que foi enviado — confirme o resultado com `pessoa get`. `pessoa update` é substituição de verdade: responde **200** com o cadastro completo, mas exige o objeto inteiro. Para uma pessoa Física a API cobra, um erro de cada vez, todos estes campos: | Campo | Observação | |---|---| | `nome`, `tipo_pessoa`, `perfis` | como em `pessoa create` | | `cpf` | **não** `documento` — o campo de leitura (`documento`) e o de escrita (`cpf`) têm nomes diferentes | | `codigo`, `rg`, `data_nascimento`, `email`, `telefone_comercial`, `observacao` | strings vazias são rejeitadas | | `inscricoes[]` | ex: `[{"indicador_inscricao_estadual":"NAO CONTRIBUINTE","inscricao_estadual":"","inscricao_municipal":"","inscricao_suframa":""}]` | | `outros_contatos[]` | não pode ser vazio; cada item exige `telefone_celular` | | `enderecos[]` | não pode ser vazio; `complemento` também é obrigatório | > Por isso, para mexer em um ou dois campos use `pessoa patch`. `pessoa > update` só compensa quando você já tem o cadastro completo em mãos. ## `pessoa ativar`, `pessoa inativar`, `pessoa excluir` ✅ { #pessoa-ativar } - `pessoa ativar` — `POST /v1/pessoas/ativar` - `pessoa inativar` — `POST /v1/pessoas/inativar` - `pessoa excluir` — `POST /v1/pessoas/excluir` O payload esperado pela API é `{"uuids":[...]}`. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Objeto JSON com os IDs | `ativar` e `inativar` respondem **200** com `{todos[], ativos[], inativos[]}` — o balde que não se aplica à operação vem `null`. `excluir` responde **204 No Content** (renderizado como `[]`) e a exclusão é permanente: `pessoa get` passa a devolver 404. > **Mas `excluir` pode simplesmente ser recusado.** Uma pessoa vinculada a > qualquer lançamento devolve `400` com a lista de ids que sobraram: "Os IDs > […] não podem ser excluídos, pois já foram removidos anteriormente ou estão > vinculados a um lançamento (negociações, contratos, eventos financeiros ou > faturas de serviços/produtos)". Note que a mensagem **funde dois casos** — > "já excluído" e "ainda vinculado" —, então ela não diz qual dos dois > aconteceu. Confirmado em 2026-08-19 com o fornecedor que a Captura criou: > depois do `captura aceitar`, ele passou a ter evento financeiro e a > exclusão parou de ser possível. Nesses casos o caminho é `pessoa inativar`, > que continua funcionando. ## `pessoa conta-conectada` ✅ { #pessoa-conta-conectada } `GET /v1/pessoas/conta-conectada` — retorna os dados da empresa vinculada ao token. Sem parâmetros. Retorna `{id_empresa, documento, razao_social, nome_fantasia, data_fundacao, email}`. ============================================================================== # Produtos Os payloads de criação e atualização seguem o schema da API e são enviados sem transformação. Use `--json` com um objeto JSON. > **`GET /v1/produtos` ignora em silêncio todo parâmetro que não reconhece.** > Um filtro com o nome errado não vira 400: vira `200` com o catálogo > inteiro, como se tudo casasse. Por isso os filtros abaixo são só os que > foram confirmados contra a produção — `--ids` e `--categoria-id` > existiram até 2026-08-19 e foram removidos por não filtrarem nada. ## `produto list` ✅ { #produto-list } `GET /v1/produtos` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | | `--busca` | não | — | Busca textual por nome | | `--codigo` | não | — | Filtra pelo SKU; vai para a API como `sku` | | `--status` | não | — | `ATIVO` ou `INATIVO` | Retorna `{totalItems, items[]}` — camelCase, como `pessoa list`. Cada item traz `id`, `id_legado`, `nome`, `codigo`, `tipo`, `status`, `saldo`, `valor_venda`, `custo_medio`, `estoque_minimo`/`maximo`, `integracao_ecommerce_ativada`, `ean` e `produtos_variacao[]`. > O SKU aparece como `codigo` em `produto list` e como `codigo_sku` em > `produto get` — três nomes para o mesmo dado, contando o `sku` da query. ## `produto create` ✅ { #produto-create } `POST /v1/produtos` | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Objeto JSON do produto | **Só `nome` é obrigatório** — `{"nome":"…"}` basta. A API preenche o resto: gera um `codigo_sku` derivado do nome, atribui a categoria `Outras`, `status: ATIVO`, `formato: SIMPLES` e `versao: 0`. Retorna o produto completo, já com `id` e `id_legado`. ## `produto get`, `produto delete` ✅ { #produto-get } - `produto get` — `GET /v1/produtos/{id}` - `produto delete` — `DELETE /v1/produtos/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | ID do produto | `produto get` devolve o cadastro completo, com os blocos aninhados `categoria`, `estoque`, `fiscal` (`ncm`, `cest`, `unidade_medida`), `ecommerce`, `variacao[]` e `detalhe_kit[]`. `produto delete` responde **204 No Content** (renderizado como `[]`) e a exclusão é permanente: `produto get` passa a devolver 404. ## `produto update` ✅ { #produto-update } `PATCH /v1/produtos/{id}` — atualiza apenas os campos enviados. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | ID do produto | | `--json` | **sim** | Objeto JSON da atualização | Responde **204 No Content** (renderizado como `[]`) — confirme o resultado com `produto get`. Cada atualização bem-sucedida **incrementa `versao`**, mas, diferente de `baixa update`, a API não exige que você envie a versão atual: não há controle de concorrência otimista aqui. ## Catálogos de produtos ✅ { #produto-categorias } - `produto categorias` — `GET /v1/produtos/categorias` - `produto cest` — `GET /v1/produtos/cest` - `produto ncm` — `GET /v1/produtos/ncm` - `produto unidades-medida` — `GET /v1/produtos/unidades-medida` - `produto ecommerce-marcas` — `GET /v1/produtos/ecommerce-marcas` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | | `--busca` | não | — | Filtro busca | | `--codigo` | não | — | Filtro codigo | Os comandos `produto categorias`, `produto cest`, `produto ncm`, `produto unidades-medida` e `produto ecommerce-marcas` consultam, respectivamente, `GET /v1/produtos/categorias`, `/cest`, `/ncm`, `/unidades-medida` e `/ecommerce-marcas`. Todos aceitam `--pagina`, `--tamanho-pagina` e `--busca`; CEST, NCM e unidades de medida também aceitam `--codigo`. Diferente de `produto list`, os catálogos respondem `{total_items, items[]}` — **snake_case**. As duas listagens do mesmo recurso não usam a mesma convenção de nome. | Comando | Formato de item | |---|---| | `produto categorias` | `{id, uuid, descricao}` | | `produto cest` | `{id, codigo, descricao}` | | `produto ncm` | `{id, codigo, descricao}` | | `produto unidades-medida` | `{id, descricao, abreviacao, em_uso}` | | `produto ecommerce-marcas` | `{}` — a conta de teste não tem marcas cadastradas | ## `produto ecommerce-categorias` ⚠️ { #produto-ecommerce-categorias } `GET /v1/produtos/ecommerce-categorias` !!! warning "Não verificado" Escrito a partir da documentação e **nunca exercitado** contra a API. Path, nomes de filtro, campos do payload, tipo do id e formato da resposta são todos não confiáveis. Veja [Notas para quem for estender](../guia/estendendo.md). | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | | `--busca` | não | — | Filtro busca | Aceita `--pagina`, `--tamanho-pagina` e `--busca`. > **Único comando do grupo que não foi possível verificar.** Em 2026-08-19 > ele respondeu `400` com > `{"error":"Os filtros informados para busca de categoria de e-commerce são inválidos"}` > em **todas** as tentativas — inclusive sem nenhum parâmetro, o que > descarta a hipótese de filtro malformado. O path está certo: caminhos > vizinhos inventados (`/ecommerce-categoria`, `/categorias-ecommerce`) > caem na rota `/v1/produtos/{id}` e falham reclamando de uuid, enquanto > este cai num handler de e-commerce de verdade. O irmão > `produto ecommerce-marcas` responde `200` com lista vazia na mesma conta. > A hipótese que sobra é uma pré-condição de conta (integração de > e-commerce não configurada) que a API reporta como erro de filtro. ============================================================================== # Serviços Os payloads seguem o schema da API e são enviados sem transformação. > **Serviços têm duas identidades, e os comandos não usam a mesma.** > `id` é o uuid — é o que `servico get` e `servico update` consomem. > `id_servico` é o inteiro legado — é o que **`servico delete` exige**. > Trocar um pelo outro no delete devolve 400 reclamando de `int64`. ## `servico list` ✅ { #servico-list } `GET /v1/servicos` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página — aqui **só `10`, `20`, `50` ou `100`** | | `--busca` | não | — | Busca textual pela descrição; vai para a API como `busca_textual` | Retorna `{itens[], paginacao}` — **uma terceira convenção**, diferente do `{totalItems, items[]}` de `pessoa`/`produto list` e do `{total_items, items[]}` dos catálogos de produto. A contagem fica em `paginacao.total_itens`, ao lado de `pagina_atual`, `total_paginas` e `tamanho_pagina`. Cada item traz `id`, `id_servico`, `codigo`, `descricao`, `preco`, `custo`, `status`, `tipo_servico`, `codigo_cnae`, `lei_116`, `codigo_municipio_servico`, `id_externo`, `lista_cenario_tributario[]` e `natureza_operacional`. > ⚠️ **`--tamanho-pagina` aceita menos valores aqui do que o validador do > CLI.** A validação local libera `200`, `500` e `1000`, mas este endpoint > os rejeita com 400 (`deve ser um dos seguintes valores: 10, 20, 50 ou > 100`). Mesma restrição de `nota-fiscal list`. > **Este endpoint honra um único filtro.** `GET /v1/servicos` responde 200 e > descarta em silêncio qualquer parâmetro que não reconheça, então um nome > errado devolve a lista inteira em vez de erro. `--codigo`, `--ids` e > `--status` existiram até 2026-08-19 e foram removidos: nenhum nome > testado (`codigo`, `codigos`, `codigo_servico`, `sku`, `ids`, `id`, > `uuid`, `uuids`, `id_servico`, `status`, `situacao`, `ativo`, …) filtrava > coisa alguma. Ordenação (`campo_ordenado_ascendente`/`descendente`) também > não existe aqui, diferente de `contrato list`. ## `servico create` ✅ { #servico-create } `POST /v1/servicos` — resposta **201 Created** com o serviço completo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Objeto JSON do serviço | Três campos são obrigatórios, cobrados um erro por vez: | Campo | Valores | |---|---| | `descricao` | texto livre | | `status` | `ATIVO` ou `INATIVO` | | `tipo_servico` | `PRESTADO`, `TOMADO` ou `AMBOS` | ```json {"descricao":"…","status":"ATIVO","tipo_servico":"PRESTADO"} ``` > Diferente de `produto create`, que se vira só com `nome`, aqui não há > default: sem `status` e `tipo_servico` a criação falha. ## `servico get`, `servico update` ✅ { #servico-get } - `servico get` — `GET /v1/servicos/{id}` - `servico update` — `PATCH /v1/servicos/{id}` Ambos pelo **uuid**. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | ID do serviço | | `--json` | não | Payload JSON do serviço | `servico get` recebe ``. `servico update` recebe `` e `--json` com os campos a atualizar; responde **204 No Content** (renderizado como `[]`), então confirme o resultado com `servico get`. ## `servico delete` ✅ { #servico-delete } `DELETE /v1/servicos` — exclui serviços em lote. Resposta **204 No Content** (renderizada como `[]`). | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | `{"ids":[…]}` com os **`id_servico` inteiros**, não os uuids | (renderizada como `[]`). ```json {"ids":[495926356]} ``` > **É exclusão lógica, e ela não aparece em `servico get`.** Depois do > delete o serviço some das listagens — `servico list` deixa de trazê-lo e a > contagem cai —, mas `servico get` pelo uuid **continua respondendo 200** > com o registro intacto e `status` ainda `ATIVO`. Não dá para descobrir por > `servico get` que um serviço foi excluído; use `servico list`. É o oposto > de `produto delete`, que passa a devolver 404. ============================================================================== # Notas fiscais A API só suporta **consulta** (NFe de produto emitida e NFS-e de serviço) e vínculo a MDF-e — não há emissão pelo CLI. **Verificado contra produção em 2026-08-19** — os quatro comandos, incluindo o ciclo completo de `vincular-mdfe` (`AUTORIZADO` → `ENCERRADO` → `CANCELADO`) sobre notas de 2024 marcadas com o `identificador` de teste `TESTE CLI CONTA AZUL`. > **As duas listagens limitam o intervalo a 15 dias**, não só a de serviço. > `nota-fiscal list` respondia `400` em *toda* invocação sem datas, porque o > default era o mês corrente. Medido: 15 dias de diferença passam, 16 respondem > `{"error":"O período entre data_inicial e data_final não pode ser maior que > 15 dias"}`. Corrigido — o default agora são os últimos 15 dias, como na NFS-e. ## `nota-fiscal list` ✅ { #nota-fiscal-list } `GET /v1/notas-fiscais` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-inicial` | não¹ | 15 dias atrás | Início do intervalo (`YYYY-MM-DD`) | | `--data-final` | não¹ | hoje | Fim do intervalo (`YYYY-MM-DD`) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `10` | Itens por página (`10`, `20`, `50` ou `100`) | | `--documento-tomador` | não | — | Documento (CPF/CNPJ) do tomador, só dígitos | | `--numero-nota` | não | — | Número da nota fiscal | | `--id-venda` | não | — | **UUID** da venda | ¹ A API exige o intervalo e o limita a 15 dias; o CLI supre e avisa em stderr. Os três filtros foram provados um a um com valor que casa com um único registro — obrigatório, porque a listagem **responde 200 e descarta em silêncio** o que não reconhece (`zzz_bogus=abc` devolve a coleção inteira). > **`--id-venda` quer o UUID, não o `id_legado`.** É o raro caso em que a API > valida: o inteiro devolve `400 {"error":"O valor informado deve estar no > formato UUID válido"}`. Só `numero_nota` funciona como nome do filtro de > número — `numero`, `numeroNota`, `numero_nf`, `nota`, `numero_documento` e > `numero_nota_fiscal` são todos descartados em silêncio. **Resposta:** `{itens[], paginacao{pagina_atual, total_paginas, tamanho_pagina, total_itens}}`. > **`total_itens` superconta, e muito.** Ele conta as notas de *todos* os > status, mas `itens` só traz `EMITIDA` e `CORRIGIDA_SUCESSO`. Numa janela real > a resposta foi `itens: []` com `total_itens: 3`; noutra, 7 itens com > `total_itens: 12`. Varrendo dois anos: 147 notas devolvidas, 146 `EMITIDA` e > 1 `CORRIGIDA_SUCESSO`. **Conte `len(itens)`** — é o mesmo defeito de > `orcamento list`, aqui numa escala que chega a 100% de divergência. > Sem nenhum resultado, a API devolve `tamanho_pagina: 9223372036854775807` > (o `PHP_INT_MAX`) em vez do tamanho pedido. ## `nota-fiscal get` ✅ { #nota-fiscal-get } `GET /v1/notas-fiscais/{chave}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Chave de acesso (44 dígitos) | A resposta da API é binária, não JSON. Para manter o contrato de stdout, o comando devolve `{"content_base64", "content_type"}` — decodifique `content_base64` para obter os bytes originais. **`content_type` é sempre `application/octet-stream`**, inclusive no XML puro, então ele não serve para distinguir os dois formatos; olhe os bytes: | Status da nota | Conteúdo | Como reconhecer | |---|---|---| | `EMITIDA` | XML da NF-e | começa com ` Chave inexistente devolve **`404`** com `{"error":"Nenhuma nota fiscal > encontrada com a chave informada"}` — e uma string que nem chave é (`NOTAVALIDA`) > devolve o mesmo `404`, não um `400` de formato. Diferente de `contrato get`, > que responde `500` para id desconhecido. ## `nota-fiscal vincular-mdfe` ✅ { #nota-fiscal-vincular-mdfe } `POST /v1/notas-fiscais/vinculo-mdfe` — **escrita síncrona**, resposta `204 No Content` (renderizada como `[]`). | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON do vínculo | Registra na Conta Azul que um conjunto de notas fiscais pertence a um MDF-e (Manifesto Eletrônico de Documentos Fiscais, modelo 58) emitido em outro sistema. **Não emite MDF-e e não transmite nada à SEFAZ** — o payload não tem veículo, motorista nem percurso, que a SEFAZ exigiria. > **Para que este endpoint existe.** A Conta Azul [**não emite MDF-e > nativamente**](https://ajuda.contaazul.com/hc/pt-br/articles/42794589052301-Notas-fiscais-a-Conta-Azul-emite-CT-e-ou-MDF-e); > a emissão é feita por um parceiro externo, a **LOG CT-e**, contratada à > parte e conectada em *Integrações > Conecte-se por um parceiro > Emissão > fiscal e Obrigações*. Este endpoint é o **caminho de volta** dessa > integração: quem emitiu o manifesto lá fora avisa a Conta Azul de que > aquelas NF-e foram manifestadas, e em que estado o manifesto está. Isso > explica o formato do payload — chaves, um identificador opaco e o estado — > e explica por que não há `GET`: quem chama já é o dono do dado. Campos do payload — **os três são obrigatórios**: | Campo | Tipo | Observação | |---|---|---| | `chaves_acesso` | array de string | Chaves de acesso das NF-e. Aceita mais de uma | | `identificador` | string | **Texto livre** — não é validado como chave de MDF-e | | `status` | string | `AUTORIZADO`, `ENCERRADO` ou `CANCELADO`, caixa-alta exata | > **`status` é obrigatório**, ao contrário do que esta página afirmava até > 2026-08-19. Sem ele a API responde `400` com a lista de valores aceitos, e > ela valida esse campo **antes** dos demais — por isso ele é o primeiro erro > que aparece, mesmo faltando os outros dois. O erro não era desta página: a > própria documentação oficial descreve o campo como "também é possível > informar o status do vínculo". Produção discorda. Ordem de validação observada, um campo por vez: `status` → campos obrigatórios (`chaves_acesso`, `identificador`) → existência das chaves. **Comportamento confirmado contra produção:** | Cenário | Resultado | |---|---| | Uma chave válida, qualquer `status` | `204` | | Duas chaves na mesma chamada | `204` — o array é mesmo plural | | Repetir a mesma chave e `identificador` | `204`, sem erro de duplicata | | Mesma chave com `identificador` diferente | `204`, também aceito | | `AUTORIZADO` → `ENCERRADO` → `CANCELADO` | `204` em todas; nenhuma máquina de estados é imposta | | `AUTORIZADO` depois de `CANCELADO` | `204` — cancelar não trava a chave | | `status` em minúsculas | `400` | | Chave inexistente | `404` | | Chave válida **junto de** uma inexistente | `404` (ver abaixo) | | `chaves_acesso: []` | **`500`**, não `400` | > **O vínculo tem uma consequência conhecida: ele trava o cancelamento da > NF-e.** A Conta Azul [documenta o erro "Há um CT-e ou MDF-e vinculado a esta > nota"](https://ajuda.contaazul.com/hc/pt-br/articles/115007937188-NF-e-Erro-H%C3%A1-um-CT-e-ou-MDF-e-vinculado-a-esta-nota): > para cancelar uma NF-e manifestada, o MDF-e precisa ser cancelado antes e o > cancelamento processado pela SEFAZ. O artigo trata do vínculo **que existe na > SEFAZ**, não do registro interno que este endpoint grava — se o ERP também > consulta o registro interno antes de deixar cancelar, não foi testado. > > Na prática isso **não afeta as notas usadas na verificação**, e não por > sorte de estado: o prazo de cancelamento de NF-e é de 24 h da autorização, e > mesmo o cancelamento extemporâneo mais generoso entre as UFs (30 dias, no > RJ) expirou há muito para notas de 2024. Uma nota velha não é cancelável por > ninguém, com ou sem manifesto. Todas as notas tocadas ficaram, além disso, > em estado `CANCELADO` — que é justamente o estado que destravaria o > cancelamento, se ele ainda fosse possível. > > Para uso real, a ordem importa: **não vincule uma NF-e que ainda esteja > dentro do prazo de cancelamento** sem que o manifesto exista de fato. > **O vínculo não é legível por lugar nenhum da API.** Não há `GET` do vínculo, > a nota não muda no `list`, e o XML devolvido por `nota-fiscal get` continua > **byte a byte idêntico** (conferido por SHA-256 antes e depois, em quatro > notas). Isso é a boa notícia — a escrita não toca o documento fiscal — mas > significa que **não dá para verificar nem desfazer um vínculo pelo CLI**. > Como repetir a chamada nunca dá erro, também não existe sonda indireta. > **Atomicidade indeterminada.** Uma chamada com uma chave válida e uma > inexistente devolve `404`. Se a válida chegou a ser vinculada, não há como > saber — pela ordem de validação é provável que o lote inteiro seja rejeitado > antes de gravar, mas isso **não está provado**. Mande chaves conferidas. > `chaves_acesso: []` devolve `500`, que o CLI classifica como erro de escrita > "a operação pode ter sido aplicada" e manda reconciliar. É alarme falso — nada > foi gravado. A classificação é compartilhada por todos os grupos e por isso > não foi mexida aqui. ## `nota-fiscal-servico list` ✅ { #nota-fiscal-servico-list } `GET /v1/notas-fiscais-servico` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--data-competencia-de` | não¹ | 15 dias atrás | Competência inicial (`YYYY-MM-DD`) | | `--data-competencia-ate` | não¹ | hoje | Competência final (`YYYY-MM-DD`) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `10` | Itens por página (`10`, `20`, `50` ou `100`) | | `--ids` | não | — | UUID da nota fiscal de serviço; repetível | | `--id-cliente` | não | — | UUID de cliente; repetível | | `--numero-venda` | não | — | Número da venda | | `--numero-nfse-inicial` / `--numero-nfse-final` | não | — | Intervalo de número da NFS-e | | `--numero-nfse-final` | não | — | Número final da NFS-e | | `--numero-rps-inicial` / `--numero-rps-final` | não | — | Intervalo de número do RPS | | `--numero-rps-final` | não | — | Número final do RPS | | `--status` | não | — | `EMITIDA`, `CANCELADA`, `PENDENTE`, …; repetível | | `--tipo-negociacao` | não | — | `VENDA` ou `CONTRATO` | ¹ A API exige o intervalo e o limita a **15 dias**; o CLI supre e avisa em stderr. **Os onze filtros foram provados individualmente** contra produção, cada um com um valor que casa com um subconjunto conhecido — esta listagem também descarta em silêncio o que não reconhece. Foi o único grupo `⚠️` da campanha cujo conjunto de filtros veio inteiro correto da documentação. Os repetíveis (`--ids`, `--id-cliente`, `--status`) vão como array (`ids[0]=…&ids[1]=…`) e a API casa por união: duas notas pedidas, duas devolvidas. **Resposta:** `{itens[], paginacao{…}}`, com `total_itens` **fiel** ao tamanho de `itens` — ao contrário de `nota-fiscal list`. Diferente da NFe, devolve NFS-e em qualquer status (numa varredura de um ano: 59 `EMITIDA`, 10 `CANCELADA`). Cada item traz `id`, `id_venda`, `numero_venda`, `numero_rps`, `numero_nfse`, `status`, `valor_total_nfse`, `data_competencia`, `nome_cliente`, `documento_cliente`, `codigo_cnae`, `cidade_emissao{nome, estado}`, `escriturado_manualmente` e `informacao_transmissao{data_inicio_emissao}`. > `numero_venda` volta como **string** (`"7206"`) apesar de o filtro aceitar > inteiro. E `tipo_negociacao`, que dá para filtrar, **não aparece** em nenhum > item da resposta. ============================================================================== # Vendas Os payloads de criação e atualização seguem o schema da API e são enviados sem transformação. Use `--json` com um objeto JSON. **Verificado contra produção em 2026-08-19** — os nove comandos, incluindo o ciclo completo de escrita (criar, atualizar e excluir três vendas de teste). `venda list` foi o primeiro grupo em que **todos** os filtros documentados existiam de verdade: os oito passaram na receita de baseline + `zzz_bogus` + valor discriminante. As armadilhas do grupo estão na escrita, não na leitura. ## `venda list` ✅ { #venda-list } `GET /v1/venda/busca` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página | | `--campo-ordenado-ascendente` | não | — | Filtro campo_ordenado_ascendente | | `--campo-ordenado-descendente` | não | — | Filtro campo_ordenado_descendente | | `--data-criacao-ate` | não | — | Filtro data_criacao_ate | | `--data-criacao-de` | não | — | Filtro data_criacao_de | | `--data-fim` | não | — | Filtro data_fim | | `--data-inicio` | não | — | Filtro data_inicio | | `--termo-busca` | não | — | Filtro termo_busca | | `--totais` | não | — | Filtro totais | Diferente de `contrato list`, o intervalo de datas é opcional — a API não o exige. Retorna `{totais, quantidades, total_itens, itens[]}`. Detalhes confirmados exercitando: - `--termo-busca` casa **nome do cliente e número da venda**, não as observações. Buscar por `TESTE CLI CONTA AZUL` — o texto gravado em `observacoes` das vendas de teste — devolveu `0`; buscar pelo número devolveu exatamente 1. - `--data-inicio`/`--data-fim` filtram a **data da venda**; `--data-criacao-de`/`--data-criacao-ate` filtram a data de criação. São intervalos distintos e o mesmo par de datas deu totais diferentes (27 vs 19). - `--totais` é um **filtro de situação**, apesar do nome. `--totais CANCELED` reduziu 6652 para 1. Valor fora do enum devolve `400` — é dos poucos parâmetros que a API valida em vez de descartar. - `--campo-ordenado-*` não muda a contagem (é ordenação), mas um valor inválido devolve `400` com a lista de valores aceitos — foi assim que se provou que o parâmetro é reconhecido. - Aceita `--tamanho-pagina 1000`: **não** é afetado pelo limite de 100 que atinge `servico list` e as notas fiscais. ## `venda create` ✅ { #venda-create } `POST /v1/venda` — **escrita síncrona**, sem protocolo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON da venda (ver campos obrigatórios abaixo) | A documentação lista cinco campos obrigatórios; a API cobra **seis**, e `condicao_pagamento` não estava na lista. Descobertos um a um, cada `400` revelando só o próximo: | Campo | Formato | |---|---| | `id_cliente` | uuid da pessoa | | `numero` | inteiro (use `venda proximo-numero`) | | `situacao` | `EM_ANDAMENTO` ou `APROVADO` — nada mais | | `data_venda` | `YYYY-MM-DD` | | `itens` | array de `{id, quantidade, valor}` — `id` é o uuid do produto ou serviço, **não** `id_item` | | `condicao_pagamento` | `{opcao_condicao_pagamento, parcelas[]}` | `opcao_condicao_pagamento` aceita `À vista` (acentuado e capitalizado assim), `Nx` (`1x`, `12x`) ou dias separados por vírgula (`30`, `30,60`, `15,30,45`). Cada parcela é `{data_vencimento, valor}`. Payload mínimo que passou: ```json { "id_cliente": "11111111-1111-4111-8111-111111111111", "numero": 7297, "situacao": "EM_ANDAMENTO", "data_venda": "2026-08-19", "observacoes": "TESTE CLI CONTA AZUL", "itens": [{ "id": "22222222-2222-4222-8222-222222222222", "quantidade": 1, "valor": 10 }], "condicao_pagamento": { "opcao_condicao_pagamento": "À vista", "parcelas": [{ "data_vencimento": "2026-08-19", "valor": 10 }] } } ``` **A resposta do `POST` e a do `GET` falam enums diferentes.** O `create` devolve `situacao.nome` em inglês (`IN_PROCESS`) e chama o campo de `pendencia`; o `get` devolve `EM_ANDAMENTO` e `tipo_pendencia` para a mesma venda. Não deduza o vocabulário de um a partir do outro. Retorna `{id, id_legado, numero, origem, data_venda, situacao, pendencia, valor_composicao, condicao_pagamento, …}` — o `id` é o uuid a usar nos demais comandos. ## `venda get` ✅ { #venda-get } `GET /v1/venda/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid ou id legado da venda | Aceita os dois ids (confirmado com uuid e com `id_legado`). A resposta é um **envelope**, não a venda solta: `{evento_financeiro, notificacao, natureza_operacao, contrato, cliente, vendedor, venda}` — os campos da venda ficam sob a chave `venda`. **`get` não devolve os itens.** `venda.total_itens` é um objeto de contagens (`{contagem_produtos, contagem_servicos, contagem_nao_conciliados}`), não uma lista nem um número. Para os itens, use `venda itens`. ## `venda update` ✅ { #venda-update } `PUT /v1/venda/{id}` — **escrita síncrona**; a API não expõe `PATCH` para vendas, então o payload precisa trazer o objeto completo, incluindo `versao`. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid da venda — id legado devolve `400` | | `--json` | **sim** | Payload JSON completo da venda | Mesmos campos obrigatórios do `create`, mais `versao`. E aqui está a armadilha mais desagradável do grupo: > **`versao` precisa ser diferente de zero.** Uma venda recém-criada nasce > com `versao: 0`; devolver esse valor no `PUT` responde > `O campo 'versao' é obrigatório` — o validador não distingue zero de > ausente. Mandar `1` funciona. E `versao` **não é trava otimista**: o valor enviado é ignorado. Com a venda em `versao: 1`, tanto `1` quanto `99` foram aceitos e o servidor apenas incrementou o próprio contador (1 → 2 → 3). Ou seja, o campo é obrigatório e inútil — mande qualquer inteiro positivo. Retorna só `{id, id_legado}`. ## `venda imprimir` ✅ { #venda-imprimir } `GET /v1/venda/{id}/imprimir` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid ou id legado da venda | A resposta da API é um PDF binário, não JSON. Para manter o contrato de stdout do CLI, o comando devolve `{"content_base64", "content_type"}` — decodifique `content_base64` para obter os bytes originais. Confirmado: `content_type` é `application/pdf` e os bytes decodificados começam com `%PDF-1.5`. ## `venda itens` ✅ { #venda-itens } `GET /v1/venda/{id_venda}/itens` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `` | **sim** | — | Argumento posicional. Uuid da venda | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `10` | Itens por página | Retorna `{itens[], itens_totais, totais}`. Só aceita uuid: passar o `id_legado` devolve `400` ("o valor informado para o ID precisa ser do tipo UUID") — mesmo id que `get` e `imprimir` aceitam sem reclamar. Cada item traz `{id, id_item, nome, descricao, tipo, quantidade, valor, custo, id_centro_custo}`, onde `id` é o id da linha da venda e `id_item` é o uuid do produto ou serviço. Aceita `--tamanho-pagina 1000`. ## `venda vendedores` ✅ { #venda-vendedores } `GET /v1/venda/vendedores` Sem parâmetros e sem paginação: devolve o array completo de vendedores cadastrados. Cada item traz apenas `{id, nome}` — o `id_legado` que a documentação promete **não vem na resposta**. ## `venda proximo-numero` ✅ { #venda-proximo-numero } `GET /v1/venda/proximo-numero` Sem parâmetros. Retorna o próximo número de venda disponível como um inteiro solto (ou `null`), não um objeto — mesmo formato de `contrato proximo-numero`. O contador **volta atrás** quando as vendas são excluídas: passou de 7297 para 7298 assim que a venda 7297 foi criada e voltou a 7297 depois que as três vendas de teste foram removidas. ## `venda excluir-lote` ✅ { #venda-excluir-lote } `POST /v1/venda/exclusao-lote` — exclui vendas em lote. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON com `{"ids": [...]}` — de 1 a 10 uuids por chamada | Retorna `{atualizados, ignorados}` com `200` (não `204`). Os dois limites são cobrados de verdade: `[]` devolve `400` ("deve conter ao menos 1 item") e 11 ids devolvem `400` ("não pode conter mais de 10 itens"). Um uuid inexistente **não** é erro — entra em `ignorados` e a chamada responde `200`, então confira o contador em vez de confiar no status. **A exclusão é lógica**, como em `servico` e ao contrário de `produto`: depois do `exclusao-lote`, `venda get` e `venda itens` continuam respondendo `200` com o registro completo. O que muda é `venda.status`, que passa a `CANCELADO` — enquanto `venda.situacao` **continua** `EM_ANDAMENTO`, que é o par de campos que engana. A prova confiável é o sumiço da listagem. ============================================================================== # Orçamentos O payload de criação segue o schema da API e é enviado sem transformação. Use `--json` com um objeto JSON. **Verificado contra produção em 2026-08-19** — os quatro comandos, com três orçamentos de teste criados e excluídos. Os nove filtros de `orcamento list` passaram na receita completa (baseline, `zzz_bogus=abc`, valor discriminante). Como em `venda`, os problemas estavam fora da listagem — e aqui há dois graves o suficiente para virem antes das tabelas: > **1. `total_itens` não conta tudo o que `itens` devolve.** A listagem sem > filtro responde `total_itens: 157` e entrega **158** orçamentos distintos. > A diferença é a situação `ORCAMENTO_RECUSADO`: filtrando > `situacoes=ORCAMENTO` os dois números batem (160/160), e filtrando > `situacoes=ORCAMENTO_RECUSADO` a resposta traz um item com > `total_itens: 0`. Quem paginar por `total_itens` **perde registros** — > conte `itens`. > > **2. `observacoes` e `observacoes_pagamento` trocam de lugar entre > escrita e leitura.** O que você manda no `POST` como `observacoes` volta > no `GET` como `observacoes_pagamento`, e vice-versa. Confirmado com um > orçamento criado com os dois campos preenchidos com textos distintos, mais > `descricao` e `previsao_entrega` como controle — esses dois voltam no > lugar certo. O CLI **não corrige** a troca: `--json` é repassado sem > transformação, e compensar aqui quebraria no dia em que a API consertar. ## `orcamento list` ✅ { #orcamento-list } `GET /v1/orcamentos` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `50` | Itens por página (aceita até `1000`) | | `--campo-ordenado-ascendente` | não | — | Filtro campo_ordenado_ascendente | | `--campo-ordenado-descendente` | não | — | Filtro campo_ordenado_descendente | | `--data-alteracao-ate` | não | — | Filtro data_alteracao_ate | | `--data-alteracao-de` | não | — | Filtro data_alteracao_de | | `--data-criacao-ate` | não | — | Filtro data_criacao_ate | | `--data-criacao-de` | não | — | Filtro data_criacao_de | | `--data-fim` | não | — | Filtro data_fim | | `--data-inicio` | não | — | Filtro data_inicio | | `--termo-busca` | não | — | Filtro termo_busca | Retorna `{itens[], total_itens}` — com a ressalva sobre `total_itens` acima. **Os pares de data são tudo-ou-nada.** Omitir os dois lados é válido (o intervalo é opcional), mas mandar **um só** devolve `400`: | Par | Formato | Erro se vier só um lado | |---|---|---| | `--data-inicio` / `--data-fim` | `YYYY-MM-DD` | "É necessário informar ambos os parâmetros de data" | | `--data-criacao-de` / `--data-criacao-ate` | `YYYY-MM-DD` | "…ambos os parâmetros de data de criação" | | `--data-alteracao-de` / `--data-alteracao-ate` | **`YYYY-MM-DDTHH:MM:SS`** | "…ambos os parâmetros de data de alteração" | O par de alteração é o único que **exige data-time ISO 8601**: com `2024-11-18` a API responde `400` pedindo o formato `2025-10-20T07:59:59`. Os outros dois pares recusam justamente esse formato estendido — não são intercambiáveis. `--termo-busca` casa nome do cliente e número do orçamento. `--campo-ordenado-*` não muda a contagem, mas um valor fora de `[CLIENTE, DATA, NUMERO]` devolve `400` — foi assim que se provou que o parâmetro é lido, e não descartado. **Filtros por array não expostos.** A API também aceita `ids_vendedores`, `ids_clientes`, `ids_natureza_operacao`, `ids_categorias`, `ids_produtos`, `situacoes`, `origens`, `numeros`, `ids_legado_donos`, `ids_legado_clientes` e `ids_legado_produtos`. Eles são **reais** — `situacoes`, `numeros` e `ids_clientes` foram exercitados e filtraram corretamente — e aceitam tanto um valor único quanto vários repetidos (`numeros=1&numeros=2`) ou separados por vírgula (`numeros=1,2`); a forma `numeros[]` devolve `400`. Ou seja: um valor escalar já funciona, então a razão antiga para não expô-los ("o comando genérico só suporta filtros escalares") não se sustenta. Continuam fora da CLI por decisão de escopo, não por impedimento técnico. O enum de `situacoes` é `ORCAMENTO`, `ORCAMENTO_ACEITO` ou `ORCAMENTO_RECUSADO`. ## `orcamento create` ✅ { #orcamento-create } `POST /v1/orcamentos` — **escrita síncrona**, sem protocolo. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON do orçamento (ver campos obrigatórios abaixo) | Quatro campos obrigatórios, e a documentação acertou desta vez: | Campo | Formato | |---|---| | `id_cliente` | uuid da pessoa | | `data_orcamento` | `YYYY-MM-DD` | | `data_validade` | `YYYY-MM-DD` | | `itens` | array de `{id, quantidade, valor}` — `id` é o uuid do produto ou serviço | `quantidade` e `valor` precisam ser **maiores que zero**; a API recusa `0` com mensagem própria para cada um. Ao contrário de `venda create`, **`numero` não é aceito nem exigido** — a API atribui sozinha, e do **mesmo contador das vendas**: com `venda proximo-numero` em 7297, o orçamento criado em seguida saiu como 7297. `situacao` também não entra no payload; todo orçamento nasce `ORCAMENTO`. Retorna apenas `{id}`. Payload mínimo que passou: ```json { "id_cliente": "11111111-1111-4111-8111-111111111111", "data_orcamento": "2026-08-19", "data_validade": "2026-09-19", "descricao": "TESTE CLI CONTA AZUL", "itens": [{ "id": "22222222-2222-4222-8222-222222222222", "quantidade": 1, "valor": 10 }] } ``` ## `orcamento get` ✅ { #orcamento-get } `GET /v1/orcamentos/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Uuid do orçamento | Devolve o orçamento **solto**, sem envelope — ao contrário de `venda get` — e com os `itens` inclusos, também ao contrário de `venda get`. Id inexistente devolve `404` com `"Orçamento não encontrado com o ID informado"`. Lembre da troca de `observacoes` ↔ `observacoes_pagamento` ao ler o que você mesmo escreveu. ## `orcamento excluir-lote` ✅ { #orcamento-excluir-lote } `DELETE /v1/orcamentos` — exclui orçamentos em lote. | Parâmetro | Obrig. | Descrição | |---|---|---| | `--json` | **sim** | Payload JSON com `{"ids": [...]}` — de 1 a 10 uuids por chamada | Resposta `204 No Content` — sem corpo, que o CLI imprime como `[]`. Os dois limites são cobrados: `[]` e 11 ids devolvem `400`. **A exclusão é física**, ao contrário de `venda excluir-lote` e `servico delete`: depois da chamada, `orcamento get` responde `404`. É a mesma semântica de `produto delete`. **O `204` não diz o que foi excluído.** Um uuid inexistente responde `204` igual, sem os contadores `{atualizados, ignorados}` que `venda excluir-lote` devolve. A única confirmação é reler a listagem. Não existe exclusão individual: `DELETE /v1/orcamentos/{id}` responde `405`. E o grupo **não tem update** — `PUT /v1/orcamentos/{id}` também responde `405`, então os quatro comandos são a superfície completa do recurso. ============================================================================== # Captura Fluxo da IA Captura: `captura enviar` sobe um arquivo e devolve o `id` do documento; `captura status` consulta esse `id` e, quando o processamento termina, devolve a `id_captura`; `captura get` traz a prévia extraída para essa `id_captura`; `captura aceitar`/`captura recusar` decidem o que fazer com a prévia. Verificado contra a produção em 2026-08-19 com dois recibos em PDF gerados para o teste. **É o único grupo que escreve em outro cadastro sem avisar** e o único cujo `id` de recurso não é uuid v4 — veja as duas notas abaixo. > **`captura enviar` cria um fornecedor no cadastro de pessoas.** Não é o > `aceitar` que faz isso: assim que a IA termina de extrair, o > `previa_evento_financeiro.fornecedor` já vem com um `id` que > `pessoa get` resolve, com `criado_em` do dia e perfil `Fornecedor`. Um > segundo documento do mesmo CNPJ reaproveita o registro em vez de duplicar. > Ou seja: **subir um documento é uma escrita no cadastro de pessoas**, não > uma leitura. Quem for exercitar isso em produção deve contar com esse > registro a mais. > **Os ids da Captura são uuid v7**, não v4 (`01a01a96-61f5-7007-…`, com `7` > na posição da versão). Qualquer validação local que exija v4 recusaria um > id legítimo. A API, por sua vez, valida o formato: um id que não é uuid > devolve `400` ("O ID da captura informado é inválido") e um uuid válido > porém inexistente devolve `404` ("Captura não encontrada com o ID > informado"). > **Este grupo devolve erro em outro envelope.** Onde o resto da API usa > `{"timestamp", "status", "error", "message", "path"}`, a Captura responde > `{"error": "mensagem"}`. Serve para distinguir rota inexistente de id > inexistente: `/v1/captura/documentos/{id}` (rota que não existe) devolve o > `404 page not found` em **texto puro** do gateway, enquanto as rotas reais > devolvem JSON. ## `captura enviar` ✅ { #captura-enviar } `POST /v1/captura/documentos` — multipart/form-data. Responde **`201`**, não `200`. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. Caminho de um arquivo local (PDF, JPEG, PNG ou BMP; máximo de 10 MB) | | `--descricao` | não | Descrição do documento (máximo de 255 caracteres) | `200`. O CLI valida que o arquivo existe e é legível antes de enviar. Retorna `{id, nome}`, onde `id` identifica o documento para `captura status`. O **tipo do arquivo é validado pela API**, não pelo CLI: um `.txt` volta `415` com "Formato não suportado. Aceitos: PDF, JPEG, PNG, BMP." — mensagem clara o bastante para não valer duplicar a regra localmente. > **`--descricao` é escrita sem leitura.** Nenhuma das três respostas de > leitura (`status`, `get`, `aceitar`) devolve a descrição enviada, e a > descrição do evento financeiro criado vem do **texto que a IA extraiu do > documento**, não dela. Serve para o histórico na interface web; pelo CLI > não há como conferir o que foi gravado. O limite de 255 caracteres ficou > **sem exercitar**: comprová-lo exigiria mais um upload, e documento > enviado não tem como ser apagado (veja `captura recusar`). ## `captura status` ✅ { #captura-status } `GET /v1/captura/documentos/status` | Parâmetro | Obrig. | Padrão | Descrição | |---|---|---|---| | `--ids` | **sim** | — | IDs de documentos separados por vírgula (até 20) | | `--pagina` | não | `1` | Número da página | | `--tamanho-pagina` | não | `10` | Itens por página (**qualquer inteiro de 1 a 20**) | Retorna `{itens[], paginacao}`; cada item traz `status_documento` e a lista de `capturas` geradas (pode estar vazia). O processamento é assíncrono — repita a consulta até o status chegar a um estado final. Aqui o `total_itens` **confere** com `len(itens)`. > **`--ids` com mais de um id nunca funcionou até 2026-08-19.** O OpenAPI > publicado declara `style: form, explode: false` — os ids juntos num > parâmetro só, separados por vírgula — e a API responde `400` ("O valor > informado para o campo 'ids' é inválido") exatamente a essa forma. Ela quer > o parâmetro **repetido**: `ids=a&ids=b`. Com **um** id as duas formas > coincidem, e foi por isso que o defeito atravessou incólume todo teste que > passava um id só. Varridas e descartadas: vírgula, espaço, `|`, `;`, JSON > e `ids[]=` (todas `400`). Funciona também `ids[0]=a&ids[1]=b`, que é o que > a opção `query` do Symfony geraria — mas o CLI manda a forma repetida, que > é a canônica. Limites medidos, todos com mensagem própria: mais de 20 ids devolve `400` ("O campo 'ids' não pode conter mais de 20 itens"), e o CLI passou a barrar isso antes da ida à API. > **Zero é buraco na validação da API, e o CLI é mais rígido de propósito.** > `tamanho_pagina=0` e `pagina=0` respondem `200` e caem no default (a > resposta volta com `tamanho_pagina: 10, pagina_atual: 1`), enquanto `-1` > devolve `400` ("deve ser maior ou igual a 1") — ou seja, a API valida o > negativo e deixa o zero passar como se fosse ausente. O CLI **recusa** > `--tamanho-pagina 0` localmente: zero não é um tamanho de página, e aceitar > silenciosamente um valor que não faz o que foi pedido é o mesmo defeito do > descarte silencioso. `--pagina 0` segue passando, e a API normaliza para 1. `--ids` vazio ou ausente é recusado pela própria API (`400`), sem descarte silencioso — ao contrário do que acontece com parâmetro desconhecido, que aqui também some sem avisar (`zzz_bogus=abc` devolve a resposta inalterada). Estados possíveis, do OpenAPI e confirmados no fluxo real — `status_documento`: `PENDENTE`, `PROCESSANDO`, `EXTRAINDO_DADOS`, `APLICANDO_REGRAS`, `AGUARDANDO_VINCULO_LANCAMENTO`, `CRIANDO_LANCAMENTOS_FINANCEIROS`, `PRONTO`, `IGNORADO`, `RESOLVIDO`, `ERRO`, `EXCLUIDO`; `status_captura`: `PROCESSANDO`, `PENDENTE`, `ACEITA`, `REJEITADA`, `FALHA`. Na verificação o documento percorreu `EXTRAINDO_DADOS` → `AGUARDANDO_VINCULO_LANCAMENTO` → `PRONTO` em poucos segundos, e foi para `RESOLVIDO` depois do aceite. ## `captura get` ✅ { #captura-get } `GET /v1/captura/{id}` | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. `id_captura`, obtido em `captura status` | Retorna `{id, id_documento, status, previa_evento_financeiro}`. A prévia só vem enquanto `status` é `PENDENTE`: depois do aceite a resposta encolhe para `{id, id_documento, status}`, e depois da recusa o `get` passa a responder **`404`**. > **`sugestao_evento_financeiro` não existe na resposta real.** O OpenAPI a > declara e este arquivo a documentava; a API nunca a devolveu, nem com a > captura em `PENDENTE`. Documentar campo de resposta a partir da spec é o > mesmo erro que documentar filtro a partir dela. A `previa_evento_financeiro` traz `tipo`, `valor`, `data_competencia`, `descricao`, `observacao`, `referencia_externa`, `fornecedor{id, nome, documento}`, `categoria{id, nome}` e `parcelas[]` com `data_vencimento`, `metodo_pagamento` e `composicao_valor`. Tanto o `fornecedor.id` quanto o `categoria.id` apontam para registros que existem de verdade no cadastro. ## `captura aceitar` ✅ { #captura-aceitar } `POST /v1/captura/{id}` — sem corpo. Responde `200`. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. `id_captura` cuja prévia será aceita | Cria o evento financeiro a partir da prévia e retorna `{id, status, evento_financeiro{id, valor, tipo, data_competencia, descricao}}`. > **Não há volta.** O evento financeiro criado é uma conta a pagar/receber > comum, e a API **não publica `DELETE` de evento financeiro** — só sai pela > interface web. Some-se a isso que o documento também não tem `DELETE` e a > captura aceita não pode mais ser recusada: `aceitar` é a transição mais > cara do CLI inteiro. Decida antes de chamar. > **É idempotente, e isso é a parte boa.** Chamar `aceitar` de novo na mesma > captura devolve `200` com `{id, status: "ACEITA"}` e **sem** > `evento_financeiro` — não cria um segundo lançamento. Conferido contando > `conta-a-pagar list` antes e depois: 37 → 38, com duas chamadas de aceite. O evento criado guarda o caminho de volta: em `parcela get`, o `evento.referencia` vem `{id: , origem: "LANCAMENTO_FINANCEIRO"}`. É o único vínculo legível entre o financeiro e a captura que o originou. ## `captura recusar` ✅ { #captura-recusar } `DELETE /v1/captura/{id}` — sem corpo. Resposta **`204 No Content`**, renderizada como `[]`. | Parâmetro | Obrig. | Descrição | |---|---|---| | `` | **sim** | Argumento posicional. `id_captura` a ser recusada | renderizada como `[]`. > **É o único caminho de volta do grupo, e funciona.** Recusar a captura tira > o documento da listagem de status — comprovado: consultar os dois ids > enviados passou a devolver só o outro. Como não existe > `DELETE /v1/captura/documentos/{id}` (a rota devolve o `404 page not found` > do gateway), recusar é a **única** forma de fazer um documento enviado > desaparecer. Se você for exercitar este grupo, planeje recusar tudo que > não precisar aceitar. Depois da recusa, `captura get` no mesmo id responde `404` — a captura sai do caminho de leitura de vez. Mas **recusar de novo continua respondendo `204` e saindo com código `0`**: a recusa é idempotente e nunca acusa "já recusada", ao contrário do `get`. Uma captura **já aceita** não pode ser recusada: `409` ("A captura não pode ser recusada no status atual (já foi aceita ou está em processamento)"), que o CLI classifica corretamente como `client_error` e sai com `1`. ==============================================================================