API

Todos os endpoints usam o mesmo contrato de resposta: { data, meta } em sucesso, { error: { code, message } } em erro. Rotas sob /api/v1/* exigem sessão de tenant (cookie do NextAuth) salvo indicação em contrário; a permissão por módulo segue a matriz descrita em Arquitetura. Rotas sob /api/platform/* exigem uma sessão de PlatformUser: uma instância NextAuth completamente separada (cookie próprio), nunca a mesma sessão de tenant.

meta está sempre presente numa resposta de sucesso, mesmo quando um exemplo abaixo mostra só {} por brevidade: o servidor nunca a omite.

O erro aceita um terceiro campo, opcional: { error: { code, message, field } }. field aparece só quando a recusa pertence a um campo do formulário, e traz o nome do campo na API (quantity, ear_tag), para o cliente mostrar a mensagem no lugar certo em vez de num rodapé genérico. A maioria dos erros não pertence a campo nenhum (rede, permissão, conflito) e nesses a chave não vem. Quem não a lê continua funcionando como antes.

Autenticação e conta

Login não é uma rota /api/v1 própria: usa o fluxo padrão do NextAuth (signIn("credentials", {...}) no client, contra /api/auth/callback/credentials). A sessão resultante carrega tenant_id e role.

POST/api/v1/signup/startPúblico

Etapa 1 do cadastro verificado. NÃO cria conta: valida, checa duplicidade de documento/email contra os dados reais, abre um cadastro pendente e dispara o código de WhatsApp. O identificador do cadastro volta em cookie httpOnly (`tibe-signup`), nunca no corpo. Sem senha: ela é gerada no fim e enviada pelos canais verificados. `utm_source`/`utm_medium`/`utm_campaign` são opcionais (first-touch, ver `src/lib/utm.ts`): persistidos no `Tenant` quando o cadastro conclui.

Request

{
  "company_name": "Fazenda Boa Vista",
  "owner_name": "Maria Silva",
  "owner_email": "maria@fazendaboavista.com.br",
  "document": "12345678000199",
  "phone": "22999990000",
  "plan": "fazenda",
  "utm_source": "google",
  "utm_medium": "cpc",
  "utm_campaign": "lancamento"
}

Response

201
{ "data": { "state": { "whatsapp_verified": false, "email_verified": false, "phone_masked": "5522*****0000", "email_masked": "ma****@fazendaboavista.com.br", "current_step": "whatsapp", "allow_edit_after_seconds": 120 } }, "meta": {} }
POST/api/v1/signup/verifyPúblico (cookie do cadastro pendente)

Confirma o código de 6 dígitos de um canal (WhatsApp primeiro, email depois). Quando o SEGUNDO canal é confirmado, o Tenant (trial de 14 dias) e o User owner são criados, a senha temporária é enviada pelos dois canais e devolvida aqui para o login automático. Código errado, expirado ou ausente devolvem o mesmo `INVALID_CODE`; o código expira em 10 minutos e aceita no máximo 5 tentativas. Duas formas de resposta: confirmar o PRIMEIRO canal devolve `completed: false` com o estado atualizado (mesmo formato de `GET /signup/state`); confirmar o SEGUNDO devolve `completed: true` com a credencial.

Request

{ "channel": "whatsapp", "code": "123456" }

Response

200 (1º canal)
{ "data": { "completed": false, "state": { "whatsapp_verified": true, "email_verified": false, "current_step": "email", "...": "..." } }, "meta": {} }

200 (2º canal, conta criada)
{ "data": { "completed": true, "email": "maria@fazendaboavista.com.br", "temp_password": "Xy9k2Qmz" }, "meta": {} }
POST/api/v1/signup/resendPúblico (cookie do cadastro pendente)

Reenvia o código e, com `destination`, corrige o número ou o email antes de reenviar. Só vale para canal ainda não confirmado. Limitado por destino e por origem: a rota dispara mensagem sem login, então sem limite viraria ferramenta de perturbação.

Request

{ "channel": "whatsapp", "destination": "22988887777" }

Response

200
{ "data": { "whatsapp_verified": false, "current_step": "whatsapp", "...": "..." }, "meta": {} }
GET/api/v1/signup/statePúblico (cookie do cadastro pendente)

Estado das etapas do cadastro em andamento, usado pelas páginas para renderizar o passo certo na retomada. Devolve `SIGNUP_EXPIRED` (410) quando o cadastro venceu.

Response

200
{ "data": { "whatsapp_verified": true, "email_verified": false, "current_step": "email", "...": "..." }, "meta": {} }
POST/api/v1/auth/change-passwordSessão (sem guard de módulo/cobrança)

Troca OBRIGATÓRIA da senha temporária, no primeiro acesso. Não pede a senha atual de propósito: quem chega aqui acabou de provar posse dos canais (cadastro verificado) ou digitou a temporária no login. Zera `must_change_password` e exige senha forte (8+ caracteres, maiúscula, número e símbolo).

Request

{ "new_password": "MinhaSenha1!" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/auth/change-password-selfSessão (sem guard de módulo/cobrança)

Troca VOLUNTÁRIA da própria senha, a qualquer momento. Aqui a senha atual É exigida: o cenário é uma sessão aberta num computador destravado. Disponível para qualquer papel, não só Owner/Admin.

Request

{ "current_password": "SenhaAtual1!", "new_password": "MinhaSenha1!" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/tenant/profilesSessão · OWNER/ADMIN

Ativa (ou reativa) um perfil de negócio do tenant. Idempotente. Ativar 'fazenda' provisiona o catálogo padrão de vacinas.

Request

{ "profile_type": "fazenda" }

Response

201
{ "data": { "id": "cl...", "profile_type": "fazenda", "active": true, "created_at": "2026-07-10T12:00:00.000Z" }, "meta": {} }
GET/api/v1/tenantSessão · qualquer papel

Dados cadastrais do tenant (nome, documento, telefone, email). Leitura liberada pra qualquer papel, inclusive VISUALIZADOR: usado pelo aplicativo mobile pra mostrar o nome da fazenda na tela Início.

Response

200
{ "data": { "id": "cl...", "name": "...", "document": "...", "phone": "...", "email": "..." }, "meta": {} }
PATCH/api/v1/tenantSessão · usuarios:write

Edita dados cadastrais do tenant (nome, documento, telefone, email).

Request

{ "name": "Fazenda Boa Vista LTDA", "document": "12345678000199", "phone": "22999990000", "email": "contato@fazendaboavista.com.br" }

Response

200
{ "data": { "id": "cl...", "name": "...", "document": "...", "phone": "...", "email": "..." }, "meta": {} }

Recuperação de senha

3 etapas, todas SEM sessão (usuário esqueceu a senha, por natureza). Só para User de tenant, não para PlatformUser.

POST/api/v1/password-reset/requestPúblico

Pede um código de recuperação por email ou WhatsApp. Resposta sempre `{ requested: true }`, exista ou não a conta (proteção contra enumeração). Limitado por email (3/hora).

Request

{ "email": "maria@fazendaboavista.com.br", "channel": "whatsapp" }

Response

200
{ "data": { "requested": true }, "meta": {} }
POST/api/v1/password-reset/verifyPúblico

Valida o código de 6 dígitos (expira em 10 minutos, máximo 5 tentativas). Código errado e conta inexistente devolvem o mesmo `INVALID_CODE`. Sucesso devolve `reset_id`, exigido pela etapa seguinte.

Request

{ "email": "maria@fazendaboavista.com.br", "code": "123456" }

Response

200
{ "data": { "reset_id": "cl..." }, "meta": {} }
POST/api/v1/password-reset/confirmPúblico (posse do `reset_id` verificado)

Define a nova senha. Exige `reset_id` com `verified_at` preenchido e `consumed_at` nulo (uso único). Zera `must_change_password` e exige senha forte (8+ caracteres, maiúscula, número e símbolo).

Request

{ "reset_id": "cl...", "new_password": "MinhaSenha1!" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/auth/tokenPúblico (é o passo que cria a identidade)

Login do aplicativo por email e senha, devolvendo o par access + refresh. Roda sem sessão por natureza, como as rotas de recuperação de senha. Não aplica o gate de sessão de propósito: quem tem senha temporária precisa autenticar para conseguir trocá-la, e as rotas de negócio seguem barradas pelo guard(). O rate limit é aplicado ANTES da busca pelo usuário, para não diferenciar conta inexistente de senha errada nem pelo erro nem pelo tempo de resposta.

Request

{ "email": "maria@fazenda.com.br", "password": "..." }

Response

200
{ "data": { "access_token": "...", "refresh_token": "...", "expires_in": 900, "user": { "id": "cl...", "name": "Maria", "email": "maria@fazenda.com.br", "role": "OWNER" } }, "meta": {} }
POST/api/v1/auth/token/refreshPúblico (posse do refresh token)

Troca o refresh por um par novo. Uso único: o token apresentado é invalidado na troca (rotação), então um refresh capturado e reusado depois falha. Só o hash do refresh é persistido, nunca o token em claro.

Request

{ "refresh_token": "..." }

Response

200
{ "data": { "access_token": "...", "refresh_token": "...", "expires_in": 900 }, "meta": {} }
POST/api/v1/auth/token/revokePúblico (posse do refresh token)

Invalida o refresh token informado. Usado no logout do aplicativo, em melhor esforço: o app limpa a sessão local mesmo se esta chamada falhar.

Request

{ "refresh_token": "..." }

Response

200
{ "data": { "revoked": true }, "meta": {} }
PATCH/api/v1/auth/profileSessão (qualquer papel)

O próprio usuário renomeia a si mesmo. Sem guard() de módulo nem checagem de cobrança de propósito: editar o próprio nome não é privilégio de papel nem deve ser bloqueado por inadimplência.

Request

{ "name": "Maria Silva" }

Response

200
{ "data": { "id": "cl...", "name": "Maria Silva" }, "meta": {} }

Usuários

GET/api/v1/usersSessão · usuarios:read

Lista os usuários do tenant. `meta.seats` traz o uso de assentos do plano (usuários ativos contra o limite).

Response

200
{ "data": [{ "id": "cl...", "name": "...", "email": "...", "phone": "...", "role": "OPERADOR", "active": true, "created_at": "..." }], "meta": { "total": 1, "seats": { "used": 1, "limit": 2, "has_room": true } } }
POST/api/v1/usersSessão · usuarios:write

Convida um novo usuário: cria o User com senha temporária (exibida uma única vez na resposta: não há envio de email neste ambiente). Só Owner pode convidar outro Owner. Respeita o limite de assentos do plano (campo 1, fazenda 2, grupo 5): devolve `SEAT_LIMIT_REACHED` (422) quando o limite de usuários ativos já foi atingido.

Request

{ "name": "João Souza", "email": "joao@fazendaboavista.com.br", "phone": "22988887777", "role": "OPERADOR" }

Response

201
{ "data": { "id": "cl...", "temp_password": "Xy9k2Qmz" }, "meta": {} }
PATCH/api/v1/users/:id/roleSessão · usuarios:write

Altera o papel de um usuário. Não é possível alterar o próprio papel, nem promover a Owner sem ser Owner.

Request

{ "role": "ADMIN" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
PATCH/api/v1/users/:id/activeSessão · usuarios:write

Ativa ou desativa um usuário (nunca deleta: preserva histórico de ações). Não é possível desativar a própria conta. Desativar sempre é permitido e libera um assento; reativar consome um assento e devolve `SEAT_LIMIT_REACHED` (422) se o plano já estiver no limite.

Request

{ "active": false }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }

Minha Fazenda: Propriedades e Pastos

Módulo 29. "Pasto" (model Pasture) é a divisão da fazenda em áreas de pastagem; não confundir com Plot ("Talhão", domínio de Lavoura).

GET/api/v1/propertiesSessão · rebanho:read · perfil fazenda

Lista propriedades. ?include_archived=true inclui as arquivadas (excluídas por padrão).

Response

200
{ "data": [{ "id": "cl...", "name": "Sede", "address": "...", "city": "Montes Claros", "district": null, "area_hectares": 120.5, "archived_at": null }], "meta": { "total": 1 } }
POST/api/v1/propertiesSessão · rebanho:write · perfil fazenda

Cadastra propriedade. city e area_hectares (> 0) são obrigatórios; address e district são opcionais.

Request

{ "name": "Sede", "city": "Montes Claros", "district": "São João da Vereda", "address": "Rodovia BR-101, km 42", "area_hectares": 120.5 }

Response

201
{ "data": { "id": "cl...", "name": "Sede", "city": "Montes Claros", "district": "São João da Vereda", "area_hectares": 120.5, "archived_at": null } }
GET/api/v1/properties/:idSessão · rebanho:read · perfil fazenda

Detalhe da propriedade.

Response

200
{ "data": { "id": "cl...", "name": "Sede", "address": "...", "city": "Montes Claros", "district": null, "area_hectares": 120.5 } }
PATCH/api/v1/properties/:idSessão · rebanho:write · perfil fazenda

Edita propriedade. Todos os campos opcionais.

Request

{ "name": "Sede Nova", "city": "Montes Claros" }

Response

200
{ "data": { "id": "cl...", "name": "Sede Nova" } }
POST/api/v1/properties/:id/archiveSessão · rebanho:write · perfil fazenda

Arquiva a propriedade (não deleta, preserva histórico de animais/talhões/pastos). Idempotente.

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-07-10T12:00:00.000Z" } }
GET/api/v1/emporioSessão · emporio:read · recurso `emporio` liberado pela Plataforma

Porta do Meu Empório (Módulo 39, F0): devolve a contagem de estabelecimentos não arquivados. 404 `NOT_FOUND` quando o recurso não está liberado para o usuário, como se a rota não existisse.

Response

200
{ "data": { "establishments_count": 0 }, "meta": {} }
GET/api/v1/emporio/establishmentsSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Lista os estabelecimentos do Empório. Esconde os arquivados; `?incluir_arquivados=1` os inclui. Quem tem vínculo (`user_ids`) vê só as lojas vinculadas; sem vínculo, vê todas.

Response

200
{ "data": [{ "id": "cl...", "name": "Central", "kind": "central", "address": null, "city": null, "responsible_user_id": null, "allow_negative_stock": false, "max_discount_pct": null, "default_expense_pct": 15, "default_profit_pct": 20, "archived_at": null, "user_ids": [] }], "meta": {} }
POST/api/v1/emporio/establishmentsSessão · emporio:write · só OWNER e ADMIN (403 `FORBIDDEN`)

Cria um estabelecimento (`kind`: `central` ou `ponto_venda`). Nome repetido: 409 `DUPLICATE_NAME` no campo `name`. Percentual fora de 0 a 1000: 422 no campo.

Request

{ "name": "Loja Centro", "kind": "ponto_venda", "default_expense_pct": 15, "default_profit_pct": 20 }

Response

201
{ "data": { "id": "cl...", "name": "Loja Centro", "kind": "ponto_venda", "user_ids": [] }, "meta": {} }
GET/api/v1/emporio/establishments/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Um estabelecimento. Quem tem vínculo e pede outra loja recebe 404 `NOT_FOUND`.

Response

200
{ "data": { "id": "cl...", "name": "Loja Centro", "kind": "ponto_venda", "user_ids": [] }, "meta": {} }
PATCH/api/v1/emporio/establishments/:idSessão · emporio:write · só OWNER e ADMIN

Edita os campos enviados (os ausentes ficam como estão; `null` limpa os opcionais).

Request

{ "max_discount_pct": 10, "allow_negative_stock": false }

Response

200
{ "data": { "id": "cl...", "name": "Loja Centro", "max_discount_pct": 10 }, "meta": {} }
POST/api/v1/emporio/establishments/:id/archiveSessão · emporio:write · só OWNER e ADMIN

Arquiva (`archived: true`) ou desarquiva (`false`). Nunca apaga.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-10-27T12:00:00.000Z" }, "meta": {} }
PUT/api/v1/emporio/establishments/:id/usersSessão · emporio:write · só OWNER e ADMIN

Substitui os vínculos usuário-loja. Lista vazia = ninguém vinculado. Usuário de outra conta ou inativo: 422 `VALIDATION_ERROR` no campo `user_ids`.

Request

{ "user_ids": ["cl..."] }

Response

200
{ "data": { "id": "cl...", "user_ids": ["cl..."] }, "meta": {} }
GET/api/v1/emporio/productsSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Lista os produtos do Empório (configuração comercial sobre o `Product`). Esconde os arquivados; `?incluir_arquivados=1` os inclui. `track_sealed` nulo é automático e `track_sealed_efetivo` traz o valor resolvido.

Response

200
{ "data": [{ "id": "cl...", "product_id": "cl...", "name": "Sal grosso", "category_id": "cl...", "category_name": "Outros", "brand": null, "base_unit": "quilograma", "created_in_emporio": true, "reference_cost": 10, "track_sealed": null, "track_sealed_efetivo": false, "track_lot": false, "track_expiry": false, "expense_pct": null, "profit_pct": null, "archived_at": null }], "meta": {} }
POST/api/v1/emporio/productsSessão · emporio:write · OWNER, ADMIN e OPERADOR

Põe um produto no Empório. Com `product_id`, usa um produto que já existe na conta; com `name` e `category_id`, cria o produto (e o marca como criado no Empório). `base_unit` (`quilograma`, `litro` ou `unidade`) é obrigatória nos dois casos. Produto já no Empório: 409 `ALREADY_IN_EMPORIO`. Nome repetido: 409 `DUPLICATE_NAME` no campo `name`.

Request

{ "name": "Sal grosso", "category_id": "cl...", "base_unit": "quilograma", "reference_cost": 10 }

Response

201
{ "data": { "id": "cl...", "name": "Sal grosso", "base_unit": "quilograma", "presentations": [], "prices": [] }, "meta": {} }
GET/api/v1/emporio/products/candidatesSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Produtos da conta ainda fora do Empório (não arquivados), para o "usar no Empório".

Response

200
{ "data": [{ "id": "cl...", "name": "Sal mineral", "category_id": "cl...", "category_name": "Nutrição", "brand": null, "unit": "saca" }], "meta": {} }
GET/api/v1/emporio/products/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberado

O produto (`:id` é o do produto do Empório) com as apresentações e a tabela de preço: uma linha por loja visível ao usuário e apresentação ativa, mesmo sem preço cadastrado, com custo (`reference_cost × base_qty`), percentuais efetivos e preço sugerido (`custo × (1 + despesas/100) × (1 + lucro/100)`, nulo sem custo).

Response

200
{ "data": { "id": "cl...", "name": "Sal grosso", "presentations": [{ "id": "cl...", "name": "Saca 10 kg", "base_qty": 10, "is_sealed_package": true, "archived_at": null }], "prices": [{ "establishment_id": "cl...", "establishment_name": "Loja Centro", "presentation_id": "cl...", "price": null, "cost": 100, "expense_pct": null, "profit_pct": null, "expense_pct_efetivo": 15, "profit_pct_efetivo": 20, "suggested_price": 138 }] }, "meta": {} }
PATCH/api/v1/emporio/products/:idSessão · emporio:write · OWNER, ADMIN e OPERADOR (`expense_pct` e `profit_pct` só OWNER e ADMIN)

Edita custo de referência, `brand`, lacrado/avulso (`track_sealed`: `true`, `false` ou `null` para voltar ao automático), lote, validade e a exceção de percentuais do produto.

Request

{ "reference_cost": 12.5, "track_sealed": null, "expense_pct": 10 }

Response

200
{ "data": { "id": "cl...", "reference_cost": 12.5, "track_sealed": null, "expense_pct": 10 }, "meta": {} }
POST/api/v1/emporio/products/:id/archiveSessão · emporio:write · OWNER, ADMIN e OPERADOR

Arquiva (`archived: true`) ou desarquiva (`false`) o produto do Empório.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-10-27T12:00:00.000Z" }, "meta": {} }
POST/api/v1/emporio/products/:id/presentationsSessão · emporio:write · OWNER, ADMIN e OPERADOR

Cria uma apresentação (forma de venda). `base_qty` é quanto ela vale na unidade-base: maior que zero, e inteira quando a unidade-base é `unidade`. Nome repetido no produto: 409 `DUPLICATE_NAME`.

Request

{ "name": "Saca 10 kg", "base_qty": 10, "is_sealed_package": true }

Response

201
{ "data": { "id": "cl...", "name": "Saca 10 kg", "base_qty": 10, "is_sealed_package": true, "archived_at": null }, "meta": {} }
PATCH/api/v1/emporio/presentations/:idSessão · emporio:write · OWNER, ADMIN e OPERADOR

Edita nome, `base_qty` e `is_sealed_package` da apresentação.

Request

{ "base_qty": 25 }

Response

200
{ "data": { "id": "cl...", "name": "Saca 10 kg", "base_qty": 25, "is_sealed_package": true, "archived_at": null }, "meta": {} }
POST/api/v1/emporio/presentations/:id/archiveSessão · emporio:write · OWNER, ADMIN e OPERADOR

Arquiva (`archived: true`) ou desarquiva (`false`) a apresentação.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-10-27T12:00:00.000Z" }, "meta": {} }
PUT/api/v1/emporio/pricesSessão · emporio:write · só OWNER e ADMIN

Upsert do preço de uma apresentação numa loja, com a exceção de percentuais dela. Campo `null` ou ausente limpa. Loja fora do vínculo do usuário: 404. Preço negativo ou percentual fora de 0 a 1000: 422 no campo.

Request

{ "establishment_id": "cl...", "presentation_id": "cl...", "price": 138, "expense_pct": null, "profit_pct": null }

Response

200
{ "data": { "establishment_id": "cl...", "presentation_id": "cl...", "price": 138, "expense_pct": null, "profit_pct": null }, "meta": {} }
GET/api/v1/emporio/stockSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Saldo da loja por produto (`?establishment_id=` obrigatório; sem ele, 422 no campo). Cada linha: total, avulso, lacrado por apresentação em embalagens, custo médio ponderado móvel, `estimated` quando alguma entrada não teve custo, limites, `needs_restock` e `suggested_qty`. Saldo é sempre a soma das movimentações. Loja fora do vínculo do usuário: 404.

Response

200
{ "data": [{ "establishment_id": "cl...", "emporio_product_id": "cl...", "product_name": "Ração Pet", "base_unit": "quilograma", "total_qty": 25, "loose_qty": 5, "sealed": [{ "presentation_id": "cl...", "name": "Saca 10 kg", "packages": 2, "base_qty": 10 }], "average_cost": 19.36, "estimated": false, "min_qty": 10, "max_qty": 50, "needs_restock": false, "suggested_qty": 25 }], "meta": {} }
GET/api/v1/emporio/stock/consolidatedSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Um saldo por produto somando as lojas visíveis ao usuário, com o custo médio ponderado pelo saldo de cada loja.

Response

200
{ "data": [{ "emporio_product_id": "cl...", "product_name": "Ração Pet", "total_qty": 40, "average_cost": 19.1 }], "meta": {} }
GET/api/v1/emporio/products/:id/stockSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Saldo de um produto do Empório em cada loja visível ao usuário, mais o consolidado.

Response

200
{ "data": { "emporio_product_id": "cl...", "product_name": "Ração Pet", "base_unit": "quilograma", "stores": [], "consolidated": { "total_qty": 40 } }, "meta": {} }
POST/api/v1/emporio/stock/implantationSessão · emporio:write · OWNER, ADMIN e OPERADOR

Estoque inicial, uma vez por produto e loja (segunda vez: 409 `ALREADY_IMPLANTED`; depois, só contagem). `sealed` em embalagens por apresentação fechada (produto sem separação lacrado/avulso: 422 `sealed`), `loose_qty` na unidade-base, `unit_cost` opcional (sem ele, o saldo fica `estimated`).

Request

{ "establishment_id": "cl...", "emporio_product_id": "cl...", "sealed": [{ "presentation_id": "cl...", "packages": 3 }], "loose_qty": 2.5, "unit_cost": 9.5 }

Response

201
{ "data": { "total_qty": 32.5, "loose_qty": 2.5, "estimated": false }, "meta": {} }
POST/api/v1/emporio/stock/countSessão · emporio:write · OWNER, ADMIN e OPERADOR

Contagem física: informa o que existe (lacrado por apresentação e avulso) e o motivo (obrigatório, 422 `reason`). Grava um ajuste por estado que mudou, com o saldo anterior e o corrigido. Nunca é compra nem venda.

Request

{ "establishment_id": "cl...", "emporio_product_id": "cl...", "sealed": [{ "presentation_id": "cl...", "packages": 2 }], "loose_qty": 1, "reason": "Contagem de sexta" }

Response

201
{ "data": { "total_qty": 21, "loose_qty": 1 }, "meta": {} }
POST/api/v1/emporio/stock/lossSessão · emporio:write · OWNER, ADMIN e OPERADOR

Perda ou avaria. `reason`: `vencido`, `avaria`, `furto`, `consumo_proprio` ou `outro`. Lacrado: `presentation_id` e `quantity` em embalagens; avulso: `quantity` na unidade-base. Sai pelo custo médio do momento. Acima do saldo: 409 `INSUFFICIENT_STOCK` em `quantity`, salvo loja com estoque negativo liberado.

Request

{ "establishment_id": "cl...", "emporio_product_id": "cl...", "package_state": "avulso", "quantity": 2, "reason": "avaria", "notes": "Saco rasgado" }

Response

201
{ "data": { "total_qty": 19 }, "meta": {} }
PUT/api/v1/emporio/stock/limitsSessão · emporio:write · só OWNER e ADMIN

Mínimo e máximo do produto na loja, na unidade-base (`null` limpa). Mínimo maior que o máximo: 422 `max_qty`.

Request

{ "establishment_id": "cl...", "emporio_product_id": "cl...", "min_qty": 10, "max_qty": 50 }

Response

200
{ "data": { "min_qty": 10, "max_qty": 50, "needs_restock": false, "suggested_qty": 31 }, "meta": {} }
GET/api/v1/emporio/stock/restockSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Precisa repor: linhas de saldo com saldo menor ou igual ao mínimo, nas lojas visíveis (`?establishment_id=` opcional). Sugestão = máximo menos (saldo mais compras ainda não recebidas); sem máximo, nula.

Response

200
{ "data": [{ "establishment_id": "cl...", "product_name": "Ração Pet", "total_qty": 4, "min_qty": 10, "max_qty": 50, "needs_restock": true, "suggested_qty": 46 }], "meta": {} }
POST/api/v1/emporio/stock/restock/to-listSessão · emporio:write · OWNER, ADMIN e OPERADOR

Encaminha à Lista de Compra: cria o item com a loja e a quantidade. Já há item pendente desse produto na mesma loja: 409 `ITEM_JA_NA_LISTA`.

Request

{ "establishment_id": "cl...", "emporio_product_id": "cl...", "quantity": 46 }

Response

201
{ "data": { "id": "cl...", "establishment_id": "cl...", "quantity": 46, "status": "pendente" }, "meta": {} }
GET/api/v1/emporio/purchasesSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Lista as compras das lojas visíveis ao usuário, da mais recente. Filtros: `establishment_id` (loja fora do vínculo: 404), `status` (`pendente`, `recebida`, `cancelada`), `contact_id`. Cada compra traz itens, total e os lançamentos financeiros.

Response

200
{ "data": [{ "id": "cl...", "establishment_id": "cl...", "establishment_name": "Loja Centro", "status": "recebida", "freight": 20, "items_total": 200, "total": 220, "payment_kind": "a_vista", "items": [], "entries": [] }], "meta": {} }
POST/api/v1/emporio/purchasesSessão · emporio:write · OWNER, ADMIN e OPERADOR

Cria a compra. `operation_key` é obrigatória e única na conta: repetir a chave devolve a compra gravada com 200, sem escrever nada (primeira vez: 201). Cada item traz `presentation_id` (a quantidade é de apresentações) ou só a quantidade na unidade-base, e `item_total`. O frete é rateado pelo valor dos itens e o `unit_cost` sai por unidade-base, frete incluído. Com `received: true` o estoque entra na hora (lacrado ou avulso conforme o produto); senão, só no recebimento. `payment.kind`: `a_vista` (um lançamento pago), `a_prazo` (um a pagar, exige `due_date`) ou `parcelada` (`down_payment` opcional pago mais um a pagar por item de `installments`). Parcelas que não fecham total + frete menos a entrada: 422 `installments`. Item inválido (quantidade zero, valor negativo, fracionado em `unidade`, apresentação de outro produto): 422 `items`. Despesa na categoria "Mercadorias para revenda", `related_module` `emporio`.

Request

{ "operation_key": "k-123", "establishment_id": "cl...", "contact_id": null, "purchased_at": "2026-10-27T12:00:00.000Z", "received": true, "freight": 20, "items": [{ "emporio_product_id": "cl...", "presentation_id": "cl...", "quantity": 4, "item_total": 200 }], "payment": { "kind": "a_vista" } }

Response

201
{ "data": { "id": "cl...", "status": "recebida", "total": 220, "items": [{ "base_quantity": 40, "unit_cost": 5.5 }], "entries": [{ "amount": 220, "status": "paid" }] }, "meta": {} }
GET/api/v1/emporio/purchases/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Uma compra com itens e lançamentos. Compra de loja fora do vínculo do usuário: 404.

Response

200
{ "data": { "id": "cl...", "status": "pendente", "received_at": null, "items": [], "entries": [] }, "meta": {} }
POST/api/v1/emporio/purchases/:id/receiveSessão · emporio:write · OWNER, ADMIN e OPERADOR

Recebe a compra inteira (`received_at` opcional, padrão agora) e dá entrada no estoque. Já recebida: 409 `ALREADY_RECEIVED`. Cancelada: 409 `PURCHASE_CANCELED`.

Request

{ "received_at": "2026-10-28T09:00:00.000Z" }

Response

200
{ "data": { "id": "cl...", "status": "recebida", "received_at": "2026-10-28T09:00:00.000Z" }, "meta": {} }
POST/api/v1/emporio/purchases/:id/cancelSessão · emporio:write · só OWNER e ADMIN (403 `FORBIDDEN`)

Cancela a compra com `reason`: cancela as contas a pagar pendentes e marca como canceladas as movimentações de estoque. Nada é apagado. Algum lançamento pago (inclusive entrada ou compra à vista): 409 `PURCHASE_HAS_PAYMENT`. Já cancelada: 409 `ALREADY_CANCELED`.

Request

{ "reason": "Fornecedor não entregou" }

Response

200
{ "data": { "id": "cl...", "status": "cancelada", "canceled_reason": "Fornecedor não entregou" }, "meta": {} }
GET/api/v1/emporio/products/:id/compositionSessão · emporio:read · perfil emporio · recurso `emporio` liberado

A composição do produto (uma por produto), ou `null` se não tiver. Cada item traz o nome e a unidade-base do componente.

Response

200
{ "data": { "emporio_product_id": "cl...", "reference_qty": 100, "yield_pct": null, "items": [{ "component_emporio_product_id": "cl...", "name": "Milho", "base_unit": "quilograma", "quantity": 65 }] }, "meta": {} }
PUT/api/v1/emporio/products/:id/compositionSessão · emporio:write · OWNER e ADMIN

Define a composição, substituindo a anterior inteira. `reference_qty` é a quantidade do produto final (na unidade-base) que os itens rendem; `yield_pct` é opcional (acima de 0 até 100, vazio = 100). Componente repetido, igual ao produto, que leve de volta a ele por qualquer caminho (ciclo), arquivado, com quantidade zero ou fracionado em unidade-base `unidade`: 422 `items`. Não movimenta estoque. OPERADOR: 403.

Request

{ "reference_qty": 100, "yield_pct": 95, "items": [{ "component_emporio_product_id": "cl...", "quantity": 65 }, { "component_emporio_product_id": "cl...", "quantity": 35 }] }

Response

200
{ "data": { "emporio_product_id": "cl...", "reference_qty": 100, "yield_pct": 95, "items": [{ "component_emporio_product_id": "cl...", "name": "Milho", "base_unit": "quilograma", "quantity": 65 }] }, "meta": {} }
POST/api/v1/emporio/products/:id/composition/from-recipeSessão · emporio:write · OWNER e ADMIN

Cria a composição a partir da Calculadora de receita (Q114). Quantidades em quilo: produto final que não é medido em quilo dá 422 `emporio_product_id`, e componente que não é medido em quilo dá 422 `items` ("{nome} é medido em {unidade}; a receita só casa com produto medido em quilo."). Se o produto já tem composição e `replace` não for `true`: 409 `COMPOSITION_EXISTS` no campo `replace`. O resto (ciclo, componente repetido, arquivado) é o do PUT da composição. O rendimento fica vazio e nada mexe no estoque. OPERADOR: 403.

Request

{ "reference_qty": 10, "items": [{ "component_emporio_product_id": "cl...", "quantity": 6.5 }], "replace": false }

Response

200
{ "data": { "emporio_product_id": "cl...", "reference_qty": 10, "yield_pct": null, "items": [{ "component_emporio_product_id": "cl...", "name": "Milho", "base_unit": "quilograma", "quantity": 6.5 }] }, "meta": {} }
GET/api/v1/emporio/first-stepsSessão · emporio:read · OWNER e ADMIN · perfil emporio · recurso `emporio` liberado

Primeiros passos por loja do usuário (Q116). Cada passo traz `key`, `essential`, `done`, `detail` (quando houver) e o `href` do formulário. Essenciais: `preco`, `estoque_inicial` (sem implantação ou com produto sem custo), `saldo_caixa`, `taxa_cartao` (só aparece se a loja já vendeu no cartão) e `despesa_fixa`; recomendados: `minimo_maximo` e `conta_pix`. `complete` é verdadeiro quando todos os essenciais de todas as lojas estão feitos. Abaixo de ADMIN: 403 `FORBIDDEN`.

Response

200
{ "data": { "complete": false, "establishments": [{ "establishment_id": "cl...", "name": "Loja Centro", "steps": [{ "key": "preco", "essential": true, "done": true, "href": "/emporio/produtos" }, { "key": "estoque_inicial", "essential": true, "done": false, "detail": "3 produtos sem custo", "href": "/emporio/produtos" }] }] }, "meta": {} }
GET/api/v1/emporio/productionSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Lista as produções das lojas visíveis ao usuário, da mais recente. Filtros: `establishment_id` (loja fora do vínculo: 404), `emporio_product_id`, `status` (`ativa`, `cancelada`). Cada produção traz a cópia da composição usada, com a quantidade baixada de cada componente.

Response

200
{ "data": [{ "id": "cl...", "establishment_id": "cl...", "establishment_name": "Loja Centro", "product_name": "Ração", "produced_qty": 400, "unit_cost": 1.29, "estimated": false, "composition_snapshot": { "reference_qty": 100, "yield_pct": null, "items": [{ "name": "Milho", "quantity": 65, "consumed_qty": 260 }] }, "sealed": [], "status": "ativa" }], "meta": {} }
POST/api/v1/emporio/productionSessão · emporio:write · OWNER, ADMIN e OPERADOR

Registra o produto feito na loja. `operation_key` é obrigatória e única na conta: repetir a chave devolve a produção gravada com 200 (primeira vez: 201). Baixa cada componente: `quantity × produced_qty ÷ reference_qty ÷ (yield_pct/100)`, em 3 casas (composição 65/29/6 para 100 kg, produzir 400 kg: baixa 260, 116 e 24). Numa transação grava um `producao_consumo` por componente, pelo custo médio dele na loja, e um `producao_entrada` do produto, com `unit_cost` = custo consumido ÷ `produced_qty`. `sealed` (só em produto que separa lacrado de avulso) entra lacrado e o resto avulso; lacrado acima do produzido: 422 `sealed`. Sem composição: 422 `COMPOSITION_REQUIRED`. Fracionado em `unidade`: 422 `produced_qty`. Componente sem saldo na loja (o avulso, se ele separa lacrado): 409 `INSUFFICIENT_STOCK` em `components`, dizendo qual falta, salvo estoque negativo liberado. Loja fora do vínculo: 404.

Request

{ "operation_key": "k-123", "establishment_id": "cl...", "emporio_product_id": "cl...", "produced_qty": 400, "sealed": [{ "presentation_id": "cl...", "packages": 10 }] }

Response

201
{ "data": { "id": "cl...", "produced_qty": 400, "unit_cost": 1.29, "estimated": false, "sealed": [{ "presentation_id": "cl...", "name": "Saco 25 kg", "packages": 10 }], "status": "ativa" }, "meta": {} }
POST/api/v1/emporio/production/:id/cancelSessão · emporio:write · OWNER e ADMIN

Cancela a produção: marca `canceled_at` em todas as movimentações dela (os componentes voltam e o produto sai), sem apagar nada. `reason` obrigatório (422 `reason`). Já cancelada: 409 `ALREADY_CANCELED`. Saldo do produto ficaria negativo: 409 `INSUFFICIENT_STOCK`, salvo estoque negativo liberado. OPERADOR: 403. Loja fora do vínculo: 404.

Request

{ "reason": "Lançada em duplicidade" }

Response

200
{ "data": { "id": "cl...", "status": "cancelada", "canceled_reason": "Lançada em duplicidade" }, "meta": {} }
GET/api/v1/emporio/transfersSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Lista as transferências em que a origem OU o destino é visível ao usuário, da mais recente. Filtros: `establishment_id` (origem ou destino; loja fora do vínculo: 404) e `status` (`em_transito`, `recebida`, `cancelada`). Cada transferência traz os itens com `sent_qty`, `received_qty`, `loss_reason` e `unit_cost`.

Response

200
{ "data": [{ "id": "cl...", "origin_name": "Central", "destination_name": "Loja Centro", "status": "em_transito", "items": [{ "id": "cl...", "package_state": "lacrado", "sent_qty": 50, "received_qty": null, "unit_cost": 5.5 }] }], "meta": {} }
POST/api/v1/emporio/transfersSessão · emporio:write · OWNER, ADMIN e OPERADOR com a origem no vínculo

Envia a transferência. `operation_key` é obrigatória e única na conta: repetir a chave devolve a gravada com 200 (primeira vez: 201). Item lacrado leva `presentation_id` e `packages`; avulso leva `quantity` na unidade-base; produto sem separação leva só `quantity`. Grava uma saída por item na origem pelo custo médio dela, e o saldo da origem cai na hora. Origem igual ao destino: 422 `destination_establishment_id`. Saldo insuficiente na origem (por estado), salvo estoque negativo liberado: 409 `INSUFFICIENT_STOCK` em `items`. Nunca gera lançamento financeiro.

Request

{ "operation_key": "k-123", "origin_establishment_id": "cl...", "destination_establishment_id": "cl...", "items": [{ "emporio_product_id": "cl...", "presentation_id": "cl...", "packages": 5 }, { "emporio_product_id": "cl...", "quantity": 3 }] }

Response

201
{ "data": { "id": "cl...", "status": "em_transito", "items": [{ "sent_qty": 50, "unit_cost": 5.5 }] }, "meta": {} }
GET/api/v1/emporio/transfers/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Uma transferência com os itens. Origem e destino fora do vínculo do usuário: 404.

Response

200
{ "data": { "id": "cl...", "status": "em_transito", "sent_at": "2026-10-28T09:00:00.000Z", "items": [] }, "meta": {} }
POST/api/v1/emporio/transfers/:id/receiveSessão · emporio:write · OWNER, ADMIN e OPERADOR com o destino no vínculo (senão 404)

Recebe a transferência: `items` cobre todos os itens, cada um com `item_id` e `received_qty`. Grava uma entrada por item no destino com a quantidade recebida e o custo da origem. Mais do que o enviado: 422 `items`. Menos sem `loss_reason` (`vencido`, `avaria`, `furto`, `consumo_proprio`, `outro`): 422 `items`. A diferença não gera movimentação: fica no item como perda da origem. Fora de trânsito: 409 `TRANSFER_NOT_IN_TRANSIT`.

Request

{ "items": [{ "item_id": "cl...", "received_qty": 48, "loss_reason": "avaria" }] }

Response

200
{ "data": { "id": "cl...", "status": "recebida", "items": [{ "sent_qty": 50, "received_qty": 48, "loss_reason": "avaria" }] }, "meta": {} }
POST/api/v1/emporio/transfers/:id/cancelSessão · emporio:write · quem enviou, OWNER ou ADMIN (senão 403 `FORBIDDEN`)

Cancela a transferência em trânsito com `reason` obrigatório (422 `reason`): marca as saídas como canceladas e a origem recupera o saldo. Nada é apagado. Fora de trânsito: 409 `TRANSFER_NOT_IN_TRANSIT`.

Request

{ "reason": "Enviado para a loja errada" }

Response

200
{ "data": { "id": "cl...", "status": "cancelada", "canceled_reason": "Enviado para a loja errada" }, "meta": {} }
GET/api/v1/emporio/salesSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Lista as vendas das lojas visíveis ao usuário, da mais recente. Filtros: `establishment_id` (fora do vínculo: 404), `contact_id`, `status` (`concluida`, `cancelada`), `de`, `ate`, `limit`. Cada venda traz itens (com `returnable_qty`), pagamentos, devoluções, `paid_amount` e `open_amount` do lançamento.

Response

200
{ "data": [{ "id": "cl...", "establishment_name": "Loja Centro", "net_amount": 200, "discount_amount": 0, "status": "concluida", "paid_amount": 120, "open_amount": 80, "items": [], "payments": [], "returns": [] }], "meta": {} }
POST/api/v1/emporio/salesSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculo (desconto acima do limite: só OWNER e ADMIN)

Registra a venda numa transação. `operation_key` obrigatória e única na conta: repetir devolve a venda gravada com 200 (primeira vez: 201). Preço = o cadastrado na loja; sem ele `unit_price` é obrigatório (422 `items`). Abaixo do cadastrado é desconto, com o `max_discount_pct` da loja (vazio = 0%): acima do limite, OPERADOR recebe 403 `DISCOUNT_ABOVE_LIMIT` em `items` e OWNER/ADMIN passam, gravando quem autorizou. Venda a granel baixa do avulso e, se faltar, abre embalagens lacradas da menor para a maior (`abertura_lacrado` mais `abertura_avulso`). Apresentação fechada sem embalagem lacrada suficiente: 409 `INSUFFICIENT_STOCK` em `items`, sempre; a granel, salvo estoque negativo liberado. `payments`: dinheiro, PIX, fiado, `debito`, `credito_vista`, `credito_parcelado` e `cheque` (soma igual ao total, senão 422 `payments`). Cartão: exige a regra de taxa da loja para a forma (422 `payments` se não houver); o parcelado leva `installments` de 2 a 12; grava um pagamento do valor cheio sem movimento de caixa, a despesa "Taxas de cartão" e uma parcela a receber por vez (a 1ª em `sold_at` mais `days`, as outras a cada 30 dias), que entra no saldo da conta na data. Cheque: `check: { number, bank, good_for }` obrigatórios (422 `payments`) e `contact_id` obrigatório (422 `contact_id`); fica na carteira até depositar e compensar. Fiado exige `contact_id` ativo (422 `contact_id`) e aparece uma vez. PIX vai para `bank_account_id` ou a conta PIX da loja; sem nenhuma, 422 `payments`. Gera um lançamento de receita Vendas do Empório: pago na hora se não houver fiado, senão pendente com o saldo do fiado.

Request

{ "operation_key": "k-123", "establishment_id": "cl...", "contact_id": "cl...", "items": [{ "emporio_product_id": "cl...", "presentation_id": "cl...", "quantity": 1, "unit_price": 12.5 }], "payments": [{ "kind": "dinheiro", "amount": 5 }, { "kind": "fiado", "amount": 7.5, "due_date": "2026-11-30" }] }

Response

201
{ "data": { "id": "cl...", "net_amount": 12.5, "status": "concluida", "entry_id": "cl...", "paid_amount": 5, "open_amount": 7.5 }, "meta": {} }
GET/api/v1/emporio/sales/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberado

Uma venda com itens, pagamentos e devoluções. Loja fora do vínculo: 404.

Response

200
{ "data": { "id": "cl...", "status": "concluida", "items": [], "payments": [], "returns": [] }, "meta": {} }
POST/api/v1/emporio/sales/:id/cancelSessão · emporio:write · só OWNER e ADMIN

Cancela a venda inteira: `reason` obrigatório (422 `reason`). Marca as `venda` do estoque como canceladas (as aberturas de saca ficam), cancela o lançamento e, para cada pagamento recebido, grava uma saída de caixa no mesmo lugar em que entrou (cartão e cheque não geram saída de gaveta). Cartão: as parcelas futuras são canceladas, cada parcela que já caiu vira uma saída `estorno_cartao` da conta e a despesa "Taxas de cartão" é cancelada. Cheque `recebido` vira `devolvido_ao_cliente`; `depositado` ou `compensado`: 409 `SALE_HAS_DEPOSITED_CHECK`. Já cancelada: 409 `SALE_CANCELED`. Com devolução: 409 `SALE_HAS_RETURNS`.

Request

{ "reason": "Venda lançada em duplicidade" }

Response

200
{ "data": { "id": "cl...", "status": "cancelada", "canceled_reason": "Venda lançada em duplicidade" }, "meta": {} }
POST/api/v1/emporio/sales/:id/returnSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculo

Devolução parcial. `operation_key` obrigatória (repetir devolve a gravada com 200). `refund_kind`: `dinheiro` (despesa paga mais saída da gaveta) ou `abater_fiado` (reduz o lançamento da venda, que precisa ter saldo em aberto: 422 `amount`). Cada item traz `sale_item_id` e `quantity` até o vendido menos o devolvido (422 `items`); `loss_reason` marca avariado e grava também a perda. A mercadoria volta pelo custo da venda. Venda cancelada: 409 `SALE_CANCELED`.

Request

{ "operation_key": "k-9", "refund_kind": "dinheiro", "items": [{ "sale_item_id": "cl...", "quantity": 1, "loss_reason": "avaria" }], "reason": "Saco furado" }

Response

201
{ "data": { "id": "cl...", "sale_id": "cl...", "refund_kind": "dinheiro", "amount": 12.5, "items": [] }, "meta": {} }
POST/api/v1/emporio/sales/:id/exchangeSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculo

Troca: uma devolução (`items_devolvidos`) mais uma venda nova (`venda`, no formato do POST de venda; `establishment_id` e `operation_key` são opcionais e assumem a loja da venda e `<operation_key>:venda`), numa transação. O valor devolvido paga a venda nova como pagamento `troca`, sem caixa; `venda.payments` cobre só o que sobra (422 `payments`). Se o devolvido passa da venda nova, a diferença sai da gaveta em dinheiro.

Request

{ "operation_key": "k-10", "items_devolvidos": [{ "sale_item_id": "cl...", "quantity": 1 }], "venda": { "items": [{ "emporio_product_id": "cl...", "presentation_id": "cl...", "quantity": 1 }], "payments": [{ "kind": "dinheiro", "amount": 3 }] } }

Response

201
{ "data": { "devolucao": { "id": "cl...", "refund_kind": "troca", "amount": 12.5 }, "venda": { "id": "cl...", "exchange_return_id": "cl..." } }, "meta": {} }
GET/api/v1/emporio/receivablesSessão · emporio:read · perfil emporio · recurso `emporio` liberado

O fiado em aberto, por cliente: `owed`, `oldest_due_date`, `overdue_amount` e os lançamentos com o saldo de cada um. O saldo é o valor menos a soma dos pagamentos. Filtros: `establishment_id`, `contact_id`.

Response

200
{ "data": [{ "contact_id": "cl...", "contact_name": "Seu Zé", "owed": 80, "oldest_due_date": "2026-11-30T03:00:00.000Z", "overdue_amount": 0, "entries": [{ "entry_id": "cl...", "open": 80 }] }], "meta": {} }
POST/api/v1/emporio/receivables/receiveSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculo

Recebe fiado sem criar venda. `operation_key` obrigatória (repetir devolve o recebimento gravado com 200). `kind`: `dinheiro` (entra na gaveta da loja) ou `pix` (conta PIX da loja, ou `bank_account_id`). Com `entry_id` paga aquele lançamento; sem ele distribui da VENDA mais antiga para a mais nova. Valor acima do devido: 422 `amount`.

Request

{ "operation_key": "k-11", "contact_id": "cl...", "establishment_id": "cl...", "amount": 50, "kind": "dinheiro" }

Response

201
{ "data": { "amount": 50, "kind": "dinheiro", "parts": [{ "entry_id": "cl...", "amount": 50 }] }, "meta": {} }
GET/api/v1/emporio/variable-expense-rulesSessão · emporio:read · só OWNER e ADMIN (403 `FORBIDDEN`); com vínculo, só as lojas dele

Regras do "Vendeu, Pagou" (F4a, Q97). Filtros: `establishment_id`, `incluir_arquivados=1`. Cada venda copia a regra que casou: mudar a regra não mexe nas vendas passadas.

Response

200
{ "data": [{ "id": "cl...", "establishment_id": "cl...", "name": "Comissão", "kind": "percentual", "value": 5, "payment_kind": null, "emporio_product_id": null, "presentation_id": null, "packaging_emporio_product_id": null, "archived_at": null }], "meta": {} }
POST/api/v1/emporio/variable-expense-rulesSessão · emporio:write · só OWNER e ADMIN

Cria a regra da loja. `kind`: `percentual` (value de 0 a 100, sobre o total dos itens que casam; com `payment_kind`, só a parte paga naquela forma), `fixo_por_venda` (R$ uma vez por venda), `por_unidade` (R$ por unidade vendida) ou `embalagem` (quantidade na unidade-base do produto de `packaging_emporio_product_id`, obrigatório e só aqui, baixada do estoque pelo custo médio; nunca bloqueia a venda). `emporio_product_id` e `presentation_id` filtram os itens. Percentual, fixo e por unidade geram uma conta a pagar por venda (categoria = nome da regra) com vencimento no último dia do mês da venda. Recusas: 422 `name` (vazio ou nome de lançamento reservado), `kind`, `value`, `payment_kind`, `emporio_product_id`, `presentation_id`, `packaging_emporio_product_id`.

Request

{ "establishment_id": "cl...", "name": "Comissão", "kind": "percentual", "value": 5, "payment_kind": "pix" }

Response

201
{ "data": { "id": "cl...", "name": "Comissão", "kind": "percentual", "value": 5, "payment_kind": "pix" }, "meta": {} }
PATCH/api/v1/emporio/variable-expense-rules/:idSessão · emporio:write · só OWNER e ADMIN

Edita a regra ou a arquiva (`archived: true`; `false` reativa). Mesmas recusas do cadastro.

Request

{ "value": 6 }

Response

200
{ "data": { "id": "cl...", "value": 6, "archived_at": null }, "meta": {} }
GET/api/v1/emporio/fixed-expensesSessão · emporio:read · só OWNER e ADMIN; com vínculo, só as despesas que têm loja dele

Despesas fixas mensais (F4a, Q100) com o rateio, `generated_until` (último mês com conta gerada), `months_ahead` e `needs_renewal` (verdadeiro quando faltam menos de 3 meses gerados). `incluir_encerradas=1` traz as encerradas.

Response

200
{ "data": [{ "id": "cl...", "name": "Aluguel", "category": "Aluguel", "amount": 3000, "due_day": 10, "split_kind": "percentual", "starts_on": "2026-11-01", "ended_at": null, "splits": [{ "establishment_id": "cl...", "establishment_name": "Loja Centro", "value": 60 }, { "establishment_id": "cl...", "establishment_name": "Loja Norte", "value": 40 }], "generated_until": "2027-10", "months_ahead": 12, "needs_renewal": false }], "meta": {} }
POST/api/v1/emporio/fixed-expensesSessão · emporio:write · só OWNER e ADMIN

Cria o modelo e gera 12 meses a partir de `starts_on` (padrão: o mês atual): uma conta a pagar pendente por loja e mês, no `due_day` (no mês curto, o último dia). `split_kind` `percentual` fecha 100 e `valor` fecha o `amount` (422 `splits`); no percentual o centavo que sobra vai na primeira loja. Outras recusas: 422 `name`, `amount`, `due_day`, `split_kind`. `category` omitida usa o nome.

Request

{ "name": "Aluguel", "amount": 3000, "due_day": 10, "split_kind": "percentual", "starts_on": "2026-11-01", "splits": [{ "establishment_id": "cl...", "value": 60 }, { "establishment_id": "cl...", "value": 40 }] }

Response

201
{ "data": { "id": "cl...", "name": "Aluguel", "amount": 3000, "months_ahead": 12 }, "meta": {} }
PATCH/api/v1/emporio/fixed-expenses/:idSessão · emporio:write · só OWNER e ADMIN

Edita o modelo. As contas dos meses do atual em diante que não têm pagamento são refeitas (valor e vencimento; a loja que saiu do rateio é cancelada; a loja nova ganha as contas); as pagas e as de meses passados ficam. Despesa encerrada: 409 `EXPENSE_ENDED`. Mudar `amount` ou `split_kind` exige mandar `splits` coerentes (422 `splits`).

Request

{ "amount": 3200, "splits": [{ "establishment_id": "cl...", "value": 50 }, { "establishment_id": "cl...", "value": 50 }] }

Response

200
{ "data": { "id": "cl...", "amount": 3200 }, "meta": {} }
POST/api/v1/emporio/fixed-expenses/:id/endSessão · emporio:write · só OWNER e ADMIN

Encerra a despesa (`ended_at`) e cancela as contas de meses depois do atual que não têm pagamento. Já encerrada: 409 `EXPENSE_ENDED`.

Response

200
{ "data": { "id": "cl...", "ended_at": "2026-11-05T12:00:00.000Z" }, "meta": {} }
POST/api/v1/emporio/fixed-expenses/:id/renewSessão · emporio:write · só OWNER e ADMIN

Estende as contas até 12 meses a partir do mês atual, sem duplicar (único por modelo, loja e mês). Nada é gerado sozinho: a tela oferece o botão quando faltam menos de 3 meses. `created` diz quantas contas nasceram. Encerrada: 409 `EXPENSE_ENDED`.

Response

200
{ "data": { "id": "cl...", "months_ahead": 12, "created": 4 }, "meta": {} }
GET/api/v1/emporio/payablesSessão · emporio:read · só OWNER e ADMIN; com vínculo, só as lojas dele

Contas a pagar do "Vendeu, Pagou" (`origin` `vendeu_pagou`) e das despesas fixas (`despesa_fixa`), por vencimento. Filtros: `establishment_id`, `status` (`pending` por padrão, `paid`, `cancelled`, `all`), `de` e `ate` (vencimento), `limit`. `can_correct` diz se ainda dá para corrigir o valor.

Response

200
{ "data": [{ "entry_id": "cl...", "origin": "vendeu_pagou", "name": "Comissão", "establishment_id": "cl...", "amount": 5, "paid_amount": 0, "open_amount": 5, "due_date": "2026-11-30", "status": "pending", "sale_id": "cl...", "can_correct": true }], "meta": {} }
PATCH/api/v1/emporio/payables/:idSessão · emporio:write · só OWNER e ADMIN

Corrige o valor real de uma conta pendente sem pagamento (o `amount` da despesa da venda acompanha). Conta já paga: 409 `ENTRY_PAID` em `amount`; valor inválido: 422 `amount`; conta que não é do Empório: 404.

Request

{ "amount": 4.5 }

Response

200
{ "data": { "entry_id": "cl...", "amount": 4.5, "can_correct": true }, "meta": {} }
POST/api/v1/emporio/payables/paySessão · emporio:write · só OWNER e ADMIN

Paga várias contas de uma vez, cada uma pelo valor dela, saindo da gaveta (`from.establishment_id`) OU da conta (`from.bank_account_id`): um pagamento e um movimento `pagamento_despesa` por conta. Saldo menor que o total: 409 `INSUFFICIENT_CASH` em `from`. Conta de fora do Empório, já paga ou cancelada: 422 `entry_ids`. `paid_at` opcional (padrão agora). Pagar pelo Financeiro (fora do Empório) quita a conta mas não tira do caixa do Empório (dívida 2.26).

Request

{ "entry_ids": ["cl...", "cl..."], "from": { "establishment_id": "cl..." } }

Response

201
{ "data": { "total": 9.5, "from": { "establishment_id": "cl...", "bank_account_id": null }, "payments": [{ "entry_id": "cl...", "amount": 5, "financial_payment_id": "cl..." }] }, "meta": {} }
GET/api/v1/emporio/resultSessão · emporio:read · só OWNER e ADMIN; com vínculo, só as lojas dele (404 `establishment_id` nas outras)

Resultado da Loja no mês (F4a, Q101), calculado na leitura. Query: `establishment_id` (ou `all`, a visão somada de todas as lojas não arquivadas, só para quem não tem vínculo: 403 `FORBIDDEN`; a resposta ganha `stores`, uma linha por loja), `month` (AAAA-MM, 422 `month`). Devolve `sales`, `cost`, `variable_expenses` (regras mais `card_fees`), `margin` e `margin_pct`, `losses` (perdas e quebras, linha própria), `fixed_expenses`, `result`, `break_even` (`status` `ok`, `sem_dados` ou `nao_se_paga`; `amount` e `missing`, nunca negativo), `tracking` (vendas por dia, acumulado e projeção linear) e `estimated` com `estimated_reasons`.

Response

200
{ "data": { "month": "2026-11", "sales": 12000, "cost": 6000, "variable_expenses": 600, "margin": 5400, "margin_pct": 45, "losses": 100, "fixed_expenses": 3000, "result": 2300, "break_even": { "status": "ok", "amount": 6666.67, "missing": 0, "margin_pct_used": 45, "basis": "mes" }, "estimated": false, "estimated_reasons": [] }, "meta": {} }
GET/api/v1/emporio/result/productsSessão · emporio:read · só OWNER e ADMIN; `establishment_id=all` só sem vínculo (403 `FORBIDDEN`)

Produtos do mês (F4b, Q104): `top_sales` e `top_margin`, até 10 cada, ordenados por `sales` e por `margin` (desempate pelo nome). Query: `establishment_id` (id ou `all`), `month` (AAAA-MM). Cada linha: `emporio_product_id`, `name`, `base_unit`, `quantity` (unidade-base), `sales`, `cost`, `variable_expenses` (o "Vendeu, Pagou" rateado pelo valor dos itens, a taxa de cartão entre todos), `margin` e `margin_pct` (nulo sem vendas). Devoluções do mês descontam pelo item devolvido.

Response

200
{ "data": { "establishment_id": "all", "month": "2026-11", "top_sales": [{ "emporio_product_id": "cl...", "name": "Milho", "base_unit": "quilograma", "quantity": 120, "sales": 600, "cost": 360, "variable_expenses": 30, "margin": 210, "margin_pct": 35 }], "top_margin": [] }, "meta": {} }
GET/api/v1/emporio/result/negative-salesSessão · emporio:read · só OWNER e ADMIN; `establishment_id=all` só sem vínculo (403 `FORBIDDEN`)

Vendas concluídas do mês com margem negativa (F4b, Q105), da pior para a melhor: `net_amount` menos o custo dos itens menos o "Vendeu, Pagou" da venda (taxa de cartão inclusive). Query: `establishment_id` (id ou `all`), `month`. `cause`, na primeira que vale: `custo_provisorio`, `preco_abaixo_do_custo`, `desconto`, `taxa_cartao`, `despesas_da_venda`.

Response

200
{ "data": [{ "sale_id": "cl...", "sold_at": "2026-11-05T15:00:00.000Z", "establishment_name": "Loja Centro", "net_amount": 90, "margin": -12.5, "cause": "preco_abaixo_do_custo" }], "meta": {} }
GET/api/v1/emporio/bank-accountsSessão · emporio:read · só OWNER e ADMIN

Contas bancárias do Empório com o saldo (soma dos movimentos) e as lojas que a usam como conta PIX. `incluir_arquivados=1` traz as arquivadas.

Response

200
{ "data": [{ "id": "cl...", "name": "Conta PIX", "bank": "Sicredi", "balance": 320.5, "archived_at": null, "pix_establishment_ids": ["cl..."] }], "meta": {} }
POST/api/v1/emporio/bank-accountsSessão · emporio:write · só OWNER e ADMIN

Cadastra a conta. `name` obrigatório (422 `name`) e único na conta do cliente (409 `DUPLICATE_NAME`); `bank` opcional.

Request

{ "name": "Conta PIX", "bank": "Sicredi" }

Response

201
{ "data": { "id": "cl...", "name": "Conta PIX", "balance": 0 }, "meta": {} }
PATCH/api/v1/emporio/bank-accounts/:idSessão · emporio:write · só OWNER e ADMIN

Edita nome e banco, ou arquiva (`archived: true`) e desarquiva. Nome repetido: 409 `DUPLICATE_NAME`.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-10-28T12:00:00.000Z" }, "meta": {} }
PUT/api/v1/emporio/establishments/:id/pix-accountSessão · emporio:write · só OWNER e ADMIN

Define a conta que recebe o PIX da loja (`bank_account_id`, ou `null` para limpar). Conta inexistente ou arquivada: 422 `bank_account_id`.

Request

{ "bank_account_id": "cl..." }

Response

200
{ "data": { "establishment_id": "cl...", "pix_bank_account_id": "cl..." }, "meta": {} }
GET/api/v1/emporio/cashSessão · emporio:read · gaveta de quem tem a loja no vínculo; contas só OWNER e ADMIN

Saldos e movimentos. `drawers` traz o saldo da gaveta de cada loja visível, `accounts` o de cada conta (vazio para quem não é OWNER ou ADMIN) e `movements` os lançamentos de caixa, do mais recente. Saldo da gaveta = entradas menos saídas; o da conta soma também as parcelas do cartão que já caíram (`expected_on` até hoje em Brasília). Nada é gravado. Cada gaveta traz `last_count` (a última conferência). `accounts[].to_receive` e `card_receivables` (parcelas futuras do cartão, por data) só vêm para OWNER e ADMIN; `checks` é a carteira de cheques (recebido e depositado) das lojas visíveis. Filtros: `establishment_id`, `bank_account_id` (só OWNER e ADMIN), `limit`.

Response

200
{ "data": { "drawers": [{ "establishment_id": "cl...", "name": "Loja Centro", "balance": 150 }], "accounts": [], "movements": [{ "direction": "entrada", "amount": 50, "origin": "venda" }] }, "meta": {} }
POST/api/v1/emporio/cash/openingSessão · emporio:write · loja: OWNER, ADMIN e OPERADOR com a loja no vínculo; conta: só OWNER e ADMIN

Saldo inicial da gaveta de uma loja (`establishment_id`) OU de uma conta (`bank_account_id`), uma vez só (409 `OPENING_ALREADY_DONE`). Informar os dois ou nenhum: 422. `amount` maior que zero (422 `amount`).

Request

{ "establishment_id": "cl...", "amount": 200 }

Response

201
{ "data": { "direction": "entrada", "amount": 200, "origin": "implantacao" }, "meta": {} }
GET/api/v1/emporio/card-fee-rulesSessão · emporio:read · quem tem a loja no vínculo (o nome da conta só para OWNER e ADMIN)

Taxas de cartão das lojas visíveis: uma regra por loja e forma (`debito`, `credito_vista`, `credito_parcelado`), com `fee_pct`, `days` (prazo da 1ª parcela) e a conta que recebe. Filtro: `establishment_id`.

Response

200
{ "data": [{ "id": "cl...", "establishment_id": "cl...", "kind": "credito_vista", "fee_pct": 3, "days": 30, "bank_account_id": "cl...", "bank_account_name": "Conta PIX" }], "meta": {} }
PUT/api/v1/emporio/card-fee-rulesSessão · emporio:write · só OWNER e ADMIN

Cria ou substitui a regra da loja para a forma. `fee_pct` de 0 a 100 (422 `fee_pct`), `days` inteiro de 0 a 120 (422 `days`), `bank_account_id` de conta não arquivada (422 `bank_account_id`). Mudar a regra não mexe nas vendas passadas: a taxa fica gravada no pagamento.

Request

{ "establishment_id": "cl...", "kind": "credito_vista", "fee_pct": 3, "days": 30, "bank_account_id": "cl..." }

Response

200
{ "data": { "id": "cl...", "kind": "credito_vista", "fee_pct": 3, "days": 30 }, "meta": {} }
DELETE/api/v1/emporio/card-fee-rules/:idSessão · emporio:write · só OWNER e ADMIN

Remove a regra. As vendas passadas guardam a taxa no pagamento; sem regra, a venda no cartão daquela forma é recusada (422 `payments`).

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
GET/api/v1/emporio/checksSessão · emporio:read · quem tem a loja no vínculo

Cheques recebidos nas vendas, por `good_for` (bom para). Filtros: `establishment_id`, `contact_id`, `status` (`recebido`, `depositado`, `compensado`, `devolvido`, `devolvido_ao_cliente`). A carteira é a soma dos `recebido` e `depositado` (também vem em `GET /api/v1/emporio/cash`).

Response

200
{ "data": [{ "id": "cl...", "number": "000123", "bank": "Sicredi", "good_for": "2026-12-10", "amount": 100, "status": "recebido", "contact_name": "Seu Zé" }], "meta": {} }
POST/api/v1/emporio/checks/:id/depositSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculo

Depositar: `recebido` para `depositado`, na conta escolhida (422 `bank_account_id`). Fora da ordem: 409 `CHECK_STATUS`.

Request

{ "bank_account_id": "cl..." }

Response

200
{ "data": { "id": "cl...", "status": "depositado", "bank_account_id": "cl..." }, "meta": {} }
POST/api/v1/emporio/checks/:id/clearSessão · emporio:write · só OWNER e ADMIN

Compensou: `depositado` para `compensado`, e o valor entra na conta do depósito (movimento `cheque_compensado`). Sem corpo. Fora da ordem: 409 `CHECK_STATUS`.

Response

200
{ "data": { "id": "cl...", "status": "compensado" }, "meta": {} }
POST/api/v1/emporio/checks/:id/returnSessão · emporio:write · só OWNER e ADMIN

Voltou: `recebido` ou `depositado` para `devolvido`. `reason` obrigatório (422 `reason`). O pagamento do cheque sai do lançamento da venda, que volta a pendente, e o cliente volta a dever no fiado. Fora da ordem: 409 `CHECK_STATUS`.

Request

{ "reason": "Sem fundos" }

Response

200
{ "data": { "id": "cl...", "status": "devolvido", "return_reason": "Sem fundos" }, "meta": {} }
POST/api/v1/emporio/cash/transfersSessão · emporio:write · sai de gaveta: OWNER, ADMIN e OPERADOR com a loja no vínculo; sai de conta: só OWNER e ADMIN

Transfere dinheiro entre caixas. `from` e `to` levam `establishment_id` (gaveta) OU `bank_account_id` (conta). Origem igual ao destino: 422 `to`. Saldo de origem menor que o valor: 409 `INSUFFICIENT_CASH` em `amount`. Grava dois movimentos `transferencia` e nada no Financeiro. `operation_key` obrigatória: repetir devolve a transferência gravada com 200.

Request

{ "operation_key": "k-20", "from": { "establishment_id": "cl..." }, "to": { "bank_account_id": "cl..." }, "amount": 150 }

Response

201
{ "data": { "id": "cl...", "amount": 150, "from": { "establishment_id": "cl...", "bank_account_id": null }, "to": { "establishment_id": null, "bank_account_id": "cl..." } }, "meta": {} }
GET/api/v1/emporio/cash/countsSessão · emporio:read · quem tem a loja no vínculo

Histórico das conferências de gaveta, da mais recente. Filtros: `establishment_id`, `limit`.

Response

200
{ "data": [{ "id": "cl...", "establishment_id": "cl...", "expected": 200, "counted": 190, "difference": -10, "justification": "Troco errado" }], "meta": {} }
POST/api/v1/emporio/cash/countsSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculo

Confere a gaveta: `expected` é o saldo no momento e `difference` é `counted` menos `expected`. `counted` negativo: 422 `counted`. Diferença diferente de zero exige `justification` (422 `justification`) e grava um movimento de ajuste (`conferencia`) e o lançamento pago "Diferença de caixa" (receita se sobra, despesa se falta).

Request

{ "establishment_id": "cl...", "counted": 190, "justification": "Troco errado" }

Response

201
{ "data": { "id": "cl...", "expected": 200, "counted": 190, "difference": -10 }, "meta": {} }
GET/api/v1/pasturesSessão · rebanho:read · perfil fazenda

Lista pastos ativos de uma fazenda. property_id é obrigatório. meta.area_summary traz a soma das áreas x tamanho total da fazenda.

Response

200
{ "data": [{ "id": "cl...", "property_id": "cl...", "name": "Pasto da Sede", "area_hectares": 25, "archived_at": null }], "meta": { "total": 1, "area_summary": { "total_area": 120.5, "distributed_area": 25, "remaining_area": 95.5, "over_allocated": false } } }
POST/api/v1/pasturesSessão · rebanho:write · perfil fazenda

Cadastra pasto. area_hectares (> 0) e property_id são obrigatórios. Não bloqueia se a soma dos pastos ultrapassar o tamanho da fazenda: meta.area_summary.over_allocated só avisa.

Request

{ "name": "Pasto da Sede", "area_hectares": 25, "property_id": "cl..." }

Response

201
{ "data": { "id": "cl...", "property_id": "cl...", "name": "Pasto da Sede", "area_hectares": 25 }, "meta": { "area_summary": { "total_area": 120.5, "distributed_area": 25, "remaining_area": 95.5, "over_allocated": false } } }
PATCH/api/v1/pastures/:idSessão · rebanho:write · perfil fazenda

Edita nome/tamanho do pasto.

Request

{ "area_hectares": 30 }

Response

200
{ "data": { "id": "cl...", "area_hectares": 30 }, "meta": { "area_summary": { "total_area": 120.5, "distributed_area": 30, "remaining_area": 90.5, "over_allocated": false } } }
POST/api/v1/pastures/:id/archiveSessão · rebanho:write · perfil fazenda

Desativa o pasto (não deleta). Idempotente. Pasto com gado (qualquer dono ou situação) é recusado com 409 PASTO_OCUPADO: transfira as cabeças antes.

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-08-04T12:00:00.000Z" }, "meta": { "area_summary": { "total_area": 120.5, "distributed_area": 0, "remaining_area": 120.5, "over_allocated": false } } }

Rebanho: Categorias e Lotes

Desde 2026-08-04 existe UM modelo de rebanho (AnimalBatch): sempre categoria + quantidade, com brinco opcional para quem trabalha com brinco (lote de 1 cabeça). O modelo `Animal` e o campo `status` deixaram de existir. Cadastrar um lote não cria um registro por cabeça: guarda categoria, quantidade e peso médio.

GET/api/v1/animal-categoriesSessão · rebanho:read · perfil fazenda

Lista as categorias de rebanho do tenant. Cada tenant nasce com um conjunto padrão no seed e pode criar as próprias.

Response

200
{ "data": [{ "id": "cl...", "name": "Bezerro", "active": true }], "meta": { "total": 1 } }
POST/api/v1/animal-categoriesSessão · rebanho:write · perfil fazenda

Cria uma categoria de rebanho.

Request

{ "name": "Novilha" }

Response

201
{ "data": { "id": "cl...", "name": "Novilha", "active": true }, "meta": {} }
PATCH/api/v1/animal-categories/:idSessão · rebanho:write · perfil fazenda

Renomeia ou desativa uma categoria. Desativar não apaga: categoria usada por lotes existentes continua referenciada, só deixa de ser oferecida em cadastros novos.

Request

{ "name": "Novilha 12-24m", "active": false }

Response

200
{ "data": { "id": "cl...", "name": "Novilha 12-24m", "active": false }, "meta": {} }

Negociações (Módulo 31)

GET/api/v1/contactsSessão · rebanho:read · perfil fazenda ou emporio

Lista os contatos de negociação (vendedor, comprador, frigorífico, leiloeira). Filtros opcionais: `q` (parte do nome) e `type`. Cadastro deliberadamente simples: o §5 do documento proíbe exigir CPF, CNPJ, endereço ou dados bancários nesta versão.

Response

200
{ "data": [{ "id": "cl...", "name": "João da Ponte", "type": "fazendeiro", "phone": null, "city": "Unaí" }], "meta": { "total": 1 } }
POST/api/v1/contactsSessão · rebanho:write · perfil fazenda ou emporio

Cadastra um contato. Só `name` é obrigatório: até o tipo é opcional, porque o §4 diz que o usuário não deve ser obrigado a classificar quem não souber classificar.

Request

{ "name": "Frigorífico Boa Carne", "type": "frigorifico", "city": "Paracatu" }

Response

201
{ "data": { "id": "cl...", "name": "Frigorífico Boa Carne", "type": "frigorifico" }, "meta": {} }
GET/api/v1/contacts/:idSessão · rebanho:read · perfil fazenda ou emporio

O contato mais o histórico dele: as negociações NÃO canceladas, da mais recente para a mais antiga (máx. 50). As canceladas ficam de fora porque o §17.10 mantém a linha no histórico da própria negociação, e aqui elas só inflariam a conta de quanto já se negociou com aquela pessoa.

Response

200
{ "data": { "id": "cl...", "name": "João da Ponte", "type": "fazendeiro", "archived": false, "negotiations": [{ "id": "cl...", "type": "venda_gado", "occurred_at": "2026-08-01T00:00:00.000Z", "amount": 15000 }] }, "meta": {} }
PATCH/api/v1/contacts/:idSessão · rebanho:write · perfil fazenda ou emporio

Edita o contato. Existe porque o contato criado pela conversa do WhatsApp nasce só com o nome dito (`findOrCreateContact`), e até esta rota não havia como acrescentar telefone, tipo ou município depois. Os campos são os mesmos do POST: o §5 continua proibindo CPF, endereço e dados bancários.

Request

{ "name": "João da Ponte", "type": "fazendeiro", "phone": "38999990000", "city": "Unaí" }

Response

200
{ "data": { "id": "cl...", "name": "João da Ponte", "type": "fazendeiro", "phone": "38999990000", "city": "Unaí" }, "meta": {} }
PATCH/api/v1/contacts/:id/archiveSessão · rebanho:write · perfil fazenda ou emporio

Arquiva ou desarquiva, conforme `archived`. Nunca apaga, como em Fazenda e Pasto: apagar deixaria o histórico anônimo em silêncio, porque `Negotiation.contact_id` é `onDelete: SetNull`. Um `PATCH` com booleano em vez de `DELETE` porque `DELETE` cobriria só um dos dois sentidos e deixaria o desarquivamento sem porta. O arquivado some da listagem E deixa de ser reaproveitado pela conversa, então um negócio novo com o mesmo nome cria um contato novo.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "name": "João da Ponte" }, "meta": {} }
GET/api/v1/negotiationsSessão · rebanho:read · perfil fazenda

Lista as negociações, da mais recente para a mais antiga. Canceladas aparecem por padrão (o §17.10 exige histórico completo); use `include_canceled=false` para ocultar. Filtros: type (os sete tipos, inclusive venda_leite), contact_id, property_id, since, until, situacao (confirmada, vencida, parcialmente_paga, paga, cancelada, sem_valor), limit (máx. 200), offset. A situação é derivada das contas e não gravada: com ela o período inteiro é calculado antes de paginar. Cada item já traz a SITUAÇÃO derivada e os totais do §15.

Response

200
{ "data": [{ "id": "cl...", "type": "venda_gado", "amount": 80000, "situacao": "paga", "totais": { "principal": 80000, "custos": 5500, "total": 85500, "liquido": 74500 } }], "meta": { "total": 1 } }
POST/api/v1/negotiationsSessão · rebanho:write · perfil fazenda

Registra um negócio. UMA rota para `compra_gado`, `venda_gado`, `compra_produto` e `venda_produto`, com o tipo no corpo: são a mesma operação com o sinal invertido, e só muda o filho que o negócio cria (`HerdMovement` ou `StockMovement`). Nos tipos de produto, `itens` traz `product_id` em vez de `category_id`, e a quantidade aceita casa decimal quando a unidade permite (saca sim, ferramenta não). Tudo numa transação só: se a venda não tiver saldo, NADA é gravado, nem o envelope. `pago: true` gera lançamento já quitado; `pago: false` com `parcelas` gera uma conta a pagar/receber por parcela, e a soma delas precisa bater exatamente com `amount` (§14), senão devolve 422 `PARCELAS_NAO_FECHAM`. A prazo sem `parcelas`, `due_date` é obrigatória: sem ela, 422 `VENCIMENTO_OBRIGATORIO` no campo `due_date`. `custos` (frete, comissão, taxa) viram lançamentos de DESPESA próprios, para aparecerem no DRE. `payment_method` opcional (dinheiro, pix, transferencia, boleto, cheque, cartao, outro) vai para as contas principais, todas as parcelas, e para o pagamento quando já nasce pago. Em gado, `itens[].pasture_id` opcional é o pasto de destino na compra e de origem na venda.

Request

{ "type": "compra_gado", "property_id": "cl...", "itens": [{ "category_id": "bezerro_0_7", "quantity": 20 }], "amount": 60000, "contact_id": "cl...", "pago": false, "parcelas": [{ "due_date": "2026-03-10T00:00:00.000Z", "amount": 60000 }], "payment_method": "boleto" }

Response

201
{ "data": { "id": "cl..." }, "meta": {} }
GET/api/v1/negotiations/:idSessão · rebanho:read · perfil fazenda

Detalhe do negócio, com os movimentos de rebanho e os lançamentos financeiros que ele gerou. Não existe PATCH: o §17.9 pede recálculo ao editar, e a decisão do módulo é que editar é CANCELAR e refazer, porque uma edição de valor teria que desfazer filhos que já podem ter virado dinheiro pago.

Response

200
{ "data": { "id": "cl...", "situacao": "parcialmente_paga", "movimentos": [{ "movement_type": "compra", "quantity": 20 }], "lancamentos": [{ "amount": 30000, "status": "paid", "negotiation_role": "principal" }] }, "meta": {} }
POST/api/v1/negotiations/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela a negociação: os movimentos de rebanho voltam e as contas EM ABERTO são canceladas. Não apaga nada, a linha continua no histórico. Devolve 422 `INSUFFICIENT_BALANCE` quando parte dos animais que entraram por esta negociação já saiu, dizendo quantos restam (§17.9). O que já foi PAGO segue o `dinheiro_pago`: `mantem` (padrão) deixa lançado, porque o dinheiro saiu de verdade; `devolvido` cria um lançamento de estorno com a data de hoje, sem apagar o original, para os dois meses fecharem certo; `engano` cancela o lançamento, porque aquele pagamento nunca existiu. `meta` traz `valor_recebido_mantido`, `valor_pago_mantido` e `valor_estornado`, separados porque numa venda o principal entrou e os custos sairam.

Request

{ "reason": "comprei errado", "dinheiro_pago": "devolvido" }

Response

200
{ "data": { "id": "cl...", "situacao": "cancelada", "canceled_reason": "comprei errado" }, "meta": { "valor_recebido_mantido": 0, "valor_pago_mantido": 0, "valor_estornado": 60000 } }
POST/api/v1/negotiations/eventsSessão · rebanho:write · perfil fazenda

Abre a REMESSA para leilão, feira ou evento (§8). NÃO gera lançamento financeiro nenhum, e o corpo nem aceita valor: o §17.8 diz que o envio de animais para um evento não pode virar venda antes da confirmação. As cabeças saem da quantidade física da fazenda e continuam no rebanho do produtor, na situação `evento`. Cria três coisas numa transação só: a negociação (tipo `evento`, sem valor), a estadia filha com os campos do §8.1, e a movimentação `envio_evento` apontando para as duas. Sem saldo, NADA é gravado: devolve 422 `INSUFFICIENT_BALANCE` no campo `quantity`, e nem a negociação nem o contato do organizador ficam para trás.

Request

{ "property_id": "cl...", "category_id": "femea_36_mais", "quantity": 20, "event_name": "Leilão de Outubro", "event_type": "leilão", "organizer_name": "Leiloeira Central", "expected_end_at": "2026-10-20T00:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "stay_id": "cl..." }, "meta": {} }
POST/api/v1/negotiations/:id/close-eventSessão · rebanho:write · perfil fazenda

Encerra a remessa: quantos venderam, quantos voltaram e quantos seguiram para outro destino. A SOMA DOS TRÊS precisa ser igual ao que está na remessa, senão devolve 422 `DESTINOS_NAO_BATEM` no campo `quantity` e nada se move. O dinheiro só nasce AGORA: `amount` vira o valor da mesma negociação e o lançamento `principal` (com parcelas, quando houver, somando exatamente `amount`; a prazo sem parcelas, `due_date` é obrigatória, senão 422 `VENCIMENTO_OBRIGATORIO`), e `custos` (comissão da leiloeira, taxa do evento, frete) viram lançamentos de DESPESA próprios. Sem venda, custo com valor também precisa de `pago` ou `due_date` (422 `VENCIMENTO_OBRIGATORIO`). Venda sem valor é 422 `VENDA_SEM_VALOR` e valor sem venda é 422 `VALOR_SEM_VENDA`, os dois no campo `amount`. `outro_destino` fecha esta remessa e abre uma estadia NOVA do tipo escolhido, cujo id volta em `meta.nova_estadia_id`: nenhuma cabeça some, e nenhuma volta para a fazenda por engano.

Request

{ "vendidos": 12, "retornados": 8, "amount": 60000, "pago": true, "custos": [{ "descricao": "Comissão da leiloeira", "amount": 3000 }] }

Response

200
{ "data": { "id": "cl...", "type": "evento", "amount": 60000, "situacao": "paga" }, "meta": { "nova_estadia_id": null, "encerrada": true } }
POST/api/v1/negotiations/bartersSessão · rebanho:write · perfil fazenda

Registra uma PERMUTA (§12): o produtor entrega uma coisa e recebe outra, com ou sem diferença em dinheiro. UMA chamada atualiza rebanho, estoque, máquinas e financeiro, que é o §12.6 ("o produtor não deverá precisar criar manualmente uma venda e depois uma compra"). Cada lado tem um `kind`: `animais` (vira `permuta_saida`/`permuta_entrada` no livro-razão, nunca `venda`/`compra`), `produtos` (idem no estoque), `maquina` (no lado entregue é o `machine_id` de uma máquina que já existe, que passa a `negociada`; no recebido são os campos de cadastro, e a máquina nasce ligada à permuta e SEM custo de aquisição, porque o que ela custou foi o gado) e `descricao` (serviço ou outro, que não tem área no Tibé e fica como texto). O VALOR da negociação é a `diferenca`, e só ela: `paguei` gera despesa, `recebi` gera receita, com parcelas quando houver (a soma tem que dar o valor, §14). A prazo sem parcelas, `due_date` é obrigatória (422 `VENCIMENTO_OBRIGATORIO`). Recusas: 422 `PERMUTA_INCOMPLETA` quando falta um dos lados (§12.3 os torna obrigatórios), 422 `PERMUTA_VAZIA` quando nada se move e não há dinheiro, 422 `MAQUINA_INDISPONIVEL` quando a máquina entregue já saiu, e 422 `INSUFFICIENT_BALANCE` ou `INSUFFICIENT_STOCK` sem saldo. Em qualquer recusa NADA é gravado, nem o envelope nem o contato.

Request

{ "property_id": "cl...", "entregue": { "kind": "animais", "category_id": "macho_36_mais", "quantity": 20 }, "recebido": { "kind": "maquina", "name": "Trator John Deere 6110", "type": "Trator" }, "diferenca": { "direcao": "paguei", "amount": 30000 }, "pago": true }

Response

201
{ "data": { "id": "cl...", "machine_id": "cl..." }, "meta": {} }

Estoque de insumos (Módulo 31)

GET/api/v1/product-categoriesSessão · rebanho:read · perfil fazenda

As categorias de produto do §9.1. Semeia as 15 do documento no primeiro acesso do tenant e devolve a lista; `meta.seeded` diz quantas nasceram agora. Semear na leitura, e não na migração, trata tenant novo e antigo igual sem tocar em dado de produção. Só cria quando NÃO existe nenhuma: não ressuscita a que o produtor arquivou de propósito.

Response

200
{ "data": [{ "id": "cl...", "name": "Sal mineral" }], "meta": { "seeded": 15 } }
GET/api/v1/productsSessão · rebanho:read · perfil fazenda

O catálogo com o SALDO de cada produto. O saldo nunca é coluna: é a soma das movimentações não canceladas, por produto e fazenda. `?property_id=` filtra o saldo, não o catálogo, então um produto sem movimentação naquela fazenda aparece com zero (e é isso que deixa lançar a primeira compra dele lá). `meta.units` traz as 11 unidades do §10.5, cada uma dizendo se aceita quantidade quebrada.

Response

200
{ "data": [{ "id": "cl...", "name": "Sal mineral 60 P", "unit": "saca", "saldo_total": 12.5, "minimum_stock": 10, "abaixo_do_minimo": false }], "meta": { "abaixo_do_minimo": 0 } }
POST/api/v1/productsSessão · rebanho:write · perfil fazenda

Cadastra um produto (§9.1). `minimum_stock` nulo significa sem alerta para este produto. Nome duplicado devolve 409 `DUPLICATE_PRODUCT`: dois produtos com o mesmo nome dariam dois saldos para a mesma coisa, que é o que o estoque existe para evitar.

Request

{ "name": "Sal mineral 60 P", "category_id": "cl...", "unit": "saca", "minimum_stock": 10, "storage_location": "Galpão 1" }

Response

201
{ "data": { "id": "cl...", "name": "Sal mineral 60 P", "unit_label": "saca" }, "meta": {} }
GET/api/v1/stock/movementsSessão · rebanho:read · perfil fazenda

Histórico de movimentações (§10.2). Canceladas aparecem, com `canceled_at` preenchido: o histórico mostra o que aconteceu, inclusive o que foi desfeito, e quem ignora as canceladas é a soma do saldo. Cada item traz `delta`, o quanto aquele movimento mexeu no saldo COM sinal. Filtros: product_id, property_id, movement_type, since, until, limit (máx. 200), offset.

Response

200
{ "data": [{ "id": "cl...", "movement_type": "utilizacao", "quantity": 2, "delta": -2, "product_name": "Sal mineral 60 P" }], "meta": { "total": 1 } }
POST/api/v1/stock/movementsSessão · rebanho:write · perfil fazenda

Registra `utilizacao` (§10.3, saída sem dinheiro: aplicar vacina, dar sal ao gado), ou compra/venda avulsa quando o saldo entrou por fora. Compra e venda COM dinheiro entram por `/api/v1/negotiations`, que cria o lançamento financeiro junto. Saída acima do disponível devolve 422 `INSUFFICIENT_STOCK` com a mensagem literal do §10.7; a conferência e a escrita acontecem na mesma transação serializável, senão duas saídas simultâneas leriam o mesmo saldo e as duas passariam. Quantidade quebrada em unidade que não fraciona também é 422. `ajuste` NÃO entra aqui: tem rota própria.

Request

{ "product_id": "cl...", "property_id": "cl...", "movement_type": "utilizacao", "quantity": 2, "purpose": "sal para o lote do curral" }

Response

201
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/stock/adjustSessão · rebanho:write · perfil fazenda

Ajuste de estoque (§10.6). O corpo traz `corrected_balance`, o que EXISTE de verdade, contado pelo produtor: quem calcula a diferença é o sistema, porque pedir a diferença a quem está com o produto na mão é onde nasce o erro de sinal. Guarda saldo anterior, saldo corrigido e motivo, para depois dar para saber o que o produtor via na tela quando decidiu corrigir. Saldo igual ao atual devolve 422 `NO_CHANGE` em vez de gravar um movimento de zero.

Request

{ "product_id": "cl...", "property_id": "cl...", "corrected_balance": 8, "reason": "contagem do galpão" }

Response

201
{ "data": { "id": "cl...", "diferenca": -1 }, "meta": {} }

Rebanho: livro-razão (Módulo 30)

GET/api/v1/herd/positionsSessão · rebanho:read · perfil fazenda

Saldo do rebanho por posição, onde posição é `categoria x fazenda x pasto x situação x dono`. A quantidade é sempre a SOMA das movimentações não canceladas, nunca um campo gravado. Posição zerada não é devolvida. Filtros opcionais: category_id, property_id, pasture_id, situation, owner. O total do rebanho PRÓPRIO é a consulta com `?owner=proprio`, senão animais de terceiro entram na conta.

Response

200
{ "data": [{ "category_id": "femea_36_mais", "property_id": "cl...", "pasture_id": null, "situation": "presente", "owner": "proprio", "quantity": 45 }], "meta": { "total": 1, "total_quantity": 45 } }
GET/api/v1/herd/movementsSessão · rebanho:read · perfil fazenda

Histórico obrigatório do rebanho, da movimentação mais recente para a mais antiga. Movimentações canceladas APARECEM por padrão, marcadas com `canceled_at`: o registro cancelado precisa continuar identificado no histórico. Use `include_canceled=false` para ver só o que conta no saldo. Filtros: category_id, property_id, pasture_id, movement_type, since, until, limit (máx. 200), offset.

Response

200
{ "data": [{ "id": "cl...", "movement_type": "venda", "quantity": 8, "from": { "category_id": "femea_25_36", "property_id": "cl...", "pasture_id": null, "situation": "presente", "owner": "proprio" }, "to": null, "value": 28000, "occurred_at": "2026-08-05T00:00:00.000Z", "canceled_at": null, "recorded_by": { "id": "cl...", "name": "Zé" } }], "meta": { "total": 12 } }
POST/api/v1/herd/movementsSessão · rebanho:write · perfil fazenda

Registra uma movimentação. UMA rota para os 9 tipos (`saldo_inicial`, `nascimento`, `compra`, `venda`, `morte`, `transferencia_pasto`, `transferencia_fazenda`, `mudanca_categoria`, `ajuste`), porque mudança de categoria não é caso especial: é um movimento com categorias diferentes nas duas pontas. Entrada exige só `to`, saída só `from`, transferência os dois, ajuste exatamente um. Devolve 422 `INSUFFICIENT_BALANCE` quando a origem não tem saldo. `compra` e `venda` com `value` geram lançamento financeiro; `nascimento` e `morte` nunca.

Request

{ "movement_type": "compra", "quantity": 10, "to": { "category_id": "femea_25_36", "property_id": "cl...", "pasture_id": null, "situation": "presente", "owner": "proprio" }, "value": 30000 }

Response

201
{ "data": { "id": "cl...", "movement_type": "compra", "quantity": 10, "financial_entry_id": "cl..." }, "meta": {} }
POST/api/v1/herd/movements/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela uma movimentação. Não apaga: marca a linha, que continua no histórico, e o saldo se recalcula sozinho. É POST em sub-rota (não DELETE) porque o recurso não é removido e a operação exige o motivo. Editar é cancelar e lançar de novo, por isso não existe PATCH. Devolve 422 `INSUFFICIENT_BALANCE` quando o cancelamento deixaria o destino negativo (comprou 10, vendeu 8, tentou cancelar a compra) e 422 `ALREADY_CANCELED` na segunda vez. O lançamento financeiro pendente é apagado; o pago ganha um estorno.

Request

{ "reason": "lançado errado" }

Response

200
{ "data": { "id": "cl...", "canceled_at": "2026-08-05T12:00:00.000Z", "canceled_reason": "lançado errado" }, "meta": {} }
GET/api/v1/herd/staysSessão · rebanho:read · perfil fazenda

Estadias temporárias do rebanho (fase 2): pasto de terceiros, boitel, evento, animais de terceiros na fazenda e desaparecimento. `saldo_aberto` e `aberta` são DERIVADOS das movimentações que apontam para a estadia, nunca gravados: a estadia está aberta enquanto sobrar cabeça nela. Filtros: property_id, type, abertas=true.

Response

200
{ "data": [{ "id": "cl...", "type": "pasto_terceiro", "counterparty_name": "Sítio do João", "started_at": "2026-08-27T00:00:00.000Z", "charge_type": "por_mes", "charge_value": 1200, "saldo_aberto": 20, "aberta": true, "canceled_at": null }], "meta": { "total": 1 } }
POST/api/v1/herd/staysSessão · rebanho:write · perfil fazenda

Abre uma estadia e grava a movimentação de envio no mesmo passo. UMA rota para os 5 tipos, porque os cinco fluxos são o mesmo ciclo com validação diferente. O animal continua no rebanho próprio e sai da quantidade física da fazenda, salvo `terceiro_na_fazenda`, que é entrada de animal alheio e nunca soma ao rebanho. Com `charge_value` informado nasce UM lançamento: despesa em `pasto_terceiro` e `boitel`, receita em `terceiro_na_fazenda`, com o valor exato que o produtor informou (`charge_type` é informação do acordo, não entra em cálculo). Devolve 422 `INSUFFICIENT_BALANCE` com `field: quantity` quando não há saldo, e nada fica gravado pela metade.

Request

{ "type": "pasto_terceiro", "property_id": "cl...", "category_id": "femea_36_mais", "quantity": 20, "counterparty_name": "Sítio do João", "charge_type": "por_mes", "charge_value": 1200 }

Response

201
{ "data": { "id": "cl...", "type": "pasto_terceiro", "quantity": 20, "financial_entry_id": "cl..." }, "meta": {} }
POST/api/v1/herd/stays/:id/closeSessão · rebanho:write · perfil fazenda

Encerra a estadia, total ou parcialmente: a soma dos destinos pode ser MENOR que o que está na estadia (o resto continua nela), e maior devolve 422 `DESTINOS_NAO_BATEM` com `field: quantity`. Os destinos vêm como lista por tipo de movimento (`retorno_estadia`, `venda`, `morte`, `perda_confirmada`, `saida_terceiro`, `ajuste`), e cada tipo de estadia aceita só alguns (422 `ENCERRAMENTO_NAO_PERMITIDO`). Numa `venda`, cria a negociação de gado com `contact_id` ou `contact_name` opcionais, e `value` vira a receita: `pago: true` nasce quitada, e a prazo `due_date` é obrigatória, senão 422 `VENCIMENTO_OBRIGATORIO` no campo `due_date` e nada se move. Num `retorno_estadia`, `property_id` manda para outra fazenda, `confinement_site_id` abre lote novo noutro confinamento, e `evento` (`event_name`, `event_type`, `organizer_name`) abre a remessa para leilão ou feira. `ajuste` (confinamento e boitel) exige `reason`, e as cabeças saem do rebanho.

Request

{ "destinos": [{ "movement_type": "venda", "quantity": 12, "value": 60000, "contact_name": "Frigorífico Boa Carne", "pago": false, "due_date": "2026-11-10T12:00:00.000Z" }, { "movement_type": "retorno_estadia", "quantity": 8, "confinement_site_id": "cl..." }] }

Response

200
{ "data": { "id": "cl...", "encerrada": true, "saldo_aberto": 0 }, "meta": {} }
POST/api/v1/herd/stays/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela a estadia inteira: as cabeças voltam para onde estavam e o lançamento pendente é apagado (o pago ganha estorno). Não apaga nada: a estadia e as movimentações continuam no histórico, marcadas. Devolve 422 `ESTADIA_JA_ENCERRADA` quando já houve encerramento, porque desfazer o que já foi vendido é decisão do produtor, e 422 `ESTADIA_JA_CANCELADA` na segunda vez.

Request

{ "reason": "lançado errado" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }

Confinamento (Módulo 30, fase 3)

Confinamento próprio e Boitel reusam a mesma estadia do rebanho (HerdStay), fase 2 do Módulo 30: não é um segundo modelo de "onde o animal está". ConfinementSite é só o cadastro do local; abrir uma estadia deriva o tipo (confinamento ou boitel) do tipo do site, então um site próprio nunca vira estadia boitel.

GET/api/v1/confinement/sitesSessão · rebanho:read · perfil fazenda

Lista os confinamentos cadastrados (§5). Exclui arquivados por padrão; `?include_archived=true` inclui, `?type=proprio|boitel` filtra.

Response

200
{ "data": [{ "id": "cl...", "name": "Confinamento Sede", "type": "proprio", "property_id": "cl...", "counterparty_name": null, "city": null, "capacity": 500, "notes": null, "archived": false, "archived_at": null }], "meta": { "total": 1 } }
POST/api/v1/confinement/sitesSessão · rebanho:write · perfil fazenda

Cadastra um confinamento (§5). `type: proprio` exige `property_id` de uma fazenda não arquivada; `type: boitel` exige `counterparty_name` (empresa ou proprietário). `capacity`, `city` e `notes` são opcionais.

Request

{ "name": "Confinamento Sede", "type": "proprio", "property_id": "cl...", "capacity": 500 }

Response

201
{ "data": { "id": "cl...", "name": "Confinamento Sede", "type": "proprio", "property_id": "cl...", "archived": false }, "meta": {} }
POST/api/v1/confinement/sites/:id/archiveSessão · rebanho:write · perfil fazenda

Arquiva o confinamento (não deleta, mesmo padrão de Property/Pasture). Idempotente: re-arquivar mantém o archived_at original.

Response

200
{ "data": { "id": "cl...", "archived": true, "archived_at": "2026-08-31T12:00:00.000Z" }, "meta": {} }
GET/api/v1/confinement/staysSessão · rebanho:read · perfil fazenda

Lotes de confinamento e Boitel (§9, §25 "lotes ativos"), com dias confinados e saldo aberto derivados, nunca gravados. Filtros: confinement_site_id, type (confinamento|boitel), abertas=true.

Response

200
{ "data": [{ "id": "cl...", "type": "confinamento", "confinement_site_id": "cl...", "property_id": "cl...", "started_at": "2026-08-21T00:00:00.000Z", "days_confined": 10, "quantity": 37, "aberta": true, "canceled_at": null }], "meta": { "total": 1 } }
POST/api/v1/confinement/staysSessão · rebanho:write · perfil fazenda

Abre uma estadia de confinamento (§6, §7), reusando `openStay` (fase 2). O `type` da estadia (confinamento ou boitel) é DERIVADO de `confinement_site_id`, não escolhido no corpo. Entrada não altera o total do rebanho (§27.1); os animais só saem da localização anterior. `charge_type`/`charge_value` gravam a cobrança informada, sem multiplicar por cabeça ou por dia.

Request

{ "confinement_site_id": "cl...", "category_id": "macho_25_36", "quantity": 37, "pasture_id": "cl...", "charge_type": "fechado", "charge_value": 4500 }

Response

201
{ "data": { "id": "cl...", "type": "confinamento", "quantity": 37 }, "meta": {} }
GET/api/v1/confinement/stays/:idSessão · rebanho:read · perfil fazenda

Resumo do lote (§8, §13, §14, §24): dias confinados, saldo atual, alimentação por produto (StockMovement com este stay_id) e custo financeiro acumulado (soma simples de FinancialEntry ligados, nunca estimativa).

Response

200
{ "data": { "id": "cl...", "type": "confinamento", "days_confined": 10, "quantity": 37, "aberta": true, "charge_type": "fechado", "charge_value": 4500, "feeding": [{ "product_id": "cl...", "product_name": "Ração", "unit": "kg", "quantity": 180 }], "financial_cost": 4500 }, "meta": {} }
POST/api/v1/confinement/stays/:id/feedingSessão · rebanho:write · perfil fazenda

Registra alimentação (§10, §11, §12): cria um StockMovement de `utilizacao` vinculado à estadia, e o saldo do produto cai. `product_id` é OBRIGATÓRIO: sem ele a rota devolve 422 `PRODUCT_REQUIRED` no campo `product_id`, em vez de aceitar em silêncio sem gravar nada.

Request

{ "product_id": "cl...", "quantity": 180, "notes": "trato da manhã" }

Response

201
{ "data": { "stay_id": "cl...", "registered_in_stock": true, "stock_movement_id": "cl..." }, "meta": {} }
POST/api/v1/confinement/stays/:id/costsSessão · rebanho:write · perfil fazenda

Custo avulso do lote (§13, §14): ração comprada, remédio, frete. Vira despesa no Financeiro ligada ao lote (`related_module: confinamento`), e entra no `financial_cost` do resumo. `pago: true` nasce quitada; a prazo, `due_date` é obrigatória, senão 422 `VENCIMENTO_OBRIGATORIO`.

Request

{ "category": "Ração", "amount": 3000, "pago": false, "due_date": "2026-10-10T12:00:00.000Z" }

Response

201
{ "data": { "id": "cl..." }, "meta": {} }

Leite (Módulo 32, fase 1)

Dois contadores, e nenhum dos dois é gravado: as vacas em lactação são o dobramento dos registros de lactação (a partir do último `definir`), e os litros de um período são a soma dos registros de produção. A área NÃO escreve no livro-razão do rebanho: "em lactação" é uma condição, não uma categoria, e entrar ou sair da lactação não altera o total do rebanho. Fases 2 (tanque, ponto de coleta, leite de terceiros) e 3 (venda, comprador, fechamento) ainda não existem.

GET/api/v1/milk/groupsSessão · rebanho:read · perfil fazenda

Lista os lotes leiteiros (§6): agrupamento organizacional ("vacas em maior produção", "recém-paridas"), que NÃO conta cabeça e não aparece em soma nenhuma do Rebanho. Exclui arquivados por padrão; `?include_archived=true` inclui, `?property_id=` filtra.

Response

200
{ "data": [{ "id": "cl...", "property_id": "cl...", "name": "Recém-paridas", "notes": null, "archived": false, "archived_at": null }], "meta": { "total": 1 } }
POST/api/v1/milk/groupsSessão · rebanho:write · perfil fazenda

Cadastra um lote leiteiro (§6). Devolve 422 `DUPLICATE_GROUP` quando já existe lote ativo com o mesmo nome na fazenda: dois "Recém-paridas" na lista tornariam a escolha um chute.

Request

{ "property_id": "cl...", "name": "Recém-paridas" }

Response

201
{ "data": { "id": "cl...", "property_id": "cl...", "name": "Recém-paridas", "archived": false }, "meta": {} }
PATCH/api/v1/milk/groups/:id/archiveSessão · rebanho:write · perfil fazenda

Arquiva ou desarquiva o lote. Aceita `{ "archived": false }` para desarquivar, diferente do confinamento: o lote leiteiro muda de estação, e cadastrar de novo perderia o histórico que aponta para o antigo.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived": true, "archived_at": "2026-09-02T12:00:00.000Z" }, "meta": {} }
GET/api/v1/milk/lactationSessão · rebanho:read · perfil fazenda

Histórico dos registros de lactação (§4, §7), do mais novo para o mais velho. Filtros: `property_id`, `group_id`, `de`, `ate` (AAAA-MM-DD), `limit`. A contagem vigente vem em `meta.vacas_em_lactacao`, e não como recurso próprio, porque ela é o dobramento das linhas listadas, não uma linha guardada. Sem `property_id` ela vem `null`: a contagem só existe por fazenda.

Response

200
{ "data": [{ "id": "cl...", "property_id": "cl...", "type": "entrada", "quantity": 4, "recorded_at": "2026-09-02T12:00:00.000Z", "pasture_id": null, "group_id": null, "notes": null, "cancelled": false, "cancelled_at": null }], "meta": { "total": 1, "vacas_em_lactacao": 36 } }
POST/api/v1/milk/lactationSessão · rebanho:write · perfil fazenda

Registra lactação: `definir` fixa o valor absoluto ("estou com 32 vacas dando leite"), `entrada` soma ("entraram mais 4"), `saida` subtrai ("sequei 3"). Uma `saida` maior que a contagem devolve 422 `SALDO_INSUFICIENTE` no campo `quantity`, e a conferência vale para a data do registro E para todas as seguintes: saída retroativa que deixa o presente negativo é o mesmo erro, só mais difícil de ver. `quantity: 0` só é aceito em `definir`, que é a afirmação legítima "não tenho mais nenhuma".

Request

{ "property_id": "cl...", "type": "entrada", "quantity": 4, "recorded_at": "2026-09-02" }

Response

201
{ "data": { "id": "cl...", "type": "entrada", "quantity": 4 }, "meta": { "vacas_em_lactacao": 36 } }
POST/api/v1/milk/lactation/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela um registro de lactação: ele sai do dobramento e FICA na lista, marcado (§37.11). Devolve 422 `JA_CANCELADO` na segunda vez.

Response

200
{ "data": { "id": "cl...", "cancelled": true }, "meta": { "vacas_em_lactacao": 32 } }
GET/api/v1/milk/productionSessão · rebanho:read · perfil fazenda

Lista registros de produção (§8, §11). Filtros: `property_id`, `group_id`, `de`, `ate` (AAAA-MM-DD), `limit`. `meta.total_litros` soma só os não cancelados.

Response

200
{ "data": [{ "id": "cl...", "property_id": "cl...", "liters": 300, "shift": "manha", "recorded_at": "2026-09-02T12:00:00.000Z", "group_id": null, "notes": null, "cancelled": false, "cancelled_at": null }], "meta": { "total": 1, "total_litros": 300 } }
POST/api/v1/milk/productionSessão · rebanho:write · perfil fazenda

Registra produção (§8, §9). Duas formas alternativas: `dia` (o dia inteiro num número só) OU `manha`/`tarde`/`noite`. Mandar as duas devolve 422 `FORMAS_MISTURADAS`, porque o §9 as apresenta como alternativas e somá-las faria 500 mais 300 virar 800 em silêncio. Devolve SEMPRE uma lista: cada turno é uma linha, e o total do dia é a soma delas, nunca um campo. `vacas_em_lactacao` é o atalho do §8: não vira campo do registro, e sim um `definir` de lactação na mesma data, na mesma transação.

Request

{ "property_id": "cl...", "manha": 300, "tarde": 180, "recorded_at": "2026-09-02", "vacas_em_lactacao": 32 }

Response

201
{ "data": [{ "id": "cl...", "liters": 300, "shift": "manha" }, { "id": "cl...", "liters": 180, "shift": "tarde" }], "meta": { "total_litros": 480 } }
POST/api/v1/milk/production/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela um registro de produção: sai das somas e continua na lista, marcado (§37.11). Devolve 422 `JA_CANCELADO` na segunda vez.

Response

200
{ "data": { "id": "cl...", "cancelled": true }, "meta": {} }
GET/api/v1/milk/summarySessão · rebanho:read · perfil fazenda

O painel do §34 e as seis janelas do §11 (hoje, ontem, últimos 7 dias, este mês, mês anterior, ano). `property_id` é obrigatório: a contagem de vacas e a média por vaca só existem por fazenda, e sem ele devolve 422 `FAZENDA_OBRIGATORIA`. `media_por_vaca` é litros por vaca/dia, com os dias sem contagem conhecida fora dos DOIS lados da divisão, e vem `null` quando nenhum dia da janela tem contagem: zero afirmaria uma produtividade que ninguém mediu. "Semana" são sete dias corridos, e as janelas em curso terminam hoje, não no fim do mês.

Response

200
{ "data": { "property_id": "cl...", "hoje": { "dia": "2026-09-02", "vacas_em_lactacao": 32, "litros": 480, "media_por_vaca": 15 }, "periodos": [{ "chave": "hoje", "rotulo": "Hoje", "de": "2026-09-02", "ate": "2026-09-02", "litros": 480, "dias": 1, "dias_com_registro": 1, "media_diaria": 480, "media_por_vaca": 15, "dias_com_contagem": 1 }] }, "meta": {} }
GET/api/v1/milk/sitesSessão · rebanho:read · perfil fazenda

Tanques próprios e pontos de coleta de terceiros (§13 e §16). `liters` é o volume FÍSICO de cada local, ou seja, a soma de TODOS os donos (§20): é o número que responde "cabe mais leite?", e por isso ignora de quem é o leite. `acima_da_capacidade` avisa quando o físico passou da capacidade informada, que NÃO é limite: o §13 a chama de informação, e recusar por causa dela inventaria uma regra. Filtros: `type=proprio|terceiro`, `include_archived=true`.

Response

200
{ "data": [{ "id": "cl...", "name": "Tanque Principal", "type": "proprio", "property_id": "cl...", "counterparty_name": null, "capacity": 2000, "liters": 950, "acima_da_capacidade": false, "archived": false }], "meta": { "total": 1 } }
POST/api/v1/milk/sitesSessão · rebanho:write · perfil fazenda

Cadastra tanque ou ponto de coleta (§13, §16). `type: proprio` exige `property_id` de fazenda não arquivada; `type: terceiro` exige `counterparty_name` (de quem é o ponto). `capacity`, `city` e `notes` são opcionais.

Request

{ "name": "Tanque Principal", "type": "proprio", "property_id": "cl...", "capacity": 2000 }

Response

201
{ "data": { "id": "cl...", "name": "Tanque Principal", "type": "proprio", "liters": 0, "archived": false }, "meta": {} }
PATCH/api/v1/milk/sites/:id/archiveSessão · rebanho:write · perfil fazenda

Arquiva ou desarquiva o local. NÃO exige saldo zero: tanque desativado com leite dentro é situação real, e recusar obrigaria a inventar uma retirada. O arquivamento tira o local dos destinos novos; o saldo continua aparecendo até a baixa de verdade.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived": true, "archived_at": "2026-09-02T12:00:00.000Z" }, "meta": {} }
GET/api/v1/milk/storageSessão · rebanho:read · perfil fazenda

O painel de armazenamento do §34: `posicoes` é o saldo por `local x dono` (o §20 na íntegra: dono `null` é o leite próprio), `resumo` traz próprio em tanque, próprio em ponto de coleta, de terceiros e o físico total, e `movimentos` são as últimas linhas do livro-razão. Filtros: `site_id`, `limit`. Posição com saldo zero NÃO aparece: oferecer um dono sem leite seria convidar a um lançamento que a rota recusa.

Response

200
{ "data": { "posicoes": [{ "site_id": "cl...", "owner_id": null, "liters": 400 }, { "site_id": "cl...", "owner_id": "cl...", "liters": 300 }], "resumo": { "proprio_em_tanque": 400, "proprio_em_ponto_de_coleta": 600, "de_terceiros": 550, "fisico_total": 1550 }, "movimentos": [] }, "meta": { "total_posicoes": 2 } }
POST/api/v1/milk/storageSessão · rebanho:write · perfil fazenda

As quatro conversas do §14 ao §21, escolhidas pelo campo `gesto`, com união discriminada (cada gesto tem seus obrigatórios). `armazenar` (§14): a produção entra num tanque PRÓPRIO, e não é venda (§37.5). `transferir` (§16): leite nosso sai do tanque para um ponto de coleta de TERCEIROS e continua nosso; **não gera receita** (§17 literal). `receber` (§19): leite de um `owner_id` entra no nosso tanque, aumenta o volume físico e NÃO aumenta a produção própria. `retirar` (§15 e §21): a composição por dono é INFORMADA, nunca rateada, e grava uma linha por dono, tudo ou nada. Origem sem saldo devolve 422 `SALDO_INSUFICIENTE` no campo `liters`; dono repetido na retirada devolve 422 `DONO_REPETIDO`. ⚠️ `destination: "venda"` NÃO gera dinheiro nesta fase: o §37.8 é cumprido na fase 3, quando a venda existir como negócio.

Request

{ "gesto": "armazenar", "site_id": "cl...", "liters": 480 }

// entrega em ponto de coleta (§16)
{ "gesto": "transferir", "from_site_id": "cl...", "to_site_id": "cl...", "liters": 600 }

// leite de terceiro (§19)
{ "gesto": "receber", "site_id": "cl...", "owner_id": "cl...", "liters": 300 }

// retirada com composição (§21)
{ "gesto": "retirar", "site_id": "cl...", "destination": "laticinio", "itens": [{ "owner_id": null, "liters": 400 }, { "owner_id": "cl...", "liters": 300 }] }

Response

201
{ "data": { "id": "cl...", "movement_type": "entrada_producao", "liters": 480 }, "meta": {} }

// a retirada devolve uma LISTA, uma linha por dono
{ "data": [{ "id": "cl...", "liters": 400 }, { "id": "cl...", "liters": 300 }], "meta": { "total_litros": 700 } }
POST/api/v1/milk/storage/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela uma movimentação: sai dos saldos e fica no histórico, marcada (§37.11). Cancelar uma ENTRADA pode deixar saldo negativo quando o leite já saiu, e isso é aceito: recusar prenderia o produtor a um registro errado. ⚠️ A retirada do §21 grava uma linha POR DONO, então cancelar uma desfaz a baixa daquele dono, não a retirada inteira.

Response

200
{ "data": { "id": "cl...", "canceled": true }, "meta": {} }
GET/api/v1/milk/chargesSessão · rebanho:read · perfil fazenda

As cobranças por funcionar como ponto de coleta (§22). Filtros: `owner_id`, `limit`. `meta.total_valor` soma só as não canceladas.

Response

200
{ "data": [{ "id": "cl...", "owner_id": "cl...", "type": "por_litro", "amount": 250, "period_label": "agosto/2026", "financial_entry_id": "cl...", "canceled": false }], "meta": { "total": 1, "total_valor": 250 } }
POST/api/v1/milk/chargesSessão · rebanho:write · perfil fazenda

Registra a receita do §22, com uma das seis formas de cobrança. O valor é o que o produtor DIGITOU, nunca calculado, mesmo em `por_litro`: o §22 dá o exemplo de R$ 0,05 sobre 5.000 litros mas não diz sobre qual PERÍODO somar, e isso só aparece no §28 (fase 3). Alimenta o Financeiro por `createLinkedEntry` com `related_module: "leite"`, e nasce PAGA: o §22 fala de cobrar pelo serviço prestado, não de faturar a prazo.

Request

{ "owner_id": "cl...", "type": "por_litro", "amount": 250, "period_label": "agosto/2026" }

Response

201
{ "data": { "id": "cl...", "amount": 250, "financial_entry_id": "cl..." }, "meta": {} }
POST/api/v1/milk/charges/:id/cancelSessão · rebanho:write · perfil fazenda

Cancela a cobrança E o lançamento financeiro que ela gerou, os dois juntos. Foi exatamente aqui que o confinamento errou em 31/08, deixando a conta viva depois do cancelamento. O lançamento vira `cancelled` em vez de ser apagado, porque o DRE do mês em que ele existiu precisa continuar contando a história como ela aconteceu.

Response

200
{ "data": { "id": "cl...", "canceled": true, "financial_entry_id": "cl..." }, "meta": {} }
POST/api/v1/milk/salesSessão · rebanho:write · perfil fazenda

A venda avulsa de leite (§23 a §27). **Vender JÁ RETIRA o leite**: os litros saem do local e a venda nasce na mesma transação, porque é assim que o produtor fala e o §23 lista a quantidade vendida como obrigatória. Vende sempre o leite PRÓPRIO: vender o de terceiro guardado no seu tanque seria vender o que não é seu (§19). O §25 aceita `amount` OU `price_per_liter`, nunca os dois (422 `VALOR_DUPLICADO`), e grava sempre o total, com o preço por litro derivado na leitura. A venda vira uma `Negotiation` de tipo `venda_leite` e herda parcelas (§27), custos adicionais (o desconto do laticínio) e estorno. Sem saldo próprio no local, 422 `SALDO_INSUFICIENTE` no campo `liters`. ⚠️ **Cancelar NÃO tem rota aqui**: é `POST /api/v1/negotiations/:id/cancel`, que passou a desfazer o leite junto.

Request

{ "site_id": "cl...", "property_id": "cl...", "liters": 500, "price_per_liter": 2.40, "buyer_id": "cl...", "pago": true }

// a prazo (§27), com parcelas
{ "site_id": "cl...", "property_id": "cl...", "liters": 1000, "amount": 2400, "parcelas": [{ "due_date": "2026-10-10", "amount": 1200 }, { "due_date": "2026-11-10", "amount": 1200 }] }

Response

201
{ "data": { "negotiation_id": "cl...", "liters": 500, "amount": 1200, "price_per_liter": 2.4 }, "meta": {} }
POST/api/v1/milk/sales/closeSessão · rebanho:write · perfil fazenda

O fechamento por período (§28 e §29). **Não move leite**: cobra o que já saiu. Soma as retiradas daquele `buyer_id` no período que ainda não foram liquidadas, aplica o preço e cria a venda. Cada entrega recebe o `negotiation_id` do fechamento, e é isso que impede cobrar o mesmo leite duas vezes: a segunda chamada não encontra nada em aberto e devolve 422 `SEM_ENTREGAS`. A prazo (`pago` falso, sem `parcelas`), `due_date` é obrigatória: sem ela, 422 `VENCIMENTO_OBRIGATORIO`. O exemplo do §29 sai daqui: 15 dias, 7.200 litros a R$ 2,35, R$ 16.920,00 em Contas a Receber.

Request

{ "buyer_id": "cl...", "property_id": "cl...", "de": "2026-09-01", "ate": "2026-09-15", "price_per_liter": 2.35, "due_date": "2026-09-25", "period_label": "1a quinzena de setembro" }

Response

201
{ "data": { "negotiation_id": "cl...", "liters": 7200, "amount": 16920, "price_per_liter": 2.35, "entregas": 15 }, "meta": {} }
GET/api/v1/milk/sales/pendingSessão · rebanho:read · perfil fazenda

As entregas que saíram para um comprador e ainda NÃO foram cobradas (§28), agrupadas por comprador. "Não cobradas" é `negotiation_id: null` na movimentação, e a marca é posta no fechamento: por isso a lista some sozinha depois que o período fecha. Filtros: `buyer_id`, `de`, `ate` (AAAA-MM-DD).

Response

200
{ "data": [{ "buyer_id": "cl...", "liters": 1380, "entregas": 3, "primeira": "2026-09-01T15:00:00.000Z", "ultima": "2026-09-03T15:00:00.000Z" }], "meta": { "total": 1, "total_litros": 1380 } }

Rebanho: Animais

GET/api/v1/animalsSessão · rebanho:read · perfil fazenda

Lista o rebanho. Filtros: property_id, category_id, breed, q (busca por brinco). O filtro `status` deixou de existir com o modelo único (2026-08-04): `quantity` diz o que resta, e a pergunta real passou a ser por categoria.

Response

200
{ "data": [{ "id": "cl...", "category_id": "cl...", "category_name": "Bezerro", "quantity": 20, "ear_tag": null, "breed": "Nelore", "sex": null, "average_weight": 180.5, "property_name": "Sede", "last_vaccination_at": "2026-05-01T00:00:00.000Z" }], "meta": { "total": 1 } }
POST/api/v1/animalsSessão · rebanho:write · perfil fazenda

Cadastra rebanho. `category_id`, `property_id` e `quantity` são obrigatórios; `ear_tag` é OPCIONAL, só para quem trabalha com brinco. Brinco identifica UMA cabeça: enviar `ear_tag` com `quantity` diferente de 1 devolve 422 (EAR_TAG_REQUIRES_SINGLE). O brinco é único por tenant apenas quando preenchido (índice parcial): duplicar devolve 409.

Request

{ "category_id": "cl...", "property_id": "cl...", "quantity": 20, "breed": "Nelore", "initial_weight": 180.5 }

// com brinco (uma cabeça identificada)
{ "category_id": "cl...", "property_id": "cl...", "quantity": 1, "ear_tag": "1234", "breed": "Nelore", "sex": "male" }

Response

201
{ "data": { "id": "cl...", "category_id": "cl...", "quantity": 20, "ear_tag": null, "average_weight": 180.5 } }
GET/api/v1/animals/:idSessão · rebanho:read · perfil fazenda

Detalhe do animal.

Response

200
{ "data": { "id": "cl...", "ear_tag": "1234", "quantity": 1, "breed": "Nelore", "property_name": "Sede", "average_weight": 380.5 } }
PATCH/api/v1/animals/:idSessão · rebanho:write · perfil fazenda

Edita dados cadastrais. Valida nova propriedade e unicidade de brinco, se alterados.

Request

{ "breed": "Nelore PO" }

Response

200
{ "data": { "id": "cl...", "breed": "Nelore PO" } }
GET/api/v1/animals/:id/weight-logsSessão · rebanho:read · perfil fazenda

Histórico de pesagens, ordenado por data, com GMD (ganho médio diário) calculado.

Response

200
{ "data": [{ "id": "cl...", "weight": 380.5, "measured_at": "..." }], "meta": { "gmd": 0.62, "current_weight": 380.5 } }
POST/api/v1/animals/:id/weight-logsSessão · rebanho:write · perfil fazenda

Registra pesagem. Atualiza current_weight do animal e recalcula o GMD.

Request

{ "weight": 380.5, "measured_at": "2026-07-01T00:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "weight": 380.5 }, "meta": { "current_weight": 380.5, "gmd": 0.62 } }
GET/api/v1/animals/:id/vaccinationsSessão · rebanho:read · perfil fazenda

Histórico de vacinação do animal.

Response

200
{ "data": [{ "id": "cl...", "vaccine_name": "Aftosa", "applied_at": "...", "next_due_at": "..." }], "meta": { "count": 1 } }
POST/api/v1/animals/:id/vaccinationsSessão · rebanho:write · perfil fazenda

Registra aplicação. next_due_at = applied_at + intervalo (da vacina ou customizado). cost, se informado, gera FinancialEntry de despesa.

Request

{ "vaccine_id": "cl...", "applied_at": "2026-07-01T00:00:00.000Z", "cost": 12.5 }

Response

201
{ "data": { "id": "cl...", "applied_at": "...", "next_due_at": "..." } }
GET/api/v1/animals/:id/movementsSessão · rebanho:read · perfil fazenda

Histórico de movimentações do animal.

Response

200
{ "data": [{ "id": "cl...", "movement_type": "sale", "value": 4500, "occurred_at": "..." }], "meta": { "count": 1 } }
POST/api/v1/animals/:id/movementsSessão · rebanho:write · perfil fazenda

Registra compra, venda, transferência ou morte. Venda/morte atualizam status; transferência atualiza property_id; value gera FinancialEntry (receita na venda, despesa na compra).

Request

{ "movement_type": "sale", "value": 4500, "occurred_at": "2026-07-05T00:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "movement_type": "sale", "value": 4500 } }
GET/api/v1/animals/:id/cost-summarySessão · rebanho:read · perfil fazenda

Soma as despesas (FinancialEntry) vinculadas ao animal e calcula o custo médio mensal desde a entrada na propriedade (data de compra, ou cadastro se não houver).

Response

200
{ "data": { "animal_id": "cl...", "total_cost": 245.0, "monthly_avg_cost": 40.83, "since": "...", "months": 6.0 } }
GET/api/v1/vaccinesSessão · rebanho:read · perfil fazenda

Catálogo de vacinas do tenant.

Response

200
{ "data": [{ "id": "cl...", "name": "Aftosa", "default_interval_days": 180 }], "meta": { "total": 4 } }
POST/api/v1/vaccinesSessão · rebanho:write · perfil fazenda

Cria vacina personalizada no catálogo do tenant.

Request

{ "name": "Raiva bovina", "default_interval_days": 365 }

Response

201
{ "data": { "id": "cl...", "name": "Raiva bovina", "default_interval_days": 365 } }
GET/api/v1/vaccinations/upcomingSessão · rebanho:read · perfil fazenda

Vacinações vencendo nos próximos 15 dias. Alimenta o card do dashboard e o job de alertas.

Response

200
{ "data": [{ "animal_id": "cl...", "ear_tag": "1234", "vaccine_name": "Aftosa", "next_due_at": "...", "days_remaining": 6 }] }

Máquinas e equipamentos

Módulo 26. Sem exclusão: mudar status é o único jeito de "remover" uma máquina da operação ativa.

GET/api/v1/machinesSessão · maquinas:read · perfil fazenda

Lista as máquinas do tenant.

Response

200
{ "data": [{ "id": "cl...", "name": "Trator 1", "type": "trator", "status": "active", "next_maintenance_at": null, "...": "..." }], "meta": { "total": 1 } }
POST/api/v1/machinesSessão · maquinas:write · perfil fazenda

Cadastra uma máquina. Com `acquisition_cost`, gera despesa automática vinculada.

Request

{ "property_id": "cl...", "name": "Trator 1", "type": "trator", "brand": "Massey Ferguson", "acquisition_cost": 180000 }

Response

201
{ "data": { "id": "cl..." }, "meta": {} }
GET/api/v1/machines/:idSessão · maquinas:read · perfil fazenda

Detalhe da máquina, incluindo o histórico de manutenções.

Response

200
{ "data": { "id": "cl...", "name": "Trator 1", "maintenances": [{ "id": "cl...", "description": "Troca de óleo", "...": "..." }], "...": "..." }, "meta": {} }
PATCH/api/v1/machines/:idSessão · maquinas:write · perfil fazenda

Edita dados cadastrais ou muda o status (active, maintenance, sold, inactive).

Request

{ "status": "maintenance" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/machines/:id/maintenancesSessão · maquinas:write · perfil fazenda

Registra uma manutenção. Com `cost`, gera despesa automática vinculada à manutenção (não à máquina). Com `next_due_at`, substitui a previsão anterior de `next_maintenance_at` e alimenta o alerta `maintenance_due` (janela de 15 dias).

Request

{ "description": "Troca de óleo e filtros", "cost": 450, "next_due_at": "2026-11-01T00:00:00.000Z" }

Response

201
{ "data": { "id": "cl..." }, "meta": {} }
GET/api/v1/machines/:id/servicesSessão · maquinas:read · perfil fazenda

O histórico de serviços PRESTADOS com esta máquina (§32 do documento de Máquinas): quantos serviços, quanto foi faturado e a lista de cada um com cliente e quantidade. ⚠️ `quantidade_por_unidade` é um MAPA, nunca um número só: um trator que fez 12 horas de gradagem e 25 hectares de roçada não trabalhou 37 de nada. Serviços cancelados ficam de fora. Guard `maquinas`, e não `servicos`: quem pode ver a máquina pode ver o que ela fez.

Response

200
{ "data": { "machine_id": "cl...", "servicos": 2, "quantidade_por_unidade": { "hora": 12, "hectare": 25 }, "faturado": 6300, "linhas": [{ "description": "Gradagem", "quantidade": 12, "pricing": "hora", "contact_name": "Cliente João", "total": 1800 }] }, "meta": { "total": 2 } }

Mão de Obra (Módulo 33, fase 1)

O trabalhador fixo e o dinheiro dele. NÃO é folha de pagamento: os §35 e §41 do documento do cliente excluem eSocial, FGTS, INSS, férias, 13º, rescisão e ponto. Nenhum valor pago ou devido é gravado no trabalhador: tudo é `FinancialEntry` com `related_module: mao_de_obra` e `related_id` do trabalhador, o que faz conta a pagar, alerta de vencimento e DRE funcionarem sem nada novo. ⚠️ Permissão PRÓPRIA, mais fechada que a do financeiro: só OWNER e ADMIN, porque isto guarda salário.

GET/api/v1/workersSessão · mao_de_obra:read · perfil fazenda

Lista a equipe (§38, "Minha equipe"), com o PRÓXIMO PAGAMENTO já junto de cada um. Filtros: `status` (ativo/inativo) e `property_id`. O `meta` traz `funcoes_sugeridas`, as dez funções do §6 que a tela oferece; a função em si é texto livre, porque a lista do documento termina em "Outro".

Response

200
{ "data": [{ "id": "cl...", "name": "João", "role": "Vaqueiro", "type": "fixo", "status": "ativo", "pay_frequency": "mensal", "pay_amount": 2500, "pay_day": 5, "proximo_pagamento": { "id": "cl...", "amount": 2500, "due_date": "2026-10-05T12:00:00.000Z" } }], "meta": { "total": 1, "funcoes_sugeridas": ["Vaqueiro", "..."] } }
POST/api/v1/workersSessão · mao_de_obra:write · perfil fazenda

Cadastra um trabalhador (§5). No `fixo`, `pay_frequency` e `pay_amount` são obrigatórios e o cadastro JÁ CRIA a primeira previsão de pagamento (uma `FinancialEntry` pendente, §7 e §40.2). No `eventual` não: quem paga o diarista é a diária do §13, e frequência e valor nem são gravados.

Request

{ "name": "João", "role": "Vaqueiro", "type": "fixo", "pay_frequency": "mensal", "pay_amount": 2500, "pay_day": 5 }

Response

201
{ "data": { "id": "cl...", "name": "João", "proximo_pagamento": { "amount": 2500, "due_date": "2026-09-05T12:00:00.000Z" } }, "meta": {} }
GET/api/v1/workers/:idSessão · mao_de_obra:read · perfil fazenda

O trabalhador e o histórico dele (§37): pagamentos, adiantamentos, gratificações e benefícios, pagos e pendentes. Quem separa um do outro é `kind` (`worker_entry_kind`), não a categoria, que é texto que o produtor renomeia no painel.

Response

200
{ "data": { "id": "cl...", "name": "João", "entries": [{ "id": "cl...", "kind": "adiantamento", "amount": 500, "status": "paid", "paid_at": "2026-09-02T00:00:00.000Z" }] }, "meta": {} }
PATCH/api/v1/workers/:idSessão · mao_de_obra:write · perfil fazenda

Edita o cadastro, ou muda a situação (§39). `{ "status": "inativo" }` sozinho muda só ela, e NÃO é inócuo: inativar apaga as previsões PENDENTES (um trabalhador que saiu não pode gerar conta a pagar nos meses seguintes) e preserva as pagas, que o §40.8 exige no histórico. Reativar recria a previsão, uma só.

Request

{ "pay_amount": 2800 }

Response

200
{ "data": { "id": "cl...", "pay_amount": 2800 }, "meta": {} }
POST/api/v1/workers/:id/paymentsSessão · mao_de_obra:write · perfil fazenda

Registra dinheiro pago, com `kind` escolhendo o gesto. ⚠️ `pagamento` é o único que NÃO cria lançamento novo: ele QUITA a previsão pendente e faz a PRÓXIMA nascer, ancorada no vencimento da que foi paga (não na data em que o dinheiro saiu, senão pagar adiantado repetiria o mesmo mês e pagar atrasado pularia um). Sem previsão pendente ele devolve 404 em vez de inventar um valor, porque o §40.3 diz que o sistema prevê e o produtor confirma. `amount` opcional sobrescreve o previsto. Os outros três (`adiantamento`, `gratificacao`, `beneficio`, `outro`) criam lançamento já pago e NÃO abatem da previsão do mês: o §9 pede o adiantamento mostrado separado.

Request

{ "kind": "adiantamento", "amount": 500 }

Response

201
{ "data": { "id": "cl...", "amount": 500 }, "meta": {} }

Serviços contratados e prestados (Módulos 33 e 34)

As duas direções na mesma tabela: o serviço que a fazenda CONTRATA de terceiro (a diária do §13, o empreito do §15, o serviço por unidade do §16) e o que ela PRESTA com máquina própria (`direction: prestado`), que gera RECEITA em vez de despesa. ⚠️ Permissão `servicos`, OPERACIONAL e diferente da de `mao_de_obra`: OPERADOR registra serviço e continua sem enxergar salário. Nenhum saldo é gravado: o total combinado é derivado dos logs, o pago e o restante são somas de `FinancialEntry`, e os três podem divergir se o produtor editar um lançamento em `/financeiro`, que é onde o dinheiro de verdade mora. Manutenção de máquina NÃO entra aqui: é `MachineMaintenance`, na ficha da própria máquina.

GET/api/v1/service-jobsSessão · servicos:read · perfil fazenda

Lista os serviços, do mais recente para o mais antigo, cada um já com os quatro números do §22 (quantidade trabalhada, total combinado, pago, restante). Cancelados ficam de fora por padrão; use `incluir_cancelados=true`. Filtros: `status`, `property_id`, `contact_id`. O `meta` traz `servicos_sugeridos`, os 19 do §20 que a tela oferece; a descrição em si é texto livre.

Response

200
{ "data": [{ "id": "cl...", "description": "Reforma de cerca", "pricing": "dia", "unit_price": 150, "worker_count": 3, "quantidade": 4, "total": 1800, "pago": 0, "restante": 1800, "contact_name": "Turma da cerca" }], "meta": { "total": 1, "servicos_sugeridos": ["Construção de cerca", "..."] } }
POST/api/v1/service-jobsSessão · servicos:write · perfil fazenda

Registra um serviço. `direction` ausente vale `contratado`. `pricing: fechado` exige `agreed_amount` e ignora quantidade (é o empreito do §15); qualquer outra forma exige `unit_price`, e `quantity` vira o primeiro lançamento de quantidade. `worker_count` multiplica o VALOR e nunca a quantidade: o §14 diz que 3 homens por 4 dias são 12 diárias, mas o serviço durou 4 dias. `contact_name` sem `contact_id` cria o contato pelo nome dito. `pago: true` gera lançamento já quitado; sem ele nasce uma conta pelo total (a pagar no contratado, a RECEBER no prestado). ⚠️ `pago: true` com `due_date` é recusado: o §21 lista à vista e futuro como caminhos diferentes. ⚠️ No `prestado`, `machine_id` e o cliente são obrigatórios (§17); no `contratado`, `machine_id` é RECUSADO, apontando para Máquinas, porque manutenção e máquina de terceiro são `MachineMaintenance`. Os campos `client_location`, `implement`, `operator_worker_id` e `operator_note` são do prestado. O status vem da DATA: no futuro nasce `agendado` e entra na agenda do §39; no passado nasce `concluido`.

Request

{ "property_id": "cl...", "occurred_at": "2026-09-01T12:00:00.000Z", "description": "Reforma de cerca", "pricing": "dia", "unit_price": 150, "quantity": 4, "worker_count": 3, "contact_name": "Turma da cerca" }

Response

201
{ "data": { "id": "cl...", "quantidade": 4, "total": 1800, "restante": 1800 }, "meta": {} }
GET/api/v1/service-jobs/:idSessão · servicos:read · perfil fazenda

O serviço, os lançamentos de quantidade (`logs`) e os lançamentos financeiros (`entries`). É o que a tela do §22 usa para mostrar valor total, valor já pago, valor restante e próximo vencimento.

Response

200
{ "data": { "id": "cl...", "total": 10000, "pago": 3000, "restante": 7000, "logs": [], "entries": [{ "amount": 3000, "status": "paid" }, { "amount": 7000, "status": "pending" }] }, "meta": {} }
POST/api/v1/service-jobs/:id/paymentsSessão · servicos:write · perfil fazenda

Registra um pagamento, ou um RECEBIMENTO se o serviço for `prestado` (§21, §22, §26, §27). Cria um lançamento quitado e ENCOLHE a conta em aberto; quando ela zera, é apagada, porque conta de R$ 0,00 seria ruído no Financeiro. ⚠️ O sinal do lançamento vem da direção DO SERVIÇO, nunca do corpo: um recebimento gravado como despesa mostraria "recebido R$ 3.000" na tela e uma despesa de R$ 3.000 no DRE, com o saldo batendo. ⚠️ Pagar mais que o restante devolve 422 no campo `amount` dizendo quanto falta: sem essa recusa, um dedo pesado transforma R$ 700 em R$ 7.000 e os R$ 6.300 a mais viram lançamento fantasma. Serviço já quitado ou cancelado devolve 409.

Request

{ "amount": 3000 }

Response

201
{ "data": { "pago": 3000, "restante": 7000 }, "meta": {} }
DELETE/api/v1/service-jobs/:idSessão · servicos:write · perfil fazenda

CANCELA o serviço, não apaga. Remove as contas a pagar PENDENTES (um serviço que não aconteceu não pode seguir gerando despesa no DRE dos meses seguintes) e preserva os lançamentos PAGOS, porque aquele dinheiro saiu de verdade. Os lançamentos de quantidade também ficam: o §40.8 exige histórico. Aceita `reason` opcional no corpo.

Request

{ "reason": "O tratorista não voltou" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/service-jobs/:id/logsSessão · servicos:write · perfil fazenda

Acrescenta produção a um serviço que dura vários dias, sem criar um serviço novo (§19, §20). A conta em aberto acompanha o total novo. §33: informe `hour_meter_start` e `hour_meter_end` no lugar de `quantity`, e as horas saem da diferença; os dois juntos com `quantity` é recusado. O horímetro final atualiza `Machine.hour_meter`, e SÓ ANDA PARA A FRENTE (um lançamento fora de ordem não faz a máquina voltar). Serviço `fechado` (empreito) recusa quantidade: o §16 não calcula por hora ou hectare.

Request

{ "quantity": 7, "occurred_at": "2026-09-02T12:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "quantidade": 16, "total": 2400, "horas": null }, "meta": {} }
GET/api/v1/service-jobs/:id/costsSessão · servicos:read · perfil fazenda

Os custos do serviço (§21 a §25): a lista, o total e a soma por natureza. Cancelados ficam no histórico e saem da soma.

Response

200
{ "data": { "linhas": [], "total": 800, "por_natureza": { "mao_de_obra": 600, "pedagio": 200 } }, "meta": {} }
POST/api/v1/service-jobs/:id/costsSessão · servicos:write · perfil fazenda

Registra um custo. Com `kind: "combustivel"` ou `product_id`, é o combustível do §21: SE o produto existir no estoque, baixa a quantidade informada (§35); sem produto cadastrado, o custo entra do mesmo jeito, sem baixa. `amount` e `unit_price` são as duas formas do §22 de dizer o valor (o total informado vence); sem nenhum dos dois, o custo fica com valor nulo. ⚠️ O combustível NUNCA gera lançamento financeiro: o diesel já virou despesa quando foi comprado (decisão 17). Nas outras naturezas, `saiu_do_caixa: true` gera a despesa no Financeiro, apontando para o CUSTO, nunca para o serviço, para o dinheiro que saiu não aparecer como se o cliente tivesse pagado.

Request

{ "kind": "combustivel", "product_id": "cl...", "quantity": 80, "unit_price": 6 }

Response

201
{ "data": { "id": "cl...", "kind": "combustivel", "amount": 480, "gerou_lancamento": false, "baixou_estoque": true }, "meta": {} }
PATCH/api/v1/service-jobs/:id/statusSessão · servicos:write · perfil fazenda

Começa ou encerra o serviço (§42): `status: "em_andamento"` ou `"concluido"`. Concluir NÃO quita nada: o §42 pergunta se o cliente já pagou DEPOIS de mostrar quantidade, total e o que falta receber, e a resposta é outro passo (`POST .../payments`). Serviço cancelado devolve 409.

Request

{ "status": "concluido" }

Response

200
{ "data": { "id": "cl...", "status": "concluido", "quantidade": 10, "total": 3000, "a_receber": 3000 }, "meta": {} }
GET/api/v1/workers/:id/logsSessão · mao_de_obra:read · perfil fazenda

As anotações do trabalhador: atividade (§12) e ausência (§34), da mais recente para a mais antiga. ⚠️ Guard `mao_de_obra`, e não `servicos`: quem não pode ver o salário de alguém não pode ver as faltas dele.

Response

200
{ "data": [{ "id": "cl...", "kind": "falta", "occurred_at": "2026-09-02T12:00:00.000Z", "description": null }], "meta": { "total": 1 } }
POST/api/v1/workers/:id/logsSessão · mao_de_obra:write · perfil fazenda

Registra atividade ou ausência. ⚠️ NADA aqui calcula nada, e isso é o requisito: o §34 diz que o Tibé não deve calcular consequências trabalhistas, então uma FALTA não gera desconto e não toca na previsão de pagamento. Férias, 13º, FGTS, INSS e rescisão continuam com o contador (§35). `atividade` exige descrição; as ausências dispensam, porque o tipo já é a informação inteira.

Request

{ "kind": "falta", "occurred_at": "2026-09-02T12:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "kind": "falta" }, "meta": {} }
DELETE/api/v1/workers/:id/logs/:logIdSessão · mao_de_obra:write · perfil fazenda

Apaga uma anotação. ⚠️ Aqui apagar é apagar mesmo, e é a única exceção do módulo ao 'desativar, nunca apagar' que vale para Fazenda, Pasto, Contato e Trabalhador: uma anotação errada não é histórico de dinheiro, e registrar 'folga' no dia errado sem poder corrigir seria pior que perder a linha.

Response

200
{ "data": { "id": "cl..." }, "meta": {} }

Meu Dia (tarefas)

Módulo 27. Tarefas são compartilhadas dentro do tenant (visíveis a todo mundo, não privadas por usuário). Concluir/cancelar só pelo painel: o agente WhatsApp só cria.

GET/api/v1/tasksSessão · tarefas:read

Lista as tarefas do tenant. `effective_status` inclui "overdue" (calculado: pending + due_date no passado), nunca gravado.

Response

200
{ "data": [{ "id": "cl...", "title": "Comprar sal mineral", "due_date": "...", "remind": true, "status": "pending", "effective_status": "pending", "...": "..." }], "meta": { "total": 1 } }
POST/api/v1/tasksSessão · tarefas:write

Cria uma tarefa. Só o título é obrigatório: data, horário, responsável, prioridade, fazenda, observação e recorrência são opcionais. Horário e recorrência sem data são recusados.

Request

{ "title": "Vacinar os bezerros", "due_date": "2026-09-18T00:00:00.000Z", "due_time": "14:00", "priority": "urgente", "recurrence": "semanal" }

Response

201
{ "data": { "id": "cl..." }, "meta": {} }
PATCH/api/v1/tasks/:idSessão · tarefas:write

Conclui, cancela ou edita uma tarefa. Concluir uma recorrente cria a próxima e devolve o id dela em `next_task_id`. Cancelar mantém o histórico.

Request

{ "status": "completed" }

Response

200
{ "data": { "id": "cl...", "next_task_id": "cl..." }, "meta": {} }
DELETE/api/v1/tasks/:idSessão · tarefas:write

Apaga uma tarefa de vez. Para manter o histórico, use o PATCH com status cancelled.

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/v1/tasks/:id/postponeSessão · tarefas:write

Adia uma tarefa pendente para outra data. Tarefa concluída ou cancelada é recusada.

Request

{ "due_date": "2026-09-19T00:00:00.000Z" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
GET/api/v1/meu-diaSessão · tarefas:read

As seções do Meu Dia (atenção, hoje, próximos dias e sem data), já na ordem de importância. Lê ao vivo tarefas, contas, vacinas, serviços agendados, estadias e estoque baixo. `?property_id=` filtra por fazenda; item sem fazenda aparece em todas. Quem vê o Meu Empório recebe também os itens dele (origens `emporio_pagar`, `emporio_fiado` e `emporio_caixa`, com a loja no título); a conta do Empório nunca entra para quem não o vê.

Response

200
{ "data": { "atencao": [...], "hoje": [{ "origem": "pagar", "titulo": "Pagar João", "valor": 2500, "dias": 0, "...": "..." }], "proximos": [...], "sem_data": [...] }, "meta": { "total": 12 } }

Lavoura: Talhões e ciclos

GET/api/v1/plotsSessão · lavoura:read · perfil fazenda

Lista talhões, com o ciclo ativo (se houver) embutido.

Response

200
{ "data": [{ "id": "cl...", "name": "Talhão 1", "area_hectares": 40, "property_name": "Sede", "active_cycle": { "id": "cl...", "crop_name": "Soja", "status": "growing" } }], "meta": { "total": 1 } }
POST/api/v1/plotsSessão · lavoura:write · perfil fazenda

Cadastra talhão. Rejeita propriedade arquivada.

Request

{ "name": "Talhão 1", "area_hectares": 40, "property_id": "cl..." }

Response

201
{ "data": { "id": "cl...", "name": "Talhão 1", "area_hectares": 40 } }
GET/api/v1/plots/:idSessão · lavoura:read · perfil fazenda

Detalhe do talhão: ciclo ativo e histórico completo de ciclos.

Response

200
{ "data": { "id": "cl...", "name": "Talhão 1", "active_cycle": { "id": "cl...", "status": "growing" }, "cycles": [ { "id": "cl...", "status": "growing" } ] } }
GET/api/v1/plots/:id/cyclesSessão · lavoura:read · perfil fazenda

Lista os ciclos do talhão.

Response

200
{ "data": [{ "id": "cl...", "crop_name": "Soja", "status": "growing" }], "meta": { "total": 1 } }
POST/api/v1/plots/:id/cyclesSessão · lavoura:write · perfil fazenda

Inicia novo ciclo. Rejeita se o talhão já tem um ciclo planted/growing ativo.

Request

{ "crop_name": "Soja", "planted_at": "2026-09-01T00:00:00.000Z", "expected_harvest_at": "2027-01-15T00:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "crop_name": "Soja", "status": "planted" } }
PATCH/api/v1/cycles/:id/harvestSessão · lavoura:write · perfil fazenda

Registra colheita. Muda status para harvested.

Request

{ "harvested_at": "2027-01-20T00:00:00.000Z", "yield_amount": 2400, "yield_unit": "saca" }

Response

200
{ "data": { "id": "cl...", "status": "harvested", "yield_amount": 2400, "yield_unit": "saca" } }
GET/api/v1/cycles/:id/summarySessão · lavoura:read · perfil fazenda

Custo total de insumos, custo por hectare, e produtividade por hectare (se já colhido).

Response

200
{ "data": { "total_input_cost": 3200, "area_hectares": 40, "cost_per_hectare": 80, "yield_amount": 2400, "productivity_per_hectare": 60 } }
GET/api/v1/cycles/:id/inputsSessão · lavoura:read · perfil fazenda

Lista insumos aplicados no ciclo.

Response

200
{ "data": [{ "id": "cl...", "input_type": "fertilizer", "name": "NPK 04-14-08", "cost": 1800 }], "meta": { "total": 1 } }
POST/api/v1/cycles/:id/inputsSessão · lavoura:write · perfil fazenda

Registra insumo aplicado. cost, se informado, gera FinancialEntry de despesa.

Request

{ "input_type": "fertilizer", "name": "NPK 04-14-08", "quantity": 800, "unit": "kg", "cost": 1800 }

Response

201
{ "data": { "id": "cl...", "name": "NPK 04-14-08", "cost": 1800 } }

Prestador de Serviço

GET/api/v1/service-clientsSessão · prestador:read · perfil prestador

Lista clientes. ?q= busca por nome ou telefone.

Response

200
{ "data": [{ "id": "cl...", "name": "Sítio Esperança", "phone": "22988884444" }], "meta": { "total": 1 } }
POST/api/v1/service-clientsSessão · prestador:write · perfil prestador

Cadastra cliente do prestador.

Request

{ "name": "Sítio Esperança", "document": "12345678900", "phone": "22988884444", "email": null, "notes": null }

Response

201
{ "data": { "id": "cl...", "name": "Sítio Esperança" } }
GET/api/v1/service-clients/:id/summarySessão · prestador:read · perfil prestador

Total faturado (ordens invoiced), total pendente (completed não faturadas) e histórico. Usado também pelo agente WhatsApp ("quanto o cliente X me deve").

Response

200
{ "data": { "client_id": "cl...", "client_name": "Sítio Esperança", "total_invoiced": 1200, "total_pending": 300, "orders_count": 4, "last_order_at": "..." } }
GET/api/v1/servicesSessão · prestador:read · perfil prestador

Catálogo de serviços do tenant.

Response

200
{ "data": [{ "id": "cl...", "name": "Diária de trator", "pricing_type": "day", "unit_price": 450 }], "meta": { "total": 1 } }
POST/api/v1/servicesSessão · prestador:write · perfil prestador

Cadastra tipo de serviço.

Request

{ "name": "Diária de trator", "pricing_type": "day", "unit_price": 450 }

Response

201
{ "data": { "id": "cl...", "name": "Diária de trator", "unit_price": 450 } }
PATCH/api/v1/services/:idSessão · prestador:write · perfil prestador

Edita valor/nome/tipo. Não afeta ordens já registradas (o valor é gravado na ordem no momento da criação).

Request

{ "unit_price": 480 }

Response

200
{ "data": { "id": "cl...", "unit_price": 480 } }
GET/api/v1/service-ordersSessão · prestador:read · perfil prestador

Lista ordens. Filtros: status, service_client_id.

Response

200
{ "data": [{ "id": "cl...", "client_name": "Sítio Esperança", "service_name": "Diária de trator", "total_value": 450, "status": "completed" }], "meta": { "total": 1 } }
POST/api/v1/service-ordersSessão · prestador:write · perfil prestador

Registra ordem. total_value = quantity × unit_price do serviço. Status inicial: scheduled (data futura) ou completed (hoje/passado).

Request

{ "service_client_id": "cl...", "service_id": "cl...", "quantity": 1, "performed_at": "2026-07-08T00:00:00.000Z" }

Response

201
{ "data": { "id": "cl...", "total_value": 450, "status": "completed" } }
PATCH/api/v1/service-orders/:id/statusSessão · prestador:write · perfil prestador

Transição sequencial estrita scheduled → completed → invoiced. Ao chegar em invoiced, gera FinancialEntry de receita (pending, due_date = performed_at).

Request

{ "status": "invoiced" }

Response

200
{ "data": { "id": "cl...", "status": "invoiced" } }

Financeiro

GET/api/v1/financial-entriesSessão · financeiro:read

Lista lançamentos. Filtros: start, end (sobre due_date), entry_type, category, related_module, status, area (fazenda | emporio | tudo, padrão tudo; emporio é o lançamento com establishment_id; inválido: 422 `area`).

Response

200
{ "data": [{ "id": "cl...", "entry_type": "expense", "category": "Insumo (fertilizer) - NPK", "amount": 1800, "status": "pending" }], "meta": { "total": 1 } }
POST/api/v1/financial-entriesSessão · financeiro:write

Lançamento manual (related_module sempre "geral": lançamentos de outros módulos são criados automaticamente pelas próprias ações daquele módulo).

Request

{ "entry_type": "expense", "category": "Combustível", "amount": 350, "due_date": "2026-07-15T00:00:00.000Z", "notes": null }

Response

201
{ "data": { "id": "cl...", "entry_type": "expense", "amount": 350, "status": "pending" } }
PATCH/api/v1/financial-entries/:idSessão · financeiro:write

Edita lançamento manual.

Request

{ "amount": 380 }

Response

200
{ "data": { "id": "cl...", "amount": 380 } }
PATCH/api/v1/financial-entries/:id/paySessão · financeiro:write

Quita a conta de uma vez. Desde o Módulo 35 isso registra um pagamento do SALDO restante: uma conta de R$ 10.000 com R$ 4.000 já pagos é quitada com um pagamento de R$ 6.000.

Request

{ "paid_at": "2026-07-10T00:00:00.000Z" }

Response

200
{ "data": { "id": "cl...", "status": "paid", "paid_at": "..." } }
GET/api/v1/financial-entries/:id/paymentsSessão · financeiro:read

Os pagamentos da conta, do mais recente para o mais antigo. `meta` traz o derivado: valor, pago, saldo e situacao (em_aberto, parcialmente_paga, paga, cancelada). O valor pago é sempre a SOMA dos pagamentos, nunca um campo gravado.

Response

200
{ "data": [{ "id": "cl...", "amount": 4000, "paid_at": "...", "method": "pix" }], "meta": { "total": 1, "valor": 10000, "pago": 4000, "saldo": 6000, "situacao": "parcialmente_paga" } }
POST/api/v1/financial-entries/:id/paymentsSessão · financeiro:write

Registra um pagamento (ou recebimento) parcial, §13 e §14. Recusa com 422 e `field: "amount"` quando o valor estoura o saldo, e com ENTRY_CANCELLED quando a conta está cancelada. `method` é opcional: quem paga metade no PIX e metade em dinheiro registra dois pagamentos.

Request

{ "amount": 4000, "paid_at": "2026-09-10T00:00:00.000Z", "method": "pix", "notes": "primeira parcela" }

Response

201
{ "data": { "id": "cl..." }, "meta": { "valor": 10000, "pago": 4000, "saldo": 6000, "situacao": "parcialmente_paga" } }
DELETE/api/v1/financial-entries/:id/payments/:paymentIdSessão · financeiro:write

Desfaz um pagamento registrado por engano. A linha é apagada, e a conta volta a dever o que voltou a faltar: sai de 'paga' se deixar de estar coberta. O histórico do LANÇAMENTO continua intacto.

Response

200
{ "data": { "entry_id": "cl..." }, "meta": { "valor": 10000, "pago": 0, "saldo": 10000, "situacao": "em_aberto" } }
PATCH/api/v1/financial-entries/:id/postponeSessão · financeiro:write

Adia o vencimento (Módulo 28). Só para lançamento pending; sem restrição de origem (related_module).

Request

{ "due_date": "2026-08-20T00:00:00.000Z" }

Response

200
{ "data": { "id": "cl...", "due_date": "..." } }
PATCH/api/v1/financial-entries/:id/cancelSessão · financeiro:write

Cancela um lançamento (Módulo 28). Sem restrição de origem nem de status atual; só muda status para "cancelled", não apaga.

Response

200
{ "data": { "id": "cl...", "status": "cancelled" } }
GET/api/v1/financial-categoriesSessão · financeiro:read

Lista categorias de receita/despesa do tenant (Módulo 28). Filtro opcional: entry_type.

Response

200
{ "data": [{ "id": "cl...", "name": "Ração", "entry_type": "expense", "active": true }], "meta": { "total": 1 } }
POST/api/v1/financial-categoriesSessão · financeiro:write

Cria uma categoria.

Request

{ "name": "Frete", "entry_type": "expense" }

Response

201
{ "data": { "id": "cl...", "name": "Frete", "entry_type": "expense", "active": true }, "meta": {} }
PATCH/api/v1/financial-categories/:idSessão · financeiro:write

Renomeia ou ativa/desativa uma categoria.

Request

{ "active": false }

Response

200
{ "data": { "id": "cl...", "name": "Frete", "active": false }, "meta": {} }
GET/api/v1/alert-preferencesSessão · alertas:read

Lista os 7 tipos de alerta com seu estado atual (Módulo 28). Ausência de preferência gravada = habilitado.

Response

200
{ "data": [{ "alert_type": "harvest_near", "enabled": true }], "meta": { "total": 7 } }
PATCH/api/v1/alert-preferencesSessão · alertas:write

Liga/desliga um tipo de alerta pro tenant inteiro. Nunca mexe em canal (push/WhatsApp/email): isso continua sendo decisão do notify() (Onda 2).

Request

{ "alert_type": "harvest_near", "enabled": false }

Response

200
{ "data": { "alert_type": "harvest_near", "enabled": false }, "meta": {} }
GET/api/v1/financial/cash-flowSessão · financeiro:read

Saldo por período (regime de caixa), agrupado por dia ou mês. Query: start, end, group_by=day|month, related_module, area (fazenda | emporio | tudo, padrão tudo; 422 `area`).

Response

200
{ "data": [{ "period": "2026-06", "income": 8000, "expense": 5200, "balance": 2800 }], "meta": { "group_by": "month" } }
GET/api/v1/financial/dreSessão · financeiro:read

Receita e despesa agrupadas por módulo, com resultado por módulo e total geral. Query: start, end, area (fazenda | emporio | tudo, padrão tudo; 422 `area`).

Response

200
{ "data": { "period": { "start": "2026-06-01", "end": "2026-06-30" }, "by_module": [{ "module": "rebanho", "total_income": 4500, "total_expense": 245, "result": 4255 }], "total_result": 4255 } }
GET/api/v1/financial/upcomingSessão · financeiro:read

Lançamentos pending com due_date nos próximos 7 dias. Query: area (fazenda | emporio | tudo, padrão tudo; 422 `area`).

Response

200
{ "data": [{ "id": "cl...", "entry_type": "expense", "category": "Combustível", "amount": 350, "due_date": "...", "related_module": "geral" }] }
GET/api/v1/financial/reportPúblico: autorização pelo token assinado, não por sessão (o link pode ser aberto fora do navegador logado, ex: WhatsApp)

Gera o PDF sob demanda a partir do token e transmite direto na resposta (Content-Type: application/pdf). Não há armazenamento: o arquivo não fica salvo em nenhum lugar.

Response

200: corpo binário (application/pdf), Content-Disposition: attachment
401 se o token for inválido ou tiver expirado

Minha Lista de Compra (Módulo 36)

Anotar um item NÃO é uma compra: nada aqui mexe em estoque ou financeiro. A compra só acontece por /purchase, que passa por Negociações.

GET/api/v1/shopping-itemsSessão · rebanho:read · perfil fazenda ou emporio

A lista. Sem ?status=, devolve os PENDENTES, urgentes primeiro. Filtros: status=pendente|comprado|removido, property_id, establishment_id, priority, place, purpose. meta.units traz as unidades para o seletor.

Response

200
{ "data": [{ "id": "cl...", "description": "Sal mineral", "quantity": 10, "unit": "saca", "priority": "urgente", "status": "pendente" }], "meta": { "total": 1, "units": [] } }
POST/api/v1/shopping-itemsSessão · rebanho:write · perfil fazenda ou emporio

Anota um item. Só description é obrigatória. `establishment_id` (opcional) liga o item a uma loja do Empório visível ao usuário (404 no campo se não for); junto com `property_id`, 422 no campo `establishment_id`. Item parecido já pendente é recusado com ITEM_JA_NA_LISTA (409) para que a pergunta do §19.7 possa ser feita; repetir com permitir_duplicata: true adiciona assim mesmo.

Request

{ "description": "Sal mineral", "quantity": 10, "unit": "saca", "priority": "urgente" }

Response

201
{ "data": { "id": "cl...", "description": "Sal mineral", "quantity": 10, "unit": "saca", "priority": "urgente", "status": "pendente", "negotiation_id": null }, "meta": {} }
PATCH/api/v1/shopping-items/:idSessão · rebanho:write · perfil fazenda ou emporio

Sem acao, edita os campos que vierem (a quantidade pode chegar depois). acao=concluir tira da lista sem gerar nada. acao=repetir cria um item novo igual, sem tocar no antigo. Item que já saiu da lista não é editável (NOT_EDITABLE).

Request

{ "acao": "concluir" }

Response

200
{ "data": { "id": "cl...", "description": "Sal mineral", "status": "comprado", "priority": "urgente" }, "meta": {} }
DELETE/api/v1/shopping-items/:idSessão · rebanho:write · perfil fazenda ou emporio

Remove da lista. NÃO apaga a linha: o item vira status removido e continua no histórico.

Response

200
{ "data": { "id": "cl...", "description": "Sal mineral", "status": "removido", "priority": "normal" }, "meta": {} }
POST/api/v1/shopping-items/:id/purchaseSessão · rebanho:write · perfil fazenda (ou emporio, quando há loja)

Com `establishment_id` (no corpo ou já no item) o item vira uma compra simples do Empório: à vista, recebida, um item, `operation_key` derivada do item (repetir não duplica); exige produto que esteja no Empório e a quantidade é lida na unidade-base; a resposta traz `purchase_id` e `negotiation_id: null`. Sem loja, o caminho da fazenda: o item vira compra de verdade: despesa, conta a pagar quando não foi à vista, e entrada no estoque, tudo por Negociações (type: compra_produto). Produto, quantidade e fazenda saem do item quando já estiverem lá. Item sem produto é recusado com PRODUTO_NECESSARIO no campo product_id: repita com product_id de um produto existente, ou com novo_produto para cadastrar na hora. A conclusão do item entra na MESMA transação da compra.

Request

{ "amount": 1800, "pago": true, "novo_produto": { "unit": "saca", "category_id": "cl..." } }

Response

201
{ "data": { "item_id": "cl...", "negotiation_id": "cl...", "product_id": "cl..." }, "meta": {} }

Alertas

GET/api/v1/alertsSessão · alertas:read

Lista alertas. Filtros: type, status.

Response

200
{ "data": [{ "id": "cl...", "alert_type": "vaccine_due", "message": "🐄 Atenção: a vacina de aftosa do animal 1234 vence em 3 dias", "status": "pending" }], "meta": { "total": 1 } }
POST/api/v1/alerts/:id/shopping-itemSessão · rebanho:write · perfil fazenda

Manda o produto de um alerta low_stock para a Lista de Compra (Módulo 36, §13), com unidade e categoria já preenchidas, e dispensa o alerta. Nunca roda sozinho: o sistema não adiciona sem confirmação. Produto já pendente na lista devolve ITEM_JA_NA_LISTA (409); repita com permitir_duplicata: true.

Request

{ "permitir_duplicata": false }

Response

201
{ "data": { "id": "cl...", "description": "Sal mineral", "status": "pendente", "priority": "normal" }, "meta": {} }
PATCH/api/v1/alerts/:id/dismissSessão · alertas:write

Marca um alerta como resolvido manualmente.

Response

200
{ "data": { "id": "cl...", "status": "dismissed" } }

Notificações (push web)

Push é por PESSOA: um alerta vai para os aparelhos de quem o recebe, nunca para os de outra pessoa do mesmo tenant, e é o resultado DESSA pessoa que decide se o WhatsApp entra como reserva. A inscrição guarda o endpoint do navegador porque uma pessoa pode ter vários aparelhos. Inscrição que o serviço de push reporta morta (404/410) some na hora; três falhas seguidas de outro tipo também.

GET/api/v1/notifications/public-keySessão

Devolve a chave pública VAPID que o navegador precisa para criar a inscrição. Quando o push não está configurado no ambiente (VAPID ausente), responde 200 com `vapid_public_key: null`: ausência não é erro, e o cliente simplesmente não oferece o push.

Response

200
{ "data": { "vapid_public_key": "BEl62i..." }, "meta": {} }
GET/api/v1/notifications/subscribeSessão

Se o servidor conhece este aparelho (`?endpoint=`) para a pessoa da sessão. A tela de alertas usa para não dizer "ativas neste navegador" sobre uma inscrição que o servidor já podou.

Response

200
{ "data": { "subscribed": true }, "meta": {} }
POST/api/v1/notifications/subscribeSessão

Registra o aparelho para receber push. Idempotente por endpoint: reinscrever o mesmo navegador atualiza a inscrição existente em vez de duplicar.

Request

{ "endpoint": "https://fcm.googleapis.com/fcm/send/...", "keys": { "p256dh": "...", "auth": "..." } }

Response

201
{ "data": { "subscribed": true }, "meta": {} }
DELETE/api/v1/notifications/subscribeSessão

Cancela a inscrição daquele endpoint (o usuário desliga a notificação no aparelho). `deleted` diz quantas linhas saíram: 0 quando o endpoint não é desta pessoa, e aí `unsubscribed` é false.

Request

{ "endpoint": "https://fcm.googleapis.com/fcm/send/..." }

Response

200
{ "data": { "unsubscribed": true, "deleted": 1 }, "meta": {} }

Preferências do tenant

POST/api/v1/tenant/active-propertySessão · rebanho:read

Define a propriedade ativa do seletor do topo, que filtra o painel inteiro. Guardada em cookie, não no banco: qual propriedade estou olhando agora é preferência de sessão, não dado de negócio. `property_id: null` limpa o filtro (volta para "todas").

Request

{ "property_id": "cl..." }

Response

200
{ "data": { "property_id": "cl..." }, "meta": {} }
POST/api/v1/tenant/planSessão (sem guard de propósito)

Confirma o plano escolhido em /escolher-plano, marcando `plan_confirmed`. Não passa por guard() de propósito: o guard exige plano confirmado, então quem ainda não confirmou nunca conseguiria confirmar (deadlock).

Request

{ "plan": "fazenda" }

Response

200
{ "data": { "plan": "fazenda" }, "meta": {} }

Cobrança (Asaas)

GET/api/v1/billing/subscriptionSessão · assinatura:read (acessível mesmo com a conta bloqueada)

Plano e status atuais: de Subscription se existir, senão inferido do Tenant (trial).

Response

200
{ "data": { "plan": "fazenda", "status": "active", "next_due_date": "2026-08-10T00:00:00.000Z", "trial_ends_at": null } }
POST/api/v1/billing/subscribeSessão · assinatura:write, só OWNER (acessível mesmo com a conta bloqueada: é como o tenant regulariza)

Cria ou troca a assinatura no Asaas. PIX e boleto retornam os dados para pagamento direto no painel; cartão de crédito retorna uma URL de redirecionamento para o checkout hospedado do Asaas (dados de cartão nunca tocam o backend do Tibé).

Request

{ "plan": "fazenda", "billing_type": "PIX" }

Response

201 (PIX)
{ "data": { "method": "pix", "subscriptionId": "sub_...", "payload": "00020126...", "encodedImage": "data:image/png;base64,...", "expirationDate": "..." } }

201 (cartão)
{ "data": { "method": "redirect", "subscriptionId": "sub_...", "redirectUrl": "https://sandbox.asaas.com/checkoutSession/..." } }
POST/api/v1/billing/cancelSessão · assinatura:write, só OWNER (acessível mesmo com a conta bloqueada, pelo mesmo motivo de /subscribe)

Cancela a assinatura no Asaas, marca `canceled` e grava `canceled_at`, registrando a transição em SubscriptionStatusLog. O acesso NÃO é bloqueado na hora: segue total até o fim do período pago (next_due_date), depois vira leitura por 60 dias, e só então bloqueia (getCancellationWindow em billing-access.ts). Quem cancela já vencido não tem período pago a honrar, e a janela de 60 dias começa no próprio cancelamento. Devolve 404 quando não há assinatura no Asaas para cancelar.

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/webhooks/asaasHeader asaas-access-token (comparado contra ASAAS_WEBHOOK_TOKEN, cadastrado no painel do Asaas)

Recebe eventos de pagamento. PAYMENT_CONFIRMED → Subscription ativa + next_due_date atualizado. PAYMENT_OVERDUE → status overdue. PAYMENT_DELETED → status canceled. Outros eventos são reconhecidos (200) sem processamento.

Request

{ "event": "PAYMENT_CONFIRMED", "payment": { "subscription": "sub_...", "customer": "cus_...", "value": 197.00, "status": "CONFIRMED" } }

Response

200
{ "data": { "received": true, "processed": true } }

Agente WhatsApp (rotas internas)

Chamadas pelo N8N, não pelo navegador: autenticadas por header x-internal-secret (INTERNAL_API_SECRET), não por sessão de usuário.

POST/api/internal/whatsapp/resolve-contactHeader x-internal-secret

Identifica tenant e usuário a partir do telefone de origem. Único endpoint que legitimamente faz lookup cross-tenant (ainda não se sabe o tenant). Cria o WhatsAppContact no primeiro contato.

Request

{ "phone": "5522999990000" }

Response

200 (identificado)
{ "data": { "identified": true, "tenant_id": "cl...", "user_id": "cl...", "user_name": "Maria", "role": "OWNER", "active_profiles": ["fazenda"] }, "meta": { "first_contact": false, "suggested_reply": null, "recent_history": [] } }

200 (não identificado)
{ "data": { "identified": false } }
POST/api/internal/whatsapp/execute-actionHeader x-internal-secret

Executa a intenção já classificada pelo LLM, roteando para as mesmas lib/actions/* usadas pelo painel web. Loga a interação em AgentConversationLog. Desde a Fase 2 do agente, com a mesma resposta: por dentro grava o cursor da conversa e confere o usuário (ativo, no tenant) antes do replay.

Request

{ "tenant_id": "cl...", "user_id": "cl...", "intent": "registrar_peso", "parameters": { "ear_tag": "1234", "weight": 382 }, "message_text": "o boi 1234 pesou 382" }

Response

200
{ "data": { "reply_text": "Peso de 382 kg registrado para o animal 1234. GMD: 0.65 kg/dia.", "requires_confirmation": false, "auxiliary_data": {}, "report_url": null } }
POST/api/internal/whatsapp/turnoHeader x-internal-secret

Turno inteiro do agente (Fase 2): recebe a mensagem consolidada, identifica o contato pelo telefone, entende a mensagem no Tibé (cursor da conversa quando há pergunta aberta, senão classificação em duas etapas por LLM), executa cada pedido pelo mesmo núcleo do execute-action e devolve as mensagens a enviar. `pode_humanizar` só é true para texto sem número, sem confirmação pendente e sem pergunta. `texto` só pode vir vazio com `recibo` (lido por imagem no n8n), que vai direto ao lançamento financeiro, sem classificar. Idempotente por `provider_message_id`: o reenvio devolve as mesmas mensagens com `replay: true`. Falha do modelo ou erro interno devolve as respostas já produzidas seguidas de uma frase fixa, sem registrar o turno: antes de executar qualquer pedido, a frase pede para mandar de novo; depois, avisa que parte pode já ter sido registrada e pede para conferir antes.

Request

{ "telefone": "5522999990000", "texto": "quantos animais eu tenho e o que tenho a pagar", "provider_message_id": "wamid.HBgM...", "recibo": null }

Response

200
{ "data": { "mensagens": [{ "texto": "Seu rebanho possui atualmente 23 animais.\nFêmeas: 3 | Machos: 20", "pode_humanizar": false, "report_url": null }, { "texto": "Nenhuma conta a pagar no período.", "pode_humanizar": true, "report_url": null }], "replay": false }, "meta": {} }
POST/api/internal/whatsapp/send-messageHeader x-internal-secret

Envia uma mensagem WhatsApp pelo provider ativo (Evolution ou Meta Cloud API, configurado no painel da plataforma). O N8N usa esta rota em vez de falar com o provider diretamente.

Request

{ "to": "+5511999990000", "text": "Peso registrado com sucesso." }

Response

200
{ "data": { "provider": "evolution", "message_id": "BAE5..." }, "meta": {} }
POST/api/internal/whatsapp/bufferHeader x-internal-secret

Junta mensagens picotadas numa só. Duas operações no mesmo endpoint, chamadas pelo n8n em volta de uma espera: `append` guarda o fragmento e devolve o token daquela execução; `flush` devolve o texto concatenado apenas se o token ainda for o último. Quando não for, responde `ready: false` e aquela execução do n8n deve encerrar sem responder nada, evitando que duas respostas saiam para a mesma pessoa.

Request

{ "op": "append", "phone": "5522999990000", "message_text": "cadastra 20" }

{ "op": "flush", "phone": "5522999990000", "token": 3 }

Response

200 (append)
{ "data": { "token": 3 }, "meta": {} }

200 (flush, este é o último)
{ "data": { "ready": true, "message_text": "cadastra 20 bezerros" }, "meta": {} }

200 (flush, chegou mensagem mais nova)
{ "data": { "ready": false }, "meta": {} }
POST/api/internal/whatsapp/fetch-mediaHeader x-internal-secret

Busca áudio, imagem ou documento decriptado sob demanda, pelo id da mensagem. Existe porque `webhookBase64: true` da Evolution não é confiável em produção (o campo simplesmente não vem no webhook, mesmo configurado), descoberto testando com áudio real. O n8n chama isto antes de transcrever ou extrair. Só suporta Evolution: a Meta Cloud API teria outro mecanismo de download, não implementado.

Request

{ "message_id": "BAE5F1..." }

Response

200
{ "data": { "base64": "SUQzBAAAA...", "mimetype": "audio/ogg" }, "meta": {} }
POST/api/internal/whatsapp/pending-flowsHeader x-internal-secret

Lembra quem abandonou um cadastro assistido no meio e limpa os fluxos vencidos. Chamado por um agendador do n8n a cada 15 minutos, e não pela Vercel Cron, que roda 1x/dia: lembrar no dia seguinte de um cadastro largado às 14h não ajuda ninguém. O n8n não decide nada, só acorda o Tibé; quem escolhe o destinatário, monta o texto e envia é esta rota. Varre todos os tenants ativos, por natureza.

Response

200
{ "data": { "sent": 2, "failed": 0, "expired_flows_purged": 1 }, "meta": { "tenants": 4 } }

Jobs internos

GET/api/internal/jobs/generate-alertsHeader Authorization: Bearer (CRON_SECRET, injetado automaticamente pela Vercel Cron: ver vercel.json)

Roda 1x/dia (09:00 UTC = 06:00 América/São Paulo). Gera alertas de vacina, colheita, conta a vencer, saldo negativo e trial acabando; dispara envio via WhatsApp. Varre também cadastros públicos expirados e a janela de arquivamento de quem cancelou (marca Tenant.archived_at, sem apagar nada). Idempotente por dia (lock no Redis) e por evento (não duplica Alert).

Response

200
{ "data": { "vaccine_due": 2, "harvest_near": 0, "bill_due": 1, "low_balance": 0, "trial_ending": 1, "sent": 3 }, "meta": { "date": "2026-07-10" } }
GET/api/internal/jobs/daily-digestHeader x-internal-secret

Resumo diário do que importa no dia (contas a vencer, vacinas, tarefas), entregue por push quando o tenant tem aparelho inscrito e por WhatsApp quando não tem. Disparado 1x/dia pelo n8n, e não pela Vercel Cron, para não depender de um segundo slot de cron no plano da Vercel. Idempotente por dia com lock no Redis, em chave própria (separada da chave do job de alertas): responde `skipped: true` na segunda chamada do mesmo dia. Nunca sai por email, por decisão de produto: resumo diário por email todo dia é ruído.

Response

200
{ "data": { "sent": 3, "skipped": 1 }, "meta": { "date": "2026-08-04" } }

200 (2ª chamada no mesmo dia)
{ "data": { "skipped": true, "reason": "já executado hoje" }, "meta": { "date": "2026-08-04" } }

Painel da Plataforma (Módulo 6)

Namespace /api/platform/*, fora de /api/v1: autenticado por uma sessão de PlatformUser (cookie tibe-platform-session), nunca por sessão de tenant. "equipe" lê tenants; só master_admin vê KPIs financeiros e executa ações administrativas.

GET/api/platform/tenantsSessão de PlatformUser (equipe ou master_admin)

Lista todos os tenants do sistema, com status calculado (trial se não há Subscription, senão o status da Subscription), plano, perfis ativos. Filtros: status, plan, q (nome/documento), page, limit.

Response

200
{ "data": [{ "id": "cl...", "name": "Fazenda Boa Vista", "plan": "fazenda", "status": "active", "active_profiles": ["fazenda"], "created_at": "...", "subscription_status": "active", "next_due_date": "..." }], "meta": { "total": 1, "page": 1, "limit": 20 } }
GET/api/platform/tenants/:idSessão de PlatformUser

Detalhe completo: dados cadastrais, origem (UTM), histórico de transições de assinatura (SubscriptionStatusLog) e resumo de uso (animais/talhões/ordens conforme perfis ativos). Lookup cross-tenant explícito: a exceção que dá nome ao módulo.

Response

200
{ "data": { "id": "cl...", "name": "...", "status": "active", "subscription": { "status": "active", "history": [{ "from_status": "overdue", "to_status": "active", "changed_by_platform_user_id": null, "created_at": "..." }] }, "usage": { "animals": 40, "plots": 3, "service_orders": 0 } } }
PATCH/api/platform/tenants/:id/statusSessão de PlatformUser · só master_admin

Força manualmente o status da assinatura (ex: reativar um tenant suspenso por erro). Exige uma Subscription existente (404 se o tenant nunca assinou). Grava em SubscriptionStatusLog com o PlatformUser responsável e o motivo: é o próprio log de auditoria.

Request

{ "status": "active", "reason": "reativado manualmente, erro no processamento do Asaas" }

Response

200
{ "data": { "id": "cl...", "status": "active" } }
POST/api/platform/tenantsSessão de PlatformUser · só master_admin

Cria um tenant manualmente, para dar acesso de teste a uma equipe de cliente sem passar pelo cadastro público. Reusa a mesma lógica do cadastro (trial de 14 dias, checagem de documento e email duplicados), mas GERA uma senha temporária em vez de receber uma, e marca `must_change_password`: o usuário é obrigado a trocá-la antes de acessar qualquer outra coisa. Dispara a mensagem de boas-vindas por WhatsApp e email, em melhor esforço (falha no envio não impede a criação).

Request

{ "company_name": "Fazenda Santa Helena", "document": "12345678000199", "phone": "22999990000", "owner_name": "João", "owner_email": "joao@santahelena.com.br" }

Response

201
{ "data": { "tenant_id": "cl...", "email": "joao@santahelena.com.br", "temp_password": "Xy9k2Qmz" }, "meta": {} }
PATCH/api/platform/tenants/:idSessão de PlatformUser · só master_admin

Corrige os dados cadastrais do tenant (razão social, documento, telefone, email de contato e plano). Todos os campos são opcionais: só o que vier é alterado.

Request

{ "name": "Fazenda Santa Helena LTDA", "plan": "grupo" }

Response

200
{ "data": { "id": "cl...", "status": "active" }, "meta": {} }
PATCH/api/platform/tenants/:id/owner-emailSessão de PlatformUser · só master_admin

Troca o email de login do OWNER do tenant, para quando o cliente errou o endereço no cadastro e não consegue nem entrar nem recuperar a senha. Recusa se o email novo já pertencer a outro usuário (`User.email` é único globalmente).

Request

{ "email": "joao.novo@santahelena.com.br" }

Response

200
{ "data": { "id": "cl..." }, "meta": {} }
POST/api/platform/tenants/:id/archiveSessão de PlatformUser · só master_admin

Arquiva ou desarquiva o tenant. Arquivar não apaga nada: tira o tenant das listas e dos KPIs sem perder histórico. `archived: false` reverte.

Request

{ "archived": true }

Response

200
{ "data": { "id": "cl...", "archived_at": "2026-08-04T12:00:00.000Z" }, "meta": {} }
GET/api/platform/tenants/:id/featuresSessão de PlatformUser · equipe e master_admin

Recursos em rollout da conta (Módulo 39): uma entrada por recurso conhecido. Recurso nunca ligado vem `enabled: false` e `user_ids: []`.

Response

200
{ "data": [{ "feature_key": "emporio", "enabled": true, "user_ids": [] }], "meta": {} }
PUT/api/platform/tenants/:id/features/:keySessão de PlatformUser · só master_admin

Liga ou desliga um recurso da conta. `user_ids` (opcional) restringe a quem da conta vê; vazio ou ausente libera a conta inteira, e a lista enviada substitui a anterior. Ligar `emporio` também ativa o perfil `emporio`; desligar só o desativa, sem apagar dado. 404 `NOT_FOUND` para chave ou conta desconhecida, 422 em `user_ids` quando algum usuário não é da conta.

Request

{ "enabled": true, "user_ids": ["cl..."] }

Response

200
{ "data": { "feature_key": "emporio", "enabled": true, "user_ids": ["cl..."] }, "meta": {} }
POST/api/platform/tenants/:id/welcome-messageSessão de PlatformUser · só master_admin

Reenvia as boas-vindas com uma credencial que funciona. GERA uma senha temporária nova e remarca `must_change_password`, em vez de repetir a antiga: a senha original em claro não é recuperável (só o hash é salvo), então reenviar a mesma mensagem mandaria uma senha que o usuário talvez já tenha trocado. Exige `Tenant.phone`: falha inteira sem telefone, sem tentar só o email, porque o propósito da ação é reenviar pelo WhatsApp.

Response

200
{ "data": { "sent": true }, "meta": {} }
GET/api/platform/kpis/mrrSessão de PlatformUser · só master_admin

MRR atual (soma de PLAN_PRICES das assinaturas active) e breakdown por plano.

Response

200
{ "data": { "total_mrr": 691, "by_plan": { "campo": 97, "fazenda": 197, "grupo": 397 }, "active_subscriptions_count": 3 } }
GET/api/platform/kpis/mrr-trendSessão de PlatformUser · só master_admin

Evolução de MRR nos últimos N meses (?months=6, padrão), reconstruída a partir de SubscriptionStatusLog: não é aproximação, é o status real de cada assinatura em cada checkpoint mensal.

Response

200
{ "data": [{ "period": "2026-02", "mrr": 394 }, { "period": "2026-03", "mrr": 591 }] }
GET/api/platform/kpis/churnSessão de PlatformUser · só master_admin

Churn de clientes e de MRR no período (?period=30d|90d|12m). customer_churn = cancelados no período / ativos no início do período; mrr_churn = MRR perdido / MRR no início do período.

Response

200
{ "data": { "period": "30d", "customer_churn_pct": 5.5, "mrr_churn_pct": 4.2, "canceled_count": 1 } }
GET/api/platform/kpis/ltvSessão de PlatformUser · só master_admin

LTV simplificado: ticket médio mensal / churn mensal (30d). Devolve ltv: null (não Infinity) quando não há churn observado ainda: divisão por zero evitada.

Response

200
{ "data": { "ltv": 1763.6, "avg_ticket_mensal": 230.3, "churn_mensal_pct": 13.06 } }
GET/api/platform/kpis/funnelSessão de PlatformUser · só master_admin

Funil de conversão trial → pago no período (?period=30d|90d|12m), com breakdown por lead_source_utm_source (null agrupa em "sem origem"/acesso direto) e tempo médio de conversão em dias.

Response

200
{ "data": { "period": "30d", "trials_created": 12, "converted_to_paid": 4, "conversion_rate_pct": 33.33, "avg_days_to_convert": 3.5, "by_source": [{ "utm_source": "instagram", "trials_created": 5, "converted": 2, "conversion_rate_pct": 40 }, { "utm_source": null, "trials_created": 7, "converted": 2, "conversion_rate_pct": 28.57 }] } }
GET/api/platform/teamSessão de PlatformUser · só master_admin

Lista a equipe da plataforma (PlatformUser).

Response

200
{ "data": [{ "id": "cl...", "name": "...", "email": "...", "role": "EQUIPE", "active": true }], "meta": { "total": 1 } }
POST/api/platform/teamSessão de PlatformUser · só master_admin

Convida novo membro: senha temporária exibida uma única vez na resposta (sem infra de email no projeto, mesmo padrão do convite de usuário de tenant).

Request

{ "name": "Novo Membro", "email": "membro@pleno.dev.br", "role": "EQUIPE" }

Response

201
{ "data": { "id": "cl...", "temp_password": "Xy9k2Qmz" } }
PATCH/api/platform/team/:id/roleSessão de PlatformUser · só master_admin

Altera o papel de um membro. Não é possível alterar o próprio papel.

Request

{ "role": "MASTER_ADMIN" }

Response

200
{ "data": { "id": "cl...", "role": "MASTER_ADMIN" } }
PATCH/api/platform/team/:id/activeSessão de PlatformUser · só master_admin

Ativa ou desativa um membro. Não é possível desativar a própria conta.

Request

{ "active": false }

Response

200
{ "data": { "id": "cl...", "active": false } }
GET/api/platform/whatsapp-configSessão de PlatformUser · só master_admin

Lista as configs de provider (credenciais sempre mascaradas: últimos 4 caracteres).

Response

200
{ "data": [ { "provider": "evolution", "active": true, "credentials_masked": { "api_key": "•••• 9876" }, "updated_at": "2026-07-11T12:00:00.000Z" } ], "meta": {} }
PUT/api/platform/whatsapp-configSessão de PlatformUser · só master_admin

Cria/atualiza as credenciais de um provider (criptografadas em repouso). Não altera qual está ativo.

Request

{ "provider": "evolution", "credentials": { "base_url": "https://evo.up.railway.app", "api_key": "...", "instance": "tibe" } }

Response

200
{ "data": { "provider": "evolution" }, "meta": {} }
POST/api/platform/whatsapp-config/:provider/activateSessão de PlatformUser · só master_admin

Ativa o provider (e desativa o outro, transacional). 404 se ainda não configurado.

Response

200
{ "data": { "provider": "meta_cloud_api" }, "meta": {} }
POST/api/platform/whatsapp-config/evolution/connectSessão de PlatformUser · só master_admin

Cria (ou reusa) a instância na Evolution API e devolve o QR Code para parear o número lendo pelo celular. Configura o webhook de entrada no mesmo passo; `webhook_configured: false` indica que o pareamento pode funcionar mas as mensagens recebidas não chegarão ao n8n. Host inalcançável não derruba a rota: devolve `qrcode: null` em vez de erro, porque a Evolution é infraestrutura externa e instável por natureza.

Response

200
{ "data": { "instance": "tibe", "qrcode": "data:image/png;base64,...", "webhook_configured": true }, "meta": {} }
GET/api/platform/whatsapp-config/evolution/statusSessão de PlatformUser · só master_admin

Estado do pareamento da instância Evolution, usado pela tela para saber se o número já está conectado ou se ainda espera a leitura do QR.

Response

200
{ "data": { "instance": "tibe", "state": "open", "connected": true }, "meta": {} }