Aula 98 – Django – Ecommerce – Baixa Automática de Estoque

Código da aula: Github Crie a branch feature/stock-management e entre nela
git checkout -b feature/stock-management

O que vamos fazer nessa aula?

Na Aula 96 adicionamos o campo stock ao modelo Product e o modelo CartProduct passou a registrar a quantity de cada item no carrinho. Na Aula 97 fechamos o ciclo de pagamento com cartões salvos. O que falta agora é a consequência natural de uma venda: quando o pagamento é confirmado, o estoque precisa cair. Hoje isso não acontece — você pode vender o mesmo produto infinitas vezes sem que o número de unidades disponíveis se mova. Nesta aula vamos fechar essa lacuna em três frentes:
  • Baixa automática de estoque quando o pagamento é confirmado
  • Proteção contra overselling na view de adicionar ao carrinho
  • Botão "Adicionar" desabilitado na listagem quando o estoque for zero

Boas práticas no Github

Crie a branch da aula, vou chamar a minha de: feature/stock-management Usar feature/... para branches no GitHub ajuda a organizar e identificar rapidamente branches dedicadas ao desenvolvimento de novas funcionalidades. Prefixos como feature/, bugfix/, hotfix/ e release/ são amplamente aceitos como boas práticas na comunidade de desenvolvimento e ajudam a manter um fluxo de trabalho consistente e organizado.

Atualizações no código

Parte 1 – Baixa de Estoque Pós-Pagamento

Explicação do Código – payment_success_viewe_commerce/billing/views.py

Por que essa função é o lugar certo para a baixa de estoque?

A payment_success_view já é o ponto de chegada depois que o Stripe confirma o pagamento. Ela já chama order_obj.mark_paid() e limpa a sessão. Só precisamos acrescentar a lógica de subtração de estoque nesse mesmo momento — sem criar nenhum arquivo novo.

O que mudou:

  • Importação adicionada: from django.db import transaction
  • Bloco de baixa de estoque inserido dentro do if order_obj — itera sobre cartproduct_set e subtrai a quantity do stock de cada produto
  • Uso de transaction.atomic() garante que todas as baixas acontecem juntas ou nenhuma acontece — protegendo a consistência do banco

Funcionamento linha por linha:

1. transaction.atomic()
Abre uma transação de banco de dados. Se qualquer produto falhar ao salvar, o Django faz rollback e nenhuma baixa é registrada. Isso evita que o estoque fique pela metade em caso de erro.
2. cart_obj.cartproduct_set.all()
Recupera todos os CartProducts do carrinho — cada um com seu product e sua quantity.
3. product.stock = max(0, product.stock - cart_product.quantity)
Subtrai a quantidade comprada do estoque disponível. O max(0, ...) é uma proteção extra: garante que o estoque nunca fique negativo, mesmo em situações de corrida entre duas compras simultâneas.
4. product.save()
Persiste a atualização no banco de dados.

e_commerce/billing/views.py

from django.db import transaction
# ... demais imports inalterados ...

def payment_success_view(request):
    cart_id = request.session.get("cart_id")
    if cart_id:
        cart_obj = Cart.objects.get(id=cart_id)
        order_obj = Order.objects.filter(cart=cart_obj).first()
        if order_obj:
            order_obj.mark_paid()
            # NOVO – baixa de estoque após pagamento confirmado
            with transaction.atomic():
                for cart_product in cart_obj.cartproduct_set.all():
                    product = cart_product.product
                    product.stock = max(0, product.stock - cart_product.quantity)
                    product.save()
            request.session['cart_items'] = 0
            del request.session['cart_id']
    return render(request, 'billing/payment-success.html')

Parte 2 – Proteção Contra Overselling

Explicação do Código – add_to_carte_commerce/carts/views.py

O que é overselling?

Overselling acontece quando um cliente consegue comprar mais unidades do que existem em estoque. O campo max no input HTML da listagem já limita o que o usuário pode digitar na interface. Mas qualquer pessoa com acesso ao DevTools do navegador — ou enviando uma requisição direta via curl ou Postman — consegue ignorar esse limite e enviar a quantidade que quiser. A proteção real precisa estar no backend.

O que mudou:

  • Verificação de estoque disponível antes de aceitar o item no carrinho
  • Cálculo da quantidade já existente no carrinho para o mesmo produto, evitando que o total ultrapasse o stock
  • Resposta JSON com mensagem de erro amigável quando o estoque é insuficiente

Funcionamento linha por linha:

