Catalisa.Building Blocks
Catálogo/Comércio/Commerce

Commerce

Beta

Catálogo, estoque, preço e pedido multi-loja na mesma API

130
Endpoints
32
Entidades
1
Provedores
Tenant
Escopo
3026
Porta
2026-03
Desde

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.

Para quem é
  • 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
Substitui
  • 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
O que não é
  • 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
O que dá para fazer

6 endpoints em 2 recursos.

Explorar a API →
01

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.

AtributoValor
Identificadorcommerce
CategoriaComércio
EscopoTenant (exige organizationId no token)
Porta (standalone)3026
Path alias@commerce
Prefixo HTTP/commerce
Schema no bancocommerce
Endpoints130 pela contagem oficial (137 rotas HTTP — ver §9)
Modelos Prisma32
StatusBeta, publicado desde 2026-03
Depende dePostgreSQL, Redis, IAM

02

O problema

negócio

O cenário. Uma empresa vende. Começa vendendo por um canal, depois abre outro, depois abre uma segunda unidade física, depois um cliente grande pede tabela de preço própria. Nada disso é exótico — é o curso normal de um negócio que dá certo. O problema é que cada um desses passos, no software, costuma custar uma reescrita.

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 if em 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:

CanalTeto de cancelamento por culpa do vendedorConsequência declarada
Mercado Livre1,5% para reputação verde; 0,5% para Mercado Líder; acima de 4%, vermelhoQueda de reputação, perda do selo e da exposição (termômetro oficial)
Amazon2,5% pré-envio; 0,5% no Seller Fulfilled PrimeDesativação das ofertas seller-fulfilled (Order Performance, aplicável ao Brasil)
Magalu5%, e indisponibilidade de estoque conta como culpa do vendedorAumento do prazo de desbloqueio de recebíveis — a punição entra no fluxo de caixa (Magalu, 15/08/2026)
ShopeeSistema de pontos; +1 ponto para quem passa de 40% de não envio na semanaCongelamento 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"]
  end

E a direção do mercado é de mais cobrança sobre GMV, não menos: em 1º de junho de 2026 a BigCommerce — hoje Commerce.com — criou uma taxa de 2,0% a 0,6% sobre pedido processado fora dos gateways embarcados, encerrando o "sem taxa de transação" que era o argumento dela contra a Shopify.


03

Proposta de valor

negócio
AntesDepois
Estoque em planilha por unidade, conferido no fim do mêsStockLocation por unidade, com movimentação tipada e rastreável item a item
Preço é um campo no produto e o resto é ifListas 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 escreveMá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 faturamentoCobranç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 note

Um modelo canônico para verticais diferentes

O mesmo Product atende PHYSICAL, DIGITAL, SERVICE e FOOD; o mesmo pedido atende PICKUP, DELIVERY, DINE_IN e DIGITAL; PriceModifier cobre adicional de lanche e opcional de serviço com a mesma estrutura. Trocar de vertical não exige trocar de building block.

VerticalTipo de produtoTipo de entrega típicoO que o PriceModifier cobre
Varejo físicoPHYSICALPICKUP ou DELIVERYEmbalagem para presente, gravação
Food serviceFOODDELIVERY ou DINE_INBacon extra, ponto da carne, bebida
Produto digitalDIGITALDIGITALLicença adicional, período estendido
ServiçoSERVICEPICKUP ou DIGITALOpcional contratado junto

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.


04

Casos de uso reais

negócio

Caso 1 — Uma rede de oito lojas para de vender o que não tem Cenário ilustrativo

Contexto

Rede de varejo de moda com oito unidades físicas e uma loja online. Cerca de quatro mil SKUs ativos entre grade de tamanho e cor.

A dor

O estoque online era uma soma manual do estoque das unidades, atualizada uma vez por dia. Um item comprado às dez da manhã já podia ter sido vendido no balcão às nove. O time de atendimento passava a manhã cancelando pedido e pedindo desculpa, e a taxa de cancelamento por ruptura era o indicador que ninguém queria apresentar na reunião.

A solução com o BB

Cada unidade vira um StockLocation da mesma loja. Toda entrada e saída passa por POST /stores/:storeId/stock-movements, com tipo declarado (INBOUND, OUTBOUND, TRANSFER, ADJUSTMENT) e referência ao documento de origem. A consulta de disponibilidade é GET /stores/:storeId/stock/levels/:variantId, que devolve quantidade e quantidade reservada por local. O job de reserva expirada devolve ao estoque o que ficou preso em carrinho abandonado, e GET /stock/low-stock alimenta o alerta de reposição usando o reorderPoint de cada item.

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 resultado

O número de disponibilidade deixa de ser uma soma diária e passa a ser o saldo corrente, com o histórico de como ele chegou ali. Quando falta, falta com aviso — não com cancelamento.

Caso 2 — Uma operação de delivery com preparo e cardápio por canal Cenário ilustrativo

Contexto

Operação de food service com cozinha própria, vendendo por aplicativo próprio, por telefone e por plataformas de delivery.

A dor

O ciclo do pedido de comida não é o do varejo. Entre "confirmado" e "entregue" existe preparo, existe pronto para retirada e existe saída para entrega — e cada um desses momentos precisa disparar uma notificação diferente. Além disso, nem todo item do cardápio vai para todo canal: alguns pratos só saem no balcão, e o preço no aplicativo de terceiro precisa absorver a comissão da plataforma.

A solução com o BB

Produtos do tipo FOOD, com PriceModifier cobrindo adicionais e opcionais por item. Cada plataforma é um Channel do tipo DELIVERY_PLATFORM, e PUT /channels/:id/products/:productId/availability liga ou desliga cada produto naquele canal, com priceOverride quando o preço precisa ser diferente. O pedido percorre confirm → prepare → ready → out-for-delivery → deliver → complete, e cada transição publica um evento (commerce.order.preparing, commerce.order.ready, ...) que o Webhooks Engine entrega para a cozinha e para o cliente.

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 resultado

O cardápio é um só, com recorte por canal declarado em vez de duplicado. E o painel da cozinha para de depender de alguém apertar um botão em outro sistema.

Caso 3 — Uma plataforma B2B atende cinquenta clientes sem cinquenta instâncias Cenário ilustrativo

Contexto

Empresa de software que vende operação de e-commerce como serviço para outras empresas. Cinquenta clientes, cada um com uma a três lojas.

A dor

A primeira arquitetura era uma instância por cliente. Cinquenta bancos, cinquenta deploys, e uma mudança de schema que levava uma semana para chegar ao último cliente. O custo de infraestrutura crescia linearmente com as vendas, que é o oposto do que se espera de software.

A solução com o BB

Cada cliente é uma Organization do IAM. Cada operação dele é uma CommerceStore daquela organização, com @@unique([organizationId, slug]) garantindo que o identificador de loja é dele. O organizationId chega por claim assinado no token, nunca pelo corpo, e resolveStore recusa com 404 a tentativa de alcançar uma loja de outra organização.

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 --> DB
O resultado

Uma instância, um deploy, uma migração. Adicionar um cliente é POST /iam/api/v1/organizations seguido de POST /commerce/api/v1/stores — não é provisionar ambiente.

Caso 4 — Por que sincronizar catálogo é o gargalo, e não a vitrine Referência de mercado

Contexto

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 dor

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 operacionalPlataformaFonte
Atualizar estoque é PUT no item inteiro, e omitir o id de uma variação nesse PUT apaga aquela variaçãoMercado Livre—
A callback de webhook precisa responder HTTP 200 em 500 milissegundos ou o tópico é desativado automaticamenteMercado Livredoc oficial
O getOrders da SP-API é limitado a 0,0167 requisição por segundo — cerca de uma por minutoAmazonOrders API rate limits
A solução com o BB

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
O resultado

A arquitetura está pronta e testada contra o provedor interno. Os adaptadores para as plataformas externas ainda não existem — é o item número um do §15, e é 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).


05

Mercado e diferenciais

negócio

