Catalisa.
Building blocks/ComércioBeta

Commerce

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

130
Endpoints
32
Entidades
1
Provedores
Tenant
Escopo
3026
Porta

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

01Resumo executivo

O Commerce é o miolo de uma operação de venda: ele guarda o que você vende, quanto disso existe em cada lugar, por quanto sai em cada canal e o que acontece com um pedido do momento em que ele nasce até o momento em que ele é entregue ou estornado. Ele não desenha a loja — ele responde às perguntas que a loja faz.

Na prática, uma rede com oito unidades para de ter oito planilhas de estoque e passa a ter oito StockLocation dentro de uma única loja, com movimentação rastreada peça por peça. Quando o pedido é confirmado, o histórico de status registra quem mudou, quando e por quê — e isso vale tanto para uma venda de sapato quanto para um pedido de delivery que passa por preparo, saída para entrega e conclusão.

O serviço está publicado nas pilhas de staging e de produção desde março de 2026, na porta 3026. Está marcado como beta por uma razão específica e não cosmética: os adaptadores para plataformas externas — Mercado Livre, Shopee, VTEX, Shopify e os demais nomes que aparecem no enum CommerceProviderType — ainda não foram implementados. O que existe hoje de sincronização é a máquina completa (mapeamento de SKU, resolução de conflito, jobs, webhooks) rodando contra o provedor interno. Ver §15 antes de vender integração de marketplace.

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

02O problemanegócio

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

O que trava hoje.

  • O estoque mora em mais de um lugar e ninguém sabe qual está certo. A loja física tem o número dela, o marketplace tem o dele, e a diferença entre os dois só aparece quando um cliente compra algo que não existe. Vender e não ter é pior que não vender: cancelar pedido derruba reputação no canal e às vezes gera multa.
  • Preço deixa de ser um número. Passa a ser preço de tabela, preço de atacado a partir de doze unidades, preço promocional entre sexta e domingo, preço específico de um canal. Cada uma dessas regras acaba virando um 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 se organizou em torno de marketplaces, e vender em mais de um canal deixou de ser estratégia avançada para virar operação padrão de quem quer volume. Cada canal adicional multiplica o número de lugares onde o mesmo SKU precisa estar correto — e o erro mais caro dessa conta não é o técnico, é o comercial: o pedido vendido sem lastro de estoque, que vira cancelamento, penalidade de reputação e cliente perdido.

E há o custo de licença. O modelo dominante no varejo brasileiro de médio e grande porte é o take rate — a plataforma cobra um percentual do que você fatura. A VTEX, referência da categoria no país, define no próprio formulário anual da SEC que "a taxa baseada em transação responde pela maior parte da nossa receita de assinatura e é primariamente estruturada como take rate", calculado sobre o valor dos pedidos incluindo impostos e frete (20-F FY2025). O pricebook público deles parte de 2,5% e desce até 0,5% conforme o porte do contrato (consultado em 2026-08-16).

É um modelo confortável no começo e desconfortável exatamente quando dá certo, porque a linha de custo cresce junto com a linha de receita sem que a plataforma passe a entregar mais. E a direção do mercado é de mais cobrança sobre GMV, não menos: em 1º de junho de 2026 a BigCommerce — hoje Commerce.com — criou uma taxa de 2,0% a 0,6% sobre pedido processado fora dos gateways embarcados, encerrando o "sem taxa de transação" que era o argumento dela contra a Shopify.


03Proposta de valornegócio

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.

Preço tem uma resposta única e auditável. GET /price-lists/effective-price/:variantId resolve, para uma variante, uma quantidade e opcionalmente um canal, qual lista de preço vigente vence: filtra por validade e atividade, ordena por prioridade da lista e depois pela maior faixa de quantidade aplicável. A pergunta "por que saiu por este valor" tem uma resposta, não uma investigação.

Pedido com processo explícito. As transições válidas são uma tabela declarada em código (VALID_ORDER_TRANSITIONS), cada transição tem endpoint próprio com permissão própria, cada mudança grava carimbo de tempo dedicado e uma linha em CommerceOrderStatusHistory com autor e motivo. Cancelar e estornar são permissões separadas de "avançar o pedido".

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

Encaixa com o resto do catálogo. O token é o mesmo do IAM, os eventos saem no barramento compartilhado e são consumidos pelo Webhooks Engine, o pagamento é o building block Payments, a imagem do produto é o File Storage. Você não reintegra identidade a cada peça nova.


04Casos de uso reaisnegócio

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Contexto. Vender em mais de um canal é hoje a operação padrão de quem quer volume no varejo online brasileiro, e o mercado respondeu criando uma categoria inteira de intermediários — hubs de integração — cuja única função é manter o mesmo SKU coerente em várias plataformas ao mesmo tempo. A existência dessa categoria é a evidência do problema: se sincronizar catálogo e estoque fosse simples, ninguém pagaria por uma camada só para isso.

A dor do mercado. O intermediário resolve o problema e cria dois: o dado do seu catálogo passa a viver fora do seu domínio, e a latência entre a venda no canal e a baixa no seu estoque vira um parâmetro do fornecedor, não seu. Quando o hub atrasa, você descobre pelo cancelamento.

Como a Catalisa endereça. O Commerce trata sincronização como parte do próprio modelo, e não como serviço externo: StoreProvider liga uma loja a um provedor com papel (PRIMARY, SECONDARY, SOURCE, BIDIRECTIONAL) e direção (PUSH, PULL, BOTH); ProductMapping amarra SKU interno a identificador externo com fieldOwnership por campo; o resolvedor de conflito decide, campo a campo, quem manda. O provedor CATALISA é provisionado como PRIMARY automaticamente, o que fixa uma regra clara: o seu catálogo é a fonte de verdade, o canal é réplica.

