Pular para o conteúdoPedro Farbo
Lição 24 / 2555 min

Projeto E-commerce: Produtos e Categorias

Projeto E-commerce - Parte 2

Continuando o projeto, vamos implementar carrinho, pedidos e pagamentos.

Módulo de Carrinho

typescript
// src/modules/cart/cart.service.tsimport { prisma } from '../../config/database';import { AppError } from '../../shared/errors/AppError'; export class CartService {  async getCart(userId: string) {    const cart = await prisma.cart.findUnique({      where: { userId },      include: {        items: {          include: {            product: {              select: {                id: true,                name: true,                slug: true,                price: true,                images: true,                stock: true,              },            },          },        },      },    });     if (!cart) {      return prisma.cart.create({        data: { userId },        include: { items: { include: { product: true } } },      });    }     // Calcula totais    const subtotal = cart.items.reduce((acc, item) => {      return acc + Number(item.product.price) * item.quantity;    }, 0);     return {      ...cart,      subtotal,      itemCount: cart.items.reduce((acc, item) => acc + item.quantity, 0),    };  }   async addItem(userId: string, productId: string, quantity: number) {    const product = await prisma.product.findUnique({      where: { id: productId },    });     if (!product || !product.active) {      throw new AppError('Produto não encontrado', 404);    }     if (product.stock < quantity) {      throw new AppError('Estoque insuficiente', 400);    }     const cart = await prisma.cart.findUnique({      where: { userId },    });     if (!cart) {      throw new AppError('Carrinho não encontrado', 404);    }     // Verifica se item já existe no carrinho    const existingItem = await prisma.cartItem.findUnique({      where: {        cartId_productId: {          cartId: cart.id,          productId,        },      },    });     if (existingItem) {      const newQuantity = existingItem.quantity + quantity;       if (product.stock < newQuantity) {        throw new AppError('Estoque insuficiente', 400);      }       await prisma.cartItem.update({        where: { id: existingItem.id },        data: { quantity: newQuantity },      });    } else {      await prisma.cartItem.create({        data: {          cartId: cart.id,          productId,          quantity,        },      });    }     return this.getCart(userId);  }   async updateItem(userId: string, productId: string, quantity: number) {    const cart = await prisma.cart.findUnique({      where: { userId },    });     if (!cart) {      throw new AppError('Carrinho não encontrado', 404);    }     if (quantity <= 0) {      return this.removeItem(userId, productId);    }     const product = await prisma.product.findUnique({      where: { id: productId },    });     if (product && product.stock < quantity) {      throw new AppError('Estoque insuficiente', 400);    }     await prisma.cartItem.update({      where: {        cartId_productId: {          cartId: cart.id,          productId,        },      },      data: { quantity },    });     return this.getCart(userId);  }   async removeItem(userId: string, productId: string) {    const cart = await prisma.cart.findUnique({      where: { userId },    });     if (!cart) {      throw new AppError('Carrinho não encontrado', 404);    }     await prisma.cartItem.delete({      where: {        cartId_productId: {          cartId: cart.id,          productId,        },      },    });     return this.getCart(userId);  }   async clearCart(userId: string) {    const cart = await prisma.cart.findUnique({      where: { userId },    });     if (cart) {      await prisma.cartItem.deleteMany({        where: { cartId: cart.id },      });    }     return this.getCart(userId);  }}

Módulo de Pedidos