Panorama. O mercado de commerce se partiu em três famílias. No topo estão as plataformas composable — commercetools à frente — que vendem API-first para varejo global de grande porte, com contrato empresarial e implantação medida em trimestres. No meio está a plataforma completa, dominada no Brasil pela VTEX, que entrega loja, checkout, marketplace, OMS e encaixe fiscal em um pacote e cobra percentual sobre o que você vende. E na base está o open source — Medusa, Saleor, Vendure, Spree — que entrega o código e transfere a operação para o seu time.

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 2026QuemDataEfeito na conversa de venda
Taxa de 2,0% a 0,6% sobre pedido fora dos gateways embarcadosBigCommerce, hoje Commerce.com01/06/2026Acaba o argumento "sem taxa de transação" contra a Shopify
MIT trocado por GPLv3 na versão 3VendureVersão 3Licença deixa de ser permissiva
RBAC e SSO saem do MIT e viram licença comercial, inclusive no auto-hospedadoMedusa11/08/2026Compliance corporativa passa a exigir acordo comercial
Oscilação de licença duas vezes em menos de dois anosSpree2025–2026Risco de licença entra na diligência
Núcleo BSD-3 sem reserva, nuvem cara e com percentual sobre GMV excedenteSaleorConsultado 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 / ERPGrupoEntrada publicadaCanaisReclame Aqui
BlingLWSAR$ 60/mês250+ integrações8,2 — "Ótima"
Olist TinyOlistR$ 66/mês+40 marketplaces7,4 — "Boa"
Plugg.toLinx / Stonea partir de R$ 399/mês + take rate não publicado807,2 — "Boa"
IderisLWSAR$ 440/mês (só no fluxo de cadastro)+305,9 — "Ruim"
AnymarketDB1Não publica+1509,2 — "Ótima"
MagazordIndependenteNão publica+357,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érioCatalisa CommerceVTEXShopifycommercetoolsMedusaSaleor
Como o custo crescePor loja e por pedido (em definição)Com o seu faturamentoAssinatura + % fora do gateway próprioPor volume de pedidosAssinatura + computeAssinatura + % sobre GMV excedente
Preço de entrada públicoEm definiçãoNão (pricebook exposto: 2,5% → 0,5%)Sim (US$ 399/mês Advanced)NãoSim (US$ 29/mês)Sim (US$ 1.599/mês)
Multi-tenant por contratoSim, organizationId no tokenUma conta por clienteUma conta por lojaProjetos separadosVocê implementaVocê implementa
Multi-loja na mesma contaSim, nativoSimNãoSim (até 300 mil stores)ParcialSim, via canais
Estoque multi-local com reservaSimSimSimSimSimSim
Máquina de estados de pedidoDeclarada, com histórico e permissão por transiçãoNativa do fluxo VTEXFixa no modelo ShopifyConfigurávelWorkflows com rollbackConfigurável
Conectores de marketplace prontosNão (§15)Sim, e é o ponto forte no BrasilVia aplicativosVia parceirosVia comunidadeNão
Storefront prontoNão, é só APISimSimNãoNãoNão
Checkout e pagamentoFora do escopo, é o PaymentsNativoNativo, e é o ativo delesFora do escopoPlugávelNativo, multi-gateway
Fiscal brasileiroGuarda NCM/CEST/CFOP/alíquotas; não emiteNativoVia aplicativosFora do escopoFora do escopoFora do escopo
Você opera a infraestruturaNãoNãoNãoNãoSim, se auto-hospedarSim, se auto-hospedar
LicençaProprietáriaProprietária (repos de storefront sem licença declarada)Proprietária (Hydrogen MIT)ProprietáriaMIT com reserva desde 2026-08-11BSD-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

  1. O isolamento é estrutural, não disciplinar. Toda rota de domínio nasce sob /stores/:storeId, e o resolveStore valida 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.
  2. 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.
  3. Um modelo canônico que já atende quatro verticais. PHYSICAL, DIGITAL, SERVICE e FOOD convivem no mesmo Product; PICKUP, DELIVERY, DINE_IN e DIGITAL convivem no mesmo pedido. Quem separou varejo de food service em produtos diferentes não junta os dois sem uma migração.
  4. 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…RecomendePor quê, em uma linha
Vender em marketplace brasileiro amanhãBling, Olist Tiny ou AnymarketNossos adaptadores externos não existem (§15)
Loja no ar em uma semana, com vitrine e checkoutShopifyO Commerce é uma API, não uma loja
Plataforma completa com marketplace, sellers e OMSVTEXDuas décadas de encaixe fiscal, logístico e de pagamento no Brasil
Catálogo e B2B de porte globalcommercetoolsLimites e hierarquia de unidades de negócio que não temos
O código na mão, com time próprio de plataformaMedusa ou SaleorVocê opera a infraestrutura e resolve multi-tenancy
Commerce por dentro do próprio produto, para várias empresas clientesCatalisa CommerceIsolamento 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.


06

Modelo de cobrança e ROI

negócio

Unidade de cobrança: precificação em definição. O Commerce ainda não tem tabela publicada. Não invente número em proposta — descreva os drivers e leve o caso para a mesa comercial.

O que dispara custo.

DriverPor que ele importa
Lojas ativasÉ a unidade de isolamento e o que o cliente reconhece como "uma operação"
Pedidos processados por mêsMelhor proxy de valor entregue e o número que o cliente já acompanha
SKUs em catálogoDimensiona armazenamento, índice e custo de sincronização
Execuções de sincronização com provedorCada 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.

FornecedorComo o custo é montadoOrdem de grandeza anualFonte
Catalisa CommercePor loja e por pedidoPrecificação em definição—
VTEX, plano BUSINESS1,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 PlusUS$ 2.300/mês em contrato de 3 anos + 0,2% sobre pedido fora do Shopify PaymentsUS$ 27,6 mil de licença + o percentual do gatewayshopify.com/plus/pricing, consultado em 2026-08-16
Saleor Cloud, plano VolumeUS$ 3.999/mês, cobrindo até US$ 1 milhão de GMV/mês; 0,4% sobre o excedenteUS$ 48 milsaleor.io/pricing, consultado em 2026-08-16
Medusa Cloud, plano ScaleUS$ 299/mês, sem taxa sobre GMV; compute e edge excedentes à parteUS$ 3,6 mil + excedentes + o seu time de operaçãomedusajs.com/pricing, consultado em 2026-08-16
commercetoolsPor volume de pedidos, explicitamente sem taxa sobre GMVNão publicado. Terceiros relatam US$ 40 mil a US$ 150 mil/anocommercetools.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 VTEXTake rateO que o valor fixo compra
ON DEMAND2,5%Nada — é a alíquota inicial da especificação
BUSINESS1,8%Redução de 0,7 ponto sobre a alíquota inicial
CORPORATE1,1%Redução de 1,4 ponto
ENTERPRISE0,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 escreverPor que o erro é caro
Catálogo com variantes e gradeSKU duplicado entre lojas corrompe pedido e integração
Estoque multi-local com reservaErro aparece como estoque negativo, em produção
Resolução de preço efetivoPreço espalhado em if não tem resposta auditável
Máquina de estados de pedido com históricoPedido em estado impossível faz o relatório mentir
Multi-tenancyMedusa, 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 clienteTake rate de 1,8%O que a plataforma passa a entregar
R$ 50 milhõesR$ 900 mil/ano—
R$ 100 milhõesR$ 1,8 milhão/anoO mesmo
R$ 200 milhõesR$ 3,6 milhões/anoO 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.


07

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.

PassoVerificaçãoSe falha
1storeId é UUID válido?400
2O token traz organizationId?403
3A loja existe e pertence à organização?404
4A loja está ativa?403
→c.set('storeId') e segue para a rota—

Os 19 sub-routers, por família

FamíliaSub-routers
Catálogocategories · products · variants · images · price-modifiers
Estoquestock-locations · stock · stock-movements · batches
Preçoprice-lists · promotions
Vendacarts · orders
Distribuiçãochannels · providers · provider-sync · provider-webhooks
Sincronizaçãostore-providers · product-mappings

A camada de serviço