O resultado. A arquitetura está pronta e testada contra o provedor interno. Os adaptadores para as plataformas externas ainda não existem — é o item número um do §15, e é a diferença entre "o desenho resolve" e "o produto entrega".


05Mercado e diferenciaisnegócio

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

Duas coisas se mexeram nesse mercado em 2026, e vale conhecê-las porque mudam a conversa com o comprador.

A cobrança sobre GMV está avançando, não recuando. A BigCommerce — que virou Commerce.com (NASDAQ: CMRC) em agosto de 2025 — reformou a precificação em 1º de junho de 2026 e criou uma taxa sobre pedido processado fora dos gateways embarcados: 2,0%, 1,0% e 0,6% conforme o plano, exatamente os mesmos degraus da Shopify. Isso enterrou o "sem taxa de transação" que era o argumento mais alto deles contra a Shopify. Do outro lado da linha, commercetools, Medusa e Vendure fizeram do "não cobramos sobre GMV" um argumento comercial explícito. Essa é hoje a divisória mais nítida da categoria. (página oficial da mudança, consultada em 2026-08-16)

O open source de commerce está estreitando. A Vendure trocou MIT por GPLv3 na versão 3. A Medusa fechou RBAC e SSO em 11 de agosto de 2026 — cinco dias antes desta redação —, deixando o núcleo em MIT mas movendo justamente os requisitos de compliance corporativa para licença comercial, inclusive para quem auto-hospeda (`ENTERPRISE-LICENSE.md`). A Spree oscilou de licença duas vezes em menos de dois anos. Restou a Saleor como único núcleo BSD-3 sem reserva — e é justamente a que cobra mais caro na nuvem, e sobre GMV.

Nenhuma das três famílias foi desenhada para o caso que a Catalisa atende: um componente de catálogo, estoque, preço e pedido que outra empresa embute no produto dela, operando várias empresas clientes na mesma instância. Shopify e VTEX assumem que a loja é o produto final; commercetools assume um cliente do porte que sustenta contrato empresarial; Medusa e Saleor assumem que você opera a infraestrutura e resolve multi-tenancy sozinho.

Crité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.

Se o que ele precisa é vender em marketplace brasileiro amanhã, a VTEX entrega isso hoje e o Commerce não entrega. Marketplace, gestão de sellers e OMS nativos no mesmo núcleo são o diferencial mais defensável deles, e o encaixe com os 500 marketplaces, 200 meios de pagamento e 90 operadores logísticos do ecossistema VTEX IO é trabalho de duas décadas. Nossos adaptadores externos simplesmente não existem (§15).

Se ele precisa de loja no ar em uma semana, com vitrine, checkout, meio de pagamento e tema pronto, a resposta é Shopify — e não há discussão, porque o Commerce é uma API e não uma loja. A conversão de checkout deles é o ativo defensável da categoria, e o preço de entrada é público até o Plus.

Se ele é um varejista global de grande porte com time de plataforma próprio, o commercetools tem limites de catálogo e um modelo B2B — hierarquia de unidades de negócio, milhares de associados por unidade — que nós não temos. Vale a ressalva, porém, e ela é factual: a empresa demitiu cerca de 20% do quadro em 2025, trocou de CEO três vezes em 16 meses e, em julho de 2026, lançou módulos avulsos prometendo modernização sem replatform — o que compradores leem como recuo do pitch composable puro. É risco de fornecedor a considerar dos dois lados da mesa.

Se ele tem time de engenharia forte e quer o código na mão, a Medusa e a Saleor entregam isso. Duas ressalvas úteis para a conversa: na Medusa, RBAC e SSO deixaram de ser MIT em 11 de agosto de 2026 e agora exigem acordo comercial mesmo no auto-hospedado; na Saleor, o núcleo continua BSD-3 puro, mas a nuvem começa em US$ 1.599/mês e cobra percentual sobre o GMV excedente.

O Commerce ganha quando o problema é construir um produto que precisa de commerce por dentro, para várias empresas clientes, sem pagar percentual sobre o faturamento delas e sem operar a infraestrutura. Fora desse recorte, recomende o concorrente e ganhe a credibilidade — ela volta na próxima conversa.


06Modelo de cobrança e ROInegócio

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

O que dispara custo.

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: 2,5% no ON DEMAND, 1,8% no BUSINESS, 1,1% no CORPORATE e 0,5% no ENTERPRISE. Ou seja, o cliente paga adiantado para que o percentual doa menos. É um modelo coerente para quem opera a própria loja e desconfortável para quem revende — porque o percentual incide sobre o faturamento do cliente final e sai da margem do revendedor.

ROI. O retorno não está na linha de licença — o open source auto-hospedado tem licença zero e continuará tendo. Está em duas contas.

A primeira é o que não se reescreve. Catálogo com variantes, estoque multi-local com reserva, resolução de preço efetivo e máquina de estados de pedido com histórico são, somados, meses de trabalho de um time que já tem o que fazer — e são o tipo de código cujo erro só aparece em produção, na forma de estoque negativo ou pedido em estado impossível. Some a isso o multi-tenancy, que Medusa, Saleor e Vendure deixam inteiramente por sua conta.