typescript
// src/modules/orders/order.service.tsimport { prisma } from '../../config/database';import { AppError } from '../../shared/errors/AppError';import { CartService } from '../cart/cart.service';import { PaymentService } from '../payments/payment.service';import { MailService } from '../../shared/services/mail.service'; interface CreateOrderDTO {  userId: string;  addressId: string;  paymentMethod: string;  notes?: string;  couponCode?: string;} export class OrderService {  private cartService = new CartService();  private paymentService = new PaymentService();  private mailService = new MailService();   async create(data: CreateOrderDTO) {    const { userId, addressId, paymentMethod, notes, couponCode } = data;     // Busca carrinho com itens    const cart = await this.cartService.getCart(userId);     if (!cart.items.length) {      throw new AppError('Carrinho vazio', 400);    }     // Valida endereço    const address = await prisma.address.findFirst({      where: { id: addressId, userId },    });     if (!address) {      throw new AppError('Endereço não encontrado', 404);    }     // Valida estoque    for (const item of cart.items) {      if (item.product.stock < item.quantity) {        throw new AppError(          `Estoque insuficiente para ${item.product.name}`,          400        );      }    }     // Calcula valores    let subtotal = cart.subtotal;    let discount = 0;     // Aplica cupom    if (couponCode) {      const coupon = await this.validateCoupon(couponCode, subtotal);      discount = this.calculateDiscount(coupon, subtotal);    }     const shipping = this.calculateShipping(address.state);    const total = subtotal + shipping - discount;     // Cria pedido    const order = await prisma.$transaction(async (tx) => {      // Cria o pedido      const newOrder = await tx.order.create({        data: {          userId,          status: 'PENDING',          subtotal,          shipping,          discount,          total,          paymentMethod,          notes,          shippingAddress: {            street: address.street,            number: address.number,            complement: address.complement,            city: address.city,            state: address.state,            zipCode: address.zipCode,          },          items: {            create: cart.items.map((item) => ({              productId: item.productId,              name: item.product.name,              price: item.product.price,              quantity: item.quantity,            })),          },        },        include: {          items: true,          user: { select: { email: true, name: true } },        },      });       // Atualiza estoque      for (const item of cart.items) {        await tx.product.update({          where: { id: item.productId },          data: { stock: { decrement: item.quantity } },        });      }       // Limpa carrinho      await tx.cartItem.deleteMany({        where: { cartId: cart.id },      });       // Incrementa uso do cupom      if (couponCode) {        await tx.coupon.update({          where: { code: couponCode },          data: { usedCount: { increment: 1 } },        });      }       return newOrder;    });     // Envia email    await this.mailService.sendOrderConfirmation(order.user, order);     return order;  }   async findByUser(userId: string, page = 1, limit = 10) {    const skip = (page - 1) * limit;     const [orders, total] = await Promise.all([      prisma.order.findMany({        where: { userId },        skip,        take: limit,        orderBy: { createdAt: 'desc' },        include: {          items: {            include: {              product: { select: { images: true, slug: true } },            },          },        },      }),      prisma.order.count({ where: { userId } }),    ]);     return {      data: orders,      pagination: {        page,        limit,        total,        totalPages: Math.ceil(total / limit),      },    };  }   async findById(id: string, userId: string) {    const order = await prisma.order.findFirst({      where: { id, userId },      include: {        items: {          include: {            product: { select: { images: true, slug: true } },          },        },      },    });     if (!order) {      throw new AppError('Pedido não encontrado', 404);    }     return order;  }   async updateStatus(id: string, status: string) {    const order = await prisma.order.update({      where: { id },      data: { status: status as any },      include: { user: { select: { email: true, name: true } } },    });     // Notifica usuário sobre atualização    await this.mailService.sendOrderStatusUpdate(order.user, order);     return order;  }   private async validateCoupon(code: string, subtotal: number) {    const coupon = await prisma.coupon.findUnique({      where: { code },    });     if (!coupon || !coupon.active) {      throw new AppError('Cupom inválido', 400);    }     if (coupon.expiresAt && coupon.expiresAt < new Date()) {      throw new AppError('Cupom expirado', 400);    }     if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {      throw new AppError('Cupom esgotado', 400);    }     if (coupon.minPurchase && subtotal < Number(coupon.minPurchase)) {      throw new AppError(        `Compra mínima de R$ ${coupon.minPurchase} para este cupom`,        400      );    }     return coupon;  }   private calculateDiscount(coupon: any, subtotal: number): number {    if (coupon.discountType === 'PERCENTAGE') {      return (subtotal * Number(coupon.discountValue)) / 100;    }    return Number(coupon.discountValue);  }   private calculateShipping(state: string): number {    // Lógica simplificada - em produção usar API dos Correios    const rates: Record<string, number> = {      SP: 15,      RJ: 20,      MG: 25,    };    return rates[state] || 35;  }}

Rotas do Carrinho

typescript
// src/modules/cart/cart.routes.tsimport { Router } from 'express';import { CartController } from './cart.controller';import { authenticate } from '../../shared/middlewares/auth.middleware';import { validate } from '../../shared/middlewares/validate.middleware';import { addItemSchema, updateItemSchema } from './cart.schema'; const router = Router();const controller = new CartController(); router.use(authenticate); router.get('/', controller.show);router.post('/items', validate(addItemSchema), controller.addItem);router.put('/items/:productId', validate(updateItemSchema), controller.updateItem);router.delete('/items/:productId', controller.removeItem);router.delete('/', controller.clear); export default router;

Resumo

Nesta parte implementamos:

  • ✅ Serviço de carrinho completo
  • ✅ Adicionar, atualizar, remover itens
  • ✅ Criação de pedidos com transação
  • ✅ Validação de cupom
  • ✅ Cálculo de frete
  • ✅ Atualização de estoque

Próxima aula: Projeto E-commerce - Parte 3! 💳

Gostou do conteúdo? Sua contribuição ajuda a manter tudo online e gratuito!

PIX:0737160d-e98f-4a65-8392-5dba70e7ff3e