PastaQuantosQuem mora ali
services/23product, variant, stock, batch, price-list, promotion, order, order-lifecycle, cart, channel, store-provider, provider-sync, provider-webhook, orchestration, tax-config e os demais
sync/3sync-engine · mapping · conflict-resolver
jobs/3cart-cleanup · reservation-cleanup · low-stock-alert
providers/2catalisa (implementado) · ifood (molde com resposta simulada)
repositories/31Acesso 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 middleware resolveStore roda antes de qualquer serviço, em todos os 19 sub-routers, sem exceção. A alternativa — passar organizationId para 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 provedor PRIMARY, e é provisionado automaticamente. Quando você anexa o primeiro provedor externo a uma loja sem PRIMARY, o StoreProviderService cria o provedor interno como PRIMARY antes 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 um PRIMARY por loja, e trocar exige rebaixar o atual primeiro.

  • A sincronização de produtos roda em sequência, não em paralelo. No pullProducts e no pushProducts do sync engine, os itens são encadeados um a um com andThen. É 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. findEffectivePrice monta a decisão inteira em uma única query: filtra listas ativas e vigentes da organização e da loja, respeita minQuantity, opcionalmente restringe ao canal, ordena por priority da lista e depois por minQuantity decrescente, 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.


08

Conceitos e modelo de dados

Glossário

TermoSignifica
StoreUma 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.
ProductO item comercial abstrato — "Camiseta Básica". Não tem preço nem estoque próprio.
VariantO 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.
StockLocationOnde o estoque fica: depósito, loja física, unidade. Tem código único por loja e uma marcada como padrão.
StockItemO saldo de uma variante em um local: quantity total e reservedQty separado. Disponível é a diferença dos dois.
StockMovementO registro imutável de uma alteração de saldo, com tipo, quantidade, origem, destino, motivo e autor. É a trilha do estoque.
StockReservationUma quantidade separada para um pedido ou carrinho, com validade. Enquanto ativa, sai do disponível sem sair do total.
BatchItemLote com número, data de produção e validade, por variante e local. Serve a quem controla perecível ou rastreabilidade.
PriceListUma tabela de preço com tipo (DEFAULT, WHOLESALE, VIP, CHANNEL), prioridade, janela de validade e canal opcional.
PriceModifierAdicional ou opcional preso a um produto — bacon extra, embalagem para presente.
PromotionDesconto com código, tipo, valor, valor mínimo de pedido, limite de usos e janela.
ChannelUm ponto de venda: vitrine própria, marketplace, plataforma de delivery, PDV, rede social. Recorta disponibilidade e preço.
CartCarrinho com validade (24h por padrão), que vira pedido no checkout.
OrderUm pedido, com número sequencial único por loja, itens congelados no momento da criação e ciclo de vida próprio.
ProviderConfigCredencial e configuração de conexão com uma plataforma externa.
StoreProviderO vínculo entre uma loja e um provedor, com papel (PRIMARY/SECONDARY/SOURCE/BIDIRECTIONAL), direção e prioridade.
ProductMappingA ponte entre o SKU interno e o identificador do produto na plataforma externa, com dono por campo.
fieldOwnershipJSON 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 PrismaTabelaPropósitoCampos-chave
CommerceStorecommerce_storesA operação de venda@@unique([organizationId, slug]), type, currency, timezone, isActive, deletedAt
CommerceCategorycommerce_categoriesÁrvore de categorias@@unique([storeId, slug]), parentId (auto-relação)
CommerceCategoryTaxConfigcommerce_category_tax_configsFiscal por categoriancm, cest, cfop, icmsRate, ipiRate, pisRate, cofinsRate
CommerceProductcommerce_productsItem comercial@@unique([storeId, slug]), type, status, brand, attributes, deletedAt
CommerceProductCategorycommerce_product_categoriesProduto ↔ categoria (N:N)@@unique([productId, categoryId])
CommerceProductVariantcommerce_product_variantsO que se vende@@unique([productId, sku]), @@unique([storeId, sku]), basePrice, costPrice, compareAtPrice, barcode
CommerceProductImagecommerce_product_imagesImagens do produtofileId (File Storage) ou url, sortOrder, isPrimary
CommerceProductTagcommerce_product_tagsEtiquetas livres@@unique([productId, tag])
CommerceProductTaxConfigcommerce_product_tax_configsFiscal por produtoSobrepõe a da categoria; inclui origem
CommerceStockLocationcommerce_stock_locationsOnde o estoque fica@@unique([storeId, code]), isDefault, isActive
CommerceStockItemcommerce_stock_itemsSaldo por variante e local@@unique([variantId, locationId]), quantity, reservedQty, reorderPoint, reorderQty
CommerceStockMovementcommerce_stock_movementsTrilha imutável de estoquetype, quantity, fromLocationId, toLocationId, referenceType, referenceId, createdBy
CommerceStockReservationcommerce_stock_reservationsQuantidade separadastatus, referenceType, referenceId, expiresAt
CommerceBatchItemcommerce_batch_itemsLote e validade@@unique([variantId, locationId, batchNumber]), expiresAt, producedAt
CommercePriceListcommerce_price_listsTabela de preçotype, priority, validFrom, validUntil, channelId
CommercePriceListEntrycommerce_price_list_entriesPreço de uma variante@@unique([priceListId, variantId, minQuantity])
CommercePromotioncommerce_promotionsDesconto@@unique([storeId, code]), type, value, minOrderValue, maxUses, usedCount
CommercePriceModifiercommerce_price_modifiersAdicional por produtotype, price, sortOrder, isActive
CommerceOrdercommerce_ordersPedido@@unique([storeId, orderNumber]), status, source, deliveryType, externalId, nove carimbos de tempo
CommerceOrderItemcommerce_order_itemsItem do pedidoCongela productName, variantName, sku, unitPrice; onDelete: Restrict na variante
CommerceOrderStatusHistorycommerce_order_status_historyTrilha do pedidofromStatus, toStatus, reason, changedBy
CommerceChannelcommerce_channelsPonto de vendatype, status, config
CommerceChannelProductAvailabilitycommerce_channel_product_availabilityProduto no canal@@unique([channelId, productId]), isAvailable, priceOverride
CommerceProviderConfigcommerce_provider_configsConexão externaproviderType, credentials, webhookUrl, webhookSecret, isActive
CommerceProviderSyncJobcommerce_provider_sync_jobsExecução de sincronizaçãostatus, direction, entityType, contadores de itens, errorMessage
CommerceProviderWebhookLogcommerce_provider_webhook_logsWebhook recebidoeventType, payload, headers, processed, processedAt
CommerceCartcommerce_cartsCarrinhostatus, sessionId, customerId, expiresAt
CommerceCartItemcommerce_cart_itemsItem do carrinho@@unique([cartId, variantId])
CommerceStoreProvidercommerce_store_providersLoja ↔ provedor@@unique([storeId, providerConfigId]), role, syncDirection, priority, lastSyncAt
CommerceProductMappingcommerce_product_mappingsSKU ↔ id externo@@unique([storeProviderId, sku]), fieldOwnership, status, lastSyncError
CommerceOrderMappingcommerce_order_mappingsPedido ↔ id externo@@unique([storeProviderId, externalOrderId]), externalStatus
CommerceCategoryMappingcommerce_category_mappingsCategoria ↔ 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