A segunda é o percentual, e ela é a que decide. Na conta acima, um take rate de 1,8% custa quase um milhão de reais por ano sobre R$ 50 milhões de GMV — e dobra se o cliente dobrar de tamanho, sem que a plataforma passe a entregar mais. Trocar percentual variável por custo previsível por loja e por pedido é, para quem embute commerce no próprio produto, o argumento econômico central. Ele não depende de sermos mais baratos hoje: depende de a curva ser diferente.


07Arquitetura

                                    HTTP
                                      │
  ┌───────────────────────────────────┴─────────────────────────────────────────┐
  │ Hono app  basePath('/commerce')                                              │
  │   applyCommonMiddleware: bodyLimit 1MB · CORS · security headers · rate limit│
  │                                                                              │
  │   /health                              sonda de versão                       │
  │   /api/v1/stores                       storesRouter  (CRUD de loja)          │
  └───────────────────────────────────┬──────────────────────────────────────────┘
                                      │  tudo abaixo vive sob /:storeId
  ┌───────────────────────────────────┴──────────────────────────────────────────┐
  │ authMiddleware  →  resolveStore                                              │
  │   1. storeId é UUID válido?                    senão 400                     │
  │   2. token traz organizationId?                senão 403                     │
  │   3. loja existe E pertence à organização?     senão 404                     │
  │   4. loja está ativa?                          senão 403                     │
  │   → c.set('storeId')                                                         │
  └───────────────────────────────────┬──────────────────────────────────────────┘
                                      │  requirePermission(COMMERCE_*)
  ┌───────────────────────────────────┴──────────────────────────────────────────┐
  │ 19 sub-routers montados em /api/v1/stores/:storeId/...                       │
  │                                                                              │
  │  CATÁLOGO      categories · products · variants · images · price-modifiers   │
  │  ESTOQUE       stock-locations · stock · stock-movements · batches           │
  │  PREÇO         price-lists · promotions                                      │
  │  VENDA         carts · orders                                                │
  │  DISTRIBUIÇÃO  channels · providers · provider-sync · provider-webhooks      │
  │  SINCRONIZAÇÃO store-providers · product-mappings                            │
  └───────────────────────────────────┬──────────────────────────────────────────┘
                                      │  Zod parse → ResultAsync<T, AppError>
  ┌───────────────────────────────────┴──────────────────────────────────────────┐
  │ services/ (23)          sync/ (3)                jobs/ (3)                   │
  │   product, variant        sync-engine             cart-cleanup               │
  │   stock, batch            mapping                 reservation-cleanup        │
  │   price-list, promotion   conflict-resolver       low-stock-alert            │
  │   order, order-lifecycle                                                     │
  │   cart, channel                                 providers/                   │
  │   store-provider                                  catalisa  (implementado)   │
  │   provider-sync/webhook                           ifood     (mock/molde)     │
  │   orchestration, tax-config                                                  │
  └───────────────────────────────────┬──────────────────────────────────────────┘
                                      │
  ┌───────────────────────────────────┴──────────────────────────────────────────┐
  │ repositories/ (31, Prisma)  →  PostgreSQL, schema "commerce", 32 tabelas     │
  │ EventPublisher              →  barramento compartilhado (Redis)              │
  └──────────────────────────────────────────────────────────────────────────────┘

Decisões não óbvias.

  • Tudo nasce sob /stores/:storeId, inclusive o que não parece precisar. É a decisão mais consequente do módulo. Ela custa uma URL mais longa e paga com uma invariante: nenhuma rota de domínio existe fora do escopo de uma loja já validada contra a organização do token. O 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.

Monolito vs. standalone. Em monolito, o Commerce resolve as dependências pelo container TypeDI e roda junto com os demais na porta 3000. Em standalone — o modo usado em produção — ele sobe sozinho na porta 3026 com MODULE_SELF=commerce. A diferença que importa: em standalone os middlewares globais do src/app.ts não rodam, e é por isso que o app.ts do módulo chama applyCommonMiddleware explicitamente. Sem essa chamada, o serviço subiria sem limite de corpo, sem cabeçalho de segurança e sem rate limit.


08Conceitos e modelo de dados

Glossário

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])

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.

                          POST /orders
                                │
                                ▼
                          ┌───────────┐
                          │   DRAFT   │◀── único estado em que PATCH altera o pedido
                          └─────┬─────┘
                        confirm │
                                ▼
                          ┌───────────┐
                          │ CONFIRMED │
                          └─────┬─────┘
                        prepare │
                                ▼
                          ┌───────────┐
                          │ PREPARING │
                          └─────┬─────┘
                          ready │
                                ▼
                ┌───────────┬─────────┬──────────────┐
           ship │           │  READY  │              │ complete
                │           └────┬────┘              │
                ▼    out-for-delivery                │
          ┌──────────┐           │                   │
          │ SHIPPED  │           │                   │
          └────┬─────┘           │                   │
   out-for-delivery              │                   │
                └───────┬────────┘                   │
                        ▼                            │
              ┌───────────────────┐                  │
              │ OUT_FOR_DELIVERY  │                  │
              └─────────┬─────────┘                  │
                deliver │                            │
                        ▼                            ▼
                 ┌────────────┐   complete    ┌─────────────┐
                 │ DELIVERED  │──────────────▶│  COMPLETED  │
                 └──────┬─────┘               └──────┬──────┘
                 refund │                            │ refund
                        └───────────┬────────────────┘
                                    ▼
                            ┌──────────────┐
                            │   REFUNDED   │  terminal
                            └──────────────┘

  cancel  aceito de DRAFT, CONFIRMED, PREPARING, READY, SHIPPED e OUT_FOR_DELIVERY
          ──▶ CANCELLED (terminal). NÃO é aceito a partir de DELIVERED nem de
              COMPLETED — depois de entregue, o caminho é refund.

  Cada transição: grava o carimbo próprio (confirmedAt, preparingAt, readyAt,
  shippedAt, outForDeliveryAt, deliveredAt, completedAt, cancelledAt, refundedAt),
  insere linha em CommerceOrderStatusHistory com autor e motivo, e publica
  commerce.order.<status em minúsculas> no barramento.