1. Verificação de produto com estoque zero
Antes de qualquer operação no carrinho, checamos se product.stock > 0. Se o produto já esgotou, retornamos erro imediatamente.
2. Quantidade já no carrinho
Um cliente pode adicionar o mesmo produto em momentos diferentes. Precisamos somar o que ele já tem no carrinho com o que está tentando adicionar agora, e comparar esse total com o estoque disponível.
3. Resposta de erro padronizada
O JSON de retorno segue o mesmo padrão já usado na view — success: False e uma mensagem legível para o frontend exibir ao usuário.

e_commerce/carts/views.py

def add_to_cart(request):
    if request.method == "POST":
        product_id = request.POST.get("product_id")
        quantity = request.POST.get("quantity", 1)

        try:
            quantity = int(quantity)
        except ValueError:
            return JsonResponse({"success": False, "message": "Quantidade inválida."})

        product = get_object_or_404(Product, id=product_id)

        # NOVO – proteção contra overselling
        if product.stock <= 0:
            return JsonResponse({
                "success": False,
                "message": "Produto esgotado."
            })

        cart_obj, created = Cart.objects.new_or_get(request)

        # NOVO – verifica quantidade já existente no carrinho
        cart_product = CartProduct.objects.filter(
            cart=cart_obj, product=product
        ).first()
        quantity_in_cart = cart_product.quantity if cart_product else 0

        # NOVO – impede que o total ultrapasse o estoque
        if quantity_in_cart + quantity > product.stock:
            return JsonResponse({
                "success": False,
                "message": f"Estoque insuficiente. Disponível: {product.stock - quantity_in_cart} unidade(s)."
            })

        cart_product_obj, created = CartProduct.objects.get_or_create(
            cart=cart_obj, product=product
        )
        cart_product_obj.quantity = cart_product_obj.quantity + quantity if not created else quantity
        cart_product_obj.save()

        cart_obj.update_totals()

        request.session["cart_id"] = cart_obj.id
        request.session["cart_items"] = cart_obj.cartproduct_set.count()

        return JsonResponse({"success": True, "cart_items": cart_obj.cartproduct_set.count()})
    return JsonResponse({"success": False, "message": "Método não permitido."})

Parte 3 – Botão Desabilitado Quando Sem Estoque

Explicação do Código – card.htmle_commerce/products/templates/products/snippets/card.html

Por que precisamos mudar o template?

O campo max="{{ instance.stock }}" no input de quantidade já estava correto — ele impede que o usuário escolha mais unidades do que existe. Mas quando o estoque é zero, o formulário ainda aparece normalmente e o botão "Adicionar" continua clicável. O usuário tenta adicionar, o backend rejeita, mas a experiência é ruim. A correção é simples: quando stock == 0, escondemos o formulário e exibimos uma mensagem de esgotado no lugar.

O que mudou:

  • Condicional {% if instance.stock > 0 %} envolve todo o formulário de adicionar ao carrinho
  • Bloco {% else %} exibe badge "Esgotado" quando stock é zero
  • Nenhuma alteração nos campos existentes — apenas o wrapper condicional foi adicionado

e_commerce/products/templates/products/snippets/card.html