EnumValores
CommerceProductTypePHYSICAL · DIGITAL · SERVICE · FOOD
CommerceProductStatusDRAFT · ACTIVE · ARCHIVED
CommerceVariantStatusACTIVE · INACTIVE
CommerceStockMovementTypeINBOUND · OUTBOUND · ADJUSTMENT · TRANSFER · RESERVATION · RESERVATION_RELEASE
CommerceStockReservationStatusACTIVE · RELEASED · CONSUMED
CommercePriceListTypeDEFAULT · WHOLESALE · VIP · CHANNEL
CommercePromotionTypePERCENTAGE_DISCOUNT · FIXED_DISCOUNT · BUY_X_GET_Y · FLASH_SALE
CommercePromotionStatusDRAFT · ACTIVE · EXPIRED · CANCELLED
CommerceOrderStatusDRAFT · CONFIRMED · PREPARING · READY · SHIPPED · OUT_FOR_DELIVERY · DELIVERED · COMPLETED · CANCELLED · REFUNDED
CommerceOrderSourceSTOREFRONT · MARKETPLACE · DELIVERY_PLATFORM · POS · API · MANUAL
CommerceDeliveryTypePICKUP · DELIVERY · DINE_IN · DIGITAL
CommerceChannelTypeSTOREFRONT · MARKETPLACE · DELIVERY_PLATFORM · POS · SOCIAL
CommerceChannelStatusACTIVE · INACTIVE · MAINTENANCE
CommerceProviderTypeCATALISA · IFOOD · RAPPI · UBER_EATS · MERCADO_LIVRE · AMAZON · SHOPEE · MAGALU · VTEX · NUVEMSHOP · SHOPIFY · CUSTOM
CommerceProviderRolePRIMARY · SECONDARY · SOURCE · BIDIRECTIONAL
CommerceSyncDirectionPUSH · PULL · BOTH
CommerceSyncEntityTypePRODUCT · ORDER · INVENTORY · PRICE · CATEGORY
CommerceMappingStatusACTIVE · STALE · CONFLICT · UNMAPPED
CommerceProviderSyncStatusPENDING · IN_PROGRESS · COMPLETED · FAILED
CommerceCartStatusACTIVE · 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 note

Atençã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.

StatusCarimbo gravadoEvento publicado
CONFIRMEDconfirmedAtcommerce.order.confirmed
PREPARINGpreparingAtcommerce.order.preparing
READYreadyAtcommerce.order.ready
SHIPPEDshippedAtcommerce.order.shipped
OUT_FOR_DELIVERYoutForDeliveryAtcommerce.order.out_for_delivery
DELIVEREDdeliveredAtcommerce.order.delivered
COMPLETEDcompletedAtcommerce.order.completed
CANCELLEDcancelledAtcommerce.order.cancelled
REFUNDEDrefundedAtcommerce.order.refunded

Efeitos colaterais de estoque nas transições

TransiçãoEfeito declarado no OrderLifecycleService
→ CONFIRMEDChama reserveOrderStock — hoje é um no-op, não reserva nada (§15)
→ CANCELLEDstockService.releaseReservations('order', orderId) — devolve o reservado ao disponível
→ COMPLETEDstockService.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 atualizado

Movimento 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çãoO que faz em reservedQtyO que faz em quantityRecusa quando
reserveStockIncrementaNão mexeNão há StockItem no local, ou o disponível é menor que o pedido
releaseReservationsDecrementaNão mexe—
consumeReservationsDecrementaDecrementa—

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 note

Atençã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
  }
EnumEstado inicialTransições previstasObservação
CommerceProductStatusDRAFTDRAFT → ACTIVE → ARCHIVEDO status é escrito por PATCH; não há máquina que o valide
CommerceVariantStatusACTIVEACTIVE ↔ INACTIVEPadrão ACTIVE na criação
CommercePromotionStatusDRAFTDRAFT → ACTIVE → EXPIRED ou CANCELLEDNenhuma 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
CommerceMappingStatusSignificaQuem escreve
ACTIVESKU mapeado e coerenteupsertProductMapping ao fim da sincronização
CONFLICTO resolvedor achou divergência de campoupsertProductMapping quando conflicts.length > 0
STALEMapeamento envelhecidoPATCH {base}/product-mappings/:id
UNMAPPEDSem correspondente externoPATCH {base}/product-mappings/:id

09

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 commerce devolve 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 helper lifecycleRoute(...) dentro de orders.router.ts e o script não as enxerga. O total real de caminhos HTTP servidos é 137. Todas estão documentadas abaixo.

Saúde

MétodoRotaDescriçãoPermissão
GET/commerce/healthNome do serviço e versãoPública

Lojas — /commerce/api/v1/stores

authMiddleware + requirePermission + requireOrganization.

MétodoRotaDescriçãoPermissão
POST/commerce/api/v1/storesCria lojaCOMMERCE_STORES_CREATE
GET/commerce/api/v1/storesLista lojas. Filtros: type, isActiveCOMMERCE_STORES_READ
GET/commerce/api/v1/stores/:storeIdBusca lojaCOMMERCE_STORES_READ
PATCH/commerce/api/v1/stores/:storeIdAtualiza lojaCOMMERCE_STORES_UPDATE
DELETE/commerce/api/v1/stores/:storeIdExclusão lógicaCOMMERCE_STORES_DELETE

Daqui em diante, {base} = /commerce/api/v1/stores/:storeId.

Categorias — {base}/categories

MétodoRotaDescriçãoPermissão
POST{base}/categoriesCria categoriaCOMMERCE_CATEGORIES_CREATE
GET{base}/categoriesLista. ?tree=true devolve a árvore; ?parentId= filtra por paiCOMMERCE_CATEGORIES_READ
GET{base}/categories/:idBusca categoriaCOMMERCE_CATEGORIES_READ
PATCH{base}/categories/:idAtualizaCOMMERCE_CATEGORIES_UPDATE
DELETE{base}/categories/:idRemoveCOMMERCE_CATEGORIES_DELETE
GET{base}/categories/:id/tax-configConfig fiscal da categoriaCOMMERCE_CONFIG_MANAGE
PUT{base}/categories/:id/tax-configCria ou substitui a config fiscalCOMMERCE_CONFIG_MANAGE
DELETE{base}/categories/:id/tax-configRemove a config fiscalCOMMERCE_CONFIG_MANAGE

Produtos — {base}/products

MétodoRotaDescriçãoPermissão
POST{base}/productsCria produtoCOMMERCE_PRODUCTS_CREATE
GET{base}/productsLista. Filtros: status, type, searchCOMMERCE_PRODUCTS_READ
GET{base}/products/:idBusca produtoCOMMERCE_PRODUCTS_READ
PATCH{base}/products/:idAtualizaCOMMERCE_PRODUCTS_UPDATE
DELETE{base}/products/:idExclusão lógicaCOMMERCE_PRODUCTS_DELETE
GET{base}/products/:id/tagsEtiquetas do produtoCOMMERCE_PRODUCTS_READ
POST{base}/products/:id/tagsAdiciona etiquetaCOMMERCE_PRODUCTS_UPDATE
DELETE{base}/products/:id/tags/:tagRemove etiquetaCOMMERCE_PRODUCTS_UPDATE
GET{base}/products/:id/categoriesCategorias do produtoCOMMERCE_PRODUCTS_READ
POST{base}/products/:id/categoriesVincula a uma categoriaCOMMERCE_PRODUCTS_UPDATE
DELETE{base}/products/:id/categories/:categoryIdDesvinculaCOMMERCE_PRODUCTS_UPDATE
GET{base}/products/:id/tax-configConfig fiscal do produtoCOMMERCE_CONFIG_MANAGE
PUT{base}/products/:id/tax-configCria ou substituiCOMMERCE_CONFIG_MANAGE
DELETE{base}/products/:id/tax-configRemoveCOMMERCE_CONFIG_MANAGE

Variantes — {base}/products/:productId/variants

MétodoRotaDescriçãoPermissão
POST{base}/products/:productId/variantsCria varianteCOMMERCE_PRODUCTS_CREATE
GET{base}/products/:productId/variantsLista variantesCOMMERCE_PRODUCTS_READ
GET{base}/products/:productId/variants/:variantIdBusca varianteCOMMERCE_PRODUCTS_READ
PATCH{base}/products/:productId/variants/:variantIdAtualizaCOMMERCE_PRODUCTS_UPDATE
DELETE{base}/products/:productId/variants/:variantIdRemoveCOMMERCE_PRODUCTS_DELETE

Imagens de produto — {base}/products/:productId/images

MétodoRotaDescriçãoPermissão
POST{base}/products/:productId/imagesAdiciona imagem (fileId do File Storage ou url)COMMERCE_PRODUCTS_UPDATE
GET{base}/products/:productId/imagesLista imagensCOMMERCE_PRODUCTS_READ
GET{base}/products/:productId/images/:imageIdBusca imagemCOMMERCE_PRODUCTS_READ
PATCH{base}/products/:productId/images/:imageIdAtualiza altText, isPrimary, sortOrderCOMMERCE_PRODUCTS_UPDATE
DELETE{base}/products/:productId/images/:imageIdRemove imagemCOMMERCE_PRODUCTS_UPDATE
POST{base}/products/:productId/images/reorderReordena em loteCOMMERCE_PRODUCTS_UPDATE

