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! 💳