Efeitos colaterais de estoque nas transições

TransiçãoEfeito declarado no OrderLifecycleService
→ CONFIRMEDChama reserveOrderStockhoje é 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

Ciclo de vida do carrinho

   POST /carts                        checkout
       │                                  │
       ▼                                  ▼
   ┌────────┐                       ┌───────────┐
   │ ACTIVE │──────────────────────▶│ CONVERTED │  vira pedido em DRAFT
   └───┬────┘                       └───────────┘
       │  passou de expiresAt (padrão: 24h)
       ▼
   ┌─────────┐        ┌───────────┐
   │ EXPIRED │        │ ABANDONED │  estado previsto no enum, sem rotina
   └─────────┘        └───────────┘  que o atribua hoje (§15)

   O job CommerceCartCleanupJob marca ACTIVE vencido como EXPIRED.

09Referência da API

Prefixo: /commerce. Em standalone, a base é https://commerce.bb.stg.catalisa.app.

Regra estrutural que vale para tudo abaixo. Exceto /commerce/health, toda rota vive sob /commerce/api/v1/stores. As rotas de loja aplicam authMiddleware + requirePermission + um requireOrganization local. Todas as demais aplicam authMiddleware + resolveStore no use('*') do sub-router, e depois requirePermission por rota. O resolveStore já cobre o papel do requireOrganization: sem organizationId no token, ele devolve 403 antes de qualquer coisa.

Sobre a contagem. scripts/docs/contar-endpoints.sh 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

{
  "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, ... } }

Erros400 corpo inválido · 403 token sem organizationId · 409 slug já em uso na organização


POST {base}/products

Cria o produto. Ele nasce sem preço e sem estoque: quem carrega os dois é a variante.

Request

{
  "name": "Camiseta Básica",
  "type": "PHYSICAL",
  "status": "DRAFT",
  "brand": "Marca Própria",
  "categoryIds": ["6f1c...uuid"],
  "tags": ["verao", "algodao"],
  "attributes": { "material": "algodão", "genero": "unissex" }
}
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", ... } }

Erros400 corpo inválido ou type ausente · 404 loja inexistente ou de outra organização · 409 slug duplicado na loja


POST {base}/products/:productId/variants

A variante é o que se vende. Um produto sem variante não entra em pedido nem em carrinho.

Request

{
  "sku": "CAM-BAS-P-PRETA",
  "name": "Camiseta Básica P Preta",
  "basePrice": 79.90,
  "costPrice": 32.00,
  "compareAtPrice": 99.90,
  "barcode": "7891234567890",
  "options": { "tamanho": "P", "cor": "preta" }
}
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

Erros400 corpo inválido · 409 SKU já existe na loja


POST {base}/stock-movements

Toda alteração de saldo passa por aqui. Não existe endpoint que escreva quantity direto: o saldo é sempre consequência de uma movimentação registrada.

Request

{
  "variantId": "9a2f...uuid",
  "type": "INBOUND",
  "quantity": 120,
  "toLocationId": "3c7d...uuid",
  "reason": "Recebimento NF 4471",
  "referenceType": "invoice",
  "referenceId": "b81e...uuid"
}
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_RELEASETipos 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.

Erros400 VALIDATION quando falta o local exigido pelo tipo (ex.: toLocationId required for INBOUND)

Sem trava de saldo negativo. O serviço não recusa uma saída maior que o disponível. Se a sua operação exige essa trava, valide antes com GET {base}/stock/levels/:variantId — ver §15.


GET {base}/stock/levels/:variantId

Saldo da variante em cada local.

Resposta 200

{
  "data": [
    {
      "variantId": "9a2f...",
      "locationId": "3c7d...",
      "quantity": 120,
      "reservedQty": 8,
      "reorderPoint": 20,
      "reorderQty": 100
    }
  ]
}

Disponível para venda é quantity - reservedQty. O campo não vem calculado — faça a conta no seu lado.


GET {base}/price-lists/effective-price/:variantId

Resolve qual preço vale agora para uma variante, uma quantidade e opcionalmente um canal.

ParâmetroOndePadrãoDescrição
variantIdrotaA variante
quantityquery1Compara com minQuantity das entradas
channelIdqueryRestringe a listas daquele canal

Como a decisão é tomada

  Entradas de lista de preço da variante
            │
            ├── minQuantity <= quantity solicitada
            ├── priceList.organizationId = organização do token
            ├── priceList.storeId = loja da rota
            ├── priceList.isActive = true
            ├── validFrom nulo OU já passou
            ├── validUntil nulo OU ainda não passou
            └── channelId, se informado
            │
            ▼
      ordena por  priceList.priority DESC
             e por  minQuantity DESC
            │
            ▼
       devolve a primeira

Resposta 200 — a entrada vencedora, com a lista de preço embutida. Se nenhuma lista se aplica, a resposta é vazia e o preço a usar é o basePrice da variante.


POST {base}/orders

Cria o pedido. Ele nasce em DRAFT e com uma linha de histórico já gravada.

Request

{
  "source": "STOREFRONT",
  "channelId": "1e4b...uuid",
  "customerId": "77aa...uuid",
  "customerName": "Maria Souza",
  "customerEmail": "maria@exemplo.com.br",
  "deliveryType": "DELIVERY",
  "deliveryFee": 12.50,
  "items": [
    { "variantId": "9a2f...uuid", "quantity": 2 },
    { "variantId": "5b81...uuid", "quantity": 1, "unitPrice": 45.00 }
  ]
}
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".

Erros400 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.

Erros400 VALIDATION com Cannot transition from X to CONFIRMED quando o estado atual não permite · 403 sem COMMERCE_ORDERS_MANAGE · 404 pedido de outra organização


10Início rápido

Do zero a um pedido confirmado, com estoque movimentado e preço de atacado resolvido.

Os comandos abaixo não foram executados nesta redação — o Commerce não está listado em documentacao/credenciais/AMBIENTES.md. Confirme o host de staging com o time de plataforma antes de rodar. Credenciais de staging, nunca de produção.

1. Autenticar no IAM

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

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

2. Criar a loja

STORE=$(curl -s -X POST "$BASE/stores" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Loja Demo","slug":"loja-demo","type":"GENERAL"}' | jq -r '.data.id')

echo "store: $STORE"

3. Criar produto e variante

PROD=$(curl -s -X POST "$BASE/stores/$STORE/products" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Camiseta Básica","type":"PHYSICAL","status":"ACTIVE"}' | jq -r '.data.id')

VAR=$(curl -s -X POST "$BASE/stores/$STORE/products/$PROD/variants" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"sku":"CAM-BAS-P-PRETA","name":"Camiseta Básica P Preta","basePrice":79.90}' \
  | jq -r '.data.id')

4. Criar o local de estoque e dar entrada

LOC=$(curl -s -X POST "$BASE/stores/$STORE/stock-locations" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Depósito Central","code":"DEP-01","isDefault":true}' | jq -r '.data.id')

curl -s -X POST "$BASE/stores/$STORE/stock-movements" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"variantId\":\"$VAR\",\"type\":\"INBOUND\",\"quantity\":120,
       \"toLocationId\":\"$LOC\",\"reason\":\"Carga inicial\"}" | jq '.data.type'

5. Conferir o saldo

curl -s "$BASE/stores/$STORE/stock/levels/$VAR" \
  -H "Authorization: Bearer $TOKEN" | jq '.data[0] | {quantity, reservedQty}'
{ "quantity": 120, "reservedQty": 0 }

6. Criar uma tabela de atacado e testar o preço efetivo

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

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

# 1 unidade: nenhuma entrada se aplica → use o basePrice (79.90)
curl -s "$BASE/stores/$STORE/price-lists/effective-price/$VAR?quantity=1" \
  -H "Authorization: Bearer $TOKEN" | jq '.data'

# 12 unidades: a entrada de atacado vence
curl -s "$BASE/stores/$STORE/price-lists/effective-price/$VAR?quantity=12" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.price'
"59.9000"

7. Criar e confirmar o pedido

ORDER=$(curl -s -X POST "$BASE/stores/$STORE/orders" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"source\":\"API\",\"customerName\":\"Maria Souza\",
       \"items\":[{\"variantId\":\"$VAR\",\"quantity\":2}]}" | jq -r '.data.id')

curl -s -X POST "$BASE/stores/$STORE/orders/$ORDER/confirm" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"reason":"Pagamento aprovado"}' | jq '{status: .data.status, at: .data.confirmedAt}'
{ "status": "CONFIRMED", "at": "2026-08-16T14:02:11.417Z" }

8. Ver o histórico

curl -s "$BASE/stores/$STORE/orders/$ORDER/history" \
  -H "Authorization: Bearer $TOKEN" | jq '.data[] | {fromStatus, toStatus, reason}'

9. Confirmar que o isolamento é real

# Um storeId que não é da sua organização responde 404, não 403 e não dado
curl -s -o /dev/null -w "%{http_code}\n" \
  "$BASE/stores/00000000-0000-0000-0000-000000000000/products" \
  -H "Authorization: Bearer $TOKEN"

Retorna 404. A loja de outra organização não existe do seu ponto de vista.


11Receitas

Publicar um produto completo, do zero ao pronto para venda

Objetivo. Sair de nada até um produto ativo, com grade, imagem, categoria e preço.

# 1. Categoria
CAT=$(curl -s -X POST "$BASE/stores/$STORE/categories" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Camisetas","slug":"camisetas"}' | jq -r '.data.id')

# 2. Produto já vinculado à categoria
PROD=$(curl -s -X POST "$BASE/stores/$STORE/products" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"name\":\"Camiseta Básica\",\"type\":\"PHYSICAL\",
       \"categoryIds\":[\"$CAT\"],\"tags\":[\"verao\"]}" | jq -r '.data.id')

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

# 4. Imagem — fileId do File Storage é preferível a url externa
curl -s -X POST "$BASE/stores/$STORE/products/$PROD/images" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"fileId":"a1b2...uuid","altText":"Camiseta preta vista frontal","isPrimary":true}'

# 5. Config fiscal
curl -s -X PUT "$BASE/stores/$STORE/products/$PROD/tax-config" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ncm":"61091000","cfop":"5102","icmsRate":0.18,"origem":"0"}'

# 6. Ativar
curl -s -X PATCH "$BASE/stores/$STORE/products/$PROD" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"status":"ACTIVE"}' | jq '.data.status'

Armadilhas.

  • type é obrigatório na criação do produto e não tem valor padrão. Omitir 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.

CH=$(curl -s -X POST "$BASE/stores/$STORE/channels" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"App de Delivery","type":"DELIVERY_PLATFORM"}' | jq -r '.data.id')

# Um produto por vez, com preço específico do canal
curl -s -X PUT "$BASE/stores/$STORE/channels/$CH/products/$PROD/availability" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"isAvailable":true,"priceOverride":94.90}'

# Em lote
curl -s -X PUT "$BASE/stores/$STORE/channels/$CH/products/bulk" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"products\":[
        {\"productId\":\"$PROD\",\"isAvailable\":true,\"priceOverride\":94.90},
        {\"productId\":\"$PROD2\",\"isAvailable\":false}
      ]}"

Armadilhas.

  • 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.

# 1. Antes de confirmar, verifique o disponível
curl -s "$BASE/stores/$STORE/stock/levels/$VAR" -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | (.quantity - .reservedQty)'

# 2. Registre a saída amarrada ao pedido, como referência rastreável
curl -s -X POST "$BASE/stores/$STORE/stock-movements" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"variantId\":\"$VAR\",\"type\":\"OUTBOUND\",\"quantity\":2,
       \"fromLocationId\":\"$LOC\",\"referenceType\":\"order\",
       \"referenceId\":\"$ORDER\",\"reason\":\"Baixa por pedido confirmado\"}"

# 3. Se o pedido for cancelado, estorne com o movimento inverso
curl -s -X POST "$BASE/stores/$STORE/stock-movements" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"variantId\":\"$VAR\",\"type\":\"INBOUND\",\"quantity\":2,
       \"toLocationId\":\"$LOC\",\"referenceType\":\"order\",
       \"referenceId\":\"$ORDER\",\"reason\":\"Estorno de cancelamento\"}"

Armadilhas.

  • Não há trava de saldo negativo. 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. Em que estado ele está
curl -s "$BASE/stores/$STORE/orders/$ORDER" -H "Authorization: Bearer $TOKEN" \
  | jq '{status: .data.status, confirmedAt: .data.confirmedAt}'

# 2. Como ele chegou aí
curl -s "$BASE/stores/$STORE/orders/$ORDER/history" -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {from: .fromStatus, to: .toStatus, por: .changedBy, quando: .createdAt}'

Ordem de diagnóstico:

  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.

# 1. Configuração do provedor
CFG=$(curl -s -X POST "$BASE/stores/$STORE/providers" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"providerType":"CATALISA","name":"Catálogo interno","credentials":{}}' \
  | jq -r '.data.id')

# 2. Anexar à loja — o primeiro vínculo define a topologia
SP=$(curl -s -X POST "$BASE/stores/$STORE/store-providers" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"providerConfigId\":\"$CFG\",\"role\":\"PRIMARY\",\"syncDirection\":\"BOTH\"}" \
  | jq -r '.data.id')

# 3. Sincronizar produtos
curl -s -X POST "$BASE/stores/$STORE/store-providers/$SP/sync/products" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '{sincronizados: .data.itemsSynced, criados: .data.itemsCreated,
         atualizados: .data.itemsUpdated, ignorados: .data.itemsSkipped}'

