Skip to content

feat(catalog,orders)!: product variants — size becomes the sellable unit - #19

Merged
ArtRiv merged 1 commit into
feature/cart-totalsfrom
feature/product-variants
Aug 27, 2026
Merged

ArtRiv merged 1 commit into
feature/cart-totalsfrom
feature/product-variants

Conversation

@ArtRiv

@ArtRiv ArtRiv commented Aug 26, 2026

Copy link
Copy Markdown
Owner

PR empilhado. A base deste PR é feature/cart-totals (#18), não a
main: os dois regeneram o openapi.json e abrir os dois contra a main
só produziria conflito. Ao mergear o #18 o GitHub reaponta este aqui pra
main sozinho — então a ordem é #18 primeiro, este depois.

O que muda

A API não tinha variante: um produto, um preço, um estoque. Toda loja de
roupa precisa de tamanho, e este é o buraco que trava a vitrine da AVESSO —
o desenho tem um seletor P/M/G/GG/XGG em que o tamanho indisponível aparece
riscado, nunca escondido, e pra isso a resposta precisa conseguir dizer
"este tamanho existe e acabou", que é diferente de "este tamanho não
existe".

Isto é domínio puro: schema, estoque por variante, linha de pedido. Não tem
como resolver no front-end sem inventar um campo.

⚠️ Isto quebra o contrato, de propósito

POST /cart/items, as respostas do carrinho e as do pedido mudam de
forma
, e não há caminho de compatibilidade. Um productId aceito "por
enquanto" teria de escolher um tamanho sozinho, e a única escolha possível
seria "o primeiro" — errada em toda camiseta com mais de um.

Existe um consumidor, a loja AVESSO, ela está sendo escrita agora e regenera
o cliente do openapi.json deste repo. Este é o momento mais barato que esta
mudança vai ter.

A quebra é barulhenta: o ValidationPipe global roda com
forbidNonWhitelisted, então POST /cart/items com productId e
POST /products com stockQuantity respondem 400 nomeando a propriedade
que não existe — nunca 200 ignorando o campo em silêncio.

Decisões que valem revisão

  1. A coluna products.stock_quantity foi REMOVIDA, não mantida como
    soma desnormalizada. Duas fontes de verdade pra estoque é como se vende o
    que não se tem. ProductResponse.stockQuantity continua existindo, agora
    calculado na leitura a partir das variantes — a grade continua dizendo
    "Esgotado" com um número só, e ele não pode divergir do que o checkout
    decrementa.
  2. Preço continua no produto. Uma peça custa o mesmo em P e em XGG.
    Registrado na spec como decisão, não como esquecimento: vira uma coluna
    nullable no dia em que uma loja real cobrar mais pelo XGG.
  3. Nenhum produto sem variante, nunca. A migration dá Único a todos os
    que existem e POST /products sem variants cria Único sozinho — é a
    regra que impede a bifurcação "produto com variante / produto sem", que é
    como isto ficaria impossível de manter.
  4. Não há rota pra renomear, reordenar ou remover variante. Criar é
    seguro; remover exige política pro que já foi vendido (order_items faz
    RESTRICT), e política é decisão, não detalhe. Adiado explicitamente.
  5. CartProductResponse perdeu stockQuantity. Numa linha de carrinho o
    único número que significa alguma coisa é o estoque daquele tamanho, que
    está em variant ao lado. A soma do produto ali convidaria a exibir "10
    disponíveis" numa linha de M que acabou.

Superfície nova

ProductResponse

{
  // ...inalterado: id, name, slug, description, priceCents, imageUrls,
  //    status, weightGrams, categories, createdAt, updatedAt
  "stockQuantity": 42,        // agora a SOMA das variantes, calculada na leitura
  "variants": [               // NUNCA vazio; ordenado por position
    { "id": "uuid", "label": "P",   "position": 0, "stockQuantity": 3 },
    { "id": "uuid", "label": "M",   "position": 1, "stockQuantity": 0 },
    { "id": "uuid", "label": "G",   "position": 2, "stockQuantity": 7 }
  ]
}

POST /products / POST /products/{id}/variants

// CreateProductDto: stockQuantity SAIU, variants entrou (opcional)
{ "name": "…", "priceCents": 7900,
  "variants": [{ "label": "P" }, { "label": "M", "stockQuantity": 4 }] }

// Omitir `variants` cria uma variante `Único` com estoque 0.
// CreateVariantDto: { label (obrigatório), position?, stockQuantity? }
// `position` ausente = índice no array. Nunca alfabético.

Carrinho

// POST /cart/items
{ "variantId": "uuid", "quantity": 2 }

// CartItemResponse
{
  "variantId": "uuid",       // a identidade da LINHA; PATCH/DELETE endereçam isto
  "quantity": 2,
  "product": { "id", "name", "slug", "priceCents", "status", "weightGrams" },
  "variant": { "id", "label", "position", "stockQuantity" }
}

PATCH /cart/items/{variantId} e DELETE /cart/items/{variantId}.

Pedido

// OrderItemResponse ganha dois campos, ambos congelados no checkout
{ "productId", "productName", "variantId", "variantLabel", "unitPriceCents", "quantity" }

// os dois 409 de estoque do POST /orders trocam `productIds: string[]` por
{ "message": "Insufficient stock or product no longer for sale",
  "unavailableItems": [
    { "variantId", "productId", "productName", "variantLabel" }
  ] }

Rotas

nova POST /products/{id}/variants
substitui PATCH /products/{id}/stock PATCH /products/{id}/variants/{variantId}/stock

38 → 39 rotas. EXPECTED_ROUTE_COUNT em src/openapi/document.spec.ts
acompanhou.

Migration

20260826120000_add_product_variants, escrita à mão como todas as outras:
cria product_variants, dá a todo produto existente uma variante Único
com o estoque que ele tinha, migra cart_items e order_items pra
endereçarem variante (com variant_label = 'Único' no histórico, que é a
verdade — era o que se vendia), derruba products.stock_quantity, e
fecha com os três CHECK e o ENABLE ROW LEVEL SECURITY sem policy que
toda tabela nova deste repo tem.

Critérios de aceitação

Todos verdes (docs/specs/product-variants.md):

  • P/M/G/GG/XGG saem na ordem de position, não na alfabética (que seria
    G, GG, M, P, XGG)
  • Variante com estoque 0 aparece na resposta
  • stockQuantity do produto é a soma das variantes
  • Produto criado sem variants nasce com exatamente uma, Único
  • Dois variants com o mesmo label ⇒ 400
  • POST /cart/items com variantId de produto ACTIVE entra com o
    rótulo daquele tamanho
  • variantId de produto DRAFT/ARCHIVED ou inexistente ⇒ 404
    indistinguíveis
  • P e M da mesma peça no carrinho ⇒ duas linhas
  • Checkout de 2 unidades de M derruba o estoque de M, e só dele
  • Estoque de M insuficiente ⇒ 409 nomeando a peça, carrinho e estoque
    intactos
  • Dois checkouts concorrentes pela última unidade de M ⇒ exatamente
    um pedido
  • Itens do pedido trazem variantId e variantLabel congelados
  • Cancelamento devolve o estoque pra variante certa
  • PATCH .../variants/{variantId}/stock mexe só naquela variante, e
    404 se a variante for de outro produto
  • Migration mapeia o estoque existente sem perder unidade
  • openapi.json regenerado descreve tudo isso

Verificação

pnpm lint:check ✅
pnpm typecheck ✅
pnpm test ✅ 468 unitários
pnpm build ✅
pnpm test:e2e ⚠️ 189 testes, 8 arquivos — 188 verdes na primeira passada; ver abaixo

A única falha do e2e foi um teste meu, não o código: eu assumi que as
duas linhas do carrinho voltariam na ordem P, M. Elas voltam ordenadas por
cart_items.id, um UUID — estável, mas sem significado. Corrigi a
asserção pra comparar como conjunto (com o porquê no comentário), registrei
a ordenação em docs/known-issues.md com esboço de
conserto — cart_items não tem created_at, que é justamente por que a
ordem de inserção não está disponível — e rodei
test/orders.e2e-spec.ts inteiro de novo: verde. Os outros sete arquivos
passaram na primeira passada e não foram tocados depois.

A migration foi aplicada e conferida contra o Supabase de
desenvolvimento (commerce-core-dev) com catálogo, carrinho e pedido
dentro: SUM(products.stock_quantity) antes = 42;
SUM(product_variants.stock_quantity) depois = 42, 3 produtos → 3
variantes, todas Único, nenhum produto sem variante, a linha de carrinho e
o item de pedido remapeados. Nenhuma unidade criada ou perdida.

Uma ressalva honesta sobre como ela foi aplicada: prisma migrate deploy
não roda desta máquina — o schema engine morre contra o pooler de
transação da Supabase com Error: Schema engine error: e mensagem vazia
(mesmo sintoma que docs/deploy.md já registra pra porta 5432, que aqui está
bloqueada). A migration foi aplicada pelo mesmo driver pg que o app usa,
numa transação, com a linha correspondente escrita em _prisma_migrations
com o checksum SHA-256 correto do arquivo — exatamente o que
prisma migrate resolve --applied faria. Em produção nada disso se aplica: o
docker/entrypoint.sh roda prisma migrate deploy normalmente, e lá a
migration nunca foi aplicada, então ela roda inteira e limpa.

BREAKING CHANGE: POST /cart/items takes { variantId, quantity } instead of
{ productId, quantity }; the cart and order response shapes change with it;
CreateProductDto loses stockQuantity and gains variants; and
PATCH /products/:id/stock is replaced by
PATCH /products/:id/variants/:variantId/stock.

The API had no variants: one product, one price, one stock count. Every
clothing store needs size, and this is the storefront's blocking gap — its
design has a P/M/G/GG/XGG selector where an unavailable size is struck
through, never hidden, so the response has to be able to say "this size
exists and has none left", which is a different sentence from "this size
does not exist".

Stock lives on the variant, always. The migration gives every existing
product exactly one variant labelled `Único` carrying its stock, and a
product created without variants gets the same, so there is never a second
code path for "products without variants" — that fork is how this becomes
unmaintainable.

products.stock_quantity is DROPPED rather than kept as a denormalized sum:
a second place for stock to live is a second place for it to be wrong.
ProductResponse.stockQuantity survives as a sum computed on read, so the
catalogue grid still says "Esgotado" from one number and that number cannot
drift from what checkout decrements.

Price stays on the product — a shirt costs the same in P and in XGG, and
per-variant pricing is a nullable column on the day a real store needs one.

Cart lines and order items address a variant; order items snapshot
variantLabel beside productName, so renaming a size later cannot rewrite
what somebody bought. Checkout decrements variant stock through the same
conditional UPDATE as before, and both stock 409s now name the piece —
`unavailableItems: [{ variantId, productId, productName, variantLabel }]`
— because "Camiseta Preta is out of stock" is false when only the M is.

No compatibility path for a bare productId: it does not say which size, and
the only guess available would be wrong on every shirt with more than one.
There is exactly one consumer, it regenerates its client from openapi.json,
and this is the cheapest this change will ever be.

See docs/specs/product-variants.md.
@ArtRiv
ArtRiv merged commit f755a2b into main Aug 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant