Commerce
BetaCatálogo, estoque, preço e pedido multi-loja na mesma API
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
6 endpoints em 2 recursos.
Resumo 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 |
O problema
negócioO 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.
flowchart LR A["1 canal de venda"] -->|"abre o segundo canal"| B["2 canais"] B -->|"abre a segunda unidade"| C["2 canais + 2 estoques"] C -->|"cliente grande pede tabela própria"| D["2 canais + 2 estoques + 2 preços"] A -.->|reescrita| R1["custo de engenharia"] B -.->|reescrita| R1 C -.->|reescrita| R1 D -.->|reescrita| R1
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 fechou 2025 em R$ 235,5 bilhões e 438,9 milhões de pedidos, com projeção de R$ 259,8 bilhões para 2026 (ABIACOM, ex-ABComm, divulgado em 13/02/2026).
E ele se organizou em torno de marketplaces: no Brasil, 71% das compras online acontecem em marketplace contra 20% em loja própria (ECDB, Global eCommerce Outlook 2026, via E-Commerce Brasil em 02/03/2026 — o relatório não esclarece se o recorte é de pedidos, compradores ou receita). Vender em mais de um canal deixou de ser estratégia avançada e virou 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 é técnico, é comercial: o pedido vendido sem lastro de estoque.
flowchart TD A["Estoque divergente entre canais"] --> B["Cliente compra o que não existe"] B --> C["Cancelamento por culpa do vendedor"] C --> D["Métrica de contrato do canal estoura"] D --> E["Queda de reputação e perda de exposição"] D --> F["Multa ou taxa de penalidade"] D --> G["Recebível bloqueado por mais tempo"] E --> H["Menos venda com o mesmo catálogo"] F --> H G --> H
Os marketplaces não tratam isso como acidente — tratam como métrica de contrato, e as margens são estreitas:
| Canal | Teto de cancelamento por culpa do vendedor | Consequência declarada |
|---|---|---|
| Mercado Livre | 1,5% para reputação verde; 0,5% para Mercado Líder; acima de 4%, vermelho | Queda de reputação, perda do selo e da exposição (termômetro oficial) |
| Amazon | 2,5% pré-envio; 0,5% no Seller Fulfilled Prime | Desativação das ofertas seller-fulfilled (Order Performance, aplicável ao Brasil) |
| Magalu | 5%, e indisponibilidade de estoque conta como culpa do vendedor | Aumento do prazo de desbloqueio de recebíveis — a punição entra no fluxo de caixa (Magalu, 15/08/2026) |
| Shopee | Sistema de pontos; +1 ponto para quem passa de 40% de não envio na semana | Congelamento de 30 a 120 dias e taxa de R$ 100 a R$ 1.500, cobrada desde 01/06/2026 (regras oficiais) |
Um vendedor Mercado Líder tem margem de meio por cento. Em cem pedidos, o segundo oversell já custa o selo. É por isso que a precisão do estoque, num varejo multicanal, é problema de receita — não de inventário.
O custo de licença
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.
flowchart LR
subgraph TR["Take rate"]
T1["Cliente fatura mais"] --> T2["Plataforma fatura mais"]
T2 --> T3["Entrega da plataforma segue igual"]
end
subgraph FX["Custo desacoplado"]
F1["Cliente fatura mais"] --> F2["Custo por loja e por pedido"]
F2 --> F3["Margem do revendedor preservada"]
endE 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.
Proposta de valor
negó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.
flowchart LR R["Requisição HTTP"] --> A["authMiddleware"] A -->|"organizationId do token"| S["resolveStore"] S -->|"loja da organização e ativa"| P["requirePermission"] P --> N["Regra de negócio"] S -->|"loja de outra organização"| X["404"]
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.
flowchart LR V["variante"] --> E["findEffectivePrice"] Q["quantidade"] --> E C["canal opcional"] --> E E --> D["uma entrada vencedora, com a lista que a justifica"] E -->|"nenhuma lista se aplica"| B["basePrice da variante"]
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".
stateDiagram-v2
[*] --> DRAFT
DRAFT --> CONFIRMED: confirm
CONFIRMED --> PREPARING: prepare
PREPARING --> READY: ready
READY --> COMPLETED: complete
READY --> SHIPPED: ship
SHIPPED --> OUT_FOR_DELIVERY: out-for-delivery
OUT_FOR_DELIVERY --> DELIVERED: deliver
DELIVERED --> COMPLETED: complete
note right of DRAFT
A tabela completa, com cancelamento
e estorno, está na seção 8.
end noteUm 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.
| Vertical | Tipo de produto | Tipo de entrega típico | O que o PriceModifier cobre |
|---|---|---|---|
| Varejo físico | PHYSICAL | PICKUP ou DELIVERY | Embalagem para presente, gravação |
| Food service | FOOD | DELIVERY ou DINE_IN | Bacon extra, ponto da carne, bebida |
| Produto digital | DIGITAL | DIGITAL | Licença adicional, período estendido |
| Serviço | SERVICE | PICKUP ou DIGITAL | Opcional contratado junto |
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.
Casos de uso reais
negócioCaso 1 — Uma rede de oito lojas para de vender o que não tem Cenário ilustrativo
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.
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.
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.
flowchart LR U1["Unidade 1"] --> M["POST /stock-movements"] U2["Unidade 2"] --> M U8["Unidade 8"] --> M M -->|"INBOUND, OUTBOUND, TRANSFER, ADJUSTMENT"| SI["StockItem por variante e local"] SI --> L["GET /stock/levels/:variantId"] SI --> LS["GET /stock/low-stock pelo reorderPoint"] L --> V["Vitrine e canais leem o saldo corrente"] LS --> RP["Alerta de reposição"]
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
Operação de food service com cozinha própria, vendendo por aplicativo próprio, por telefone e por plataformas de delivery.
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.
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.
sequenceDiagram autonumber participant App as App do cliente participant C as Commerce participant W as Webhooks Engine participant K as Cozinha e cliente App->>C: POST /orders C-->>App: pedido em DRAFT App->>C: POST /orders/:id/confirm C->>W: commerce.order.confirmed W->>K: pedido entrou na fila App->>C: POST /orders/:id/prepare C->>W: commerce.order.preparing App->>C: POST /orders/:id/ready C->>W: commerce.order.ready W->>K: pronto para sair App->>C: POST /orders/:id/out-for-delivery C->>W: commerce.order.out_for_delivery App->>C: POST /orders/:id/deliver C->>W: commerce.order.delivered
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
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 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.
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.
flowchart LR
I["IAM"] --> O1["Organization: cliente A"]
I --> O2["Organization: cliente B"]
I --> O50["Organization: cliente 50"]
O1 --> S1["CommerceStore matriz"]
O1 --> S2["CommerceStore filial"]
O2 --> S3["CommerceStore única"]
O50 --> S4["CommerceStore única"]
S1 --> DB[("Uma instância, um banco, schema commerce")]
S2 --> DB
S3 --> DB
S4 --> DBUma 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 sincronizar catálogo é o gargalo, e não a vitrine Referência de mercado
O mercado brasileiro respondeu ao multicanal criando uma categoria inteira de intermediários — os hubs de integração — cuja única função é manter o mesmo SKU coerente em várias plataformas. A categoria já está consolidada em quatro grupos: Anymarket é do Grupo DB1 (institucional; o DB1 fechou 2025 com R$ 202 milhões de faturamento, Exame, 16/02/2026); Plugg.to foi comprada pela Linx, do grupo Stone, em 14/06/2022 (Baguete); Bling (R$ 524,3 milhões, 2021) e Ideris (R$ 18,3 milhões, 2020) são ambos da LWSA; e o Tiny virou Olist Tiny em 2021. Que exista um mercado inteiro só para isso é a evidência de que o problema é real: se sincronizar catálogo e estoque fosse simples, ninguém pagaria uma camada só para fazê-lo.
flowchart LR DB1["Grupo DB1"] --> AM["Anymarket"] LWSA["LWSA"] --> BL["Bling"] LWSA --> ID["Ideris"] STONE["Linx, do grupo Stone"] --> PL["Plugg.to"] OL["Olist"] --> TY["Olist Tiny"] AM --> MK["Manter o mesmo SKU coerente em várias plataformas"] BL --> MK ID --> MK PL --> MK TY --> MK
A raiz do problema é documentável, e não é opinião: os modelos de dados dos marketplaces são estruturalmente irreconciliáveis. O Mercado Livre representa variação como um array variations[] aninhado no item, com teto de 100 variações. A Amazon usa um grafo separado de ASIN pai e filho, onde o pai nem é comprável e o variation_theme escolhido muda quais atributos passam a ser obrigatórios. A Shopee usa uma matriz de no máximo 2 tiers — o que significa que um produto com três eixos, digamos cor, tamanho e voltagem, simplesmente não tem representação possível na plataforma (`init_tier_variation`). O Magalu trata SKU, estoque e preço como três recursos planos e independentes. O estoque mora num nível diferente da árvore em cada uma das quatro. Some a isso as armadilhas operacionais: nenhuma delas aparece numa proposta comercial, e todas aparecem no terceiro mês de integração.
flowchart LR P["Uma camiseta preta tamanho P"] --> ML["Mercado Livre: array variations aninhado no item"] P --> AZ["Amazon: grafo de ASIN pai e filho"] P --> SH["Shopee: matriz de no máximo 2 tiers"] P --> MG["Magalu: SKU, estoque e preço em recursos planos"] ML --> E1["Estoque dentro da variação"] AZ --> E2["Estoque no ASIN filho"] SH --> E3["Estoque no model da matriz"] MG --> E4["Estoque em recurso próprio"]
| Armadilha operacional | Plataforma | Fonte |
|---|---|---|
Atualizar estoque é PUT no item inteiro, e omitir o id de uma variação nesse PUT apaga aquela variação | Mercado Livre | — |
A callback de webhook precisa responder HTTP 200 em 500 milissegundos ou o tópico é desativado automaticamente | Mercado Livre | doc oficial |
O getOrders da SP-API é limitado a 0,0167 requisição por segundo — cerca de uma por minuto | Amazon | Orders API rate limits |
O Commerce trata sincronização como parte do próprio modelo, 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. E o tipo canônico (CanonicalProduct, CanonicalVariant) existe precisamente porque o mapeamento 1:1 entre plataformas não existe: é preciso um formato neutro no meio. O provedor CATALISA é provisionado como PRIMARY automaticamente, fixando a regra que o mercado de hubs inverte: o seu catálogo é a fonte de verdade, o canal é réplica.
flowchart LR CAT["Catálogo interno, provedor CATALISA e papel PRIMARY"] --> CANON["CanonicalProduct e CanonicalVariant"] CANON --> SP1["StoreProvider: papel, direção e prioridade"] SP1 --> MAP["ProductMapping: SKU interno para id externo"] MAP --> CR["Resolvedor de conflito lê fieldOwnership campo a campo"] CR --> EXT["Plataforma externa recebe a réplica"] EXT -.->|"PULL traz o que mudou lá"| CR
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 é exatamente a distância entre "o desenho resolve" e "o produto entrega". Hoje, para quem precisa vender em marketplace amanhã, o Anymarket ou o Bling resolvem e nós não.
E o intermediário resolve um problema criando 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 parâmetro do fornecedor. Quando o hub atrasa, você descobre pelo cancelamento — e o cancelamento tem preço tabelado (§2).
Mercado e diferenciais
negócioPanorama. 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.
flowchart TD M["Mercado de commerce"] --> C1["Composable API-first"] M --> C2["Plataforma completa"] M --> C3["Open source"] M --> C4["Hubs e ERPs de varejo, quarta família brasileira"] C1 --> N1["commercetools: contrato empresarial, implantação em trimestres"] C2 --> N2["VTEX: loja, checkout, marketplace, OMS e fiscal, com percentual sobre a venda"] C3 --> N3["Medusa, Saleor, Vendure, Spree: o código é seu, a operação também"] C4 --> N4["Bling, Olist Tiny, Plugg.to, Ideris, Anymarket, Magazord"]
Duas coisas se mexeram nesse mercado em 2026, e vale conhecê-las porque mudam a conversa com o comprador.
O que mudou em 2026
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.
| Movimento de 2026 | Quem | Data | Efeito na conversa de venda |
|---|---|---|---|
| Taxa de 2,0% a 0,6% sobre pedido fora dos gateways embarcados | BigCommerce, hoje Commerce.com | 01/06/2026 | Acaba o argumento "sem taxa de transação" contra a Shopify |
| MIT trocado por GPLv3 na versão 3 | Vendure | Versão 3 | Licença deixa de ser permissiva |
| RBAC e SSO saem do MIT e viram licença comercial, inclusive no auto-hospedado | Medusa | 11/08/2026 | Compliance corporativa passa a exigir acordo comercial |
| Oscilação de licença duas vezes em menos de dois anos | Spree | 2025–2026 | Risco de licença entra na diligência |
| Núcleo BSD-3 sem reserva, nuvem cara e com percentual sobre GMV excedente | Saleor | Consultado em 2026-08-16 | Única alternativa open source sem reserva, e a mais cara na nuvem |
A quarta família brasileira
No Brasil existe uma quarta família, e ela é a concorrente real da metade de sincronização. São os hubs de integração e ERPs de varejo, já consolidados sob quatro grupos: LWSA (Bling e Ideris), Stone/Linx (Plugg.to), Olist (Tiny) e DB1 (Anymarket), com a Magazord independente. Eles não competem com o Commerce inteiro — não têm modelo multi-tenant para revenda, nem se propõem a ser componente embutido no produto de outra empresa. Competem com o pedaço que o Commerce ainda não entrega, e é honesto dizer o preço deles:
| Hub / ERP | Grupo | Entrada publicada | Canais | Reclame Aqui |
|---|---|---|---|---|
| Bling | LWSA | R$ 60/mês | 250+ integrações | 8,2 — "Ótima" |
| Olist Tiny | Olist | R$ 66/mês | +40 marketplaces | 7,4 — "Boa" |
| Plugg.to | Linx / Stone | a partir de R$ 399/mês + take rate não publicado | 80 | 7,2 — "Boa" |
| Ideris | LWSA | R$ 440/mês (só no fluxo de cadastro) | +30 | 5,9 — "Ruim" |
| Anymarket | DB1 | Não publica | +150 | 9,2 — "Ótima" |
| Magazord | Independente | Não publica | +35 | 7,4 — "Boa" |
Consultado em 2026-08-16 nas páginas oficiais de plano e nas fichas públicas do Reclame Aqui. Notas do Reclame Aqui têm volumes de reclamação muito diferentes entre si (de 15 a 582 por ano), então servem para ler o padrão de queixa — predominantemente contratual e de cobrança, não técnico —, e não para ranquear.
Onde a Catalisa entra
Nenhuma das quatro 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; e os hubs brasileiros assumem que você é o varejista, não quem constrói a plataforma para ele.
| 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.
flowchart TD
Q1{"Precisa vender em marketplace brasileiro já?"} -->|sim| H["Bling, Olist Tiny ou Anymarket"]
Q1 -->|não| Q2{"Precisa de loja no ar em uma semana?"}
Q2 -->|sim| SH["Shopify"]
Q2 -->|não| Q3{"É varejista global de grande porte com time de plataforma?"}
Q3 -->|sim| CT["commercetools"]
Q3 -->|não| Q4{"Quer o código na mão e opera a infraestrutura?"}
Q4 -->|sim| OS["Medusa ou Saleor"]
Q4 -->|não| Q5{"Está construindo um produto que precisa de commerce por dentro, para várias empresas clientes?"}
Q5 -->|sim| CC["Catalisa Commerce"]
Q5 -->|não| VT["VTEX, se quer plataforma completa com marketplace e OMS"]Se o que ele precisa é vender em marketplace brasileiro amanhã, o Commerce não entrega e não adianta contornar. Para um varejista que só quer conectar loja e canais, o Bling a R$ 60/mês ou o Olist Tiny a R$ 66/mês resolvem hoje, por menos do que custa uma reunião de projeto; para operação de porte com muitos canais, o Anymarket integra mais de 150. Para quem quer plataforma completa com marketplace, gestão de sellers e OMS no mesmo núcleo, a VTEX é a resposta madura, e o encaixe dela com o ecossistema brasileiro de pagamento e logística é 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 o cliente precisa de… | Recomende | Por quê, em uma linha |
|---|---|---|
| Vender em marketplace brasileiro amanhã | Bling, Olist Tiny ou Anymarket | Nossos adaptadores externos não existem (§15) |
| Loja no ar em uma semana, com vitrine e checkout | Shopify | O Commerce é uma API, não uma loja |
| Plataforma completa com marketplace, sellers e OMS | VTEX | Duas décadas de encaixe fiscal, logístico e de pagamento no Brasil |
| Catálogo e B2B de porte global | commercetools | Limites e hierarquia de unidades de negócio que não temos |
| O código na mão, com time próprio de plataforma | Medusa ou Saleor | Você opera a infraestrutura e resolve multi-tenancy |
| Commerce por dentro do próprio produto, para várias empresas clientes | Catalisa Commerce | Isolamento por token e custo desacoplado do faturamento |
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.
Modelo de cobrança e ROI
negócioUnidade 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.
| Plano VTEX | Take rate | O que o valor fixo compra |
|---|---|---|
ON DEMAND | 2,5% | Nada — é a alíquota inicial da especificação |
BUSINESS | 1,8% | Redução de 0,7 ponto sobre a alíquota inicial |
CORPORATE | 1,1% | Redução de 1,4 ponto |
ENTERPRISE | 0,5% | Redução de 2,0 pontos |
Pricebook público de
assine.vtex.com, consultado em 2026-08-16. É referência comercial, não necessariamente o que uma conta enterprise negocia.
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 conta é 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.
| O que você deixa de escrever | Por que o erro é caro |
|---|---|
| Catálogo com variantes e grade | SKU duplicado entre lojas corrompe pedido e integração |
| Estoque multi-local com reserva | Erro aparece como estoque negativo, em produção |
| Resolução de preço efetivo | Preço espalhado em if não tem resposta auditável |
| Máquina de estados de pedido com histórico | Pedido em estado impossível faz o relatório mentir |
| Multi-tenancy | Medusa, Saleor e Vendure deixam por sua conta |
A segunda conta é 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.
| GMV anual do cliente | Take rate de 1,8% | O que a plataforma passa a entregar |
|---|---|---|
| R$ 50 milhões | R$ 900 mil/ano | — |
| R$ 100 milhões | R$ 1,8 milhão/ano | O mesmo |
| R$ 200 milhões | R$ 3,6 milhões/ano | O mesmo |
Atenção. A tabela acima é aritmética sobre a alíquota BUSINESS publicada, não uma proposta: é estimativa, e ignora a licença anual e a taxa fixa mensal que aparecem na tabela de comparação de custo. Reconsulte o pricebook antes de levar qualquer número a proposta.
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.
Arquitetura
O caminho da requisição
flowchart TD
HTTP["HTTP"] --> APP["Hono app com basePath /commerce"]
APP --> COMMON["applyCommonMiddleware"]
COMMON --> HEALTH["GET /health — sonda de versão"]
COMMON --> STORES["/api/v1/stores — storesRouter, CRUD de loja"]
STORES --> AUTH["authMiddleware"]
AUTH --> RESOLVE["resolveStore — tudo abaixo vive sob /:storeId"]
RESOLVE --> PERM["requirePermission COMMERCE_*"]
PERM --> ROUTERS["19 sub-routers em /api/v1/stores/:storeId/..."]
ROUTERS --> ZOD["Zod parse e ResultAsync de T e AppError"]
ZOD --> SVC["services, sync e jobs"]
SVC --> REPO["repositories, 31 arquivos sobre Prisma"]
SVC --> EV["EventPublisher"]
REPO --> PG[("PostgreSQL, schema commerce, 32 tabelas")]
EV --> REDIS[("Redis, barramento compartilhado")]O applyCommonMiddleware aplica quatro proteções de borda, nesta ordem: limite de corpo de 1 MB, CORS, cabeçalhos de segurança e rate limit.
O que o resolveStore confere, na ordem. Cada linha da tabela era uma anotação dentro da caixa do middleware — e cada uma tem um código de erro próprio.
| Passo | Verificação | Se falha |
|---|---|---|
| 1 | storeId é UUID válido? | 400 |
| 2 | O token traz organizationId? | 403 |
| 3 | A loja existe e pertence à organização? | 404 |
| 4 | A loja está ativa? | 403 |
| → | c.set('storeId') e segue para a rota | — |
Os 19 sub-routers, por família
| Família | Sub-routers |
|---|---|
| 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 |
A camada de serviço
| Pasta | Quantos | Quem mora ali |
|---|---|---|
services/ | 23 | product, variant, stock, batch, price-list, promotion, order, order-lifecycle, cart, channel, store-provider, provider-sync, provider-webhook, orchestration, tax-config e os demais |
sync/ | 3 | sync-engine · mapping · conflict-resolver |
jobs/ | 3 | cart-cleanup · reservation-cleanup · low-stock-alert |
providers/ | 2 | catalisa (implementado) · ifood (molde com resposta simulada) |
repositories/ | 31 | Acesso a dados via Prisma, sobre o schema commerce |
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.
Como a resolução de conflito decide, campo a campo
O CommerceConflictResolverService compara name, description, status e brand entre o valor interno e o externo, e resolve cada campo divergente de forma independente.
flowchart TD
D["Campo diverge entre interno e externo"] --> O{"fieldOwnership declara dono para este campo?"}
O -->|"o dono é este provedor"| EXT["Vence o valor externo"]
O -->|"o dono é outro provedor"| INT1["Vence o valor interno"]
O -->|"não há dono declarado"| P{"Este vínculo é PRIMARY?"}
P -->|sim| INT2["Vence o valor interno — o dado interno é o primário"]
P -->|não| INT3["Vence o valor interno — a réplica não sobrescreve o original"]
EXT --> M["Produto mesclado"]
INT1 --> M
INT2 --> M
INT3 --> M
M --> S{"Houve conflito?"}
S -->|sim| C["Mapeamento marcado como CONFLICT"]
S -->|não| A["Mapeamento marcado como ACTIVE"]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.
Conceitos 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]) |
Como os 32 modelos se ligam
Trinta e dois modelos não cabem em um desenho legível — o diagrama abaixo mostra o esqueleto: quem pendura em quem, do isolamento até o pedido.
erDiagram
CommerceStore ||--o{ CommerceCategory : "organiza"
CommerceStore ||--o{ CommerceProduct : "publica"
CommerceStore ||--o{ CommerceStockLocation : "opera"
CommerceStore ||--o{ CommercePriceList : "precifica com"
CommerceStore ||--o{ CommerceChannel : "vende por"
CommerceStore ||--o{ CommerceCart : "recebe"
CommerceStore ||--o{ CommerceOrder : "fecha"
CommerceStore ||--o{ CommerceStoreProvider : "sincroniza com"
CommerceProduct ||--o{ CommerceProductVariant : "tem grade"
CommerceProduct ||--o{ CommerceProductImage : "ilustra com"
CommerceProduct ||--o{ CommercePriceModifier : "aceita adicional"
CommerceProductVariant ||--o{ CommerceStockItem : "tem saldo em"
CommerceStockLocation ||--o{ CommerceStockItem : "guarda"
CommerceProductVariant ||--o{ CommerceStockReservation : "reserva"
CommerceProductVariant ||--o{ CommerceStockMovement : "movimenta"
CommerceProductVariant ||--o{ CommerceOrderItem : "é vendida em"
CommercePriceList ||--o{ CommercePriceListEntry : "lista preço de"
CommerceOrder ||--o{ CommerceOrderItem : "contém"
CommerceOrder ||--o{ CommerceOrderStatusHistory : "registra"
CommerceCart ||--o{ CommerceCartItem : "contém"
CommerceStoreProvider ||--o{ CommerceProductMapping : "mapeia SKU"
CommerceStoreProvider ||--o{ CommerceOrderMapping : "mapeia pedido"
CommerceProviderConfig ||--o{ CommerceProviderSyncJob : "executa"
CommerceProviderConfig ||--o{ CommerceProviderWebhookLog : "recebe"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 adaptador CATALISA existe 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.
stateDiagram-v2
direction TB
[*] --> DRAFT: POST /orders
DRAFT --> CONFIRMED: confirm
CONFIRMED --> PREPARING: prepare
PREPARING --> READY: ready
READY --> SHIPPED: ship
READY --> OUT_FOR_DELIVERY: out-for-delivery
READY --> COMPLETED: complete
SHIPPED --> OUT_FOR_DELIVERY: out-for-delivery
OUT_FOR_DELIVERY --> DELIVERED: deliver
DELIVERED --> COMPLETED: complete
DELIVERED --> REFUNDED: refund
COMPLETED --> REFUNDED: refund
DRAFT --> CANCELLED: cancel
CONFIRMED --> CANCELLED: cancel
PREPARING --> CANCELLED: cancel
READY --> CANCELLED: cancel
SHIPPED --> CANCELLED: cancel
OUT_FOR_DELIVERY --> CANCELLED: cancel
CANCELLED --> [*]
REFUNDED --> [*]
note right of DRAFT
Único estado em que PATCH
altera o pedido.
end noteAtenção. cancel é aceito a partir de DRAFT, CONFIRMED, PREPARING, READY, SHIPPED e OUT_FOR_DELIVERY, e leva a CANCELLED, que é terminal. Não é aceito a partir de DELIVERED nem de COMPLETED — depois de entregue, o caminho é refund. REFUNDED também é terminal.
O que cada transição grava. Além de mudar o status, toda transição carimba o campo de tempo próprio, insere uma linha em CommerceOrderStatusHistory com autor e motivo, e publica commerce.order.<status em minúsculas> no barramento.
| Status | Carimbo gravado | Evento publicado |
|---|---|---|
CONFIRMED | confirmedAt | commerce.order.confirmed |
PREPARING | preparingAt | commerce.order.preparing |
READY | readyAt | commerce.order.ready |
SHIPPED | shippedAt | commerce.order.shipped |
OUT_FOR_DELIVERY | outForDeliveryAt | commerce.order.out_for_delivery |
DELIVERED | deliveredAt | commerce.order.delivered |
COMPLETED | completedAt | commerce.order.completed |
CANCELLED | cancelledAt | commerce.order.cancelled |
REFUNDED | refundedAt | commerce.order.refunded |
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 |
sequenceDiagram
autonumber
participant R as orders.router
participant L as OrderLifecycleService
participant DB as Repositórios
participant S as StockService
participant B as Barramento
R->>L: transition para o status alvo
L->>DB: findById do pedido pela organização
L->>L: o alvo está em VALID_ORDER_TRANSITIONS?
L->>DB: update com status e carimbo de tempo
L->>DB: insert em CommerceOrderStatusHistory
L->>B: safePublish do evento da transição
alt alvo é CONFIRMED
L->>S: reserveOrderStock
Note over L,S: hoje não reserva nada — ver seção 15
else alvo é CANCELLED
L->>S: releaseReservations por order e orderId
else alvo é COMPLETED
L->>S: consumeReservations por order e orderId
end
L-->>R: pedido atualizadoMovimento de estoque e reserva
Saldo não é campo que se escreve: é consequência de uma movimentação registrada. quantity é o total físico e reservedQty é a parte separada — o disponível para venda é a diferença dos dois.
flowchart LR IN["INBOUND"] -->|"soma em toLocation"| SI["StockItem: quantity e reservedQty"] OUT["OUTBOUND"] -->|"subtrai de fromLocation"| SI TR["TRANSFER"] -->|"subtrai de from e soma em to"| SI ADJ["ADJUSTMENT"] -->|"subtrai de from e ou soma em to"| SI SI --> AV["Disponível igual a quantity menos reservedQty"] RES["reserveStock"] -->|"incrementa reservedQty"| SI REL["releaseReservations"] -->|"decrementa reservedQty"| SI CON["consumeReservations"] -->|"decrementa reservedQty e quantity"| SI
stateDiagram-v2
[*] --> ACTIVE: reserveStock separa a quantidade
ACTIVE --> RELEASED: releaseReservations devolve ao disponível
ACTIVE --> CONSUMED: consumeReservations baixa do saldo
RELEASED --> [*]
CONSUMED --> [*]
note right of ACTIVE
O job CommerceReservationCleanupJob
libera as reservas vencidas por expiresAt.
end note| Operação | O que faz em reservedQty | O que faz em quantity | Recusa quando |
|---|---|---|---|
reserveStock | Incrementa | Não mexe | Não há StockItem no local, ou o disponível é menor que o pedido |
releaseReservations | Decrementa | Não mexe | — |
consumeReservations | Decrementa | Decrementa | — |
Atenção. reserveStock é a única operação de estoque que recusa por falta de saldo, com Insufficient stock. Available: X, Requested: Y. A movimentação comum (processMovement) não compara com o disponível — ver §15.
Ciclo de vida do carrinho
stateDiagram-v2
[*] --> ACTIVE: POST /carts
ACTIVE --> CONVERTED: checkout gera pedido em DRAFT
ACTIVE --> EXPIRED: passou de expiresAt, padrão de 24h
ACTIVE --> ABANDONED: estado previsto no enum, sem rotina que o atribua hoje
CONVERTED --> [*]
EXPIRED --> [*]
ABANDONED --> [*]
note right of EXPIRED
Quem marca é o CommerceCartCleanupJob,
que hoje não tem agendador — ver seção 15.
end noteAtenção. ABANDONED existe no enum e nenhuma rotina o atribui hoje (§15). O checkout recusa carrinho que não esteja ACTIVE e recusa carrinho vazio.
Estados dos demais recursos
O catálogo, a promoção, a sincronização e o mapeamento também têm status — e status sem diagrama é bug de integração esperando acontecer.
stateDiagram-v2
direction LR
state "Produto" as P {
state "DRAFT" as PDRAFT
state "ACTIVE" as PACTIVE
state "ARCHIVED" as PARCH
[*] --> PDRAFT
PDRAFT --> PACTIVE
PACTIVE --> PARCH
PARCH --> PACTIVE
}
state "Variante" as V {
state "ACTIVE" as VACTIVE
state "INACTIVE" as VINACTIVE
[*] --> VACTIVE
VACTIVE --> VINACTIVE
VINACTIVE --> VACTIVE
}
state "Promoção" as PR {
state "DRAFT" as RDRAFT
state "ACTIVE" as RACTIVE
state "EXPIRED" as REXPIRED
state "CANCELLED" as RCANCELLED
[*] --> RDRAFT
RDRAFT --> RACTIVE
RACTIVE --> REXPIRED
RACTIVE --> RCANCELLED
}| Enum | Estado inicial | Transições previstas | Observação |
|---|---|---|---|
CommerceProductStatus | DRAFT | DRAFT → ACTIVE → ARCHIVED | O status é escrito por PATCH; não há máquina que o valide |
CommerceVariantStatus | ACTIVE | ACTIVE ↔ INACTIVE | Padrão ACTIVE na criação |
CommercePromotionStatus | DRAFT | DRAFT → ACTIVE → EXPIRED ou CANCELLED | Nenhuma rotina expira a promoção sozinha (§15) |
stateDiagram-v2 [*] --> PENDING: padrão do modelo no banco PENDING --> IN_PROGRESS: o sync engine cria o job já em IN_PROGRESS IN_PROGRESS --> COMPLETED: completeSyncJob grava os contadores IN_PROGRESS --> FAILED: failSyncJob grava errorMessage FAILED --> IN_PROGRESS: POST /provider-sync/jobs/:id/retry COMPLETED --> [*]
Atenção. PENDING é o padrão da coluna no banco, mas o createSyncJob do sync engine grava IN_PROGRESS já na criação. Na prática, um job só aparece como PENDING se alguém o criar por fora do engine.
stateDiagram-v2 direction LR [*] --> ACTIVE: upsert sem conflito de campo ACTIVE --> CONFLICT: o resolvedor detectou divergência CONFLICT --> ACTIVE: divergência resolvida na sincronização seguinte ACTIVE --> STALE: o mapeamento envelheceu STALE --> ACTIVE: nova sincronização atualiza [*] --> UNMAPPED: SKU sem correspondente externo
CommerceMappingStatus | Significa | Quem escreve |
|---|---|---|
ACTIVE | SKU mapeado e coerente | upsertProductMapping ao fim da sincronização |
CONFLICT | O resolvedor achou divergência de campo | upsertProductMapping quando conflicts.length > 0 |
STALE | Mapeamento envelhecido | PATCH {base}/product-mappings/:id |
UNMAPPED | Sem correspondente externo | PATCH {base}/product-mappings/:id |
Referê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"
}{
"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" }
}{
"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" }
}{
"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"
}{
"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
}
]
}{
"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
flowchart TD E["Entradas de lista de preço da variante"] --> F["Filtra"] F --> O["Ordena por priceList.priority DESC e depois por minQuantity DESC"] O --> R["Devolve a primeira"]
| Filtro aplicado | Regra |
|---|---|
minQuantity | Menor ou igual à quantity solicitada |
priceList.organizationId | Igual à organização do token |
priceList.storeId | Igual à loja da rota |
priceList.isActive | true |
validFrom | Nulo ou já passou |
validUntil | Nulo ou ainda não passou |
channelId | Aplicado só se informado |
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 }
]
}{
"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
Início rápido
Do zero a um pedido confirmado, com estoque movimentado e preço de atacado resolvido.
flowchart LR P1["1. Autenticar no IAM"] --> P2["2. Criar a loja"] P2 --> P3["3 e 4. Produto e variante"] P3 --> P4["5 e 6. Local de estoque e entrada"] P4 --> P5["7. Conferir o saldo"] P5 --> P6["8 e 9. Tabela de atacado e preço efetivo"] P6 --> P7["10 e 11. Criar e confirmar o pedido"] P7 --> P8["12. Ver o histórico"] P8 --> P9["13. Confirmar que o isolamento é real"]
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/v1TOKEN=$(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/v12. 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"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"Formato esperado da saída — um UUID:
store: 8f3c1d2a-5b47-4c9e-9f10-2ab3c4d5e6f7store: 8f3c1d2a-5b47-4c9e-9f10-2ab3c4d5e6f73. Criar o produto
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')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')4. Criar a variante — é ela que carrega SKU e preço
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')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')5. Criar o local de estoque
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')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')6. Dar entrada no estoque
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'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'Resposta esperada:
"INBOUND""INBOUND"7. Conferir o saldo
curl -s "$BASE/stores/$STORE/stock/levels/$VAR" \
-H "Authorization: Bearer $TOKEN" | jq '.data[0] | {quantity, reservedQty}'curl -s "$BASE/stores/$STORE/stock/levels/$VAR" \
-H "Authorization: Bearer $TOKEN" | jq '.data[0] | {quantity, reservedQty}'{ "quantity": 120, "reservedQty": 0 }{ "quantity": 120, "reservedQty": 0 }8. Criar a tabela de atacado
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/nullPL=$(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/null9. Testar o preço efetivo nas duas pontas
# 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'# 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""59.9000"10. Criar 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')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')11. Confirmar o pedido
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}'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" }{ "status": "CONFIRMED", "at": "2026-08-16T14:02:11.417Z" }12. Ver o histórico
curl -s "$BASE/stores/$STORE/orders/$ORDER/history" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {fromStatus, toStatus, reason}'curl -s "$BASE/stores/$STORE/orders/$ORDER/history" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {fromStatus, toStatus, reason}'Resposta esperada — a linha gravada na criação, sem fromStatus, seguida de uma linha por transição:
{ "fromStatus": null, "toStatus": "DRAFT", "reason": null }
{ "fromStatus": "DRAFT", "toStatus": "CONFIRMED", "reason": "Pagamento aprovado" }{ "fromStatus": null, "toStatus": "DRAFT", "reason": null }
{ "fromStatus": "DRAFT", "toStatus": "CONFIRMED", "reason": "Pagamento aprovado" }13. 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"# 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.
Receitas
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.
flowchart LR C["1. Categoria"] --> P["2. Produto vinculado à categoria"] P --> V["3. Grade de variantes"] V --> I["4. Imagem"] I --> F["5. Config fiscal"] F --> A["6. Ativar o produto"] A --> OK["Produto pronto para venda"]
1. Criar a 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')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. Criar o 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')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. Criar a 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'
donefor 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'
doneResposta esperada — um SKU por linha:
CAM-BAS-P-PRETA
CAM-BAS-M-PRETA
CAM-BAS-G-PRETACAM-BAS-P-PRETA
CAM-BAS-M-PRETA
CAM-BAS-G-PRETA4. Anexar a 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}'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. Gravar a configuração 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"}'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 o produto
curl -s -X PATCH "$BASE/stores/$STORE/products/$PROD" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"status":"ACTIVE"}' | jq '.data.status'curl -s -X PATCH "$BASE/stores/$STORE/products/$PROD" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"status":"ACTIVE"}' | jq '.data.status'Resposta esperada:
"ACTIVE""ACTIVE"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.
flowchart LR CAT["Catálogo único da loja"] --> CH1["Canal STOREFRONT"] CAT --> CH2["Canal DELIVERY_PLATFORM"] CAT --> CH3["Canal POS"] CH1 --> D1["isAvailable e priceOverride por produto"] CH2 --> D2["isAvailable e priceOverride por produto"] CH3 --> D3["isAvailable e priceOverride por produto"]
1. Criar o canal
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')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')2. Ligar 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}'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}'3. Ou ligar e desligar 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}
]}"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.
flowchart TD
A["Checkout do cliente"] --> B["GET /stock/levels/:variantId"]
B --> C{"Disponível maior ou igual ao pedido?"}
C -->|não| D["Recuse antes de criar o pedido"]
C -->|sim| E["POST /stock-movements OUTBOUND com referenceType order"]
E --> F["POST /orders/:id/confirm"]
F --> G{"O pedido foi cancelado?"}
G -->|sim| H["POST /stock-movements INBOUND, o inverso, com a mesma referência"]
G -->|não| I["Baixa já está feita; complete o pedido"]1. Antes de confirmar, verifique o disponível
curl -s "$BASE/stores/$STORE/stock/levels/$VAR" -H "Authorization: Bearer $TOKEN" \
| jq '.data[] | (.quantity - .reservedQty)'curl -s "$BASE/stores/$STORE/stock/levels/$VAR" -H "Authorization: Bearer $TOKEN" \
| jq '.data[] | (.quantity - .reservedQty)'Resposta esperada — o disponível de cada local, um por linha:
1201202. 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\"}"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\"}"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. Descobrir em que estado ele está
curl -s "$BASE/stores/$STORE/orders/$ORDER" -H "Authorization: Bearer $TOKEN" \
| jq '{status: .data.status, confirmedAt: .data.confirmedAt}'curl -s "$BASE/stores/$STORE/orders/$ORDER" -H "Authorization: Bearer $TOKEN" \
| jq '{status: .data.status, confirmedAt: .data.confirmedAt}'Resposta esperada:
{ "status": "READY", "confirmedAt": "2026-08-16T14:02:11.417Z" }{ "status": "READY", "confirmedAt": "2026-08-16T14:02:11.417Z" }2. Ver 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}'curl -s "$BASE/stores/$STORE/orders/$ORDER/history" -H "Authorization: Bearer $TOKEN" \
| jq '.data[] | {from: .fromStatus, to: .toStatus, por: .changedBy, quando: .createdAt}'3. Seguir a árvore de diagnóstico
flowchart TD
E["A chamada de transição falhou"] --> S{"Qual é o status HTTP?"}
S -->|400| T{"O destino está na lista do estado atual?"}
T -->|não| T1["Corrija o caminho pelo diagrama da seção 8"]
T -->|sim| T2["Confira o corpo contra o schema da seção 9"]
S -->|403| P{"Qual transição você chamou?"}
P -->|cancel| P1["Falta COMMERCE_ORDERS_CANCEL"]
P -->|refund| P2["Falta COMMERCE_ORDERS_REFUND"]
P -->|"as demais"| P3["Falta COMMERCE_ORDERS_MANAGE"]
S -->|404| N["Pedido de outra organização, ou storeId da URL não é o da loja do pedido"]
S -->|"400 em PATCH"| A["Só pedido em DRAFT aceita PATCH"]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.
O que acontece dentro de uma execução de sincronização
sequenceDiagram
autonumber
participant R as provider-sync.router
participant E as SyncEngine
participant J as CommerceProviderSyncJob
participant P as Adaptador do provedor
participant M as MappingService
participant C as ConflictResolver
R->>E: syncProducts com direção e storeProviderId
E->>J: cria o job já em IN_PROGRESS
E->>E: instantiateProvider pelo providerType
alt tipo sem adaptador
E->>J: failSyncJob com Unsupported provider type
E-->>R: 500 INTERNAL
else PULL
E->>P: fetchProducts
P-->>E: lista canônica
loop um produto por vez, em sequência
E->>M: findExternalMapping pelo SKU
alt já mapeado
E->>C: resolveProductConflict campo a campo
C-->>E: produto mesclado e lista de conflitos
E->>M: upsertProductMapping com ACTIVE ou CONFLICT
else novo
E->>E: cria produto e variantes internos
E->>M: upsertProductMapping com ACTIVE
end
end
E->>J: completeSyncJob com os contadores
E->>R: evento commerce.provider.sync_completed
end1. Criar a 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')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')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}'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}'Resposta esperada — os mesmos contadores que ficam gravados no job:
{ "sincronizados": 3, "criados": 3, "atualizados": 0, "ignorados": 0 }{ "sincronizados": 3, "criados": 3, "atualizados": 0, "ignorados": 0 }4. Ver os mapeamentos gerados
curl -s "$BASE/stores/$STORE/product-mappings?status=ACTIVE" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {sku, externalProductId, status}'curl -s "$BASE/stores/$STORE/product-mappings?status=ACTIVE" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {sku, externalProductId, status}'5. Consultar 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" | jqcurl -s "$BASE/stores/$STORE/product-mappings/by-sku/CAM-BAS-P-PRETA" \
-H "Authorization: Bearer $TOKEN" | jqArmadilhas.
- 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.
flowchart LR
A["POST /promotions cria o cupom"] --> B["POST /promotions/validate"]
B --> C{"Existe, tem uso disponível e o valor mínimo bate?"}
C -->|não| D["Recuse o cupom no seu checkout"]
C -->|sim| E["Confira startsAt e endsAt na resposta"]
E --> F["Aplique o desconto no seu lado"]
F --> G["Controle o consumo no seu lado — usedCount não incrementa"]1. Criar a promoção
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" \
-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'2. Validar o código contra o valor do pedido
curl -s -X POST "$BASE/stores/$STORE/promotions/validate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"code":"AGOSTO20","orderValue":150.00}' | jqcurl -s -X POST "$BASE/stores/$STORE/promotions/validate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"code":"AGOSTO20","orderValue":150.00}' | jqArmadilhas.
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.
Integraçã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 |
flowchart TD IAM["IAM — token com organizationId e permissões"] IAM -->|"Bearer JWT"| CU["Customers — quem compra"] IAM -->|"Bearer JWT"| FS["File Storage — imagem do produto"] IAM -->|"Bearer JWT"| CO["COMMERCE — o que, quanto tem, por quanto, o pedido"] IAM -->|"Bearer JWT"| PA["Payments — o dinheiro"] IAM -->|"Bearer JWT"| BI["Billing — a fatura"] CU -->|customerId| CO FS -->|fileId| CO PA -->|"pagamento aprovado, POST /orders/:id/confirm"| CO CO -->|"eventos commerce.*"| WE["Webhooks Engine — entrega com retentativa ao ERP, ao painel e à cozinha"] CO -->|"pedidos e lojas ativas"| BI
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.
sequenceDiagram autonumber participant B as Backend do cliente participant C as Commerce participant P as Payments participant W as Webhooks Engine participant A as Audit Trail participant F as Billing B->>C: POST /orders C-->>B: pedido em DRAFT B->>P: cobrança do valor do pedido P-->>B: pagamento aprovado B->>C: POST /orders/:id/confirm C->>W: commerce.order.confirmed W->>B: entrega ao ERP, com retentativa C->>A: quem confirmou e quando C->>F: mais um pedido na fatura do mês
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 |
Configuraçã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 |
flowchart LR
C["Commerce, porta 3026"] --> PG[("PostgreSQL, schema commerce")]
C --> RD[("Redis: rate limit e barramento")]
C --> IAM["IAM: emite e verifica o token"]
C -.->|"opcional, só com fileId"| FS["File Storage"]
RD --> WE["Webhooks Engine consome os eventos"]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.
flowchart LR
EXT["Gatilho externo — cron da sua infraestrutura"] -.->|"o que falta hoje"| RUN["job.run"]
RUN --> G{"isRunning?"}
G -->|sim| SKIP["Ignora a execução, sem sobrepor"]
G -->|não| J1["CartCleanup: ACTIVE vencido vira EXPIRED"]
G -->|não| J2["ReservationCleanup: libera reserva vencida"]
G -->|não| J3["LowStockAlert: publica commerce.stock.low_stock"]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.
Seguranç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:
sequenceDiagram autonumber participant R as Requisição participant M as resolveStore participant DB as storeRepo R->>M: storeId na URL e token já verificado M->>M: storeId é UUID? senão 400 M->>M: token tem organizationId? senão 403 M->>DB: findById com storeId e organizationId Note over M,DB: findFirst com AS DUAS chaves na mesma cláusula DB-->>M: loja ou nada M->>M: a loja existe? senão 404 M->>M: a loja está ativa? senão 403 M-->>R: segue para requirePermission e para a rota
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.
| Camada | Onde roda | O que garante | Cobertura hoje |
|---|---|---|---|
| Estrutural | Middleware resolveStore, no use('*') dos 19 sub-routers | A loja da URL pertence à organização do token e está ativa | Todas as rotas de domínio, sem exceção |
| Por recurso | findById(id, organizationId) nos repositórios | O identificador sozinho não alcança o recurso | 23 dos 23 repositórios com busca por identificador |
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.
A leva seguinte tem que alcançar os recursos que não têm coluna organizationId própria e só chegam ao 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 precisa ser uma junção (variant: { product: { organizationId } }), não uma coluna. Essa leva ainda não foi entregue — nenhum desses repositórios recebe organizationId no findById hoje.
Estado em 2026-08-17, conferido repositório a repositório contra origin/main: os 23 repositórios com busca por identificador aplicam o filtro de organização. O rollout fechou com o PR #243 (ALTO-02); antes dele eram nove, e a garantia dos demais vinha só do resolveStore.
Filtram por organizationId no findById | Ainda não filtram |
|---|---|
store · product · order · cart · category · channel · promotion · stock-location · provider-config | variant · price-modifier · product-image · order-item · price-list · price-list-entry · batch-item · stock-reservation · store-provider · product-mapping · order-mapping · category-mapping · provider-sync-job · provider-webhook-log |
Nos catorze da coluna direita a garantia vem inteiramente da primeira camada — o resolveStore, que já barrou a loja de outra organização antes de a rota rodar. 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 e recebimento de webhook
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.
flowchart TD P["Plataforma externa"] -->|"POST /provider-webhooks/:providerConfigId, sem authMiddleware"| W["Rota de webhook"] W --> F["findByIdAnyTenant na configuração do provedor"] F --> O["organizationId resolvido pela própria configuração"] O --> L["CommerceProviderWebhookLog com corpo e cabeçalhos"] L --> S["Processamento e marcação de processed"] W -.->|"ainda não implementado"| V["Verificação de assinatura pelo webhookSecret"]
Atenção. O findByIdAnyTenant existe só para este caminho e está documentado como de uso interno exclusivo — em rota autenticada, o escopo tem que vir do token. E enquanto a verificação de assinatura não existir, não publique a URL de webhook para provedor externo em produção.
Dados, retenção e regulaçã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.
Limitações conhecidas
Escrito de frente, porque é a seção que o comprador técnico lê primeiro.
flowchart LR F["createCommerceProvider"] --> C["CATALISA — implementado e testado"] F -.->|"molde com resposta simulada, sem registro no factory"| I["IFOOD"] F -.->|"não implementado"| X["RAPPI · UBER_EATS · MERCADO_LIVRE · AMAZON · SHOPEE · MAGALU · VTEX · NUVEMSHOP · SHOPIFY · CUSTOM"] X --> E["Unsupported provider type na sincronização"]
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 um adaptador externo real vai exigir. Registrado aqui porque quem pegar essa tarefa precisa dimensioná-la antes de prometer prazo — todas as restrições abaixo estão na documentação oficial das plataformas:
| Restrição | Plataforma | Consequência para o adaptador |
|---|---|---|
getOrders limitado a 0,0167 req/s (≈ 1 por minuto), com burst de 20 | Amazon SP-API | Busca de pedidos precisa ser incremental e por evento, nunca varredura |
| Callback precisa responder 200 em 500 ms ou o tópico é desativado | Mercado Livre | O webhook tem que só enfileirar; processar dentro da requisição derruba a integração |
PUT no item inteiro para atualizar estoque, e omitir o id de uma variação a apaga | Mercado Livre | Toda escrita exige leitura completa antes; um PATCH ingênuo destrói catálogo |
| Máximo de 2 tiers de variação | Shopee | Produto com 3 eixos não tem representação; o adaptador precisa recusar ou achatar |
Grafo pai/filho de ASIN, com variation_theme definindo atributos obrigatórios | Amazon | O mapeamento não é por SKU isolado, é por árvore |
| Teto de 100 variações por item (250 em algumas categorias) | Mercado Livre | Grade grande precisa ser quebrada em vários anúncios |
| Aplicações devem separar Mercado Livre e Mercado Pago desde 30/08/2026 | Mercado Livre | Credencial única para os dois perde acesso à API |
Nenhum par de plataformas tem mapeamento 1:1, e o estoque mora em nível diferente da árvore em cada uma. É exatamente o que o tipo canônico (CanonicalProduct, CanonicalVariant) existe para absorver — mas absorver isso é trabalho de adaptador, não de configuração.
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 — resolvido | Era a limitação principal deste BB. Com o PR #243 (ALTO-02), os 23 repositórios com busca por identificador passaram a filtrar por organizationId. O resolveStore continua como camada estrutural, agora como defesa em profundidade e não como única garantia |
| Sem expurgo automatizado para LGPD | Exclusão é lógica; eliminação definitiva é manual |
Perguntas frequentes
| Pergunta | Resposta em uma linha |
|---|---|
| Integra com marketplace? | Hoje não — só o provedor interno tem adaptador |
| Qual a diferença para o building block Products? | Products é catálogo de produto financeiro; Commerce é catálogo de varejo |
| Dá para usar só o estoque? | Dá, com uma loja e variantes para pendurar o saldo |
| Como faço a baixa quando confirmo o pedido? | Manualmente, por stock-movements — confirm não reserva |
| Uma loja tem vários depósitos? | Sim; o contrário, um depósito para duas lojas, não |
Por que READY não vai direto para DELIVERED? | A tabela de transições não permite; passe por OUT_FOR_DELIVERY |
Preço por canal: priceOverride ou lista? | Escolha um; só a lista é enxergada pelo effective-price |
| O que acontece com o carrinho abandonado? | Vence em 24h, mas hoje falta agendador chamando o job |
| Dá para operar centenas de lojas? | É o desenho; o limite prático é o do PostgreSQL |
| Como sei que não vejo dado de outra empresa? | Duas camadas, com resolveStore barrando antes da regra de negócio |
| Vale a pena com ERP já rodando? | Depende de onde a venda acontece |
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: um hub especializado resolve isso hoje e nós não — Bling a partir de R$ 60/mês, Olist Tiny a R$ 66/mês, ou Anymarket para quem precisa dos mais de 150 canais (preços consultados em 2026-08-16). O §15 lista o que um adaptador nosso vai precisar enfrentar, e não é trabalho de semanas.
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.
flowchart LR C["POST /orders/:id/confirm"] -.->|"chama, mas não reserva"| N["reserveOrderStock"] C --> V["Você registra OUTBOUND com referenceType order"] V --> S["Saldo baixado e trilha amarrada ao pedido"] S -->|"pedido cancelado"| I["INBOUND inverso com a mesma referência"]
Uma loja pode ter mais de um depósito? E um depósito pode servir a duas lojas?
flowchart TD L1["Loja A"] --> D1["Depósito central, isDefault"] L1 --> D2["Unidade centro"] L1 --> D3["Unidade shopping"] L2["Loja B"] --> D4["Depósito próprio"] D1 -.->|"não é possível"| L2
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?
flowchart LR R["READY"] --> S["SHIPPED"] R --> O["OUT_FOR_DELIVERY"] R --> C["COMPLETED"] R --> X["CANCELLED"] S --> O O --> D["DELIVERED"] R -.->|"não existe"| D
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: os 23 repositórios com busca por identificador filtram pela organização do token. As duas camadas são independentes, então uma falha em qualquer uma delas não basta para alcançar dado de outro cliente.
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