# 4. Ver os mapeamentos gerados
curl -s "$BASE/stores/$STORE/product-mappings?status=ACTIVE" \
  -H "Authorization: Bearer $TOKEN" | jq '.data[] | {sku, externalProductId, status}'

# 5. Um SKU específico, em todos os provedores
curl -s "$BASE/stores/$STORE/product-mappings/by-sku/CAM-BAS-P-PRETA" \
  -H "Authorization: Bearer $TOKEN" | jq

Armadilhas.

  • Só existe um 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.

curl -s -X POST "$BASE/stores/$STORE/promotions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Frete Grátis Agosto","type":"FIXED_DISCOUNT","code":"AGOSTO20",
       "value":20.00,"minOrderValue":100.00,"maxUses":500,
       "startsAt":"2026-08-01T00:00:00Z","endsAt":"2026-08-31T23:59:59Z",
       "status":"ACTIVE"}' | jq '.data.id'

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

Armadilhas.

  • 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.

12Integraçã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
                    ┌──────────────────────────────────────┐
                    │                 IAM                  │
                    │  token: organizationId + permissões  │
                    └──────────────────┬───────────────────┘
                                       │ Bearer JWT
       ┌───────────────┬───────────────┼───────────────┬────────────────┐
       ▼               ▼               ▼               ▼                ▼
 ┌───────────┐  ┌────────────┐  ┌────────────┐  ┌──────────────┐ ┌───────────┐
 │ Customers │  │File Storage│  │  COMMERCE  │  │   Payments   │ │  Billing  │
 │  quem     │  │  imagem    │  │  o que,    │  │  o dinheiro  │ │  a fatura │
 │  compra   │  │ do produto │  │ quanto tem,│  │              │ │           │
 └─────┬─────┘  └──────┬─────┘  │ por quanto,│  └───────┬──────┘ └─────┬─────┘
       │ customerId    │ fileId │  o pedido  │          │              │
       └───────────────┴───────▶└──────┬─────┘◀─────────┘              │
                                       │  pagamento aprovado           │
                                       │  ──▶ POST /orders/:id/confirm │
                                       │                               │
                                       │ eventos commerce.*            │ pedidos e
                                       ▼                               │ lojas ativas
                          ┌────────────────────────┐                   │
                          │    Webhooks Engine     │◀──────────────────┘
                          │  entrega com retry ao  │
                          │  ERP / painel / cozinha│
                          └────────────────────────┘

