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.
/api/v1/signup/startPúblicoEtapa 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": {} }/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": {} }/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": {} }/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": {} }/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": {} }/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": {} }/api/v1/tenant/profilesSessão · OWNER/ADMINAtiva (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": {} }/api/v1/tenantSessão · qualquer papelDados 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": {} }/api/v1/tenantSessão · usuarios:writeEdita 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.
/api/v1/password-reset/requestPúblicoPede 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": {} }/api/v1/password-reset/verifyPúblicoValida 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": {} }/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": {} }/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": {} }/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": {} }/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": {} }/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
/api/v1/usersSessão · usuarios:readLista 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 } } }/api/v1/usersSessão · usuarios:writeConvida 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": {} }/api/v1/users/:id/roleSessão · usuarios:writeAltera 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": {} }/api/v1/users/:id/activeSessão · usuarios:writeAtiva 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).
/api/v1/propertiesSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/propertiesSessão · rebanho:write · perfil fazendaCadastra 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 } }/api/v1/properties/:idSessão · rebanho:read · perfil fazendaDetalhe da propriedade.
Response
200
{ "data": { "id": "cl...", "name": "Sede", "address": "...", "city": "Montes Claros", "district": null, "area_hectares": 120.5 } }/api/v1/properties/:idSessão · rebanho:write · perfil fazendaEdita propriedade. Todos os campos opcionais.
Request
{ "name": "Sede Nova", "city": "Montes Claros" }Response
200
{ "data": { "id": "cl...", "name": "Sede Nova" } }/api/v1/properties/:id/archiveSessão · rebanho:write · perfil fazendaArquiva 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" } }/api/v1/emporioSessão · emporio:read · recurso `emporio` liberado pela PlataformaPorta 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": {} }/api/v1/emporio/establishmentsSessão · emporio:read · perfil emporio · recurso `emporio` liberadoLista 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": {} }/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": {} }/api/v1/emporio/establishments/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberadoUm 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": {} }/api/v1/emporio/establishments/:idSessão · emporio:write · só OWNER e ADMINEdita 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": {} }/api/v1/emporio/establishments/:id/archiveSessão · emporio:write · só OWNER e ADMINArquiva (`archived: true`) ou desarquiva (`false`). Nunca apaga.
Request
{ "archived": true }Response
200
{ "data": { "id": "cl...", "archived_at": "2026-10-27T12:00:00.000Z" }, "meta": {} }/api/v1/emporio/establishments/:id/usersSessão · emporio:write · só OWNER e ADMINSubstitui 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": {} }/api/v1/emporio/productsSessão · emporio:read · perfil emporio · recurso `emporio` liberadoLista 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": {} }/api/v1/emporio/productsSessão · emporio:write · OWNER, ADMIN e OPERADORPõ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": {} }/api/v1/emporio/products/candidatesSessão · emporio:read · perfil emporio · recurso `emporio` liberadoProdutos 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": {} }/api/v1/emporio/products/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberadoO 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": {} }/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": {} }/api/v1/emporio/products/:id/archiveSessão · emporio:write · OWNER, ADMIN e OPERADORArquiva (`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": {} }/api/v1/emporio/products/:id/presentationsSessão · emporio:write · OWNER, ADMIN e OPERADORCria 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": {} }/api/v1/emporio/presentations/:idSessão · emporio:write · OWNER, ADMIN e OPERADOREdita 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": {} }/api/v1/emporio/presentations/:id/archiveSessão · emporio:write · OWNER, ADMIN e OPERADORArquiva (`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": {} }/api/v1/emporio/pricesSessão · emporio:write · só OWNER e ADMINUpsert 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": {} }/api/v1/emporio/stockSessão · emporio:read · perfil emporio · recurso `emporio` liberadoSaldo 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": {} }/api/v1/emporio/stock/consolidatedSessão · emporio:read · perfil emporio · recurso `emporio` liberadoUm 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": {} }/api/v1/emporio/products/:id/stockSessão · emporio:read · perfil emporio · recurso `emporio` liberadoSaldo 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": {} }/api/v1/emporio/stock/implantationSessão · emporio:write · OWNER, ADMIN e OPERADOREstoque 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": {} }/api/v1/emporio/stock/countSessão · emporio:write · OWNER, ADMIN e OPERADORContagem 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": {} }/api/v1/emporio/stock/lossSessão · emporio:write · OWNER, ADMIN e OPERADORPerda 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": {} }/api/v1/emporio/stock/limitsSessão · emporio:write · só OWNER e ADMINMí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": {} }/api/v1/emporio/stock/restockSessão · emporio:read · perfil emporio · recurso `emporio` liberadoPrecisa 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": {} }/api/v1/emporio/stock/restock/to-listSessão · emporio:write · OWNER, ADMIN e OPERADOREncaminha à 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": {} }/api/v1/emporio/purchasesSessão · emporio:read · perfil emporio · recurso `emporio` liberadoLista 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": {} }/api/v1/emporio/purchasesSessão · emporio:write · OWNER, ADMIN e OPERADORCria 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": {} }/api/v1/emporio/purchases/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberadoUma 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": {} }/api/v1/emporio/purchases/:id/receiveSessão · emporio:write · OWNER, ADMIN e OPERADORRecebe 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": {} }/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": {} }/api/v1/emporio/products/:id/compositionSessão · emporio:read · perfil emporio · recurso `emporio` liberadoA 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": {} }/api/v1/emporio/products/:id/compositionSessão · emporio:write · OWNER e ADMINDefine 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": {} }/api/v1/emporio/products/:id/composition/from-recipeSessão · emporio:write · OWNER e ADMINCria 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": {} }/api/v1/emporio/first-stepsSessão · emporio:read · OWNER e ADMIN · perfil emporio · recurso `emporio` liberadoPrimeiros 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": {} }/api/v1/emporio/productionSessão · emporio:read · perfil emporio · recurso `emporio` liberadoLista 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": {} }/api/v1/emporio/productionSessão · emporio:write · OWNER, ADMIN e OPERADORRegistra 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": {} }/api/v1/emporio/production/:id/cancelSessão · emporio:write · OWNER e ADMINCancela 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": {} }/api/v1/emporio/transfersSessão · emporio:read · perfil emporio · recurso `emporio` liberadoLista 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": {} }/api/v1/emporio/transfersSessão · emporio:write · OWNER, ADMIN e OPERADOR com a origem no vínculoEnvia 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": {} }/api/v1/emporio/transfers/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberadoUma 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": {} }/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": {} }/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": {} }/api/v1/emporio/salesSessão · emporio:read · perfil emporio · recurso `emporio` liberadoLista 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": {} }/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": {} }/api/v1/emporio/sales/:idSessão · emporio:read · perfil emporio · recurso `emporio` liberadoUma venda com itens, pagamentos e devoluções. Loja fora do vínculo: 404.
Response
200
{ "data": { "id": "cl...", "status": "concluida", "items": [], "payments": [], "returns": [] }, "meta": {} }/api/v1/emporio/sales/:id/cancelSessão · emporio:write · só OWNER e ADMINCancela 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": {} }/api/v1/emporio/sales/:id/returnSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculoDevoluçã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": {} }/api/v1/emporio/sales/:id/exchangeSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculoTroca: 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": {} }/api/v1/emporio/receivablesSessão · emporio:read · perfil emporio · recurso `emporio` liberadoO 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": {} }/api/v1/emporio/receivables/receiveSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculoRecebe 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": {} }/api/v1/emporio/variable-expense-rulesSessão · emporio:read · só OWNER e ADMIN (403 `FORBIDDEN`); com vínculo, só as lojas deleRegras 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": {} }/api/v1/emporio/variable-expense-rulesSessão · emporio:write · só OWNER e ADMINCria 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": {} }/api/v1/emporio/variable-expense-rules/:idSessão · emporio:write · só OWNER e ADMINEdita 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": {} }/api/v1/emporio/fixed-expensesSessão · emporio:read · só OWNER e ADMIN; com vínculo, só as despesas que têm loja deleDespesas 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": {} }/api/v1/emporio/fixed-expensesSessão · emporio:write · só OWNER e ADMINCria 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": {} }/api/v1/emporio/fixed-expenses/:idSessão · emporio:write · só OWNER e ADMINEdita 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": {} }/api/v1/emporio/fixed-expenses/:id/endSessão · emporio:write · só OWNER e ADMINEncerra 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": {} }/api/v1/emporio/fixed-expenses/:id/renewSessão · emporio:write · só OWNER e ADMINEstende 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": {} }/api/v1/emporio/payablesSessão · emporio:read · só OWNER e ADMIN; com vínculo, só as lojas deleContas 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": {} }/api/v1/emporio/payables/:idSessão · emporio:write · só OWNER e ADMINCorrige 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": {} }/api/v1/emporio/payables/paySessão · emporio:write · só OWNER e ADMINPaga 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": {} }/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": {} }/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": {} }/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": {} }/api/v1/emporio/bank-accountsSessão · emporio:read · só OWNER e ADMINContas 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": {} }/api/v1/emporio/bank-accountsSessão · emporio:write · só OWNER e ADMINCadastra 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": {} }/api/v1/emporio/bank-accounts/:idSessão · emporio:write · só OWNER e ADMINEdita 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": {} }/api/v1/emporio/establishments/:id/pix-accountSessão · emporio:write · só OWNER e ADMINDefine 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": {} }/api/v1/emporio/cashSessão · emporio:read · gaveta de quem tem a loja no vínculo; contas só OWNER e ADMINSaldos 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": {} }/api/v1/emporio/cash/openingSessão · emporio:write · loja: OWNER, ADMIN e OPERADOR com a loja no vínculo; conta: só OWNER e ADMINSaldo 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": {} }/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": {} }/api/v1/emporio/card-fee-rulesSessão · emporio:write · só OWNER e ADMINCria 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": {} }/api/v1/emporio/card-fee-rules/:idSessão · emporio:write · só OWNER e ADMINRemove 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": {} }/api/v1/emporio/checksSessão · emporio:read · quem tem a loja no vínculoCheques 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": {} }/api/v1/emporio/checks/:id/depositSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculoDepositar: `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": {} }/api/v1/emporio/checks/:id/clearSessão · emporio:write · só OWNER e ADMINCompensou: `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": {} }/api/v1/emporio/checks/:id/returnSessão · emporio:write · só OWNER e ADMINVoltou: `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": {} }/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 ADMINTransfere 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": {} }/api/v1/emporio/cash/countsSessão · emporio:read · quem tem a loja no vínculoHistó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": {} }/api/v1/emporio/cash/countsSessão · emporio:write · OWNER, ADMIN e OPERADOR com a loja no vínculoConfere 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": {} }/api/v1/pasturesSessão · rebanho:read · perfil fazendaLista 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 } } }/api/v1/pasturesSessão · rebanho:write · perfil fazendaCadastra 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 } } }/api/v1/pastures/:idSessão · rebanho:write · perfil fazendaEdita 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 } } }/api/v1/pastures/:id/archiveSessão · rebanho:write · perfil fazendaDesativa 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.
/api/v1/animal-categoriesSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/animal-categoriesSessão · rebanho:write · perfil fazendaCria uma categoria de rebanho.
Request
{ "name": "Novilha" }Response
201
{ "data": { "id": "cl...", "name": "Novilha", "active": true }, "meta": {} }/api/v1/animal-categories/:idSessão · rebanho:write · perfil fazendaRenomeia 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)
/api/v1/contactsSessão · rebanho:read · perfil fazenda ou emporioLista 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 } }/api/v1/contactsSessão · rebanho:write · perfil fazenda ou emporioCadastra 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": {} }/api/v1/contacts/:idSessão · rebanho:read · perfil fazenda ou emporioO 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": {} }/api/v1/contacts/:idSessão · rebanho:write · perfil fazenda ou emporioEdita 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": {} }/api/v1/contacts/:id/archiveSessão · rebanho:write · perfil fazenda ou emporioArquiva 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": {} }/api/v1/negotiationsSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/negotiationsSessão · rebanho:write · perfil fazendaRegistra 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": {} }/api/v1/negotiations/:idSessão · rebanho:read · perfil fazendaDetalhe 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": {} }/api/v1/negotiations/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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 } }/api/v1/negotiations/eventsSessão · rebanho:write · perfil fazendaAbre 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": {} }/api/v1/negotiations/:id/close-eventSessão · rebanho:write · perfil fazendaEncerra 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 } }/api/v1/negotiations/bartersSessão · rebanho:write · perfil fazendaRegistra 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)
/api/v1/product-categoriesSessão · rebanho:read · perfil fazendaAs 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 } }/api/v1/productsSessão · rebanho:read · perfil fazendaO 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 } }/api/v1/productsSessão · rebanho:write · perfil fazendaCadastra 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": {} }/api/v1/stock/movementsSessão · rebanho:read · perfil fazendaHistó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 } }/api/v1/stock/movementsSessão · rebanho:write · perfil fazendaRegistra `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": {} }/api/v1/stock/adjustSessão · rebanho:write · perfil fazendaAjuste 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)
/api/v1/herd/positionsSessão · rebanho:read · perfil fazendaSaldo 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 } }/api/v1/herd/movementsSessão · rebanho:read · perfil fazendaHistó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 } }/api/v1/herd/movementsSessão · rebanho:write · perfil fazendaRegistra 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": {} }/api/v1/herd/movements/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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": {} }/api/v1/herd/staysSessão · rebanho:read · perfil fazendaEstadias 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 } }/api/v1/herd/staysSessão · rebanho:write · perfil fazendaAbre 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": {} }/api/v1/herd/stays/:id/closeSessão · rebanho:write · perfil fazendaEncerra 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": {} }/api/v1/herd/stays/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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.
/api/v1/confinement/sitesSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/confinement/sitesSessão · rebanho:write · perfil fazendaCadastra 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": {} }/api/v1/confinement/sites/:id/archiveSessão · rebanho:write · perfil fazendaArquiva 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": {} }/api/v1/confinement/staysSessão · rebanho:read · perfil fazendaLotes 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 } }/api/v1/confinement/staysSessão · rebanho:write · perfil fazendaAbre 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": {} }/api/v1/confinement/stays/:idSessão · rebanho:read · perfil fazendaResumo 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": {} }/api/v1/confinement/stays/:id/feedingSessão · rebanho:write · perfil fazendaRegistra 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": {} }/api/v1/confinement/stays/:id/costsSessão · rebanho:write · perfil fazendaCusto 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.
/api/v1/milk/groupsSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/milk/groupsSessão · rebanho:write · perfil fazendaCadastra 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": {} }/api/v1/milk/groups/:id/archiveSessão · rebanho:write · perfil fazendaArquiva 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": {} }/api/v1/milk/lactationSessão · rebanho:read · perfil fazendaHistó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 } }/api/v1/milk/lactationSessão · rebanho:write · perfil fazendaRegistra 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 } }/api/v1/milk/lactation/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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 } }/api/v1/milk/productionSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/milk/productionSessão · rebanho:write · perfil fazendaRegistra 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 } }/api/v1/milk/production/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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": {} }/api/v1/milk/summarySessão · rebanho:read · perfil fazendaO 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": {} }/api/v1/milk/sitesSessão · rebanho:read · perfil fazendaTanques 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 } }/api/v1/milk/sitesSessão · rebanho:write · perfil fazendaCadastra 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": {} }/api/v1/milk/sites/:id/archiveSessão · rebanho:write · perfil fazendaArquiva 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": {} }/api/v1/milk/storageSessão · rebanho:read · perfil fazendaO 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 } }/api/v1/milk/storageSessão · rebanho:write · perfil fazendaAs 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 } }/api/v1/milk/storage/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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": {} }/api/v1/milk/chargesSessão · rebanho:read · perfil fazendaAs 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 } }/api/v1/milk/chargesSessão · rebanho:write · perfil fazendaRegistra 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": {} }/api/v1/milk/charges/:id/cancelSessão · rebanho:write · perfil fazendaCancela 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": {} }/api/v1/milk/salesSessão · rebanho:write · perfil fazendaA 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": {} }/api/v1/milk/sales/closeSessão · rebanho:write · perfil fazendaO 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": {} }/api/v1/milk/sales/pendingSessão · rebanho:read · perfil fazendaAs 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
/api/v1/animalsSessão · rebanho:read · perfil fazendaLista 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 } }/api/v1/animalsSessão · rebanho:write · perfil fazendaCadastra 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 } }/api/v1/animals/:idSessão · rebanho:read · perfil fazendaDetalhe do animal.
Response
200
{ "data": { "id": "cl...", "ear_tag": "1234", "quantity": 1, "breed": "Nelore", "property_name": "Sede", "average_weight": 380.5 } }/api/v1/animals/:idSessão · rebanho:write · perfil fazendaEdita dados cadastrais. Valida nova propriedade e unicidade de brinco, se alterados.
Request
{ "breed": "Nelore PO" }Response
200
{ "data": { "id": "cl...", "breed": "Nelore PO" } }/api/v1/animals/:id/weight-logsSessão · rebanho:read · perfil fazendaHistó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 } }/api/v1/animals/:id/weight-logsSessão · rebanho:write · perfil fazendaRegistra 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 } }/api/v1/animals/:id/vaccinationsSessão · rebanho:read · perfil fazendaHistórico de vacinação do animal.
Response
200
{ "data": [{ "id": "cl...", "vaccine_name": "Aftosa", "applied_at": "...", "next_due_at": "..." }], "meta": { "count": 1 } }/api/v1/animals/:id/vaccinationsSessão · rebanho:write · perfil fazendaRegistra 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": "..." } }/api/v1/animals/:id/movementsSessão · rebanho:read · perfil fazendaHistórico de movimentações do animal.
Response
200
{ "data": [{ "id": "cl...", "movement_type": "sale", "value": 4500, "occurred_at": "..." }], "meta": { "count": 1 } }/api/v1/animals/:id/movementsSessão · rebanho:write · perfil fazendaRegistra 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 } }/api/v1/animals/:id/cost-summarySessão · rebanho:read · perfil fazendaSoma 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 } }/api/v1/vaccinesSessão · rebanho:read · perfil fazendaCatálogo de vacinas do tenant.
Response
200
{ "data": [{ "id": "cl...", "name": "Aftosa", "default_interval_days": 180 }], "meta": { "total": 4 } }/api/v1/vaccinesSessão · rebanho:write · perfil fazendaCria 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 } }/api/v1/vaccinations/upcomingSessão · rebanho:read · perfil fazendaVacinaçõ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.
/api/v1/machinesSessão · maquinas:read · perfil fazendaLista as máquinas do tenant.
Response
200
{ "data": [{ "id": "cl...", "name": "Trator 1", "type": "trator", "status": "active", "next_maintenance_at": null, "...": "..." }], "meta": { "total": 1 } }/api/v1/machinesSessão · maquinas:write · perfil fazendaCadastra 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": {} }/api/v1/machines/:idSessão · maquinas:read · perfil fazendaDetalhe 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": {} }/api/v1/machines/:idSessão · maquinas:write · perfil fazendaEdita dados cadastrais ou muda o status (active, maintenance, sold, inactive).
Request
{ "status": "maintenance" }Response
200
{ "data": { "id": "cl..." }, "meta": {} }/api/v1/machines/:id/maintenancesSessão · maquinas:write · perfil fazendaRegistra 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": {} }/api/v1/machines/:id/servicesSessão · maquinas:read · perfil fazendaO 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.
/api/v1/workersSessão · mao_de_obra:read · perfil fazendaLista 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", "..."] } }/api/v1/workersSessão · mao_de_obra:write · perfil fazendaCadastra 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": {} }/api/v1/workers/:idSessão · mao_de_obra:read · perfil fazendaO 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": {} }/api/v1/workers/:idSessão · mao_de_obra:write · perfil fazendaEdita 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": {} }/api/v1/workers/:id/paymentsSessão · mao_de_obra:write · perfil fazendaRegistra 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.
/api/v1/service-jobsSessão · servicos:read · perfil fazendaLista 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", "..."] } }/api/v1/service-jobsSessão · servicos:write · perfil fazendaRegistra 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": {} }/api/v1/service-jobs/:idSessão · servicos:read · perfil fazendaO 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": {} }/api/v1/service-jobs/:id/paymentsSessão · servicos:write · perfil fazendaRegistra 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": {} }/api/v1/service-jobs/:idSessão · servicos:write · perfil fazendaCANCELA 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": {} }/api/v1/service-jobs/:id/logsSessão · servicos:write · perfil fazendaAcrescenta 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": {} }/api/v1/service-jobs/:id/costsSessão · servicos:read · perfil fazendaOs 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": {} }/api/v1/service-jobs/:id/costsSessão · servicos:write · perfil fazendaRegistra 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": {} }/api/v1/service-jobs/:id/statusSessão · servicos:write · perfil fazendaComeç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": {} }/api/v1/workers/:id/logsSessão · mao_de_obra:read · perfil fazendaAs 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 } }/api/v1/workers/:id/logsSessão · mao_de_obra:write · perfil fazendaRegistra 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": {} }/api/v1/workers/:id/logs/:logIdSessão · mao_de_obra:write · perfil fazendaApaga 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.
/api/v1/tasksSessão · tarefas:readLista 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 } }/api/v1/tasksSessão · tarefas:writeCria 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": {} }/api/v1/tasks/:idSessão · tarefas:writeConclui, 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": {} }/api/v1/tasks/:idSessão · tarefas:writeApaga uma tarefa de vez. Para manter o histórico, use o PATCH com status cancelled.
Response
200
{ "data": { "id": "cl..." }, "meta": {} }/api/v1/tasks/:id/postponeSessão · tarefas:writeAdia 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": {} }/api/v1/meu-diaSessão · tarefas:readAs 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
/api/v1/plotsSessão · lavoura:read · perfil fazendaLista 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 } }/api/v1/plotsSessão · lavoura:write · perfil fazendaCadastra 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 } }/api/v1/plots/:idSessão · lavoura:read · perfil fazendaDetalhe 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" } ] } }/api/v1/plots/:id/cyclesSessão · lavoura:read · perfil fazendaLista os ciclos do talhão.
Response
200
{ "data": [{ "id": "cl...", "crop_name": "Soja", "status": "growing" }], "meta": { "total": 1 } }/api/v1/plots/:id/cyclesSessão · lavoura:write · perfil fazendaInicia 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" } }/api/v1/cycles/:id/harvestSessão · lavoura:write · perfil fazendaRegistra 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" } }/api/v1/cycles/:id/summarySessão · lavoura:read · perfil fazendaCusto 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 } }/api/v1/cycles/:id/inputsSessão · lavoura:read · perfil fazendaLista insumos aplicados no ciclo.
Response
200
{ "data": [{ "id": "cl...", "input_type": "fertilizer", "name": "NPK 04-14-08", "cost": 1800 }], "meta": { "total": 1 } }/api/v1/cycles/:id/inputsSessão · lavoura:write · perfil fazendaRegistra 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
/api/v1/service-clientsSessão · prestador:read · perfil prestadorLista clientes. ?q= busca por nome ou telefone.
Response
200
{ "data": [{ "id": "cl...", "name": "Sítio Esperança", "phone": "22988884444" }], "meta": { "total": 1 } }/api/v1/service-clientsSessão · prestador:write · perfil prestadorCadastra 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" } }/api/v1/service-clients/:id/summarySessão · prestador:read · perfil prestadorTotal 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": "..." } }/api/v1/servicesSessão · prestador:read · perfil prestadorCatá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 } }/api/v1/servicesSessão · prestador:write · perfil prestadorCadastra 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 } }/api/v1/services/:idSessão · prestador:write · perfil prestadorEdita 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 } }/api/v1/service-ordersSessão · prestador:read · perfil prestadorLista 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 } }/api/v1/service-ordersSessão · prestador:write · perfil prestadorRegistra 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" } }/api/v1/service-orders/:id/statusSessão · prestador:write · perfil prestadorTransiçã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
/api/v1/financial-entriesSessão · financeiro:readLista 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 } }/api/v1/financial-entriesSessão · financeiro:writeLanç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" } }/api/v1/financial-entries/:idSessão · financeiro:writeEdita lançamento manual.
Request
{ "amount": 380 }Response
200
{ "data": { "id": "cl...", "amount": 380 } }/api/v1/financial-entries/:id/paySessão · financeiro:writeQuita 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": "..." } }/api/v1/financial-entries/:id/paymentsSessão · financeiro:readOs 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" } }/api/v1/financial-entries/:id/paymentsSessão · financeiro:writeRegistra 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" } }/api/v1/financial-entries/:id/payments/:paymentIdSessão · financeiro:writeDesfaz 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" } }/api/v1/financial-entries/:id/postponeSessão · financeiro:writeAdia 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": "..." } }/api/v1/financial-entries/:id/cancelSessão · financeiro:writeCancela 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" } }/api/v1/financial-categoriesSessão · financeiro:readLista 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 } }/api/v1/financial-categoriesSessão · financeiro:writeCria uma categoria.
Request
{ "name": "Frete", "entry_type": "expense" }Response
201
{ "data": { "id": "cl...", "name": "Frete", "entry_type": "expense", "active": true }, "meta": {} }/api/v1/financial-categories/:idSessão · financeiro:writeRenomeia ou ativa/desativa uma categoria.
Request
{ "active": false }Response
200
{ "data": { "id": "cl...", "name": "Frete", "active": false }, "meta": {} }/api/v1/alert-preferencesSessão · alertas:readLista 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 } }/api/v1/alert-preferencesSessão · alertas:writeLiga/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": {} }/api/v1/financial/cash-flowSessão · financeiro:readSaldo 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" } }/api/v1/financial/dreSessão · financeiro:readReceita 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 } }/api/v1/financial/upcomingSessão · financeiro:readLanç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" }] }/api/v1/financial/report/linkSessão · financeiro:readGera um link assinado (HMAC, válido por 1h) para o PDF do relatório financeiro do período. Query: start, end, area (fazenda | emporio | tudo, padrão tudo; 422 `area`): a área vai dentro do token e o PDF a respeita. Usado pelo botão "Exportar relatório".
Response
200
{ "data": { "report_url": "https://.../api/v1/financial/report?token=...", "expires_in_seconds": 3600 } }/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 expiradoMinha 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.
/api/v1/shopping-itemsSessão · rebanho:read · perfil fazenda ou emporioA 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": [] } }/api/v1/shopping-itemsSessão · rebanho:write · perfil fazenda ou emporioAnota 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": {} }/api/v1/shopping-items/:idSessão · rebanho:write · perfil fazenda ou emporioSem 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": {} }/api/v1/shopping-items/:idSessão · rebanho:write · perfil fazenda ou emporioRemove 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": {} }/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
/api/v1/alertsSessão · alertas:readLista 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 } }/api/v1/alerts/:id/shopping-itemSessão · rebanho:write · perfil fazendaManda 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": {} }/api/v1/alerts/:id/dismissSessão · alertas:writeMarca 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.
/api/v1/notifications/public-keySessãoDevolve 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": {} }/api/v1/notifications/subscribeSessãoSe 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": {} }/api/v1/notifications/subscribeSessãoRegistra 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": {} }/api/v1/notifications/subscribeSessãoCancela 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
/api/v1/tenant/active-propertySessão · rebanho:readDefine 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": {} }/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)
/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 } }/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/..." } }/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": {} }/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.
/api/internal/whatsapp/resolve-contactHeader x-internal-secretIdentifica 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 } }/api/internal/whatsapp/execute-actionHeader x-internal-secretExecuta 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 } }/api/internal/whatsapp/turnoHeader x-internal-secretTurno 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": {} }/api/internal/whatsapp/send-messageHeader x-internal-secretEnvia 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": {} }/api/internal/whatsapp/bufferHeader x-internal-secretJunta 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": {} }/api/internal/whatsapp/fetch-mediaHeader x-internal-secretBusca á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": {} }/api/internal/whatsapp/pending-flowsHeader x-internal-secretLembra 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
/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" } }/api/internal/jobs/daily-digestHeader x-internal-secretResumo 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.
/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 } }/api/platform/tenants/:idSessão de PlatformUserDetalhe 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 } } }/api/platform/tenants/:id/statusSessão de PlatformUser · só master_adminForç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" } }/api/platform/tenantsSessão de PlatformUser · só master_adminCria 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": {} }/api/platform/tenants/:idSessão de PlatformUser · só master_adminCorrige 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": {} }/api/platform/tenants/:id/owner-emailSessão de PlatformUser · só master_adminTroca 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": {} }/api/platform/tenants/:id/archiveSessão de PlatformUser · só master_adminArquiva 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": {} }/api/platform/tenants/:id/featuresSessão de PlatformUser · equipe e master_adminRecursos 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": {} }/api/platform/tenants/:id/features/:keySessão de PlatformUser · só master_adminLiga 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": {} }/api/platform/tenants/:id/welcome-messageSessão de PlatformUser · só master_adminReenvia 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": {} }/api/platform/kpis/mrrSessão de PlatformUser · só master_adminMRR 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 } }/api/platform/kpis/mrr-trendSessão de PlatformUser · só master_adminEvoluçã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 }] }/api/platform/kpis/churnSessão de PlatformUser · só master_adminChurn 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 } }/api/platform/kpis/ltvSessão de PlatformUser · só master_adminLTV 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 } }/api/platform/kpis/funnelSessão de PlatformUser · só master_adminFunil 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 }] } }/api/platform/teamSessão de PlatformUser · só master_adminLista a equipe da plataforma (PlatformUser).
Response
200
{ "data": [{ "id": "cl...", "name": "...", "email": "...", "role": "EQUIPE", "active": true }], "meta": { "total": 1 } }/api/platform/teamSessão de PlatformUser · só master_adminConvida 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" } }/api/platform/team/:id/roleSessão de PlatformUser · só master_adminAltera 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" } }/api/platform/team/:id/activeSessão de PlatformUser · só master_adminAtiva ou desativa um membro. Não é possível desativar a própria conta.
Request
{ "active": false }Response
200
{ "data": { "id": "cl...", "active": false } }/api/platform/whatsapp-configSessão de PlatformUser · só master_adminLista 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": {} }/api/platform/whatsapp-configSessão de PlatformUser · só master_adminCria/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": {} }/api/platform/whatsapp-config/:provider/activateSessão de PlatformUser · só master_adminAtiva o provider (e desativa o outro, transacional). 404 se ainda não configurado.
Response
200
{ "data": { "provider": "meta_cloud_api" }, "meta": {} }/api/platform/whatsapp-config/evolution/connectSessão de PlatformUser · só master_adminCria (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": {} }/api/platform/whatsapp-config/evolution/statusSessão de PlatformUser · só master_adminEstado 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": {} }