<!-- Path: templates/products/snippets/card.html -->
{% load static %}
<div class="card h-100 shadow-sm">
  <div class="card-img-wrapper position-relative">
    {% if instance.images.all %}
      <a href="{{ instance.get_absolute_url }}">
        <img src="{{ instance.images.first.image.url }}" class="card-img-top rounded" alt="{{ instance.images.first.alt_text|default:instance.title }}">
      </a>
    {% else %}
      <img src="{% static 'placeholder.jpg' %}" class="card-img-top rounded" alt="Placeholder">
    {% endif %}
    {% if instance.discount_price %}
      <span class="badge bg-danger position-absolute top-0 start-0 m-2">Promoção</span>
    {% endif %}
  </div>
  <div class="card-body d-flex flex-column">
    <h5 class="card-title text-center text-uppercase fw-bold">{{ instance.title }}</h5>
    <p class="card-text text-muted text-center flex-grow-1">{{ instance.description|truncatewords:14 }}</p>
    {% if instance.discount_price %}
      <p class="card-text text-center">
        <span class="text-muted text-decoration-line-through">R$ {{ instance.price }}</span>
        <span class="text-danger fw-bold">R$ {{ instance.discount_price }}</span>
      </p>
    {% else %}
      <p class="card-text text-center fw-bold">R$ {{ instance.price }}</p>
    {% endif %}
  </div>
  <div class="card-footer bg-light border-0">
    {% if instance.stock > 0 %}  {# NOVO #}
    <form
      method="POST"
      action="{% url 'cart:update' %}"
      class="d-flex align-items-center justify-content-between form-product-ajax"
    >
      {% csrf_token %}
      <input type="hidden" name="product_id" value="{{ instance.id }}">
      <!-- Campo de Quantidade -->
      <div class="me-2">
        <input
          type="number"
          name="quantity"
          value="1"
          min="1"
          max="{{ instance.stock }}"
          class="form-control form-control-sm text-center"
          style="width: 60px;"
        >
      </div>
      <!-- Botão Adicionar -->
      <button
        type="submit"
        class="btn btn-primary btn-sm"
        style="padding: 0.375rem 0.75rem; font-size: 14px;"
      >
        Adicionar
      </button>
    </form>
    {% else %}  {# NOVO #}
    <div class="text-center">
      <span class="badge bg-secondary w-100 py-2">Esgotado</span>
    </div>
    {% endif %}  {# NOVO #}
  </div>
</div>

Testando a Implementação

Stripe

Certifique-se que está logado no Stripe Faça login com sua conta Stripe
stripe login
Encaminhe eventos ao seu webhook
stripe listen --forward-to localhost:8000/api/webhook
Pronto! O projeto está configurado, o banco de dados está populado, o Stripe já tá ouvindo os eventos em modo teste e você pode acompanhar a aula sem problemas e fazer todos os testes.

Fluxo de Teste

  1. No Django Admin, defina stock = 2 para um produto de teste
  2. Adicione 2 unidades desse produto ao carrinho e conclua o pagamento
  3. Após o pagamento, verifique no Admin que stock caiu para 0
  4. Tente adicionar o produto novamente — o backend deve retornar "Produto esgotado"
  5. Na listagem de produtos, o botão "Adicionar" deve ter sido substituído pelo badge "Esgotado"
  6. Tente enviar manualmente uma requisição POST para /cart/add/ com quantity maior que o estoque via DevTools ou Postman — o backend deve rejeitar com mensagem de estoque insuficiente

Cartões de Teste do Stripe

Visa: 4242 4242 4242 4242
Mastercard: 5555 5555 5555 4444
American Express: 3782 822463 10005
Discover: 6011 1111 1111 1117

CVV: Qualquer 3 dígitos (4 para Amex)
Data: Qualquer data futura
CEP: Qualquer CEP válido

Resumo da Aula

Nesta aula fechamos a regra de negócio de estoque que estava engatilhada desde a Aula 96:
  • Baixa automática de estoque em billing/views.py — com transaction.atomic() para garantir consistência
  • Proteção contra overselling em carts/views.py — valida no backend independente do frontend
  • Badge "Esgotado" em card.html — esconde o formulário quando stock é zero
🎯 Resultado: O e-commerce agora tem controle de estoque real. Cada venda confirmada desconta automaticamente as unidades disponíveis, e ninguém consegue comprar o que não existe em estoque.

🚀 Próxima Aula: Categorias Dinâmicas na Navbar

Na Aula 96 construímos todo o app categories — modelo, views e URLs. A rota category/<slug:slug>/ já está configurada e funcionando. Mas se você olhar a navbar agora, os links ainda são fixos: Home, Contato e Produtos.

1. Context Processors no Django

Como injetar dados em todos os templates sem modificar cada view individualmente.

2. Dropdown de Categorias na Navbar

Puxando todas as categorias ativas do banco automaticamente, sem hardcode.

3. Filtragem de Produtos por Categoria

Integrando a URL que já existe com a navegação do usuário na interface. 💡 Resultado: O cliente poderá navegar pela loja por categoria a partir de qualquer página — exatamente como nos grandes e-commerces do mercado.

🗺️ O Plano Completo até o Fim do Módulo

A Aula 98 fechou uma regra de negócio importante, mas ainda temos bastante chão para cobrir antes de colocar essa loja de pé de verdade. Aqui está o que vem pela frente:
  • Aula 99 — Categorias dinâmicas na navbar (context processor + dropdown)
  • Aula 100 — Verificação de e-mail no cadastro (o campo is_verified já existe no modelo, falta o fluxo completo)
  • Aula 101 — Dashboard do cliente: "Meus Pedidos" com histórico e status
  • Aula 102 — Webhook do Stripe (confirmação de pagamento no backend, independente do browser)
  • Aula 103 — Cupons de desconto aplicados no carrinho
  • Aula 104 — E-mails transacionais: pedido confirmado, boas-vindas
  • Aula 105 — Deploy com Docker + Digital Ocean
Depois que esse ciclo fechar, o Django estará completo como backend de um e-commerce real. E aí começa a Fase B. O frontend que construímos aqui cumpriu o papel dele — mas o mercado hoje trabalha diferente. No próximo módulo vamos reescrever toda a camada de apresentação com Next.js, consumindo o Django via Django REST Framework. O backend que você já domina continua valendo. O que muda é como o cliente enxerga a loja. Isso vai virar um módulo separado, com identidade própria — e você vai sair dele sabendo construir uma stack completa, do banco de dados ao frontend moderno. Por enquanto, nos vemos na Aula 99. 👊.