Modificadores de preço — {base}/products/:productId/modifiers

MétodoRotaDescriçãoPermissão
POST{base}/products/:productId/modifiersCria adicionalCOMMERCE_PRICING_CREATE
GET{base}/products/:productId/modifiersLista adicionaisCOMMERCE_PRICING_READ
PATCH{base}/products/:productId/modifiers/:modifierIdAtualizaCOMMERCE_PRICING_UPDATE
DELETE{base}/products/:productId/modifiers/:modifierIdRemoveCOMMERCE_PRICING_DELETE

Locais de estoque — {base}/stock-locations

MétodoRotaDescriçãoPermissão
POST{base}/stock-locationsCria localCOMMERCE_STOCK_CREATE
GET{base}/stock-locationsLista locaisCOMMERCE_STOCK_READ
GET{base}/stock-locations/:idBusca localCOMMERCE_STOCK_READ
PATCH{base}/stock-locations/:idAtualizaCOMMERCE_STOCK_UPDATE
DELETE{base}/stock-locations/:idRemoveCOMMERCE_STOCK_DELETE

Estoque e reservas — {base}/stock

MétodoRotaDescriçãoPermissão
GET{base}/stock/levels/:variantIdSaldo por local de uma varianteCOMMERCE_STOCK_READ
GET{base}/stock/low-stockItens abaixo do reorderPointCOMMERCE_STOCK_READ
GET{base}/stock/reservationsLista reservas. ?status= (padrão ACTIVE)COMMERCE_STOCK_READ
GET{base}/stock/reservations/:idBusca reservaCOMMERCE_STOCK_READ
DELETE{base}/stock/reservations/:idLibera as reservas da referência delaCOMMERCE_STOCK_DELETE

Movimentações de estoque — {base}/stock-movements

MétodoRotaDescriçãoPermissão
POST{base}/stock-movementsRegistra movimentação e ajusta saldoCOMMERCE_STOCK_ADJUST
GET{base}/stock-movementsLista. Filtros: variantId, typeCOMMERCE_STOCK_READ

Lotes — {base}/batches

MétodoRotaDescriçãoPermissão
POST{base}/batchesCria loteCOMMERCE_STOCK_CREATE
GET{base}/batchesLista. Filtros: variantId, locationIdCOMMERCE_STOCK_READ
GET{base}/batches/:idBusca loteCOMMERCE_STOCK_READ
PATCH{base}/batches/:idAtualiza quantidade e datasCOMMERCE_STOCK_UPDATE
DELETE{base}/batches/:idRemove loteCOMMERCE_STOCK_DELETE

Listas de preço — {base}/price-lists

MétodoRotaDescriçãoPermissão
POST{base}/price-listsCria listaCOMMERCE_PRICING_CREATE
GET{base}/price-listsLista. Filtro: typeCOMMERCE_PRICING_READ
GET{base}/price-lists/effective-price/:variantIdResolve o preço vigente. ?quantity=, ?channelId=COMMERCE_PRICING_READ
GET{base}/price-lists/:idBusca listaCOMMERCE_PRICING_READ
PATCH{base}/price-lists/:idAtualizaCOMMERCE_PRICING_UPDATE
DELETE{base}/price-lists/:idRemoveCOMMERCE_PRICING_DELETE
GET{base}/price-lists/:id/entriesLista entradasCOMMERCE_PRICING_READ
POST{base}/price-lists/:id/entriesAdiciona entradaCOMMERCE_PRICING_CREATE
GET{base}/price-lists/:id/entries/:entryIdBusca entradaCOMMERCE_PRICING_READ
PATCH{base}/price-lists/:id/entries/:entryIdAtualiza entradaCOMMERCE_PRICING_UPDATE
DELETE{base}/price-lists/:id/entries/:entryIdRemove entradaCOMMERCE_PRICING_DELETE

A rota effective-price/:variantId é declarada antes de /:id. A ordem importa: invertida, effective-price seria capturada como um id de lista.

Promoções — {base}/promotions

MétodoRotaDescriçãoPermissão
POST{base}/promotionsCria promoçãoCOMMERCE_PROMOTIONS_CREATE
GET{base}/promotionsLista. Filtros: status, typeCOMMERCE_PROMOTIONS_READ
POST{base}/promotions/validateValida um código contra um valor de pedidoCOMMERCE_PROMOTIONS_READ
GET{base}/promotions/:idBusca promoçãoCOMMERCE_PROMOTIONS_READ
PATCH{base}/promotions/:idAtualizaCOMMERCE_PROMOTIONS_UPDATE
DELETE{base}/promotions/:idRemoveCOMMERCE_PROMOTIONS_DELETE

Carrinhos — {base}/carts

MétodoRotaDescriçãoPermissão
POST{base}/cartsCria carrinho (validade padrão 24h)COMMERCE_CARTS_CREATE
GET{base}/carts/:idBusca carrinho com itensCOMMERCE_CARTS_READ
POST{base}/carts/:id/itemsAdiciona ou substitui itemCOMMERCE_CARTS_UPDATE
PATCH{base}/carts/:id/items/:variantIdAtualiza quantidade do itemCOMMERCE_CARTS_UPDATE
DELETE{base}/carts/:id/items/:variantIdRemove itemCOMMERCE_CARTS_UPDATE
POST{base}/carts/:id/checkoutConverte em pedido DRAFTCOMMERCE_ORDERS_CREATE

Pedidos — {base}/orders

MétodoRotaDescriçãoPermissão
POST{base}/ordersCria pedido em DRAFTCOMMERCE_ORDERS_CREATE
GET{base}/ordersLista. Filtros: status, source, customerId, channelIdCOMMERCE_ORDERS_READ
GET{base}/orders/:idBusca pedidoCOMMERCE_ORDERS_READ
PATCH{base}/orders/:idAtualiza (só em DRAFT)COMMERCE_ORDERS_UPDATE
GET{base}/orders/:id/itemsItens do pedidoCOMMERCE_ORDERS_READ
POST{base}/orders/:id/itemsAdiciona itemCOMMERCE_ORDERS_UPDATE
PATCH{base}/orders/:id/items/:itemIdAtualiza itemCOMMERCE_ORDERS_UPDATE
DELETE{base}/orders/:id/items/:itemIdRemove itemCOMMERCE_ORDERS_UPDATE
GET{base}/orders/:id/historyHistórico de statusCOMMERCE_ORDERS_READ
POST{base}/orders/:id/confirm→ CONFIRMEDCOMMERCE_ORDERS_MANAGE
POST{base}/orders/:id/prepare→ PREPARINGCOMMERCE_ORDERS_MANAGE
POST{base}/orders/:id/ready→ READYCOMMERCE_ORDERS_MANAGE
POST{base}/orders/:id/ship→ SHIPPEDCOMMERCE_ORDERS_MANAGE
POST{base}/orders/:id/out-for-delivery→ OUT_FOR_DELIVERYCOMMERCE_ORDERS_MANAGE
POST{base}/orders/:id/deliver→ DELIVEREDCOMMERCE_ORDERS_MANAGE
POST{base}/orders/:id/complete→ COMPLETEDCOMMERCE_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étodoRotaDescriçãoPermissão
POST{base}/channelsCria canalCOMMERCE_CHANNELS_CREATE
GET{base}/channelsLista. Filtros: type, statusCOMMERCE_CHANNELS_READ
GET{base}/channels/:idBusca canalCOMMERCE_CHANNELS_READ
PATCH{base}/channels/:idAtualizaCOMMERCE_CHANNELS_UPDATE
DELETE{base}/channels/:idRemoveCOMMERCE_CHANNELS_DELETE
PUT{base}/channels/:id/products/:productId/availabilityLiga/desliga produto no canal, com priceOverrideCOMMERCE_CHANNELS_UPDATE
PUT{base}/channels/:id/products/bulkMesma coisa em loteCOMMERCE_CHANNELS_UPDATE
GET{base}/channels/:id/productsProdutos disponíveis no canalCOMMERCE_CHANNELS_READ
DELETE{base}/channels/:id/products/:productId/availabilityRemove a regra de disponibilidadeCOMMERCE_CHANNELS_DELETE