O fluxo que vende a plataforma. Um pedido nasce no Commerce em DRAFT. O Payments cobra e, quando aprova, o seu backend chama confirm. A transição publica commerce.order.confirmed, o Webhooks Engine entrega ao ERP do cliente com retentativa, o Audit Trail registra quem confirmou e o Billing conta mais um pedido para a fatura do mês. Nenhuma dessas peças precisou de integração de identidade própria — é o mesmo token do começo ao fim.

Um concorrente de commerce entrega a primeira caixa. As outras cinco continuam sendo projeto do cliente.

Eventos publicados. O Commerce declara 74 tipos de evento em CommerceEvents. Os que uma integração normalmente assina:

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

13Configuraçã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

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
400storeId ou outro parâmetro de rota não é UUIDConfira o identificador
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove pelo refresh token do IAM
403Token sem organizationIdAutentique informando a organização
403Store 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
404Store '...' 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
429Rate 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.

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.

14Segurança e compliance

Isolamento entre tenants. O organizationId chega como claim assinado no JWT do IAM e nunca é lido do corpo da requisição. Em cima disso, o Commerce aplica duas camadas.

A primeira é estrutural: toda rota de domínio vive sob /api/v1/stores/:storeId, e o middleware resolveStore roda no use('*') de cada um dos 19 sub-routers, antes de qualquer serviço. Ele executa, nesta ordem:

  resolveStore
    1. storeId é UUID?                              → senão 400
    2. token tem organizationId?                    → senão 403
    3. storeRepo.findById(storeId, organizationId)  → findFirst com AS DUAS chaves
    4. store existe?                                → senão 404
    5. store.isActive?                              → senão 403

O passo 3 é o coração. A consulta filtra por id e organizationId na mesma cláusula. Uma loja de outra organização não retorna, e o resultado é 404 — a mesma resposta de uma loja inexistente, de propósito: distinguir permitiria enumerar lojas alheias.

A segunda camada é o escopo por recurso dentro dos repositórios: findById(id, organizationId) usando findFirst com as duas chaves, com serviço e rota repassando o organizationId do token. O comentário no cart.repository.ts registra por que isso importa — sem o filtro, o identificador sozinho bastava para alcançar o recurso.

Essa camada foi construída em ondas. O commit d1ec354 (ALTO-02) fechou categorias, canais, promoções e locais de estoque, somando aos que já estavam: lojas, produtos, pedidos, carrinhos e configurações de provedor. Uma segunda leva estendeu o escopo aos recursos que não têm coluna organizationId própria e só alcançam o tenant por relação — variante e modificador de preço pelo produto dono, imagem de produto pelo produto, item de pedido pelo pedido, lote pelo local de estoque, reserva pela variante e daí pelo produto, listas de preço e suas entradas. Nesses, o filtro é uma junção (variant: { product: { organizationId } }), não uma coluna.

Estado em 2026-08-16: 17 dos 23 repositórios com busca por identificador aplicam o filtro de organização. Os seis restantes são a família de provedores e sincronização — store-provider, product-mapping, order-mapping, category-mapping, provider-sync-job e provider-webhook-log —, e neles a garantia vem da primeira camada. O trabalho está em curso e a contagem muda a cada leva; confirme o estado corrente antes de citar este número em auditoria. Ver §15.

Autenticação e permissões. Todas as rotas exigem authMiddleware, com a única exceção do recebimento de webhook (abaixo). O vocabulário do módulo tem 53 permissões COMMERCE_*, e a granularidade segue a consequência da ação, não a conveniência do CRUD:

Separaçã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. Em standalone — o modo de produção — os middlewares globais do monolito não rodam, e por isso o app.ts do módulo chama applyCommonMiddleware explicitamente: limite de corpo de 1 MB, CORS que bloqueia origem cruzada quando não há origens configuradas, cabeçalhos de segurança (HSTS, CSP, nosniff, X-Frame-Options) e rate limit global.

Recebimento de webhook. POST {base}/provider-webhooks/:providerConfigId é a única rota sem authMiddleware, porque quem chama é a plataforma externa. Ela resolve a organização a partir da própria configuração — o repositório expõe um findByIdAnyTenant documentado como de uso interno exclusivo desse caminho, justamente para que ninguém o use em rota autenticada, onde o escopo tem que vir do token. A verificação de assinatura ainda não está implementada (§15): enquanto isso, não publique a URL de webhook para provedor externo em produção.

Dados sensíveis. O módulo guarda nome, e-mail, telefone e endereço de entrega do comprador em CommerceOrder e em CommerceCart — todos dado pessoal sob a LGPD. Guarda também CommerceProviderConfig.credentials, que é segredo de integração. Restrinja COMMERCE_PROVIDERS_* a operadores e trate credentials como campo de segredo em qualquer exportação, log ou painel que você construir por cima.

Retenção e exclusão. Lojas e produtos usam exclusão lógica (deletedAt), preservando a linha para auditoria. Pedidos, itens e histórico não são apagados: CommerceOrderItem tem onDelete: Restrict na variante, então uma variante que já vendeu não pode ser removida — o histórico de venda sobrevive à limpeza de catálogo. Atender a um pedido de eliminação de dados pessoais sob a LGPD exige processo explícito de expurgo, que hoje não é automatizado (§15).

