Você opera catálogo, estoque, preço e pedido de várias lojas pela mesma API, com o dado de cada empresa cliente separado pelo token e não por disciplina de equipe — e sem pagar percentual sobre o que vende.
- Varejistas de médio porte que vendem em loja própria e em mais de um canal ao mesmo tempo
- Plataformas B2B que revendem operação de e-commerce para várias empresas clientes na mesma instância
- Operações de delivery e food service com cardápio, estoque por unidade e pedido com ciclo de preparo
- Camada própria de catálogo, estoque e pedido reescrita dentro de cada produto
- Planilha de conciliação de SKU entre a loja própria e cada canal de venda
- Serviço separado de carrinho e checkout mantido ao lado do ERP
- Uma vitrine pronta — o Commerce entrega a API, a interface é sua
- Um gateway de pagamento (isso é o building block payments)
- Um emissor de nota fiscal — ele guarda NCM, CEST, CFOP e alíquotas, mas não emite documento
- Um hub com conectores prontos de marketplace — os adaptadores externos ainda não foram entregues
01Resumo executivo
O Commerce é o miolo de uma operação de venda: ele guarda o que você vende, quanto disso existe em cada lugar, por quanto sai em cada canal e o que acontece com um pedido do momento em que ele nasce até o momento em que ele é entregue ou estornado. Ele não desenha a loja — ele responde às perguntas que a loja faz.
Na prática, uma rede com oito unidades para de ter oito planilhas de estoque e passa a ter oito StockLocation dentro de uma única loja, com movimentação rastreada peça por peça. Quando o pedido é confirmado, o histórico de status registra quem mudou, quando e por quê — e isso vale tanto para uma venda de sapato quanto para um pedido de delivery que passa por preparo, saída para entrega e conclusão.
O serviço está publicado nas pilhas de staging e de produção desde março de 2026, na porta 3026. Está marcado como beta por uma razão específica e não cosmética: os adaptadores para plataformas externas — Mercado Livre, Shopee, VTEX, Shopify e os demais nomes que aparecem no enum CommerceProviderType — ainda não foram implementados. O que existe hoje de sincronização é a máquina completa (mapeamento de SKU, resolução de conflito, jobs, webhooks) rodando contra o provedor interno. Ver §15 antes de vender integração de marketplace.
| Atributo | Valor |
|---|---|
| Identificador | commerce |
| Categoria | Comércio |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3026 |
| Path alias | @commerce |
| Prefixo HTTP | /commerce |
| Schema no banco | commerce |
| Endpoints | 130 pela contagem oficial (137 rotas HTTP — ver §9) |
| Modelos Prisma | 32 |
| Status | Beta, publicado desde 2026-03 |
| Depende de | PostgreSQL, Redis, IAM |
02O problemanegócio
O cenário. Uma empresa vende. Começa vendendo por um canal, depois abre outro, depois abre uma segunda unidade física, depois um cliente grande pede tabela de preço própria. Nada disso é exótico — é o curso normal de um negócio que dá certo. O problema é que cada um desses passos, no software, costuma custar uma reescrita.
O que trava hoje.
- O estoque mora em mais de um lugar e ninguém sabe qual está certo. A loja física tem o número dela, o marketplace tem o dele, e a diferença entre os dois só aparece quando um cliente compra algo que não existe. Vender e não ter é pior que não vender: cancelar pedido derruba reputação no canal e às vezes gera multa.
- Preço deixa de ser um número. Passa a ser preço de tabela, preço de atacado a partir de doze unidades, preço promocional entre sexta e domingo, preço específico de um canal. Cada uma dessas regras acaba virando um
ifem algum lugar do código, e a pergunta "por que este item saiu por este valor" fica sem resposta auditável. - Pedido não é um registro, é um processo. Confirmado, em preparo, pronto, despachado, saiu para entrega, entregue, concluído. Sem uma máquina de estados explícita, alguém marca "entregue" um pedido que nunca foi confirmado, e o relatório de operação passa a mentir.
- Multi-empresa vira convenção. Uma plataforma que atende várias empresas clientes na mesma instância depende de todo desenvolvedor lembrar de filtrar por empresa em toda consulta. Funciona até o dia em que alguém não lembra.
- Integrar um canal novo custa um trimestre. Cada marketplace tem seu formato de produto, seu vocabulário de status de pedido e sua ideia de o que é um SKU. Sem uma camada canônica no meio, o segundo canal custa quase o mesmo que o primeiro.
O custo de não resolver. O e-commerce brasileiro se organizou em torno de marketplaces, e vender em mais de um canal deixou de ser estratégia avançada para virar operação padrão de quem quer volume. Cada canal adicional multiplica o número de lugares onde o mesmo SKU precisa estar correto — e o erro mais caro dessa conta não é o técnico, é o comercial: o pedido vendido sem lastro de estoque, que vira cancelamento, penalidade de reputação e cliente perdido.
E há o custo de licença. O modelo dominante no varejo brasileiro de médio e grande porte é o take rate — a plataforma cobra um percentual do que você fatura. A VTEX, referência da categoria no país, define no próprio formulário anual da SEC que "a taxa baseada em transação responde pela maior parte da nossa receita de assinatura e é primariamente estruturada como take rate", calculado sobre o valor dos pedidos incluindo impostos e frete (20-F FY2025). O pricebook público deles parte de 2,5% e desce até 0,5% conforme o porte do contrato (consultado em 2026-08-16).
É um modelo confortável no começo e desconfortável exatamente quando dá certo, porque a linha de custo cresce junto com a linha de receita sem que a plataforma passe a entregar mais. E a direção do mercado é de mais cobrança sobre GMV, não menos: em 1º de junho de 2026 a BigCommerce — hoje Commerce.com — criou uma taxa de 2,0% a 0,6% sobre pedido processado fora dos gateways embarcados, encerrando o "sem taxa de transação" que era o argumento dela contra a Shopify.
03Proposta de valornegócio
| Antes | Depois |
|---|---|
| Estoque em planilha por unidade, conferido no fim do mês | StockLocation por unidade, com movimentação tipada e rastreável item a item |
Preço é um campo no produto e o resto é if | Listas de preço com prioridade, validade, faixa de quantidade e canal, resolvidas por um único endpoint |
| Status do pedido é um campo de texto que qualquer rotina escreve | Máquina de estados com transições válidas declaradas e histórico com autor e motivo |
| "Todo mundo filtra por empresa" | Middleware confirma que a loja pertence à organização do token antes de a rota rodar |
| Custo da plataforma cresce com o faturamento | Cobrança desacoplada do que o cliente vende |
A loja é a unidade de isolamento, não um campo. Toda rota de catálogo, estoque, preço, pedido e canal fica sob /api/v1/stores/:storeId/..., e o middleware resolveStore confirma que aquela loja existe, pertence à organização do token e está ativa — antes de qualquer serviço ser chamado. Não há caminho em que uma requisição alcance a regra de negócio sem esse carimbo.
Preço tem uma resposta única e auditável. GET /price-lists/effective-price/:variantId resolve, para uma variante, uma quantidade e opcionalmente um canal, qual lista de preço vigente vence: filtra por validade e atividade, ordena por prioridade da lista e depois pela maior faixa de quantidade aplicável. A pergunta "por que saiu por este valor" tem uma resposta, não uma investigação.
Pedido com processo explícito. As transições válidas são uma tabela declarada em código (VALID_ORDER_TRANSITIONS), cada transição tem endpoint próprio com permissão própria, cada mudança grava carimbo de tempo dedicado e uma linha em CommerceOrderStatusHistory com autor e motivo. Cancelar e estornar são permissões separadas de "avançar o pedido".
Um modelo canônico para verticais diferentes. O mesmo Product atende PHYSICAL, DIGITAL, SERVICE e FOOD; o mesmo pedido atende PICKUP, DELIVERY, DINE_IN e DIGITAL; PriceModifier cobre adicional de lanche e opcional de serviço com a mesma estrutura. Trocar de vertical não exige trocar de building block.
Encaixa com o resto do catálogo. O token é o mesmo do IAM, os eventos saem no barramento compartilhado e são consumidos pelo Webhooks Engine, o pagamento é o building block Payments, a imagem do produto é o File Storage. Você não reintegra identidade a cada peça nova.
04Casos de uso reaisnegócio
Caso 1 — Uma rede de oito lojas para de vender o que não tem Cenário ilustrativo
Contexto. Rede de varejo de moda com oito unidades físicas e uma loja online. Cerca de quatro mil SKUs ativos entre grade de tamanho e cor.
A dor. O estoque online era uma soma manual do estoque das unidades, atualizada uma vez por dia. Um item comprado às dez da manhã já podia ter sido vendido no balcão às nove. O time de atendimento passava a manhã cancelando pedido e pedindo desculpa, e a taxa de cancelamento por ruptura era o indicador que ninguém queria apresentar na reunião.
A solução com o BB. Cada unidade vira um StockLocation da mesma loja. Toda entrada e saída passa por POST /stores/:storeId/stock-movements, com tipo declarado (INBOUND, OUTBOUND, TRANSFER, ADJUSTMENT) e referência ao documento de origem. A consulta de disponibilidade é GET /stores/:storeId/stock/levels/:variantId, que devolve quantidade e quantidade reservada por local. O job de reserva expirada devolve ao estoque o que ficou preso em carrinho abandonado, e GET /stock/low-stock alimenta o alerta de reposição usando o reorderPoint de cada item.
O resultado. O número de disponibilidade deixa de ser uma soma diária e passa a ser o saldo corrente, com o histórico de como ele chegou ali. Quando falta, falta com aviso — não com cancelamento.
Caso 2 — Uma operação de delivery com preparo e cardápio por canal Cenário ilustrativo
Contexto. Operação de food service com cozinha própria, vendendo por aplicativo próprio, por telefone e por plataformas de delivery.
A dor. O ciclo do pedido de comida não é o do varejo. Entre "confirmado" e "entregue" existe preparo, existe pronto para retirada e existe saída para entrega — e cada um desses momentos precisa disparar uma notificação diferente. Além disso, nem todo item do cardápio vai para todo canal: alguns pratos só saem no balcão, e o preço no aplicativo de terceiro precisa absorver a comissão da plataforma.
A solução com o BB. Produtos do tipo FOOD, com PriceModifier cobrindo adicionais e opcionais por item. Cada plataforma é um Channel do tipo DELIVERY_PLATFORM, e PUT /channels/:id/products/:productId/availability liga ou desliga cada produto naquele canal, com priceOverride quando o preço precisa ser diferente. O pedido percorre confirm → prepare → ready → out-for-delivery → deliver → complete, e cada transição publica um evento (commerce.order.preparing, commerce.order.ready, ...) que o Webhooks Engine entrega para a cozinha e para o cliente.
O resultado. O cardápio é um só, com recorte por canal declarado em vez de duplicado. E o painel da cozinha para de depender de alguém apertar um botão em outro sistema.
Caso 3 — Uma plataforma B2B atende cinquenta clientes sem cinquenta instâncias Cenário ilustrativo
Contexto. Empresa de software que vende operação de e-commerce como serviço para outras empresas. Cinquenta clientes, cada um com uma a três lojas.
A dor. A primeira arquitetura era uma instância por cliente. Cinquenta bancos, cinquenta deploys, e uma mudança de schema que levava uma semana para chegar ao último cliente. O custo de infraestrutura crescia linearmente com as vendas, que é o oposto do que se espera de software.
A solução com o BB. Cada cliente é uma Organization do IAM. Cada operação dele é uma CommerceStore daquela organização, com @@unique([organizationId, slug]) garantindo que o identificador de loja é dele. O organizationId chega por claim assinado no token, nunca pelo corpo, e resolveStore recusa com 404 a tentativa de alcançar uma loja de outra organização.
O resultado. Uma instância, um deploy, uma migração. Adicionar um cliente é POST /iam/api/v1/organizations seguido de POST /commerce/api/v1/stores — não é provisionar ambiente.
Caso 4 — Por que a sincronização de catálogo é o gargalo, e não a vitrine Referência de mercado
Contexto. Vender em mais de um canal é hoje a operação padrão de quem quer volume no varejo online brasileiro, e o mercado respondeu criando uma categoria inteira de intermediários — hubs de integração — cuja única função é manter o mesmo SKU coerente em várias plataformas ao mesmo tempo. A existência dessa categoria é a evidência do problema: se sincronizar catálogo e estoque fosse simples, ninguém pagaria por uma camada só para isso.
A dor do mercado. O intermediário resolve o problema e cria dois: o dado do seu catálogo passa a viver fora do seu domínio, e a latência entre a venda no canal e a baixa no seu estoque vira um parâmetro do fornecedor, não seu. Quando o hub atrasa, você descobre pelo cancelamento.
Como a Catalisa endereça. O Commerce trata sincronização como parte do próprio modelo, e não como serviço externo: StoreProvider liga uma loja a um provedor com papel (PRIMARY, SECONDARY, SOURCE, BIDIRECTIONAL) e direção (PUSH, PULL, BOTH); ProductMapping amarra SKU interno a identificador externo com fieldOwnership por campo; o resolvedor de conflito decide, campo a campo, quem manda. O provedor CATALISA é provisionado como PRIMARY automaticamente, o que fixa uma regra clara: o seu catálogo é a fonte de verdade, o canal é réplica.
O resultado. A arquitetura está pronta e testada contra o provedor interno. Os adaptadores para as plataformas externas ainda não existem — é o item número um do §15, e é a diferença entre "o desenho resolve" e "o produto entrega".
05Mercado e diferenciaisnegócio
Panorama. O mercado de commerce se partiu em três famílias. No topo estão as plataformas composable — commercetools à frente — que vendem API-first para varejo global de grande porte, com contrato empresarial e implantação medida em trimestres. No meio está a plataforma completa, dominada no Brasil pela VTEX, que entrega loja, checkout, marketplace, OMS e encaixe fiscal em um pacote e cobra percentual sobre o que você vende. E na base está o open source — Medusa, Saleor, Vendure, Spree — que entrega o código e transfere a operação para o seu time.
Duas coisas se mexeram nesse mercado em 2026, e vale conhecê-las porque mudam a conversa com o comprador.
A cobrança sobre GMV está avançando, não recuando. A BigCommerce — que virou Commerce.com (NASDAQ: CMRC) em agosto de 2025 — reformou a precificação em 1º de junho de 2026 e criou uma taxa sobre pedido processado fora dos gateways embarcados: 2,0%, 1,0% e 0,6% conforme o plano, exatamente os mesmos degraus da Shopify. Isso enterrou o "sem taxa de transação" que era o argumento mais alto deles contra a Shopify. Do outro lado da linha, commercetools, Medusa e Vendure fizeram do "não cobramos sobre GMV" um argumento comercial explícito. Essa é hoje a divisória mais nítida da categoria. (página oficial da mudança, consultada em 2026-08-16)
O open source de commerce está estreitando. A Vendure trocou MIT por GPLv3 na versão 3. A Medusa fechou RBAC e SSO em 11 de agosto de 2026 — cinco dias antes desta redação —, deixando o núcleo em MIT mas movendo justamente os requisitos de compliance corporativa para licença comercial, inclusive para quem auto-hospeda (`ENTERPRISE-LICENSE.md`). A Spree oscilou de licença duas vezes em menos de dois anos. Restou a Saleor como único núcleo BSD-3 sem reserva — e é justamente a que cobra mais caro na nuvem, e sobre GMV.
Nenhuma das três famílias foi desenhada para o caso que a Catalisa atende: um componente de catálogo, estoque, preço e pedido que outra empresa embute no produto dela, operando várias empresas clientes na mesma instância. Shopify e VTEX assumem que a loja é o produto final; commercetools assume um cliente do porte que sustenta contrato empresarial; Medusa e Saleor assumem que você opera a infraestrutura e resolve multi-tenancy sozinho.
| Critério | Catalisa Commerce | VTEX | Shopify | commercetools | Medusa | Saleor |
|---|---|---|---|---|---|---|
| Como o custo cresce | Por loja e por pedido (em definição) | Com o seu faturamento | Assinatura + % fora do gateway próprio | Por volume de pedidos | Assinatura + compute | Assinatura + % sobre GMV excedente |
| Preço de entrada público | Em definição | Não (pricebook exposto: 2,5% → 0,5%) | Sim (US$ 399/mês Advanced) | Não | Sim (US$ 29/mês) | Sim (US$ 1.599/mês) |
| Multi-tenant por contrato | Sim, organizationId no token | Uma conta por cliente | Uma conta por loja | Projetos separados | Você implementa | Você implementa |
| Multi-loja na mesma conta | Sim, nativo | Sim | Não | Sim (até 300 mil stores) | Parcial | Sim, via canais |
| Estoque multi-local com reserva | Sim | Sim | Sim | Sim | Sim | Sim |
| Máquina de estados de pedido | Declarada, com histórico e permissão por transição | Nativa do fluxo VTEX | Fixa no modelo Shopify | Configurável | Workflows com rollback | Configurável |
| Conectores de marketplace prontos | Não (§15) | Sim, e é o ponto forte no Brasil | Via aplicativos | Via parceiros | Via comunidade | Não |
| Storefront pronto | Não, é só API | Sim | Sim | Não | Não | Não |
| Checkout e pagamento | Fora do escopo, é o Payments | Nativo | Nativo, e é o ativo deles | Fora do escopo | Plugável | Nativo, multi-gateway |
| Fiscal brasileiro | Guarda NCM/CEST/CFOP/alíquotas; não emite | Nativo | Via aplicativos | Fora do escopo | Fora do escopo | Fora do escopo |
| Você opera a infraestrutura | Não | Não | Não | Não | Sim, se auto-hospedar | Sim, se auto-hospedar |
| Licença | Proprietária | Proprietária (repos de storefront sem licença declarada) | Proprietária (Hydrogen MIT) | Proprietária | MIT com reserva desde 2026-08-11 | BSD-3 integral |
Preços e licenças consultados em 2026-08-16 nas páginas oficiais de cada fornecedor. Onde o fornecedor não publica valor, a tabela diz "não".
Nossos diferenciais
- O isolamento é estrutural, não disciplinar. Toda rota de domínio nasce sob
/stores/:storeId, e oresolveStorevalida a posse antes da regra de negócio. Copiar isso não é difícil tecnicamente — é difícil politicamente, porque exige que a decisão tenha sido tomada no primeiro dia. Uma plataforma que começou com uma conta por loja paga muito caro para chegar aqui depois. - Custo desacoplado do faturamento do cliente. No take rate, a plataforma fatura mais quando o cliente cresce sem passar a entregar mais por isso. A própria VTEX documenta o efeito colateral no relatório do segundo trimestre de 2026: GMV crescendo 7,0% em base FX-neutra contra receita de assinatura crescendo 1,3%, porque conta grande paga alíquota menor. Para quem revende operação de e-commerce, esse percentual sai direto da sua margem, e a curva não é negociável.
- Um modelo canônico que já atende quatro verticais.
PHYSICAL,DIGITAL,SERVICEeFOODconvivem no mesmoProduct;PICKUP,DELIVERY,DINE_INeDIGITALconvivem no mesmo pedido. Quem separou varejo de food service em produtos diferentes não junta os dois sem uma migração. - A peça encaixa nas outras 31. Identidade, pagamento, entrega de webhook, armazenamento de imagem, trilha de auditoria e cobrança já existem e falam o mesmo token. O concorrente entrega uma plataforma de commerce; a Catalisa entrega commerce dentro de uma plataforma.
Quando escolher o concorrente. Seja direto aqui, porque o comprador técnico vai descobrir sozinho de qualquer forma.
Se o que ele precisa é vender em marketplace brasileiro amanhã, a VTEX entrega isso hoje e o Commerce não entrega. Marketplace, gestão de sellers e OMS nativos no mesmo núcleo são o diferencial mais defensável deles, e o encaixe com os 500 marketplaces, 200 meios de pagamento e 90 operadores logísticos do ecossistema VTEX IO é trabalho de duas décadas. Nossos adaptadores externos simplesmente não existem (§15).
Se ele precisa de loja no ar em uma semana, com vitrine, checkout, meio de pagamento e tema pronto, a resposta é Shopify — e não há discussão, porque o Commerce é uma API e não uma loja. A conversão de checkout deles é o ativo defensável da categoria, e o preço de entrada é público até o Plus.
Se ele é um varejista global de grande porte com time de plataforma próprio, o commercetools tem limites de catálogo e um modelo B2B — hierarquia de unidades de negócio, milhares de associados por unidade — que nós não temos. Vale a ressalva, porém, e ela é factual: a empresa demitiu cerca de 20% do quadro em 2025, trocou de CEO três vezes em 16 meses e, em julho de 2026, lançou módulos avulsos prometendo modernização sem replatform — o que compradores leem como recuo do pitch composable puro. É risco de fornecedor a considerar dos dois lados da mesa.
Se ele tem time de engenharia forte e quer o código na mão, a Medusa e a Saleor entregam isso. Duas ressalvas úteis para a conversa: na Medusa, RBAC e SSO deixaram de ser MIT em 11 de agosto de 2026 e agora exigem acordo comercial mesmo no auto-hospedado; na Saleor, o núcleo continua BSD-3 puro, mas a nuvem começa em US$ 1.599/mês e cobra percentual sobre o GMV excedente.
O Commerce ganha quando o problema é construir um produto que precisa de commerce por dentro, para várias empresas clientes, sem pagar percentual sobre o faturamento delas e sem operar a infraestrutura. Fora desse recorte, recomende o concorrente e ganhe a credibilidade — ela volta na próxima conversa.
06Modelo de cobrança e ROInegócio
Unidade de cobrança: precificação em definição. O Commerce ainda não tem tabela publicada. Não invente número em proposta — descreva os drivers e leve o caso para a mesa comercial.
O que dispara custo.
| Driver | Por que ele importa |
|---|---|
| Lojas ativas | É a unidade de isolamento e o que o cliente reconhece como "uma operação" |
| Pedidos processados por mês | Melhor proxy de valor entregue e o número que o cliente já acompanha |
| SKUs em catálogo | Dimensiona armazenamento, índice e custo de sincronização |
| Execuções de sincronização com provedor | Cada job toca API externa e é onde o custo variável aparece quando os adaptadores chegarem |
O princípio que orienta a discussão. A unidade de cobrança não deve ser percentual do GMV. Cobrar por loja e por pedido mantém a conta previsível para quem compra e desacopla nossa receita do sucesso comercial dele — o que, num produto revendido dentro do produto de outra empresa, é a diferença entre um custo de infraestrutura e uma mordida na margem.
Comparação de custo — cenário: varejista brasileiro com R$ 50 milhões de GMV por ano, catálogo de 8 mil SKUs, uma operação.
| Fornecedor | Como o custo é montado | Ordem de grandeza anual | Fonte |
|---|---|---|---|
| Catalisa Commerce | Por loja e por pedido | Precificação em definição | — |
| VTEX, plano BUSINESS | 1,8% de take rate sobre o GMV + licença de R$ 60.000 por 12 meses + taxa fixa de R$ 1.500/mês | ≈ R$ 978 mil (R$ 900 mil de take rate + R$ 60 mil + R$ 18 mil) | Pricebook público de assine.vtex.com, consultado em 2026-08-16 |
| Shopify Plus | US$ 2.300/mês em contrato de 3 anos + 0,2% sobre pedido fora do Shopify Payments | US$ 27,6 mil de licença + o percentual do gateway | shopify.com/plus/pricing, consultado em 2026-08-16 |
| Saleor Cloud, plano Volume | US$ 3.999/mês, cobrindo até US$ 1 milhão de GMV/mês; 0,4% sobre o excedente | US$ 48 mil | saleor.io/pricing, consultado em 2026-08-16 |
| Medusa Cloud, plano Scale | US$ 299/mês, sem taxa sobre GMV; compute e edge excedentes à parte | US$ 3,6 mil + excedentes + o seu time de operação | medusajs.com/pricing, consultado em 2026-08-16 |
| commercetools | Por volume de pedidos, explicitamente sem taxa sobre GMV | Não publicado. Terceiros relatam US$ 40 mil a US$ 150 mil/ano | commercetools.com/pricing não traz valores (consultado em 2026-08-16); a faixa vem de Vendr, Elogic e CostBench, não oficial |
Como ler esta tabela. Os valores da VTEX estão em reais e vêm do pricebook que a própria empresa expõe na loja de assinaturas — é referência comercial, não necessariamente o que uma conta enterprise negocia. Os demais estão na moeda do fornecedor, sem conversão, de propósito: somar câmbio a esta conta transformaria um número verificável em estimativa. A linha da VTEX é a única diretamente comparável ao cenário em reais. Antes de usar qualquer comparação em proposta, reconsulte a página do fornecedor e atualize a data — preço sem data é passivo, não ativo.
O detalhe que sustenta o argumento. No pricebook da VTEX, todos os planos partem de uma especificação Initial Take Rate = 2,5%, e o valor fixo pago é literalmente compra de redução dessa alíquota: 2,5% no ON DEMAND, 1,8% no BUSINESS, 1,1% no CORPORATE e 0,5% no ENTERPRISE. Ou seja, o cliente paga adiantado para que o percentual doa menos. É um modelo coerente para quem opera a própria loja e desconfortável para quem revende — porque o percentual incide sobre o faturamento do cliente final e sai da margem do revendedor.
ROI. O retorno não está na linha de licença — o open source auto-hospedado tem licença zero e continuará tendo. Está em duas contas.
A primeira é o que não se reescreve. Catálogo com variantes, estoque multi-local com reserva, resolução de preço efetivo e máquina de estados de pedido com histórico são, somados, meses de trabalho de um time que já tem o que fazer — e são o tipo de código cujo erro só aparece em produção, na forma de estoque negativo ou pedido em estado impossível. Some a isso o multi-tenancy, que Medusa, Saleor e Vendure deixam inteiramente por sua conta.
A segunda é o percentual, e ela é a que decide. Na conta acima, um take rate de 1,8% custa quase um milhão de reais por ano sobre R$ 50 milhões de GMV — e dobra se o cliente dobrar de tamanho, sem que a plataforma passe a entregar mais. Trocar percentual variável por custo previsível por loja e por pedido é, para quem embute commerce no próprio produto, o argumento econômico central. Ele não depende de sermos mais baratos hoje: depende de a curva ser diferente.
07Arquitetura
HTTP
│
┌───────────────────────────────────┴─────────────────────────────────────────┐
│ Hono app basePath('/commerce') │
│ applyCommonMiddleware: bodyLimit 1MB · CORS · security headers · rate limit│
│ │
│ /health sonda de versão │
│ /api/v1/stores storesRouter (CRUD de loja) │
└───────────────────────────────────┬──────────────────────────────────────────┘
│ tudo abaixo vive sob /:storeId
┌───────────────────────────────────┴──────────────────────────────────────────┐
│ authMiddleware → resolveStore │
│ 1. storeId é UUID válido? senão 400 │
│ 2. token traz organizationId? senão 403 │
│ 3. loja existe E pertence à organização? senão 404 │
│ 4. loja está ativa? senão 403 │
│ → c.set('storeId') │
└───────────────────────────────────┬──────────────────────────────────────────┘
│ requirePermission(COMMERCE_*)
┌───────────────────────────────────┴──────────────────────────────────────────┐
│ 19 sub-routers montados em /api/v1/stores/:storeId/... │
│ │
│ CATÁLOGO categories · products · variants · images · price-modifiers │
│ ESTOQUE stock-locations · stock · stock-movements · batches │
│ PREÇO price-lists · promotions │
│ VENDA carts · orders │
│ DISTRIBUIÇÃO channels · providers · provider-sync · provider-webhooks │
│ SINCRONIZAÇÃO store-providers · product-mappings │
└───────────────────────────────────┬──────────────────────────────────────────┘
│ Zod parse → ResultAsync<T, AppError>
┌───────────────────────────────────┴──────────────────────────────────────────┐
│ services/ (23) sync/ (3) jobs/ (3) │
│ product, variant sync-engine cart-cleanup │
│ stock, batch mapping reservation-cleanup │
│ price-list, promotion conflict-resolver low-stock-alert │
│ order, order-lifecycle │
│ cart, channel providers/ │
│ store-provider catalisa (implementado) │
│ provider-sync/webhook ifood (mock/molde) │
│ orchestration, tax-config │
└───────────────────────────────────┬──────────────────────────────────────────┘
│
┌───────────────────────────────────┴──────────────────────────────────────────┐
│ repositories/ (31, Prisma) → PostgreSQL, schema "commerce", 32 tabelas │
│ EventPublisher → barramento compartilhado (Redis) │
└──────────────────────────────────────────────────────────────────────────────┘
Decisões não óbvias.
Tudo nasce sob
/stores/:storeId, inclusive o que não parece precisar. É a decisão mais consequente do módulo. Ela custa uma URL mais longa e paga com uma invariante: nenhuma rota de domínio existe fora do escopo de uma loja já validada contra a organização do token. O middlewareresolveStoreroda antes de qualquer serviço, em todos os 19 sub-routers, sem exceção. A alternativa — passarorganizationIdpara cada serviço e torcer — é a que produz vazamento entre clientes.A loja inativa devolve
403, não lista vazia. Desativar uma loja é uma ação operacional com consequência: as chamadas param, e param com uma mensagem que diz por quê. Devolver lista vazia faria o cliente acreditar que perdeu os dados.CATALISAé sempre o provedorPRIMARY, e é provisionado automaticamente. Quando você anexa o primeiro provedor externo a uma loja semPRIMARY, oStoreProviderServicecria o provedor interno comoPRIMARYantes de anexar o seu. Isso resolve, de uma vez, a pergunta "quem manda quando os dois discordam": o seu catálogo manda, o canal é réplica. Só existe umPRIMARYpor loja, e trocar exige rebaixar o atual primeiro.A sincronização de produtos roda em sequência, não em paralelo. No
pullProductse nopushProductsdo sync engine, os itens são encadeados um a um comandThen. É mais lento de propósito: o SKU tem unicidade por loja (@@unique([storeId, sku])), e processar em paralelo produz corrida entre dois itens que reivindicam o mesmo SKU. Lote grande é lento — é o trade-off aceito para não corromper mapeamento.A resolução de conflito é por campo, com dono declarado.
ProductMapping.fieldOwnershipé um JSON que diz, campo a campo, qual provedor é dono daquele campo. Sem dono declarado, o valor interno vence. É deliberadamente conservador: na dúvida, a réplica não sobrescreve o original.O preço efetivo é uma consulta, não um cálculo espalhado.
findEffectivePricemonta a decisão inteira em uma única query: filtra listas ativas e vigentes da organização e da loja, respeitaminQuantity, opcionalmente restringe ao canal, ordena porpriorityda lista e depois porminQuantitydecrescente, e devolve a primeira. Regra de preço que mora em vários lugares é regra que ninguém consegue explicar depois.O evento sai com
safePublish. Falha de publicação não derruba a transação de negócio. Um pedido confirmado com evento não entregue é um problema de entrega; um pedido que não confirma porque o Redis piscou é um problema de venda.
Monolito vs. standalone. Em monolito, o Commerce resolve as dependências pelo container TypeDI e roda junto com os demais na porta 3000. Em standalone — o modo usado em produção — ele sobe sozinho na porta 3026 com MODULE_SELF=commerce. A diferença que importa: em standalone os middlewares globais do src/app.ts não rodam, e é por isso que o app.ts do módulo chama applyCommonMiddleware explicitamente. Sem essa chamada, o serviço subiria sem limite de corpo, sem cabeçalho de segurança e sem rate limit.
08Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
| Store | Uma operação de venda. É a unidade de isolamento dentro da organização: todo dado de catálogo, estoque, preço e pedido pertence a exatamente uma. |
| Product | O item comercial abstrato — "Camiseta Básica". Não tem preço nem estoque próprio. |
| Variant | O que de fato se vende — "Camiseta Básica, P, preta". Carrega SKU, basePrice, código de barras e peso. Estoque e preço vivem aqui. |
| StockLocation | Onde o estoque fica: depósito, loja física, unidade. Tem código único por loja e uma marcada como padrão. |
| StockItem | O saldo de uma variante em um local: quantity total e reservedQty separado. Disponível é a diferença dos dois. |
| StockMovement | O registro imutável de uma alteração de saldo, com tipo, quantidade, origem, destino, motivo e autor. É a trilha do estoque. |
| StockReservation | Uma quantidade separada para um pedido ou carrinho, com validade. Enquanto ativa, sai do disponível sem sair do total. |
| BatchItem | Lote com número, data de produção e validade, por variante e local. Serve a quem controla perecível ou rastreabilidade. |
| PriceList | Uma tabela de preço com tipo (DEFAULT, WHOLESALE, VIP, CHANNEL), prioridade, janela de validade e canal opcional. |
| PriceModifier | Adicional ou opcional preso a um produto — bacon extra, embalagem para presente. |
| Promotion | Desconto com código, tipo, valor, valor mínimo de pedido, limite de usos e janela. |
| Channel | Um ponto de venda: vitrine própria, marketplace, plataforma de delivery, PDV, rede social. Recorta disponibilidade e preço. |
| Cart | Carrinho com validade (24h por padrão), que vira pedido no checkout. |
| Order | Um pedido, com número sequencial único por loja, itens congelados no momento da criação e ciclo de vida próprio. |
| ProviderConfig | Credencial e configuração de conexão com uma plataforma externa. |
| StoreProvider | O vínculo entre uma loja e um provedor, com papel (PRIMARY/SECONDARY/SOURCE/BIDIRECTIONAL), direção e prioridade. |
| ProductMapping | A ponte entre o SKU interno e o identificador do produto na plataforma externa, com dono por campo. |
| fieldOwnership | JSON no mapeamento que declara, campo a campo, qual provedor tem autoridade. Sem declaração, o interno vence. |
Modelo de dados — schema commerce no PostgreSQL, 32 modelos.
| Modelo Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
CommerceStore | commerce_stores | A operação de venda | @@unique([organizationId, slug]), type, currency, timezone, isActive, deletedAt |
CommerceCategory | commerce_categories | Árvore de categorias | @@unique([storeId, slug]), parentId (auto-relação) |
CommerceCategoryTaxConfig | commerce_category_tax_configs | Fiscal por categoria | ncm, cest, cfop, icmsRate, ipiRate, pisRate, cofinsRate |
CommerceProduct | commerce_products | Item comercial | @@unique([storeId, slug]), type, status, brand, attributes, deletedAt |
CommerceProductCategory | commerce_product_categories | Produto ↔ categoria (N:N) | @@unique([productId, categoryId]) |
CommerceProductVariant | commerce_product_variants | O que se vende | @@unique([productId, sku]), @@unique([storeId, sku]), basePrice, costPrice, compareAtPrice, barcode |
CommerceProductImage | commerce_product_images | Imagens do produto | fileId (File Storage) ou url, sortOrder, isPrimary |
CommerceProductTag | commerce_product_tags | Etiquetas livres | @@unique([productId, tag]) |
CommerceProductTaxConfig | commerce_product_tax_configs | Fiscal por produto | Sobrepõe a da categoria; inclui origem |
CommerceStockLocation | commerce_stock_locations | Onde o estoque fica | @@unique([storeId, code]), isDefault, isActive |
CommerceStockItem | commerce_stock_items | Saldo por variante e local | @@unique([variantId, locationId]), quantity, reservedQty, reorderPoint, reorderQty |
CommerceStockMovement | commerce_stock_movements | Trilha imutável de estoque | type, quantity, fromLocationId, toLocationId, referenceType, referenceId, createdBy |
CommerceStockReservation | commerce_stock_reservations | Quantidade separada | status, referenceType, referenceId, expiresAt |
CommerceBatchItem | commerce_batch_items | Lote e validade | @@unique([variantId, locationId, batchNumber]), expiresAt, producedAt |
CommercePriceList | commerce_price_lists | Tabela de preço | type, priority, validFrom, validUntil, channelId |
CommercePriceListEntry | commerce_price_list_entries | Preço de uma variante | @@unique([priceListId, variantId, minQuantity]) |
CommercePromotion | commerce_promotions | Desconto | @@unique([storeId, code]), type, value, minOrderValue, maxUses, usedCount |
CommercePriceModifier | commerce_price_modifiers | Adicional por produto | type, price, sortOrder, isActive |
CommerceOrder | commerce_orders | Pedido | @@unique([storeId, orderNumber]), status, source, deliveryType, externalId, nove carimbos de tempo |
CommerceOrderItem | commerce_order_items | Item do pedido | Congela productName, variantName, sku, unitPrice; onDelete: Restrict na variante |
CommerceOrderStatusHistory | commerce_order_status_history | Trilha do pedido | fromStatus, toStatus, reason, changedBy |
CommerceChannel | commerce_channels | Ponto de venda | type, status, config |
CommerceChannelProductAvailability | commerce_channel_product_availability | Produto no canal | @@unique([channelId, productId]), isAvailable, priceOverride |
CommerceProviderConfig | commerce_provider_configs | Conexão externa | providerType, credentials, webhookUrl, webhookSecret, isActive |
CommerceProviderSyncJob | commerce_provider_sync_jobs | Execução de sincronização | status, direction, entityType, contadores de itens, errorMessage |
CommerceProviderWebhookLog | commerce_provider_webhook_logs | Webhook recebido | eventType, payload, headers, processed, processedAt |
CommerceCart | commerce_carts | Carrinho | status, sessionId, customerId, expiresAt |
CommerceCartItem | commerce_cart_items | Item do carrinho | @@unique([cartId, variantId]) |
CommerceStoreProvider | commerce_store_providers | Loja ↔ provedor | @@unique([storeId, providerConfigId]), role, syncDirection, priority, lastSyncAt |
CommerceProductMapping | commerce_product_mappings | SKU ↔ id externo | @@unique([storeProviderId, sku]), fieldOwnership, status, lastSyncError |
CommerceOrderMapping | commerce_order_mappings | Pedido ↔ id externo | @@unique([storeProviderId, externalOrderId]), externalStatus |
CommerceCategoryMapping | commerce_category_mappings | Categoria ↔ id externo | @@unique([storeProviderId, externalCategoryId]) |
Enumerações
| Enum | Valores |
|---|---|
CommerceProductType | PHYSICAL · DIGITAL · SERVICE · FOOD |
CommerceProductStatus | DRAFT · ACTIVE · ARCHIVED |
CommerceVariantStatus | ACTIVE · INACTIVE |
CommerceStockMovementType | INBOUND · OUTBOUND · ADJUSTMENT · TRANSFER · RESERVATION · RESERVATION_RELEASE |
CommerceStockReservationStatus | ACTIVE · RELEASED · CONSUMED |
CommercePriceListType | DEFAULT · WHOLESALE · VIP · CHANNEL |
CommercePromotionType | PERCENTAGE_DISCOUNT · FIXED_DISCOUNT · BUY_X_GET_Y · FLASH_SALE |
CommercePromotionStatus | DRAFT · ACTIVE · EXPIRED · CANCELLED |
CommerceOrderStatus | DRAFT · CONFIRMED · PREPARING · READY · SHIPPED · OUT_FOR_DELIVERY · DELIVERED · COMPLETED · CANCELLED · REFUNDED |
CommerceOrderSource | STOREFRONT · MARKETPLACE · DELIVERY_PLATFORM · POS · API · MANUAL |
CommerceDeliveryType | PICKUP · DELIVERY · DINE_IN · DIGITAL |
CommerceChannelType | STOREFRONT · MARKETPLACE · DELIVERY_PLATFORM · POS · SOCIAL |
CommerceChannelStatus | ACTIVE · INACTIVE · MAINTENANCE |
CommerceProviderType | CATALISA · IFOOD · RAPPI · UBER_EATS · MERCADO_LIVRE · AMAZON · SHOPEE · MAGALU · VTEX · NUVEMSHOP · SHOPIFY · CUSTOM |
CommerceProviderRole | PRIMARY · SECONDARY · SOURCE · BIDIRECTIONAL |
CommerceSyncDirection | PUSH · PULL · BOTH |
CommerceSyncEntityType | PRODUCT · ORDER · INVENTORY · PRICE · CATEGORY |
CommerceMappingStatus | ACTIVE · STALE · CONFLICT · UNMAPPED |
CommerceProviderSyncStatus | PENDING · IN_PROGRESS · COMPLETED · FAILED |
CommerceCartStatus | ACTIVE · CONVERTED · EXPIRED · ABANDONED |
Atenção ao
CommerceProviderType. O enum lista doze plataformas. Isso é o vocabulário previsto, não a lista do que está implementado. Só o adaptadorCATALISAexiste de fato. Ver §15.
Máquina de estados do pedido
As transições válidas estão declaradas em VALID_ORDER_TRANSITIONS (src/commerce/types/index.ts). Uma transição fora da tabela devolve 400 VALIDATION com a mensagem Cannot transition from X to Y.
POST /orders
│
▼
┌───────────┐
│ DRAFT │◀── único estado em que PATCH altera o pedido
└─────┬─────┘
confirm │
▼
┌───────────┐
│ CONFIRMED │
└─────┬─────┘
prepare │
▼
┌───────────┐
│ PREPARING │
└─────┬─────┘
ready │
▼
┌───────────┬─────────┬──────────────┐
ship │ │ READY │ │ complete
│ └────┬────┘ │
▼ out-for-delivery │
┌──────────┐ │ │
│ SHIPPED │ │ │
└────┬─────┘ │ │
out-for-delivery │ │
└───────┬────────┘ │
▼ │
┌───────────────────┐ │
│ OUT_FOR_DELIVERY │ │
└─────────┬─────────┘ │
deliver │ │
▼ ▼
┌────────────┐ complete ┌─────────────┐
│ DELIVERED │──────────────▶│ COMPLETED │
└──────┬─────┘ └──────┬──────┘
refund │ │ refund
└───────────┬────────────────┘
▼
┌──────────────┐
│ REFUNDED │ terminal
└──────────────┘
cancel aceito de DRAFT, CONFIRMED, PREPARING, READY, SHIPPED e OUT_FOR_DELIVERY
──▶ CANCELLED (terminal). NÃO é aceito a partir de DELIVERED nem de
COMPLETED — depois de entregue, o caminho é refund.
Cada transição: grava o carimbo próprio (confirmedAt, preparingAt, readyAt,
shippedAt, outForDeliveryAt, deliveredAt, completedAt, cancelledAt, refundedAt),
insere linha em CommerceOrderStatusHistory com autor e motivo, e publica
commerce.order.<status em minúsculas> no barramento.
Efeitos colaterais de estoque nas transições
| Transição | Efeito declarado no OrderLifecycleService |
|---|---|
→ CONFIRMED | Chama reserveOrderStock — hoje é um no-op, não reserva nada (§15) |
→ CANCELLED | stockService.releaseReservations('order', orderId) — devolve o reservado ao disponível |
→ COMPLETED | stockService.consumeReservations('order', orderId) — baixa definitiva do saldo |
Ciclo de vida do carrinho
POST /carts checkout
│ │
▼ ▼
┌────────┐ ┌───────────┐
│ ACTIVE │──────────────────────▶│ CONVERTED │ vira pedido em DRAFT
└───┬────┘ └───────────┘
│ passou de expiresAt (padrão: 24h)
▼
┌─────────┐ ┌───────────┐
│ EXPIRED │ │ ABANDONED │ estado previsto no enum, sem rotina
└─────────┘ └───────────┘ que o atribua hoje (§15)
O job CommerceCartCleanupJob marca ACTIVE vencido como EXPIRED.
09Referência da API
Prefixo: /commerce. Em standalone, a base é https://commerce.bb.stg.catalisa.app.
Regra estrutural que vale para tudo abaixo. Exceto /commerce/health, toda rota vive sob /commerce/api/v1/stores. As rotas de loja aplicam authMiddleware + requirePermission + um requireOrganization local. Todas as demais aplicam authMiddleware + resolveStore no use('*') do sub-router, e depois requirePermission por rota. O resolveStore já cobre o papel do requireOrganization: sem organizationId no token, ele devolve 403 antes de qualquer coisa.
Sobre a contagem.
scripts/docs/contar-endpoints.sh commercedevolve 130, e é esse o número do frontmatter. As 7 transições de ciclo de vida do pedido (confirm,prepare,ready,ship,out-for-delivery,deliver,complete) são registradas por um helperlifecycleRoute(...)dentro deorders.router.tse o script não as enxerga. O total real de caminhos HTTP servidos é 137. Todas estão documentadas abaixo.
Saúde
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /commerce/health | Nome do serviço e versão | Pública |
Lojas — /commerce/api/v1/stores
authMiddleware + requirePermission + requireOrganization.
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /commerce/api/v1/stores | Cria loja | COMMERCE_STORES_CREATE |
GET | /commerce/api/v1/stores | Lista lojas. Filtros: type, isActive | COMMERCE_STORES_READ |
GET | /commerce/api/v1/stores/:storeId | Busca loja | COMMERCE_STORES_READ |
PATCH | /commerce/api/v1/stores/:storeId | Atualiza loja | COMMERCE_STORES_UPDATE |
DELETE | /commerce/api/v1/stores/:storeId | Exclusão lógica | COMMERCE_STORES_DELETE |
Daqui em diante,
{base}=/commerce/api/v1/stores/:storeId.
Categorias — {base}/categories
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/categories | Cria categoria | COMMERCE_CATEGORIES_CREATE |
GET | {base}/categories | Lista. ?tree=true devolve a árvore; ?parentId= filtra por pai | COMMERCE_CATEGORIES_READ |
GET | {base}/categories/:id | Busca categoria | COMMERCE_CATEGORIES_READ |
PATCH | {base}/categories/:id | Atualiza | COMMERCE_CATEGORIES_UPDATE |
DELETE | {base}/categories/:id | Remove | COMMERCE_CATEGORIES_DELETE |
GET | {base}/categories/:id/tax-config | Config fiscal da categoria | COMMERCE_CONFIG_MANAGE |
PUT | {base}/categories/:id/tax-config | Cria ou substitui a config fiscal | COMMERCE_CONFIG_MANAGE |
DELETE | {base}/categories/:id/tax-config | Remove a config fiscal | COMMERCE_CONFIG_MANAGE |
Produtos — {base}/products
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/products | Cria produto | COMMERCE_PRODUCTS_CREATE |
GET | {base}/products | Lista. Filtros: status, type, search | COMMERCE_PRODUCTS_READ |
GET | {base}/products/:id | Busca produto | COMMERCE_PRODUCTS_READ |
PATCH | {base}/products/:id | Atualiza | COMMERCE_PRODUCTS_UPDATE |
DELETE | {base}/products/:id | Exclusão lógica | COMMERCE_PRODUCTS_DELETE |
GET | {base}/products/:id/tags | Etiquetas do produto | COMMERCE_PRODUCTS_READ |
POST | {base}/products/:id/tags | Adiciona etiqueta | COMMERCE_PRODUCTS_UPDATE |
DELETE | {base}/products/:id/tags/:tag | Remove etiqueta | COMMERCE_PRODUCTS_UPDATE |
GET | {base}/products/:id/categories | Categorias do produto | COMMERCE_PRODUCTS_READ |
POST | {base}/products/:id/categories | Vincula a uma categoria | COMMERCE_PRODUCTS_UPDATE |
DELETE | {base}/products/:id/categories/:categoryId | Desvincula | COMMERCE_PRODUCTS_UPDATE |
GET | {base}/products/:id/tax-config | Config fiscal do produto | COMMERCE_CONFIG_MANAGE |
PUT | {base}/products/:id/tax-config | Cria ou substitui | COMMERCE_CONFIG_MANAGE |
DELETE | {base}/products/:id/tax-config | Remove | COMMERCE_CONFIG_MANAGE |
Variantes — {base}/products/:productId/variants
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/products/:productId/variants | Cria variante | COMMERCE_PRODUCTS_CREATE |
GET | {base}/products/:productId/variants | Lista variantes | COMMERCE_PRODUCTS_READ |
GET | {base}/products/:productId/variants/:variantId | Busca variante | COMMERCE_PRODUCTS_READ |
PATCH | {base}/products/:productId/variants/:variantId | Atualiza | COMMERCE_PRODUCTS_UPDATE |
DELETE | {base}/products/:productId/variants/:variantId | Remove | COMMERCE_PRODUCTS_DELETE |
Imagens de produto — {base}/products/:productId/images
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/products/:productId/images | Adiciona imagem (fileId do File Storage ou url) | COMMERCE_PRODUCTS_UPDATE |
GET | {base}/products/:productId/images | Lista imagens | COMMERCE_PRODUCTS_READ |
GET | {base}/products/:productId/images/:imageId | Busca imagem | COMMERCE_PRODUCTS_READ |
PATCH | {base}/products/:productId/images/:imageId | Atualiza altText, isPrimary, sortOrder | COMMERCE_PRODUCTS_UPDATE |
DELETE | {base}/products/:productId/images/:imageId | Remove imagem | COMMERCE_PRODUCTS_UPDATE |
POST | {base}/products/:productId/images/reorder | Reordena em lote | COMMERCE_PRODUCTS_UPDATE |
Modificadores de preço — {base}/products/:productId/modifiers
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/products/:productId/modifiers | Cria adicional | COMMERCE_PRICING_CREATE |
GET | {base}/products/:productId/modifiers | Lista adicionais | COMMERCE_PRICING_READ |
PATCH | {base}/products/:productId/modifiers/:modifierId | Atualiza | COMMERCE_PRICING_UPDATE |
DELETE | {base}/products/:productId/modifiers/:modifierId | Remove | COMMERCE_PRICING_DELETE |
Locais de estoque — {base}/stock-locations
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/stock-locations | Cria local | COMMERCE_STOCK_CREATE |
GET | {base}/stock-locations | Lista locais | COMMERCE_STOCK_READ |
GET | {base}/stock-locations/:id | Busca local | COMMERCE_STOCK_READ |
PATCH | {base}/stock-locations/:id | Atualiza | COMMERCE_STOCK_UPDATE |
DELETE | {base}/stock-locations/:id | Remove | COMMERCE_STOCK_DELETE |
Estoque e reservas — {base}/stock
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | {base}/stock/levels/:variantId | Saldo por local de uma variante | COMMERCE_STOCK_READ |
GET | {base}/stock/low-stock | Itens abaixo do reorderPoint | COMMERCE_STOCK_READ |
GET | {base}/stock/reservations | Lista reservas. ?status= (padrão ACTIVE) | COMMERCE_STOCK_READ |
GET | {base}/stock/reservations/:id | Busca reserva | COMMERCE_STOCK_READ |
DELETE | {base}/stock/reservations/:id | Libera as reservas da referência dela | COMMERCE_STOCK_DELETE |
Movimentações de estoque — {base}/stock-movements
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/stock-movements | Registra movimentação e ajusta saldo | COMMERCE_STOCK_ADJUST |
GET | {base}/stock-movements | Lista. Filtros: variantId, type | COMMERCE_STOCK_READ |
Lotes — {base}/batches
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/batches | Cria lote | COMMERCE_STOCK_CREATE |
GET | {base}/batches | Lista. Filtros: variantId, locationId | COMMERCE_STOCK_READ |
GET | {base}/batches/:id | Busca lote | COMMERCE_STOCK_READ |
PATCH | {base}/batches/:id | Atualiza quantidade e datas | COMMERCE_STOCK_UPDATE |
DELETE | {base}/batches/:id | Remove lote | COMMERCE_STOCK_DELETE |
Listas de preço — {base}/price-lists
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/price-lists | Cria lista | COMMERCE_PRICING_CREATE |
GET | {base}/price-lists | Lista. Filtro: type | COMMERCE_PRICING_READ |
GET | {base}/price-lists/effective-price/:variantId | Resolve o preço vigente. ?quantity=, ?channelId= | COMMERCE_PRICING_READ |
GET | {base}/price-lists/:id | Busca lista | COMMERCE_PRICING_READ |
PATCH | {base}/price-lists/:id | Atualiza | COMMERCE_PRICING_UPDATE |
DELETE | {base}/price-lists/:id | Remove | COMMERCE_PRICING_DELETE |
GET | {base}/price-lists/:id/entries | Lista entradas | COMMERCE_PRICING_READ |
POST | {base}/price-lists/:id/entries | Adiciona entrada | COMMERCE_PRICING_CREATE |
GET | {base}/price-lists/:id/entries/:entryId | Busca entrada | COMMERCE_PRICING_READ |
PATCH | {base}/price-lists/:id/entries/:entryId | Atualiza entrada | COMMERCE_PRICING_UPDATE |
DELETE | {base}/price-lists/:id/entries/:entryId | Remove entrada | COMMERCE_PRICING_DELETE |
A rota
effective-price/:variantIdé declarada antes de/:id. A ordem importa: invertida,effective-priceseria capturada como um id de lista.
Promoções — {base}/promotions
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/promotions | Cria promoção | COMMERCE_PROMOTIONS_CREATE |
GET | {base}/promotions | Lista. Filtros: status, type | COMMERCE_PROMOTIONS_READ |
POST | {base}/promotions/validate | Valida um código contra um valor de pedido | COMMERCE_PROMOTIONS_READ |
GET | {base}/promotions/:id | Busca promoção | COMMERCE_PROMOTIONS_READ |
PATCH | {base}/promotions/:id | Atualiza | COMMERCE_PROMOTIONS_UPDATE |
DELETE | {base}/promotions/:id | Remove | COMMERCE_PROMOTIONS_DELETE |
Carrinhos — {base}/carts
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/carts | Cria carrinho (validade padrão 24h) | COMMERCE_CARTS_CREATE |
GET | {base}/carts/:id | Busca carrinho com itens | COMMERCE_CARTS_READ |
POST | {base}/carts/:id/items | Adiciona ou substitui item | COMMERCE_CARTS_UPDATE |
PATCH | {base}/carts/:id/items/:variantId | Atualiza quantidade do item | COMMERCE_CARTS_UPDATE |
DELETE | {base}/carts/:id/items/:variantId | Remove item | COMMERCE_CARTS_UPDATE |
POST | {base}/carts/:id/checkout | Converte em pedido DRAFT | COMMERCE_ORDERS_CREATE |
Pedidos — {base}/orders
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/orders | Cria pedido em DRAFT | COMMERCE_ORDERS_CREATE |
GET | {base}/orders | Lista. Filtros: status, source, customerId, channelId | COMMERCE_ORDERS_READ |
GET | {base}/orders/:id | Busca pedido | COMMERCE_ORDERS_READ |
PATCH | {base}/orders/:id | Atualiza (só em DRAFT) | COMMERCE_ORDERS_UPDATE |
GET | {base}/orders/:id/items | Itens do pedido | COMMERCE_ORDERS_READ |
POST | {base}/orders/:id/items | Adiciona item | COMMERCE_ORDERS_UPDATE |
PATCH | {base}/orders/:id/items/:itemId | Atualiza item | COMMERCE_ORDERS_UPDATE |
DELETE | {base}/orders/:id/items/:itemId | Remove item | COMMERCE_ORDERS_UPDATE |
GET | {base}/orders/:id/history | Histórico de status | COMMERCE_ORDERS_READ |
POST | {base}/orders/:id/confirm | → CONFIRMED | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/prepare | → PREPARING | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/ready | → READY | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/ship | → SHIPPED | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/out-for-delivery | → OUT_FOR_DELIVERY | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/deliver | → DELIVERED | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/complete | → COMPLETED | COMMERCE_ORDERS_MANAGE |
POST | {base}/orders/:id/cancel | → CANCELLED, aceita {"reason":"..."} | COMMERCE_ORDERS_CANCEL |
POST | {base}/orders/:id/refund | → REFUNDED, aceita {"reason":"..."} | COMMERCE_ORDERS_REFUND |
Cancelar e estornar têm permissão própria, separada de
COMMERCE_ORDERS_MANAGE. Um operador de expedição avança o pedido sem poder cancelar nem estornar.
Canais — {base}/channels
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/channels | Cria canal | COMMERCE_CHANNELS_CREATE |
GET | {base}/channels | Lista. Filtros: type, status | COMMERCE_CHANNELS_READ |
GET | {base}/channels/:id | Busca canal | COMMERCE_CHANNELS_READ |
PATCH | {base}/channels/:id | Atualiza | COMMERCE_CHANNELS_UPDATE |
DELETE | {base}/channels/:id | Remove | COMMERCE_CHANNELS_DELETE |
PUT | {base}/channels/:id/products/:productId/availability | Liga/desliga produto no canal, com priceOverride | COMMERCE_CHANNELS_UPDATE |
PUT | {base}/channels/:id/products/bulk | Mesma coisa em lote | COMMERCE_CHANNELS_UPDATE |
GET | {base}/channels/:id/products | Produtos disponíveis no canal | COMMERCE_CHANNELS_READ |
DELETE | {base}/channels/:id/products/:productId/availability | Remove a regra de disponibilidade | COMMERCE_CHANNELS_DELETE |
Configurações de provedor — {base}/providers
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/providers | Cria configuração de provedor | COMMERCE_PROVIDERS_CREATE |
GET | {base}/providers | Lista configurações | COMMERCE_PROVIDERS_READ |
GET | {base}/providers/:id | Busca configuração | COMMERCE_PROVIDERS_READ |
PATCH | {base}/providers/:id | Atualiza | COMMERCE_PROVIDERS_UPDATE |
DELETE | {base}/providers/:id | Remove | COMMERCE_PROVIDERS_DELETE |
POST | {base}/providers/:id/test | Testa a conexão (resposta simulada hoje — §15) | COMMERCE_PROVIDERS_READ |
Sincronização — {base}/provider-sync
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/provider-sync/:providerConfigId/sync | Dispara sincronização. Corpo: {"type":"products"} | COMMERCE_PROVIDERS_SYNC |
GET | {base}/provider-sync/:providerConfigId/jobs | Lista execuções | COMMERCE_PROVIDERS_READ |
GET | {base}/provider-sync/jobs/:id | Busca execução | COMMERCE_PROVIDERS_READ |
POST | {base}/provider-sync/jobs/:id/retry | Repete uma execução FAILED | COMMERCE_PROVIDERS_SYNC |
Webhooks de provedor — {base}/provider-webhooks
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/provider-webhooks/:providerConfigId | Recebe webhook do provedor | Sem autenticação — ver §15 |
GET | {base}/provider-webhooks/:configId/logs | Lista webhooks recebidos | COMMERCE_PROVIDERS_READ |
GET | {base}/provider-webhooks/:configId/logs/:logId | Detalhe de um webhook | COMMERCE_PROVIDERS_READ |
Provedores da loja — {base}/store-providers
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | {base}/store-providers | Anexa provedor à loja | COMMERCE_STORE_PROVIDERS_CREATE |
GET | {base}/store-providers | Lista. Filtro: isActive | COMMERCE_STORE_PROVIDERS_READ |
GET | {base}/store-providers/:id | Busca vínculo | COMMERCE_STORE_PROVIDERS_READ |
PATCH | {base}/store-providers/:id | Atualiza papel, direção, prioridade | COMMERCE_STORE_PROVIDERS_UPDATE |
DELETE | {base}/store-providers/:id | Desanexa | COMMERCE_STORE_PROVIDERS_DELETE |
POST | {base}/store-providers/:id/sync | Sincronização completa | COMMERCE_STORE_PROVIDERS_SYNC |
POST | {base}/store-providers/:id/sync/products | Só produtos | COMMERCE_STORE_PROVIDERS_SYNC |
POST | {base}/store-providers/:id/sync/orders | Só pedidos | COMMERCE_STORE_PROVIDERS_SYNC |
POST | {base}/store-providers/:id/sync/inventory | Só estoque | COMMERCE_STORE_PROVIDERS_SYNC |
GET | {base}/store-providers/:id/mappings | Mapeamentos deste provedor. Filtro: status | COMMERCE_MAPPINGS_READ |
Mapeamentos de produto — {base}/product-mappings
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | {base}/product-mappings | Lista. Filtros: status, storeProviderId | COMMERCE_MAPPINGS_READ |
GET | {base}/product-mappings/by-sku/:sku | Todos os mapeamentos de um SKU, entre provedores | COMMERCE_MAPPINGS_READ |
GET | {base}/product-mappings/:id | Busca mapeamento | COMMERCE_MAPPINGS_READ |
PATCH | {base}/product-mappings/:id | Atualiza fieldOwnership e status | COMMERCE_MAPPINGS_UPDATE |
DELETE | {base}/product-mappings/:id | Remove mapeamento | COMMERCE_MAPPINGS_DELETE |
Abaixo, o detalhe dos endpoints que um integrador usa primeiro.
POST /commerce/api/v1/stores
Cria a operação de venda. É o primeiro passo obrigatório — sem loja, nenhuma outra rota do módulo existe.
Request
{
"name": "Loja Centro",
"slug": "loja-centro",
"type": "GENERAL",
"currency": "BRL",
"timezone": "America/Sao_Paulo"
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name | string (1–255) | Sim | Nome de exibição |
slug | string (1–255) | Não | Identificador legível. Único por organização |
description | string (≤1000) | Não | — |
type | GENERAL | FOOD | DIGITAL | SERVICE | MARKETPLACE | Não | Padrão GENERAL |
logoUrl | string (URL) | Não | — |
currency | string (3) | Não | Padrão BRL |
timezone | string (≤100) | Não | Padrão America/Sao_Paulo |
address, contact, settings, metadata | object | Não | JSON livre |
Resposta 201 — { "data": { "id": "...", "slug": "loja-centro", "isActive": true, ... } }
Erros — 400 corpo inválido · 403 token sem organizationId · 409 slug já em uso na organização
POST {base}/products
Cria o produto. Ele nasce sem preço e sem estoque: quem carrega os dois é a variante.
Request
{
"name": "Camiseta Básica",
"type": "PHYSICAL",
"status": "DRAFT",
"brand": "Marca Própria",
"categoryIds": ["6f1c...uuid"],
"tags": ["verao", "algodao"],
"attributes": { "material": "algodão", "genero": "unissex" }
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name | string (1–300) | Sim | — |
type | PHYSICAL | DIGITAL | SERVICE | FOOD | Sim | Não tem padrão |
slug | string (1–300) | Não | Único por loja |
status | DRAFT | ACTIVE | ARCHIVED | Não | Padrão DRAFT |
description | string (≤10000) | Não | — |
brand, manufacturer | string (≤200) | Não | — |
weight | number > 0 | Não | — |
weightUnit | string (≤10) | Não | — |
dimensions, attributes, metadata | object | Não | JSON livre |
categoryIds | string[] (UUID) | Não | Vincula às categorias |
tags | string[] (≤100 cada) | Não | — |
Resposta 201 — { "data": { "id": "...", "status": "DRAFT", ... } }
Erros — 400 corpo inválido ou type ausente · 404 loja inexistente ou de outra organização · 409 slug duplicado na loja
POST {base}/products/:productId/variants
A variante é o que se vende. Um produto sem variante não entra em pedido nem em carrinho.
Request
{
"sku": "CAM-BAS-P-PRETA",
"name": "Camiseta Básica P Preta",
"basePrice": 79.90,
"costPrice": 32.00,
"compareAtPrice": 99.90,
"barcode": "7891234567890",
"options": { "tamanho": "P", "cor": "preta" }
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
sku | string (1–100) | Sim | Único por loja e por produto |
name | string (1–300) | Sim | — |
basePrice | number ≥ 0 | Sim | Preço de referência quando nenhuma lista se aplica |
costPrice | number ≥ 0 | Não | Custo, para margem |
compareAtPrice | number ≥ 0 | Não | "De/por" |
barcode | string (≤50) | Não | — |
status | ACTIVE | INACTIVE | Não | Padrão ACTIVE |
options | object | Não | Eixos da grade |
weight, weightUnit | number / string | Não | — |
Erros — 400 corpo inválido · 409 SKU já existe na loja
POST {base}/stock-movements
Toda alteração de saldo passa por aqui. Não existe endpoint que escreva quantity direto: o saldo é sempre consequência de uma movimentação registrada.
Request
{
"variantId": "9a2f...uuid",
"type": "INBOUND",
"quantity": 120,
"toLocationId": "3c7d...uuid",
"reason": "Recebimento NF 4471",
"referenceType": "invoice",
"referenceId": "b81e...uuid"
}
type | Locais exigidos | Efeito |
|---|---|---|
INBOUND | toLocationId | Soma em to |
OUTBOUND | fromLocationId | Subtrai de from |
TRANSFER | fromLocationId e toLocationId | Subtrai de from, soma em to |
ADJUSTMENT | pelo menos um dos dois | Subtrai de from e/ou soma em to |
RESERVATION, RESERVATION_RELEASE | — | Tipos do enum usados pelo fluxo de reserva |
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
variantId | UUID | Sim | — |
type | enum acima | Sim | — |
quantity | inteiro > 0 | Sim | Sempre positiva — o sinal vem do type |
toLocationId, fromLocationId | UUID | Conforme a tabela | — |
reason | string (≤500) | Não | Aparece na trilha |
referenceType, referenceId | string / UUID | Não | Amarra ao documento de origem |
Resposta 201 — a movimentação criada. O StockItem correspondente é criado se ainda não existir.
Erros — 400 VALIDATION quando falta o local exigido pelo tipo (ex.: toLocationId required for INBOUND)
Sem trava de saldo negativo. O serviço não recusa uma saída maior que o disponível. Se a sua operação exige essa trava, valide antes com
GET {base}/stock/levels/:variantId— ver §15.
GET {base}/stock/levels/:variantId
Saldo da variante em cada local.
Resposta 200
{
"data": [
{
"variantId": "9a2f...",
"locationId": "3c7d...",
"quantity": 120,
"reservedQty": 8,
"reorderPoint": 20,
"reorderQty": 100
}
]
}
Disponível para venda é quantity - reservedQty. O campo não vem calculado — faça a conta no seu lado.
GET {base}/price-lists/effective-price/:variantId
Resolve qual preço vale agora para uma variante, uma quantidade e opcionalmente um canal.
| Parâmetro | Onde | Padrão | Descrição |
|---|---|---|---|
variantId | rota | — | A variante |
quantity | query | 1 | Compara com minQuantity das entradas |
channelId | query | — | Restringe a listas daquele canal |
Como a decisão é tomada
Entradas de lista de preço da variante
│
├── minQuantity <= quantity solicitada
├── priceList.organizationId = organização do token
├── priceList.storeId = loja da rota
├── priceList.isActive = true
├── validFrom nulo OU já passou
├── validUntil nulo OU ainda não passou
└── channelId, se informado
│
▼
ordena por priceList.priority DESC
e por minQuantity DESC
│
▼
devolve a primeira
Resposta 200 — a entrada vencedora, com a lista de preço embutida. Se nenhuma lista se aplica, a resposta é vazia e o preço a usar é o basePrice da variante.
POST {base}/orders
Cria o pedido. Ele nasce em DRAFT e com uma linha de histórico já gravada.
Request
{
"source": "STOREFRONT",
"channelId": "1e4b...uuid",
"customerId": "77aa...uuid",
"customerName": "Maria Souza",
"customerEmail": "maria@exemplo.com.br",
"deliveryType": "DELIVERY",
"deliveryFee": 12.50,
"items": [
{ "variantId": "9a2f...uuid", "quantity": 2 },
{ "variantId": "5b81...uuid", "quantity": 1, "unitPrice": 45.00 }
]
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
items | array, mínimo 1 | Sim | — |
items[].variantId | UUID | Sim | Precisa existir |
items[].quantity | inteiro > 0 | Sim | — |
items[].unitPrice | number ≥ 0 | Não | Omitido, usa o basePrice da variante — não a lista de preço |
items[].modifiers | object | Não | — |
items[].notes | string (≤1000) | Não | — |
source | STOREFRONT | MARKETPLACE | DELIVERY_PLATFORM | POS | API | MANUAL | Não | Padrão MANUAL |
deliveryType | PICKUP | DELIVERY | DINE_IN | DIGITAL | Não | Padrão DELIVERY |
channelId, customerId, promotionId | UUID | Não | — |
customerName, customerEmail, customerPhone | string | Não | Para venda sem cadastro |
deliveryAddress, metadata | object | Não | — |
deliveryFee | number ≥ 0 | Não | Padrão 0 |
externalId | string (≤200) | Não | Id do pedido na origem, para idempotência do seu lado |
notes | string (≤5000) | Não | — |
Como o total é calculado. subtotal = soma de quantity × unitPrice dos itens. total = subtotal + deliveryFee.
Desconto e imposto não entram no total.
discountetaxexistem no modelo e nascem em0. PassarpromotionIdnão aplica desconto automaticamente — a promoção é validada porPOST {base}/promotions/validatee aplicada pelo seu lado. Ver §15.
Resposta 201 — o pedido com orderNumber sequencial da loja, itens e status: "DRAFT".
Erros — 400 corpo inválido ou lista de itens vazia · 404 variante inexistente
POST {base}/orders/:id/confirm
Move DRAFT → CONFIRMED. É a primeira transição do ciclo e a que a maioria das integrações chama logo após criar o pedido.
Request — corpo opcional. {"reason": "..."} é aceito e gravado no histórico.
O que acontece
- Verifica que
CONFIRMEDestá emVALID_ORDER_TRANSITIONS["DRAFT"]. - Grava
statuseconfirmedAt. - Insere linha em
CommerceOrderStatusHistorycomfromStatus,toStatus,reasonechangedBy(ouserIddo token). - Publica
commerce.order.confirmed. - Chama
reserveOrderStock— que hoje não reserva nada (§15).
Resposta 200 — o pedido atualizado.
Erros — 400 VALIDATION com Cannot transition from X to CONFIRMED quando o estado atual não permite · 403 sem COMMERCE_ORDERS_MANAGE · 404 pedido de outra organização
10Início rápido
Do zero a um pedido confirmado, com estoque movimentado e preço de atacado resolvido.
Os comandos abaixo não foram executados nesta redação — o Commerce não está listado em
documentacao/credenciais/AMBIENTES.md. Confirme o host de staging com o time de plataforma antes de rodar. Credenciais de staging, nunca de produção.
1. Autenticar no IAM
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
BASE=https://commerce.bb.stg.catalisa.app/commerce/api/v1
2. Criar a loja
STORE=$(curl -s -X POST "$BASE/stores" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Loja Demo","slug":"loja-demo","type":"GENERAL"}' | jq -r '.data.id')
echo "store: $STORE"
3. Criar produto e variante
PROD=$(curl -s -X POST "$BASE/stores/$STORE/products" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Camiseta Básica","type":"PHYSICAL","status":"ACTIVE"}' | jq -r '.data.id')
VAR=$(curl -s -X POST "$BASE/stores/$STORE/products/$PROD/variants" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"sku":"CAM-BAS-P-PRETA","name":"Camiseta Básica P Preta","basePrice":79.90}' \
| jq -r '.data.id')
4. Criar o local de estoque e dar entrada
LOC=$(curl -s -X POST "$BASE/stores/$STORE/stock-locations" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Depósito Central","code":"DEP-01","isDefault":true}' | jq -r '.data.id')
curl -s -X POST "$BASE/stores/$STORE/stock-movements" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"variantId\":\"$VAR\",\"type\":\"INBOUND\",\"quantity\":120,
\"toLocationId\":\"$LOC\",\"reason\":\"Carga inicial\"}" | jq '.data.type'
5. Conferir o saldo
curl -s "$BASE/stores/$STORE/stock/levels/$VAR" \
-H "Authorization: Bearer $TOKEN" | jq '.data[0] | {quantity, reservedQty}'
{ "quantity": 120, "reservedQty": 0 }
6. Criar uma tabela de atacado e testar o preço efetivo
PL=$(curl -s -X POST "$BASE/stores/$STORE/price-lists" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Atacado","type":"WHOLESALE","priority":10}' | jq -r '.data.id')
curl -s -X POST "$BASE/stores/$STORE/price-lists/$PL/entries" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"variantId\":\"$VAR\",\"price\":59.90,\"minQuantity\":12}" > /dev/null
# 1 unidade: nenhuma entrada se aplica → use o basePrice (79.90)
curl -s "$BASE/stores/$STORE/price-lists/effective-price/$VAR?quantity=1" \
-H "Authorization: Bearer $TOKEN" | jq '.data'
# 12 unidades: a entrada de atacado vence
curl -s "$BASE/stores/$STORE/price-lists/effective-price/$VAR?quantity=12" \
-H "Authorization: Bearer $TOKEN" | jq '.data.price'
"59.9000"
7. Criar e confirmar o pedido
ORDER=$(curl -s -X POST "$BASE/stores/$STORE/orders" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"source\":\"API\",\"customerName\":\"Maria Souza\",
\"items\":[{\"variantId\":\"$VAR\",\"quantity\":2}]}" | jq -r '.data.id')
curl -s -X POST "$BASE/stores/$STORE/orders/$ORDER/confirm" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"reason":"Pagamento aprovado"}' | jq '{status: .data.status, at: .data.confirmedAt}'
{ "status": "CONFIRMED", "at": "2026-08-16T14:02:11.417Z" }
8. Ver o histórico
curl -s "$BASE/stores/$STORE/orders/$ORDER/history" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {fromStatus, toStatus, reason}'
9. Confirmar que o isolamento é real
# Um storeId que não é da sua organização responde 404, não 403 e não dado
curl -s -o /dev/null -w "%{http_code}\n" \
"$BASE/stores/00000000-0000-0000-0000-000000000000/products" \
-H "Authorization: Bearer $TOKEN"
Retorna 404. A loja de outra organização não existe do seu ponto de vista.
11Receitas
Publicar um produto completo, do zero ao pronto para venda
Objetivo. Sair de nada até um produto ativo, com grade, imagem, categoria e preço.
# 1. Categoria
CAT=$(curl -s -X POST "$BASE/stores/$STORE/categories" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Camisetas","slug":"camisetas"}' | jq -r '.data.id')
# 2. Produto já vinculado à categoria
PROD=$(curl -s -X POST "$BASE/stores/$STORE/products" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"Camiseta Básica\",\"type\":\"PHYSICAL\",
\"categoryIds\":[\"$CAT\"],\"tags\":[\"verao\"]}" | jq -r '.data.id')
# 3. Grade de variantes — uma chamada por combinação
for t in P M G; do
curl -s -X POST "$BASE/stores/$STORE/products/$PROD/variants" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"sku\":\"CAM-BAS-$t-PRETA\",\"name\":\"Camiseta Básica $t Preta\",
\"basePrice\":79.90,\"options\":{\"tamanho\":\"$t\",\"cor\":\"preta\"}}" \
| jq -r '.data.sku'
done
# 4. Imagem — fileId do File Storage é preferível a url externa
curl -s -X POST "$BASE/stores/$STORE/products/$PROD/images" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"fileId":"a1b2...uuid","altText":"Camiseta preta vista frontal","isPrimary":true}'
# 5. Config fiscal
curl -s -X PUT "$BASE/stores/$STORE/products/$PROD/tax-config" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"ncm":"61091000","cfop":"5102","icmsRate":0.18,"origem":"0"}'
# 6. Ativar
curl -s -X PATCH "$BASE/stores/$STORE/products/$PROD" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"status":"ACTIVE"}' | jq '.data.status'
Armadilhas.
typeé obrigatório na criação do produto e não tem valor padrão. Omitir devolve400sem que a mensagem deixe óbvio qual campo faltou.- O SKU é único por loja, não por produto. Duas variantes de produtos diferentes com o mesmo SKU colidem com
409— o que geralmente é o comportamento que você quer. - Não existe endpoint de criação de grade em lote. Grade de 5 tamanhos × 4 cores são 20 chamadas.
PUTemtax-configsubstitui o registro inteiro. Campo omitido some; ele não é umPATCH.- A alíquota vai em fração, não em percentual:
0.18para 18%. O schema recusa valores acima de1. - A config fiscal do produto sobrepõe a da categoria. Se você configurou a categoria e depois o produto, só a do produto vale.
Ligar um produto em um canal com preço próprio
Objetivo. O mesmo catálogo aparece diferente em cada ponto de venda.
CH=$(curl -s -X POST "$BASE/stores/$STORE/channels" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"App de Delivery","type":"DELIVERY_PLATFORM"}' | jq -r '.data.id')
# Um produto por vez, com preço específico do canal
curl -s -X PUT "$BASE/stores/$STORE/channels/$CH/products/$PROD/availability" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"isAvailable":true,"priceOverride":94.90}'
# Em lote
curl -s -X PUT "$BASE/stores/$STORE/channels/$CH/products/bulk" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"products\":[
{\"productId\":\"$PROD\",\"isAvailable\":true,\"priceOverride\":94.90},
{\"productId\":\"$PROD2\",\"isAvailable\":false}
]}"
Armadilhas.
- Há dois mecanismos de preço por canal e eles não se conversam:
priceOverridena disponibilidade (por produto) ePriceListdo tipoCHANNELcomchannelId(por variante). Oeffective-pricesó enxerga o segundo. Escolha um por operação; misturar produz preço que ninguém explica. - Ausência de registro de disponibilidade não é o mesmo que indisponível. Se o seu storefront trata "sem registro" como disponível, criar o canal já publica todo o catálogo nele.
- Excluir a regra (
DELETE .../availability) volta ao estado "sem registro" — não marca como indisponível.
Reservar estoque de verdade durante o checkout
Objetivo. Segurar a quantidade enquanto o cliente paga, sem depender do ciclo de vida do pedido.
Este é o ponto que mais gera chamado. POST /orders/:id/confirm não reserva estoque — a chamada existe no código, mas o método está vazio (§15). Enquanto isso não muda, faça a reserva pelo movimento explícito e libere ou consuma junto com a transição.
# 1. Antes de confirmar, verifique o disponível
curl -s "$BASE/stores/$STORE/stock/levels/$VAR" -H "Authorization: Bearer $TOKEN" \
| jq '.data[] | (.quantity - .reservedQty)'
# 2. Registre a saída amarrada ao pedido, como referência rastreável
curl -s -X POST "$BASE/stores/$STORE/stock-movements" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"variantId\":\"$VAR\",\"type\":\"OUTBOUND\",\"quantity\":2,
\"fromLocationId\":\"$LOC\",\"referenceType\":\"order\",
\"referenceId\":\"$ORDER\",\"reason\":\"Baixa por pedido confirmado\"}"
# 3. Se o pedido for cancelado, estorne com o movimento inverso
curl -s -X POST "$BASE/stores/$STORE/stock-movements" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"variantId\":\"$VAR\",\"type\":\"INBOUND\",\"quantity\":2,
\"toLocationId\":\"$LOC\",\"referenceType\":\"order\",
\"referenceId\":\"$ORDER\",\"reason\":\"Estorno de cancelamento\"}"
Armadilhas.
- Não há trava de saldo negativo.
OUTBOUNDde 10 com 3 em estoque passa e deixa o saldo em-7. A verificação é sua, e entre a verificação e a movimentação existe uma janela de corrida — em concorrência alta, serialize por variante no seu lado. - As reservas que o
stock.servicesabe criar (reserveStock) não têm endpoint HTTP. Só o fluxo interno as cria. Pelas rotas você consulta e libera reserva, mas não cria. DELETE /stock/reservations/:idlibera todas as reservas que compartilham o mesmoreferenceType+referenceIddaquela, não apenas a que você apontou. Para um pedido com vários itens, uma chamada libera o pedido inteiro.→ CANCELLEDe→ COMPLETEDchamam liberar e consumir reservas por('order', orderId). Se você não criou reserva nenhuma, as duas chamadas não fazem nada — e é por isso que a baixa precisa ser sua.
Descobrir por que um pedido não avança
Objetivo. Resolver o 400 Cannot transition from X to Y sem abrir o código.
# 1. Em que estado ele está
curl -s "$BASE/stores/$STORE/orders/$ORDER" -H "Authorization: Bearer $TOKEN" \
| jq '{status: .data.status, confirmedAt: .data.confirmedAt}'
# 2. Como ele chegou aí
curl -s "$BASE/stores/$STORE/orders/$ORDER/history" -H "Authorization: Bearer $TOKEN" \
| jq '.data[] | {from: .fromStatus, to: .toStatus, por: .changedBy, quando: .createdAt}'
Ordem de diagnóstico:
- O destino está na lista do estado atual? Confira o diagrama da §8. O erro mais comum é tentar
delivera partir deREADY— o caminho passa porSHIPPEDouOUT_FOR_DELIVERY. - É
403e não400? Então é permissão.cancelexigeCOMMERCE_ORDERS_CANCELerefundexigeCOMMERCE_ORDERS_REFUND— nenhuma das duas vem comCOMMERCE_ORDERS_MANAGE. - É
PATCHque falhou? Só pedido emDRAFTaceitaPATCH. Depois de confirmado, dados do pedido são imutáveis pela API. - É
404? O pedido é de outra organização, ou ostoreIdda URL não é o da loja do pedido.
Anexar um provedor e disparar a primeira sincronização
Objetivo. Montar a topologia de sincronização e entender o que ela faz hoje.
# 1. Configuração do provedor
CFG=$(curl -s -X POST "$BASE/stores/$STORE/providers" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"providerType":"CATALISA","name":"Catálogo interno","credentials":{}}' \
| jq -r '.data.id')
# 2. Anexar à loja — o primeiro vínculo define a topologia
SP=$(curl -s -X POST "$BASE/stores/$STORE/store-providers" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"providerConfigId\":\"$CFG\",\"role\":\"PRIMARY\",\"syncDirection\":\"BOTH\"}" \
| jq -r '.data.id')
# 3. Sincronizar produtos
curl -s -X POST "$BASE/stores/$STORE/store-providers/$SP/sync/products" \
-H "Authorization: Bearer $TOKEN" \
| jq '{sincronizados: .data.itemsSynced, criados: .data.itemsCreated,
atualizados: .data.itemsUpdated, ignorados: .data.itemsSkipped}'
# 4. Ver os mapeamentos gerados
curl -s "$BASE/stores/$STORE/product-mappings?status=ACTIVE" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {sku, externalProductId, status}'
# 5. Um SKU específico, em todos os provedores
curl -s "$BASE/stores/$STORE/product-mappings/by-sku/CAM-BAS-P-PRETA" \
-H "Authorization: Bearer $TOKEN" | jq
Armadilhas.
- Só existe um
PRIMARYpor loja. Tentar anexar um segundo devolve400comStore already has a PRIMARY provider. - Se você anexa um provedor
SECONDARYa uma loja semPRIMARY, o serviço cria oCATALISAcomoPRIMARYautomaticamente. Isso é intencional — sem fonte de verdade declarada, a resolução de conflito não tem âncora. providerTypefora deCATALISAé aceito na configuração e falha na sincronização comUnsupported provider type. O enum tem doze valores, o factory implementa um.- A sincronização de produtos processa em sequência e busca no máximo 500 produtos internos no
PUSH. Catálogo maior exige mais de uma execução. POST /providers/:id/testsempre responde sucesso — é resposta simulada, não testa conexão nenhuma.
Fechar uma promoção com código
Objetivo. Validar um cupom antes de aplicar.
curl -s -X POST "$BASE/stores/$STORE/promotions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Frete Grátis Agosto","type":"FIXED_DISCOUNT","code":"AGOSTO20",
"value":20.00,"minOrderValue":100.00,"maxUses":500,
"startsAt":"2026-08-01T00:00:00Z","endsAt":"2026-08-31T23:59:59Z",
"status":"ACTIVE"}' | jq '.data.id'
curl -s -X POST "$BASE/stores/$STORE/promotions/validate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"code":"AGOSTO20","orderValue":150.00}' | jq
Armadilhas.
validateconfere existência, limite de usos e valor mínimo. Não confere a janela de datas — uma promoção fora do período pode validar. ChequestartsAt/endsAtna resposta.usedCountnão é incrementado por nenhuma rota. O controle de consumo é seu.- Validar não aplica. Passar
promotionIdno pedido registra o vínculo mas não alteradiscountnemtotal— o cálculo é do seu lado (§15). codeé único por loja. A mesma campanha em duas lojas precisa de dois registros.
12Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token com organizationId e as permissões COMMERCE_*. Sem ele, nenhuma rota responde | Sim |
| Payments | Recebe o valor do pedido e devolve a confirmação que dispara confirm. O Commerce não toca dinheiro | Não, mas é o par natural |
| Customers | O customerId do pedido e do carrinho aponta para a pessoa cadastrada lá. Sem ele, use os campos avulsos de nome, e-mail e telefone | Não |
| File Storage | CommerceProductImage.fileId referencia o arquivo. A alternativa é url externa, sem controle de acesso | Não |
| Webhooks Engine | Consome os eventos commerce.* e entrega ao sistema do cliente com retentativa | Não |
| Audit Trail | Registra quem fez o quê. O histórico de status do pedido é interno; a trilha transversal é lá | Não |
| Billing | Mede uso e fatura. Pedidos e lojas ativas são os drivers naturais | Não |
| API Keys | Credencial de longa duração para o PDV ou o integrador que não renova token | Não |
┌──────────────────────────────────────┐
│ IAM │
│ token: organizationId + permissões │
└──────────────────┬───────────────────┘
│ Bearer JWT
┌───────────────┬───────────────┼───────────────┬────────────────┐
▼ ▼ ▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌────────────┐ ┌──────────────┐ ┌───────────┐
│ Customers │ │File Storage│ │ COMMERCE │ │ Payments │ │ Billing │
│ quem │ │ imagem │ │ o que, │ │ o dinheiro │ │ a fatura │
│ compra │ │ do produto │ │ quanto tem,│ │ │ │ │
└─────┬─────┘ └──────┬─────┘ │ por quanto,│ └───────┬──────┘ └─────┬─────┘
│ customerId │ fileId │ o pedido │ │ │
└───────────────┴───────▶└──────┬─────┘◀─────────┘ │
│ pagamento aprovado │
│ ──▶ POST /orders/:id/confirm │
│ │
│ eventos commerce.* │ pedidos e
▼ │ lojas ativas
┌────────────────────────┐ │
│ Webhooks Engine │◀──────────────────┘
│ entrega com retry ao │
│ ERP / painel / cozinha│
└────────────────────────┘
O fluxo que vende a plataforma. Um pedido nasce no Commerce em DRAFT. O Payments cobra e, quando aprova, o seu backend chama confirm. A transição publica commerce.order.confirmed, o Webhooks Engine entrega ao ERP do cliente com retentativa, o Audit Trail registra quem confirmou e o Billing conta mais um pedido para a fatura do mês. Nenhuma dessas peças precisou de integração de identidade própria — é o mesmo token do começo ao fim.
Um concorrente de commerce entrega a primeira caixa. As outras cinco continuam sendo projeto do cliente.
Eventos publicados. O Commerce declara 74 tipos de evento em CommerceEvents. Os que uma integração normalmente assina:
| Evento | Quando |
|---|---|
commerce.product.created / .updated / .deleted | Ciclo de vida do produto |
commerce.stock.adjusted | Qualquer movimentação de estoque |
commerce.stock.low | Item abaixo do ponto de reposição |
commerce.order.created | Pedido criado em DRAFT |
commerce.order.confirmed … .completed | Uma por transição de status |
commerce.order.cancelled / .refunded | Encerramento |
commerce.cart.converted / .expired | Carrinho virou pedido ou venceu |
commerce.provider.sync_completed / .sync_failed | Fim de uma sincronização |
commerce.sync.conflict_detected | Conflito de campo entre provedores |
13Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
DATABASE_URL | PostgreSQL. O módulo usa o schema commerce | Sim | — |
REDIS_URL | Rate limit e barramento de eventos | Sim | — |
JWT_SECRET | Mesmo segredo do IAM, mínimo 44 caracteres | Sim | — |
MODULE_COMMERCE_PORT | Porta em standalone | Não | 3026 |
MODULE_COMMERCE_URL | URL do módulo para os demais building blocks | Não | "" |
DEPLOYMENT_MODE | monolith ou standalone | Não | monolith |
COMMERCE_CREDENTIAL_MASTER_KEY | 64 caracteres hex (32 bytes). Declarada na configuração e ainda não consumida pelo módulo — ver §15 | Não | — |
Dependências de infraestrutura
| Dependência | Para quê |
|---|---|
| PostgreSQL | Schema commerce, 32 tabelas |
| Redis | Contadores de rate limit e barramento de eventos |
| IAM | Emissão e verificação do token; sem ele nenhuma rota responde |
| File Storage | Opcional, só se as imagens usarem fileId |
Limites e quotas
| Limite | Valor | Onde |
|---|---|---|
| Tamanho do corpo da requisição | 1 MB | applyCommonMiddleware |
| Página máxima na paginação | 100 itens | paginationSchema |
| Página padrão | 20 itens | paginationSchema |
| Validade padrão do carrinho | 24 horas | CartService.create |
Produtos por execução de PUSH | 500 | syncEngine.pushProducts |
Produtos por página no PULL | 50 (padrão do adaptador) | CatalisaProvider.fetchProducts |
| Categorias por busca de sincronização | 500 | CatalisaProvider.fetchCategories |
| Rate limit global | Definido em rateLimitMiddleware | compartilhado |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo reprovado no Zod, ou transição de status inválida, ou movimentação sem o local exigido | Compare o corpo com o schema da §9; para pedido, veja o diagrama da §8 |
400 | — | storeId ou outro parâmetro de rota não é UUID | Confira o identificador |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove pelo refresh token do IAM |
403 | — | Token sem organizationId | Autentique informando a organização |
403 | — | Store is inactive | Reative a loja com PATCH /stores/:storeId |
403 | FORBIDDEN | Token válido, falta a permissão COMMERCE_* da rota | Confira a permissão exata na §9 e o vocabulário em GET /iam/api/v1/permissions |
404 | — | Store '...' not found | A loja não existe ou é de outra organização — a resposta é a mesma de propósito |
404 | NOT_FOUND | Recurso inexistente ou já excluído | Confira o id |
409 | CONFLICT | Slug de loja, slug de produto, SKU de variante, código de promoção ou vínculo de provedor duplicado | Escolha outro identificador |
429 | — | Rate limit global | Aplique recuo exponencial |
500 | INTERNAL | Falha de banco ou Unsupported provider type na sincronização | Verifique conectividade; se for provedor, veja §15 |
Rotinas de manutenção
Três jobs vivem em src/commerce/jobs/ e estão registrados no container:
| Job | O que faz |
|---|---|
CommerceCartCleanupJob | Marca carrinhos ACTIVE vencidos como EXPIRED |
CommerceReservationCleanupJob | Libera reservas vencidas e devolve a quantidade ao disponível |
CommerceLowStockAlertJob | Publica commerce.stock.low_stock para itens abaixo do reorderPoint |
Os três têm guarda de reentrada (isRunning) para não sobrepor execuções. Não há agendador chamando run() — hoje eles precisam ser disparados por gatilho externo. Ver §15.
Observabilidade
GET /commerce/healthdevolve nome do serviço e versão. É sonda de vida, não de dependência: não testa banco nem Redis. Um Postgres fora do ar não faz o/healthfalhar.- Toda sincronização vira uma linha em
CommerceProviderSyncJobcom contadores (itemsSynced,itemsFailed,itemsCreated,itemsUpdated,itemsSkipped), carimbos de início e fim, eerrorMessagequando falha. É a fonte primária para diagnosticar sincronização. - Todo webhook recebido vira uma linha em
CommerceProviderWebhookLogcom corpo e cabeçalhos completos, marcada comoprocessedao fim. Reprocessamento manual parte daqui. - O histórico de status do pedido responde "quem mudou e por quê" sem depender do log da aplicação.
14Segurança e compliance
Isolamento entre tenants. O organizationId chega como claim assinado no JWT do IAM e nunca é lido do corpo da requisição. Em cima disso, o Commerce aplica duas camadas.
A primeira é estrutural: toda rota de domínio vive sob /api/v1/stores/:storeId, e o middleware resolveStore roda no use('*') de cada um dos 19 sub-routers, antes de qualquer serviço. Ele executa, nesta ordem:
resolveStore
1. storeId é UUID? → senão 400
2. token tem organizationId? → senão 403
3. storeRepo.findById(storeId, organizationId) → findFirst com AS DUAS chaves
4. store existe? → senão 404
5. store.isActive? → senão 403
O passo 3 é o coração. A consulta filtra por id e organizationId na mesma cláusula. Uma loja de outra organização não retorna, e o resultado é 404 — a mesma resposta de uma loja inexistente, de propósito: distinguir permitiria enumerar lojas alheias.
A segunda camada é o escopo por recurso dentro dos repositórios: findById(id, organizationId) usando findFirst com as duas chaves, com serviço e rota repassando o organizationId do token. O comentário no cart.repository.ts registra por que isso importa — sem o filtro, o identificador sozinho bastava para alcançar o recurso.
Essa camada foi construída em ondas. O commit d1ec354 (ALTO-02) fechou categorias, canais, promoções e locais de estoque, somando aos que já estavam: lojas, produtos, pedidos, carrinhos e configurações de provedor. Uma segunda leva estendeu o escopo aos recursos que não têm coluna organizationId própria e só alcançam o tenant por relação — variante e modificador de preço pelo produto dono, imagem de produto pelo produto, item de pedido pelo pedido, lote pelo local de estoque, reserva pela variante e daí pelo produto, listas de preço e suas entradas. Nesses, o filtro é uma junção (variant: { product: { organizationId } }), não uma coluna.
Estado em 2026-08-16: 17 dos 23 repositórios com busca por identificador aplicam o filtro de organização. Os seis restantes são a família de provedores e sincronização — store-provider, product-mapping, order-mapping, category-mapping, provider-sync-job e provider-webhook-log —, e neles a garantia vem da primeira camada. O trabalho está em curso e a contagem muda a cada leva; confirme o estado corrente antes de citar este número em auditoria. Ver §15.
Autenticação e permissões. Todas as rotas exigem authMiddleware, com a única exceção do recebimento de webhook (abaixo). O vocabulário do módulo tem 53 permissões COMMERCE_*, e a granularidade segue a consequência da ação, não a conveniência do CRUD:
| Separação | Por quê |
|---|---|
COMMERCE_ORDERS_MANAGE vs. _CANCEL vs. _REFUND | Avançar o pedido, cancelar e estornar têm consequências financeiras diferentes. A expedição avança; só quem pode devolver dinheiro estorna |
COMMERCE_STOCK_ADJUST separado de _UPDATE | Ajustar saldo é a operação que reescreve o inventário. Merece permissão própria |
COMMERCE_CONFIG_MANAGE para configuração fiscal | NCM, CFOP e alíquota erradas são problema fiscal, não problema de catálogo |
COMMERCE_PROVIDERS_* separado de COMMERCE_STORE_PROVIDERS_* | Configurar a credencial de um provedor e decidir o papel dele na loja são decisões distintas |
Proteções de borda. Em standalone — o modo de produção — os middlewares globais do monolito não rodam, e por isso o app.ts do módulo chama applyCommonMiddleware explicitamente: limite de corpo de 1 MB, CORS que bloqueia origem cruzada quando não há origens configuradas, cabeçalhos de segurança (HSTS, CSP, nosniff, X-Frame-Options) e rate limit global.
Recebimento de webhook. POST {base}/provider-webhooks/:providerConfigId é a única rota sem authMiddleware, porque quem chama é a plataforma externa. Ela resolve a organização a partir da própria configuração — o repositório expõe um findByIdAnyTenant documentado como de uso interno exclusivo desse caminho, justamente para que ninguém o use em rota autenticada, onde o escopo tem que vir do token. A verificação de assinatura ainda não está implementada (§15): enquanto isso, não publique a URL de webhook para provedor externo em produção.
Dados sensíveis. O módulo guarda nome, e-mail, telefone e endereço de entrega do comprador em CommerceOrder e em CommerceCart — todos dado pessoal sob a LGPD. Guarda também CommerceProviderConfig.credentials, que é segredo de integração. Restrinja COMMERCE_PROVIDERS_* a operadores e trate credentials como campo de segredo em qualquer exportação, log ou painel que você construir por cima.
Retenção e exclusão. Lojas e produtos usam exclusão lógica (deletedAt), preservando a linha para auditoria. Pedidos, itens e histórico não são apagados: CommerceOrderItem tem onDelete: Restrict na variante, então uma variante que já vendeu não pode ser removida — o histórico de venda sobrevive à limpeza de catálogo. Atender a um pedido de eliminação de dados pessoais sob a LGPD exige processo explícito de expurgo, que hoje não é automatizado (§15).
Enquadramento regulatório. O Commerce não processa pagamento e não armazena dado de cartão — isso é o Payments, e é ele que carrega o enquadramento PCI-DSS. O que o Commerce carrega é LGPD, pelos dados do comprador, e a guarda de parâmetros fiscais (NCM, CEST, CFOP, alíquotas) que alimentam a emissão feita fora daqui.
15Limitações conhecidas
Escrito de frente, porque é a seção que o comprador técnico lê primeiro.
O que pode surpreender em produção
| Limitação | Impacto | Situação |
|---|---|---|
| Nenhum adaptador de plataforma externa | O enum CommerceProviderType lista IFOOD, RAPPI, UBER_EATS, MERCADO_LIVRE, AMAZON, SHOPEE, MAGALU, VTEX, NUVEMSHOP e SHOPIFY. O factory createCommerceProvider implementa apenas CATALISA. O adaptador de iFood existe como molde e responde valores simulados, e nem está registrado no factory. Configurar qualquer outro tipo é aceito, e a sincronização falha com Unsupported provider type | Roadmap. Não anuncie integração de marketplace |
confirm não reserva estoque | OrderLifecycleService.reserveOrderStock é chamado na transição para CONFIRMED e retorna sem fazer nada. O comentário no código diz que a estratégia de escolha de local ficou pendente. stockService.reserveStock funciona, mas ninguém o chama nesse fluxo | Roadmap. Use a receita de reserva manual da §11 |
| Sem trava de saldo negativo | processMovement não compara a quantidade com o disponível. Uma saída maior que o estoque passa e deixa o saldo negativo | Por ora, valide antes de movimentar |
| Desconto e imposto não entram no total | total = subtotal + deliveryFee. Os campos discount e tax do pedido e do item existem, nascem em 0 e nenhuma rota os calcula. Passar promotionId registra o vínculo e não altera valor | Roadmap. O cálculo é do integrador |
usedCount de promoção nunca incrementa | Nenhuma rota consome a promoção. O maxUses é conferido em validate contra um contador que ninguém atualiza | Roadmap |
validate de promoção não checa a janela de datas | Confere existência, maxUses e minOrderValue, mas não startsAt/endsAt | Roadmap. Cheque as datas na resposta |
POST /providers/:id/test é simulado | Sempre responde {"success": true, "message": "Connection test passed (mock)"}. Há um TODO no código para delegar ao adaptador | Roadmap |
| Webhook sem verificação de assinatura | O endpoint público registra e processa o corpo recebido. webhookSecret existe no modelo e ainda não é usado para validar | Roadmap. Não exponha a URL a provedor externo até lá |
COMMERCE_CREDENTIAL_MASTER_KEY não é consumida | A variável está declarada em src/shared/config/env.ts e nenhum código do módulo a lê. As credenciais de provedor são gravadas como JSON serializado | Roadmap: criptografia em repouso |
| Os três jobs não têm agendador | CartCleanup, ReservationCleanup e LowStockAlert estão implementados e registrados no container, mas nada chama run() periodicamente. Carrinho vencido continua ACTIVE e reserva vencida continua presa | Roadmap. Dispare por gatilho externo enquanto isso |
/health não checa dependência | Responde só nome e versão. Banco fora do ar não derruba a sonda | Roadmap |
O que é escolha de escopo, não pendência
| Limitação | Por quê |
|---|---|
| Sem vitrine, tema ou checkout visual | É uma API. A interface é do cliente |
| Sem processamento de pagamento | É o building block Payments |
| Sem emissão de nota fiscal | O módulo guarda NCM, CEST, CFOP, alíquotas e origem. A emissão é de um integrador fiscal |
| Sem cálculo de frete ou integração com transportadora | deliveryFee é um número que você informa |
| Sem busca facetada ou motor de busca | ?search= é filtro simples. Busca de vitrine pede Elasticsearch, Algolia ou equivalente |
| Sem recomendação, avaliação ou lista de desejos | Fora do escopo do núcleo transacional |
| Sem devolução ou logística reversa como entidade | REFUNDED é estado do pedido, não processo de RMA |
| Sem multi-moeda em tempo real | A moeda é um campo da loja e da lista de preço; não há conversão |
Limites operacionais conhecidos
| Gargalo | Detalhe |
|---|---|
| Sincronização em sequência | Produtos são processados um a um para evitar corrida no SKU. Catálogo grande é lento por construção |
Teto de 500 no PUSH | Uma execução envia no máximo 500 produtos internos |
| Sem criação de grade em lote | Cada variante é uma chamada |
| Dois mecanismos de preço por canal | priceOverride na disponibilidade e PriceList do tipo CHANNEL não conversam. effective-price só enxerga o segundo |
ABANDONED sem rotina | O estado existe no enum de carrinho e nada o atribui |
| Escopo por repositório incompleto | Em 2026-08-16, 17 dos 23 repositórios com busca por identificador filtram por organizationId. Faltam os seis da família de provedores e sincronização (store-provider, product-mapping, order-mapping, category-mapping, provider-sync-job, provider-webhook-log), onde a garantia vem só do resolveStore. Rollout em andamento — reconfira antes de citar |
| Sem expurgo automatizado para LGPD | Exclusão é lógica; eliminação definitiva é manual |
16Perguntas frequentes
O Commerce integra com Mercado Livre, Shopee e Magalu?
Hoje, não. A arquitetura de sincronização está pronta e testada — mapeamento de SKU, resolução de conflito por campo, jobs com contadores, log de webhook —, mas os adaptadores concretos dessas plataformas ainda não foram escritos. O único provedor implementado é o interno (CATALISA). Se a necessidade do cliente é vender em marketplace nas próximas semanas, seja direto: hoje um hub de integração especializado resolve o problema dele e nós não resolvemos.
Qual a diferença entre o Commerce e o building block Products?
São coisas diferentes com nomes parecidos. O Products é catálogo de produtos financeiros — taxa de juros, prazo, seguro, método de amortização — e serve a operações de crédito. O Commerce é catálogo de produtos de varejo — SKU, grade, estoque, preço, pedido. Uma financeira usa o Products; um varejista usa o Commerce; quem vende crédito consignado dentro de uma loja pode usar os dois, sem sobreposição.
Posso usar só o pedaço de estoque, sem o resto?
Pode. Você precisa de uma loja e de variantes para pendurar o estoque, mas nada obriga a usar carrinho, promoção, canal ou provedor. É um padrão comum: quem já tem catálogo em outro lugar cria a variante como espelho, com o mesmo SKU, e usa só locais, movimentações e reservas.
Como faço a baixa de estoque quando um pedido é confirmado?
Manualmente, hoje. A chamada de reserva existe na transição para CONFIRMED mas não faz nada (§15). Enquanto isso não muda, registre um OUTBOUND em stock-movements com referenceType: "order" e referenceId do pedido — assim a trilha fica amarrada e o estorno em caso de cancelamento é o INBOUND inverso. A receita completa está na §11.
Uma loja pode ter mais de um depósito? E um depósito pode servir a duas lojas?
A primeira, sim: StockLocation é por loja e você cria quantos quiser, com um marcado como padrão. A segunda, não: o local pertence a uma loja e o código é único dentro dela. Operações que compartilham depósito entre lojas precisam replicar o local em cada uma, ou modelar as duas operações como uma única loja com canais distintos — que costuma ser a modelagem mais fiel.
Por que o pedido não deixa mudar de READY direto para DELIVERED?
Porque a tabela de transições não permite. De READY você vai para SHIPPED, OUT_FOR_DELIVERY, COMPLETED ou CANCELLED. Entregar exige ter passado por OUT_FOR_DELIVERY. É rígido de propósito: o valor de uma máquina de estados está justamente em impedir o registro que descreve algo que não aconteceu. Se o seu fluxo real não tem saída para entrega — retirada no balcão, por exemplo —, o caminho é READY → COMPLETED.
Preço por canal: uso priceOverride ou lista de preço?
Escolha um e mantenha. priceOverride fica na disponibilidade do produto no canal e é mais simples; lista de preço do tipo CHANNEL é por variante e é o que o effective-price enxerga. Se você usa effective-price para decidir o preço, use lista. Os dois convivendo produzem valores diferentes dependendo de quem pergunta.
O que acontece com o carrinho abandonado?
Ele vence em 24 horas por padrão, e o CommerceCartCleanupJob marca vencidos como EXPIRED. Com uma ressalva importante: hoje não há agendador chamando o job (§15). Até que haja, dispare a rotina por um gatilho externo, ou o carrinho vencido continua ACTIVE no banco.
Dá para operar centenas de lojas na mesma instância?
É exatamente o desenho. A loja é a unidade de isolamento dentro da organização, e o índice em organizationId está em todos os modelos que precisam. O limite prático é o do PostgreSQL, não o do modelo. O que exige atenção em volume alto é a sincronização, que processa em sequência de propósito (§7).
Como o cliente sabe que não vai ver dado de outra empresa?
Duas camadas, descritas em detalhe na §14. A estrutural é que toda rota de domínio passa pelo resolveStore, que consulta a loja filtrando por id e organizationId do token na mesma cláusula, antes de qualquer regra de negócio rodar — loja de outra organização devolve 404. A segunda é o escopo por organizationId dentro dos repositórios, hoje completo em nove recursos e em extensão para os demais.
Vale a pena usar o Commerce se eu já tenho ERP?
Depende de onde a sua venda acontece. Se o ERP é a fonte de verdade do estoque e do preço e você só precisa de uma camada de venda, o Commerce entra como o lado transacional: catálogo publicado, carrinho, pedido, ciclo de vida — e o ERP recebe os eventos via Webhooks Engine. Se o ERP já faz venda multicanal bem, o ganho é pequeno e não force a barra.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md