Configurações de provedor — {base}/providers

MétodoRotaDescriçãoPermissão
POST{base}/providersCria configuração de provedorCOMMERCE_PROVIDERS_CREATE
GET{base}/providersLista configuraçõesCOMMERCE_PROVIDERS_READ
GET{base}/providers/:idBusca configuraçãoCOMMERCE_PROVIDERS_READ
PATCH{base}/providers/:idAtualizaCOMMERCE_PROVIDERS_UPDATE
DELETE{base}/providers/:idRemoveCOMMERCE_PROVIDERS_DELETE
POST{base}/providers/:id/testTesta a conexão (resposta simulada hoje — §15)COMMERCE_PROVIDERS_READ

Sincronização — {base}/provider-sync

MétodoRotaDescriçãoPermissão
POST{base}/provider-sync/:providerConfigId/syncDispara sincronização. Corpo: {"type":"products"}COMMERCE_PROVIDERS_SYNC
GET{base}/provider-sync/:providerConfigId/jobsLista execuçõesCOMMERCE_PROVIDERS_READ
GET{base}/provider-sync/jobs/:idBusca execuçãoCOMMERCE_PROVIDERS_READ
POST{base}/provider-sync/jobs/:id/retryRepete uma execução FAILEDCOMMERCE_PROVIDERS_SYNC

Webhooks de provedor — {base}/provider-webhooks

MétodoRotaDescriçãoPermissão
POST{base}/provider-webhooks/:providerConfigIdRecebe webhook do provedorSem autenticação — ver §15
GET{base}/provider-webhooks/:configId/logsLista webhooks recebidosCOMMERCE_PROVIDERS_READ
GET{base}/provider-webhooks/:configId/logs/:logIdDetalhe de um webhookCOMMERCE_PROVIDERS_READ

Provedores da loja — {base}/store-providers

MétodoRotaDescriçãoPermissão
POST{base}/store-providersAnexa provedor à lojaCOMMERCE_STORE_PROVIDERS_CREATE
GET{base}/store-providersLista. Filtro: isActiveCOMMERCE_STORE_PROVIDERS_READ
GET{base}/store-providers/:idBusca vínculoCOMMERCE_STORE_PROVIDERS_READ
PATCH{base}/store-providers/:idAtualiza papel, direção, prioridadeCOMMERCE_STORE_PROVIDERS_UPDATE
DELETE{base}/store-providers/:idDesanexaCOMMERCE_STORE_PROVIDERS_DELETE
POST{base}/store-providers/:id/syncSincronização completaCOMMERCE_STORE_PROVIDERS_SYNC
POST{base}/store-providers/:id/sync/productsSó produtosCOMMERCE_STORE_PROVIDERS_SYNC
POST{base}/store-providers/:id/sync/ordersSó pedidosCOMMERCE_STORE_PROVIDERS_SYNC
POST{base}/store-providers/:id/sync/inventorySó estoqueCOMMERCE_STORE_PROVIDERS_SYNC
GET{base}/store-providers/:id/mappingsMapeamentos deste provedor. Filtro: statusCOMMERCE_MAPPINGS_READ

Mapeamentos de produto — {base}/product-mappings

MétodoRotaDescriçãoPermissão
GET{base}/product-mappingsLista. Filtros: status, storeProviderIdCOMMERCE_MAPPINGS_READ
GET{base}/product-mappings/by-sku/:skuTodos os mapeamentos de um SKU, entre provedoresCOMMERCE_MAPPINGS_READ
GET{base}/product-mappings/:idBusca mapeamentoCOMMERCE_MAPPINGS_READ
PATCH{base}/product-mappings/:idAtualiza fieldOwnership e statusCOMMERCE_MAPPINGS_UPDATE
DELETE{base}/product-mappings/:idRemove mapeamentoCOMMERCE_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

json
{
  "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"
}
CampoTipoObrigatórioDescrição
namestring (1–255)SimNome de exibição
slugstring (1–255)NãoIdentificador legível. Único por organização
descriptionstring (≤1000)Não—
typeGENERAL | FOOD | DIGITAL | SERVICE | MARKETPLACENãoPadrão GENERAL
logoUrlstring (URL)Não—
currencystring (3)NãoPadrão BRL
timezonestring (≤100)NãoPadrão America/Sao_Paulo
address, contact, settings, metadataobjectNãoJSON 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

json
{
  "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" }
}
CampoTipoObrigatórioDescrição
namestring (1–300)Sim—
typePHYSICAL | DIGITAL | SERVICE | FOODSimNão tem padrão
slugstring (1–300)NãoÚnico por loja
statusDRAFT | ACTIVE | ARCHIVEDNãoPadrão DRAFT
descriptionstring (≤10000)Não—
brand, manufacturerstring (≤200)Não—
weightnumber > 0Não—
weightUnitstring (≤10)Não—
dimensions, attributes, metadataobjectNãoJSON livre
categoryIdsstring[] (UUID)NãoVincula às categorias
tagsstring[] (≤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

json
{
  "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" }
}
CampoTipoObrigatórioDescrição
skustring (1–100)SimÚnico por loja e por produto
namestring (1–300)Sim—
basePricenumber ≥ 0SimPreço de referência quando nenhuma lista se aplica
costPricenumber ≥ 0NãoCusto, para margem
compareAtPricenumber ≥ 0Não"De/por"
barcodestring (≤50)Não—
statusACTIVE | INACTIVENãoPadrão ACTIVE
optionsobjectNãoEixos da grade
weight, weightUnitnumber / stringNã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

json
{
  "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"
}
typeLocais exigidosEfeito
INBOUNDtoLocationIdSoma em to
OUTBOUNDfromLocationIdSubtrai de from
TRANSFERfromLocationId e toLocationIdSubtrai de from, soma em to
ADJUSTMENTpelo menos um dos doisSubtrai de from e/ou soma em to
RESERVATION, RESERVATION_RELEASE—Tipos do enum usados pelo fluxo de reserva
CampoTipoObrigatórioDescrição
variantIdUUIDSim—
typeenum acimaSim—
quantityinteiro > 0SimSempre positiva — o sinal vem do type
toLocationId, fromLocationIdUUIDConforme a tabela—
reasonstring (≤500)NãoAparece na trilha
referenceType, referenceIdstring / UUIDNãoAmarra 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

json
{
  "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âmetroOndePadrãoDescrição
variantIdrota—A variante
quantityquery1Compara com minQuantity das entradas
channelIdquery—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 aplicadoRegra
minQuantityMenor ou igual à quantity solicitada
priceList.organizationIdIgual à organização do token
priceList.storeIdIgual à loja da rota
priceList.isActivetrue
validFromNulo ou já passou
validUntilNulo ou ainda não passou
channelIdAplicado 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

json
{
  "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 }
  ]
}
CampoTipoObrigatórioDescrição
itemsarray, mínimo 1Sim—
items[].variantIdUUIDSimPrecisa existir
items[].quantityinteiro > 0Sim—
items[].unitPricenumber ≥ 0NãoOmitido, usa o basePrice da variante — não a lista de preço
items[].modifiersobjectNão—
items[].notesstring (≤1000)Não—
sourceSTOREFRONT | MARKETPLACE | DELIVERY_PLATFORM | POS | API | MANUALNãoPadrão MANUAL
deliveryTypePICKUP | DELIVERY | DINE_IN | DIGITALNãoPadrão DELIVERY
channelId, customerId, promotionIdUUIDNão—
customerName, customerEmail, customerPhonestringNãoPara venda sem cadastro
deliveryAddress, metadataobjectNão—
deliveryFeenumber ≥ 0NãoPadrão 0
externalIdstring (≤200)NãoId do pedido na origem, para idempotência do seu lado
notesstring (≤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. discount e tax existem no modelo e nascem em 0. Passar promotionId não aplica desconto automaticamente — a promoção é validada por POST {base}/promotions/validate e 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

  1. Verifica que CONFIRMED está em VALID_ORDER_TRANSITIONS["DRAFT"].
  2. Grava status e confirmedAt.
  3. Insere linha em CommerceOrderStatusHistory com fromStatus, toStatus, reason e changedBy (o userId do token).
  4. Publica commerce.order.confirmed.
  5. 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


10

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

bash
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

BASE=https://commerce.bb.stg.catalisa.app/commerce/api/v1
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

BASE=https://commerce.bb.stg.catalisa.app/commerce/api/v1

2. Criar a loja

bash
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:

texto
store: 8f3c1d2a-5b47-4c9e-9f10-2ab3c4d5e6f7
store: 8f3c1d2a-5b47-4c9e-9f10-2ab3c4d5e6f7

3. Criar o produto

bash
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

bash
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

bash
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

bash
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:

json
"INBOUND"
"INBOUND"

7. Conferir o saldo

bash
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}'
json
{ "quantity": 120, "reservedQty": 0 }
{ "quantity": 120, "reservedQty": 0 }