Enquadramento regulatório. O Commerce não processa pagamento e não armazena dado de cartão — isso é o Payments, e é ele que carrega o enquadramento PCI-DSS. O que o Commerce carrega é LGPD, pelos dados do comprador, e a guarda de parâmetros fiscais (NCM, CEST, CFOP, alíquotas) que alimentam a emissão feita fora daqui.


15Limitações conhecidas

Escrito de frente, porque é a seção que o comprador técnico lê primeiro.

O que pode surpreender em produção

Limitaçã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 é 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 incompletoEm 2026-08-16, 17 dos 23 repositórios com busca por identificador filtram por organizationId. Faltam os seis da família de provedores e sincronização (store-provider, product-mapping, order-mapping, category-mapping, provider-sync-job, provider-webhook-log), onde a garantia vem só do resolveStore. Rollout em andamento — reconfira antes de citar
Sem expurgo automatizado para LGPDExclusão é lógica; eliminação definitiva é manual

16Perguntas frequentes

O Commerce integra com Mercado Livre, Shopee e Magalu?

Hoje, não. A arquitetura de sincronização está pronta e testada — mapeamento de SKU, resolução de conflito por campo, jobs com contadores, log de webhook —, mas os adaptadores concretos dessas plataformas ainda não foram escritos. O único provedor implementado é o interno (CATALISA). Se a necessidade do cliente é vender em marketplace nas próximas semanas, seja direto: hoje um hub de integração especializado resolve o problema dele e nós não resolvemos.

Qual a diferença entre o Commerce e o building block Products?

São coisas diferentes com nomes parecidos. O Products é catálogo de produtos financeiros — taxa de juros, prazo, seguro, método de amortização — e serve a operações de crédito. O Commerce é catálogo de produtos de varejo — SKU, grade, estoque, preço, pedido. Uma financeira usa o Products; um varejista usa o Commerce; quem vende crédito consignado dentro de uma loja pode usar os dois, sem sobreposição.

Posso usar só o pedaço de estoque, sem o resto?

Pode. Você precisa de uma loja e de variantes para pendurar o estoque, mas nada obriga a usar carrinho, promoção, canal ou provedor. É um padrão comum: quem já tem catálogo em outro lugar cria a variante como espelho, com o mesmo SKU, e usa só locais, movimentações e reservas.

Como faço a baixa de estoque quando um pedido é confirmado?

Manualmente, hoje. A chamada de reserva existe na transição para CONFIRMED mas não faz nada (§15). Enquanto isso não muda, registre um OUTBOUND em stock-movements com referenceType: "order" e referenceId do pedido — assim a trilha fica amarrada e o estorno em caso de cancelamento é o INBOUND inverso. A receita completa está na §11.

Uma loja pode ter mais de um depósito? E um depósito pode servir a duas lojas?

A primeira, sim: StockLocation é por loja e você cria quantos quiser, com um marcado como padrão. A segunda, não: o local pertence a uma loja e o código é único dentro dela. Operações que compartilham depósito entre lojas precisam replicar o local em cada uma, ou modelar as duas operações como uma única loja com canais distintos — que costuma ser a modelagem mais fiel.

Por que o pedido não deixa mudar de READY direto para DELIVERED?

Porque a tabela de transições não permite. De READY você vai para SHIPPED, OUT_FOR_DELIVERY, COMPLETED ou CANCELLED. Entregar exige ter passado por OUT_FOR_DELIVERY. É rígido de propósito: o valor de uma máquina de estados está justamente em impedir o registro que descreve algo que não aconteceu. Se o seu fluxo real não tem saída para entrega — retirada no balcão, por exemplo —, o caminho é READY → COMPLETED.

Preço por canal: uso priceOverride ou lista de preço?

Escolha um e mantenha. priceOverride fica na disponibilidade do produto no canal e é mais simples; lista de preço do tipo CHANNEL é por variante e é o que o effective-price enxerga. Se você usa effective-price para decidir o preço, use lista. Os dois convivendo produzem valores diferentes dependendo de quem pergunta.

O que acontece com o carrinho abandonado?

Ele vence em 24 horas por padrão, e o CommerceCartCleanupJob marca vencidos como EXPIRED. Com uma ressalva importante: hoje não há agendador chamando o job (§15). Até que haja, dispare a rotina por um gatilho externo, ou o carrinho vencido continua ACTIVE no banco.

Dá para operar centenas de lojas na mesma instância?

É exatamente o desenho. A loja é a unidade de isolamento dentro da organização, e o índice em organizationId está em todos os modelos que precisam. O limite prático é o do PostgreSQL, não o do modelo. O que exige atenção em volume alto é a sincronização, que processa em sequência de propósito (§7).

Como o cliente sabe que não vai ver dado de outra empresa?

Duas camadas, descritas em detalhe na §14. A estrutural é que toda rota de domínio passa pelo resolveStore, que consulta a loja filtrando por id e organizationId do token na mesma cláusula, antes de qualquer regra de negócio rodar — loja de outra organização devolve 404. A segunda é o escopo por organizationId dentro dos repositórios, hoje completo em nove recursos e em extensão para os demais.

Vale a pena usar o Commerce se eu já tenho ERP?

Depende de onde a sua venda acontece. Se o ERP é a fonte de verdade do estoque e do preço e você só precisa de uma camada de venda, o Commerce entra como o lado transacional: catálogo publicado, carrinho, pedido, ciclo de vida — e o ERP recebe os eventos via Webhooks Engine. Se o ERP já faz venda multicanal bem, o ganho é pequeno e não force a barra.


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md

Building blocks relacionados