8. Criar a tabela de atacado

bash
PL=$(curl -s -X POST "$BASE/stores/$STORE/price-lists" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Atacado","type":"WHOLESALE","priority":10}' | jq -r '.data.id')

curl -s -X POST "$BASE/stores/$STORE/price-lists/$PL/entries" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"variantId\":\"$VAR\",\"price\":59.90,\"minQuantity\":12}" > /dev/null
PL=$(curl -s -X POST "$BASE/stores/$STORE/price-lists" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Atacado","type":"WHOLESALE","priority":10}' | jq -r '.data.id')

curl -s -X POST "$BASE/stores/$STORE/price-lists/$PL/entries" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"variantId\":\"$VAR\",\"price\":59.90,\"minQuantity\":12}" > /dev/null

9. Testar o preço efetivo nas duas pontas

bash
# 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'
texto
"59.9000"
"59.9000"

10. Criar o pedido

bash
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

bash
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}'
json
{ "status": "CONFIRMED", "at": "2026-08-16T14:02:11.417Z" }
{ "status": "CONFIRMED", "at": "2026-08-16T14:02:11.417Z" }

12. Ver o histórico

bash
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:

json
{ "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

bash
# 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.


11

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

bash
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

bash
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

bash
for t in P M G; do
  curl -s -X POST "$BASE/stores/$STORE/products/$PROD/variants" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"sku\":\"CAM-BAS-$t-PRETA\",\"name\":\"Camiseta Básica $t Preta\",
         \"basePrice\":79.90,\"options\":{\"tamanho\":\"$t\",\"cor\":\"preta\"}}" \
    | jq -r '.data.sku'
done
for t in P M G; do
  curl -s -X POST "$BASE/stores/$STORE/products/$PROD/variants" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"sku\":\"CAM-BAS-$t-PRETA\",\"name\":\"Camiseta Básica $t Preta\",
         \"basePrice\":79.90,\"options\":{\"tamanho\":\"$t\",\"cor\":\"preta\"}}" \
    | jq -r '.data.sku'
done

Resposta esperada — um SKU por linha:

texto
CAM-BAS-P-PRETA
CAM-BAS-M-PRETA
CAM-BAS-G-PRETA
CAM-BAS-P-PRETA
CAM-BAS-M-PRETA
CAM-BAS-G-PRETA

4. Anexar a imagem — fileId do File Storage é preferível a url externa

bash
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

bash
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

bash
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:

json
"ACTIVE"
"ACTIVE"

Armadilhas.

  • type é obrigatório na criação do produto e não tem valor padrão. Omitir devolve 400 sem 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.
  • PUT em tax-config substitui o registro inteiro. Campo omitido some; ele não é um PATCH.
  • A alíquota vai em fração, não em percentual: 0.18 para 18%. O schema recusa valores acima de 1.
  • 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

bash
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

bash
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

bash
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: priceOverride na disponibilidade (por produto) e PriceList do tipo CHANNEL com channelId (por variante). O effective-price só 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

bash
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:

texto
120
120

2. Registre a saída amarrada ao pedido, como referência rastreável

bash
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

bash
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. OUTBOUND de 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.service sabe 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/:id libera todas as reservas que compartilham o mesmo referenceType + referenceId daquela, não apenas a que você apontou. Para um pedido com vários itens, uma chamada libera o pedido inteiro.
  • → CANCELLED e → COMPLETED chamam 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á

bash
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:

json
{ "status": "READY", "confirmedAt": "2026-08-16T14:02:11.417Z" }
{ "status": "READY", "confirmedAt": "2026-08-16T14:02:11.417Z" }

2. Ver como ele chegou aí

bash
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:

  1. O destino está na lista do estado atual? Confira o diagrama da §8. O erro mais comum é tentar deliver a partir de READY — o caminho passa por SHIPPED ou OUT_FOR_DELIVERY.
  2. É 403 e não 400? Então é permissão. cancel exige COMMERCE_ORDERS_CANCEL e refund exige COMMERCE_ORDERS_REFUND — nenhuma das duas vem com COMMERCE_ORDERS_MANAGE.
  3. É PATCH que falhou? Só pedido em DRAFT aceita PATCH. Depois de confirmado, dados do pedido são imutáveis pela API.
  4. É 404? O pedido é de outra organização, ou o storeId da 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
  end

1. Criar a configuração do provedor

bash
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

bash
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

bash
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:

json
{ "sincronizados": 3, "criados": 3, "atualizados": 0, "ignorados": 0 }
{ "sincronizados": 3, "criados": 3, "atualizados": 0, "ignorados": 0 }

4. Ver os mapeamentos gerados

bash
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

bash
curl -s "$BASE/stores/$STORE/product-mappings/by-sku/CAM-BAS-P-PRETA" \
  -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/stores/$STORE/product-mappings/by-sku/CAM-BAS-P-PRETA" \
  -H "Authorization: Bearer $TOKEN" | jq

Armadilhas.

  • Só existe um PRIMARY por loja. Tentar anexar um segundo devolve 400 com Store already has a PRIMARY provider.
  • Se você anexa um provedor SECONDARY a uma loja sem PRIMARY, o serviço cria o CATALISA como PRIMARY automaticamente. Isso é intencional — sem fonte de verdade declarada, a resolução de conflito não tem âncora.
  • providerType fora de CATALISA é aceito na configuração e falha na sincronização com Unsupported 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/test sempre 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

bash
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

bash
curl -s -X POST "$BASE/stores/$STORE/promotions/validate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"code":"AGOSTO20","orderValue":150.00}' | jq
curl -s -X POST "$BASE/stores/$STORE/promotions/validate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"code":"AGOSTO20","orderValue":150.00}' | jq

Armadilhas.

  • validate confere 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. Cheque startsAt/endsAt na resposta.
  • usedCount não é incrementado por nenhuma rota. O controle de consumo é seu.
  • Validar não aplica. Passar promotionId no pedido registra o vínculo mas não altera discount nem total — o cálculo é do seu lado (§15).
  • code é único por loja. A mesma campanha em duas lojas precisa de dois registros.

12

Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token com organizationId e as permissões COMMERCE_*. Sem ele, nenhuma rota respondeSim
PaymentsRecebe o valor do pedido e devolve a confirmação que dispara confirm. O Commerce não toca dinheiroNão, mas é o par natural
CustomersO customerId do pedido e do carrinho aponta para a pessoa cadastrada lá. Sem ele, use os campos avulsos de nome, e-mail e telefoneNão
File StorageCommerceProductImage.fileId referencia o arquivo. A alternativa é url externa, sem controle de acessoNão
Webhooks EngineConsome os eventos commerce.* e entrega ao sistema do cliente com retentativaNão
Audit TrailRegistra quem fez o quê. O histórico de status do pedido é interno; a trilha transversal é láNão
BillingMede uso e fatura. Pedidos e lojas ativas são os drivers naturaisNão
API KeysCredencial de longa duração para o PDV ou o integrador que não renova tokenNã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:

EventoQuando
commerce.product.created / .updated / .deletedCiclo de vida do produto
commerce.stock.adjustedQualquer movimentação de estoque
commerce.stock.lowItem abaixo do ponto de reposição
commerce.order.createdPedido criado em DRAFT
commerce.order.confirmed … .completedUma por transição de status
commerce.order.cancelled / .refundedEncerramento
commerce.cart.converted / .expiredCarrinho virou pedido ou venceu
commerce.provider.sync_completed / .sync_failedFim de uma sincronização
commerce.sync.conflict_detectedConflito de campo entre provedores

13

Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
DATABASE_URLPostgreSQL. O módulo usa o schema commerceSim—
REDIS_URLRate limit e barramento de eventosSim—
JWT_SECRETMesmo segredo do IAM, mínimo 44 caracteresSim—
MODULE_COMMERCE_PORTPorta em standaloneNão3026
MODULE_COMMERCE_URLURL do módulo para os demais building blocksNão""
DEPLOYMENT_MODEmonolith ou standaloneNãomonolith
COMMERCE_CREDENTIAL_MASTER_KEY64 caracteres hex (32 bytes). Declarada na configuração e ainda não consumida pelo módulo — ver §15Não—

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema commerce, 32 tabelas
RedisContadores de rate limit e barramento de eventos
IAMEmissão e verificação do token; sem ele nenhuma rota responde
File StorageOpcional, 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

LimiteValorOnde
Tamanho do corpo da requisição1 MBapplyCommonMiddleware
Página máxima na paginação100 itenspaginationSchema
Página padrão20 itenspaginationSchema
Validade padrão do carrinho24 horasCartService.create
Produtos por execução de PUSH500syncEngine.pushProducts
Produtos por página no PULL50 (padrão do adaptador)CatalisaProvider.fetchProducts
Categorias por busca de sincronização500CatalisaProvider.fetchCategories
Rate limit globalDefinido em rateLimitMiddlewarecompartilhado

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo reprovado no Zod, ou transição de status inválida, ou movimentação sem o local exigidoCompare o corpo com o schema da §9; para pedido, veja o diagrama da §8
400—storeId ou outro parâmetro de rota não é UUIDConfira o identificador
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove pelo refresh token do IAM
403—Token sem organizationIdAutentique informando a organização
403—Store is inactiveReative a loja com PATCH /stores/:storeId
403FORBIDDENToken válido, falta a permissão COMMERCE_* da rotaConfira a permissão exata na §9 e o vocabulário em GET /iam/api/v1/permissions
404—Store '...' not foundA loja não existe ou é de outra organização — a resposta é a mesma de propósito
404NOT_FOUNDRecurso inexistente ou já excluídoConfira o id
409CONFLICTSlug de loja, slug de produto, SKU de variante, código de promoção ou vínculo de provedor duplicadoEscolha outro identificador
429—Rate limit globalAplique recuo exponencial
500INTERNALFalha de banco ou Unsupported provider type na sincronizaçãoVerifique conectividade; se for provedor, veja §15

Rotinas de manutenção

Três jobs vivem em src/commerce/jobs/ e estão registrados no container:

JobO que faz
CommerceCartCleanupJobMarca carrinhos ACTIVE vencidos como EXPIRED
CommerceReservationCleanupJobLibera reservas vencidas e devolve a quantidade ao disponível
CommerceLowStockAlertJobPublica 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/health devolve 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 /health falhar.
  • Toda sincronização vira uma linha em CommerceProviderSyncJob com contadores (itemsSynced, itemsFailed, itemsCreated, itemsUpdated, itemsSkipped), carimbos de início e fim, e errorMessage quando falha. É a fonte primária para diagnosticar sincronização.
  • Todo webhook recebido vira uma linha em CommerceProviderWebhookLog com corpo e cabeçalhos completos, marcada como processed ao 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.

14

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.

CamadaOnde rodaO que garanteCobertura hoje
EstruturalMiddleware resolveStore, no use('*') dos 19 sub-routersA loja da URL pertence à organização do token e está ativaTodas as rotas de domínio, sem exceção
Por recursofindById(id, organizationId) nos repositóriosO identificador sozinho não alcança o recurso23 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 findByIdAinda não filtram
store · product · order · cart · category · channel · promotion · stock-location · provider-configvariant · 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çãoPor quê
COMMERCE_ORDERS_MANAGE vs. _CANCEL vs. _REFUNDAvanç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 _UPDATEAjustar saldo é a operação que reescreve o inventário. Merece permissão própria
COMMERCE_CONFIG_MANAGE para configuração fiscalNCM, 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.


15

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çãoImpactoSituação
Nenhum adaptador de plataforma externaO 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 typeRoadmap. Não anuncie integração de marketplace
confirm não reserva estoqueOrderLifecycleService.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 fluxoRoadmap. Use a receita de reserva manual da §11
Sem trava de saldo negativoprocessMovement não compara a quantidade com o disponível. Uma saída maior que o estoque passa e deixa o saldo negativoPor ora, valide antes de movimentar
Desconto e imposto não entram no totaltotal = 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 valorRoadmap. O cálculo é do integrador
usedCount de promoção nunca incrementaNenhuma rota consome a promoção. O maxUses é conferido em validate contra um contador que ninguém atualizaRoadmap
validate de promoção não checa a janela de datasConfere existência, maxUses e minOrderValue, mas não startsAt/endsAtRoadmap. Cheque as datas na resposta
POST /providers/:id/test é simuladoSempre responde {"success": true, "message": "Connection test passed (mock)"}. Há um TODO no código para delegar ao adaptadorRoadmap
Webhook sem verificação de assinaturaO endpoint público registra e processa o corpo recebido. webhookSecret existe no modelo e ainda não é usado para validarRoadmap. Não exponha a URL a provedor externo até lá
COMMERCE_CREDENTIAL_MASTER_KEY não é consumidaA 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 serializadoRoadmap: criptografia em repouso
Os três jobs não têm agendadorCartCleanup, ReservationCleanup e LowStockAlert estão implementados e registrados no container, mas nada chama run() periodicamente. Carrinho vencido continua ACTIVE e reserva vencida continua presaRoadmap. Dispare por gatilho externo enquanto isso
/health não checa dependênciaResponde só nome e versão. Banco fora do ar não derruba a sondaRoadmap

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çãoPlataformaConsequência para o adaptador
getOrders limitado a 0,0167 req/s (≈ 1 por minuto), com burst de 20Amazon SP-APIBusca de pedidos precisa ser incremental e por evento, nunca varredura
Callback precisa responder 200 em 500 ms ou o tópico é desativadoMercado LivreO 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 apagaMercado LivreToda escrita exige leitura completa antes; um PATCH ingênuo destrói catálogo
Máximo de 2 tiers de variaçãoShopeeProduto 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óriosAmazonO mapeamento não é por SKU isolado, é por árvore
Teto de 100 variações por item (250 em algumas categorias)Mercado LivreGrade grande precisa ser quebrada em vários anúncios
Aplicações devem separar Mercado Livre e Mercado Pago desde 30/08/2026Mercado LivreCredencial ú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çãoPor 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 fiscalO 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 transportadoradeliveryFee é 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 desejosFora do escopo do núcleo transacional
Sem devolução ou logística reversa como entidadeREFUNDED é estado do pedido, não processo de RMA
Sem multi-moeda em tempo realA moeda é um campo da loja e da lista de preço; não há conversão

Limites operacionais conhecidos

GargaloDetalhe
Sincronização em sequênciaProdutos são processados um a um para evitar corrida no SKU. Catálogo grande é lento por construção
Teto de 500 no PUSHUma execução envia no máximo 500 produtos internos
Sem criação de grade em loteCada variante é uma chamada
Dois mecanismos de preço por canalpriceOverride na disponibilidade e PriceList do tipo CHANNEL não conversam. effective-price só enxerga o segundo
ABANDONED sem rotinaO estado existe no enum de carrinho e nada o atribui
Escopo por repositório — resolvidoEra 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 LGPDExclusão é lógica; eliminação definitiva é manual

16

Perguntas frequentes

PerguntaResposta 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