diff --git a/src/modules/ecommerce/controllers/ecommerce.controller.ts b/src/modules/ecommerce/controllers/ecommerce.controller.ts new file mode 100644 index 0000000..20800da --- /dev/null +++ b/src/modules/ecommerce/controllers/ecommerce.controller.ts @@ -0,0 +1,616 @@ +/** + * EcommerceController - API endpoints para e-commerce + * @module Ecommerce + * @prefix /api/ecommerce + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { cartService } from '../services/cart.service'; +import { ecommerceOrderService } from '../services/ecommerce-order.service'; +import { shippingRateService } from '../services/shipping-rate.service'; +import { EcommerceOrderStatus, PaymentStatus } from '../entities/ecommerce-order.entity'; + +const router = Router(); + +// ==================== HELPER FUNCTIONS ==================== + +function getTenantId(req: Request): string { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new Error('X-Tenant-Id header required'); + return tenantId; +} + +function getUserId(req: Request): string | undefined { + return req.headers['x-user-id'] as string; +} + +function getSessionId(req: Request): string | undefined { + return req.headers['x-session-id'] as string || req.cookies?.session_id; +} + +// ==================== CART ENDPOINTS ==================== + +/** + * GET /api/ecommerce/cart + * Get current cart (by customer or session) + */ +router.get('/cart', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const customerId = (req as any).user?.id; + const sessionId = getSessionId(req); + + const result = await cartService.getOrCreateCart(tenantId, { customerId, sessionId }); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/ecommerce/cart/:id + * Get cart by ID + */ +router.get('/cart/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await cartService.getById(tenantId, req.params.id); + + if (!result.success) { + return res.status(404).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/cart/:id/items + * Add item to cart + */ +router.post('/cart/:id/items', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id } = req.params; + + const result = await cartService.addItem(tenantId, id, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.status(201).json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * PATCH /api/ecommerce/cart/:id/items/:itemId + * Update cart item + */ +router.patch('/cart/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id, itemId } = req.params; + + const result = await cartService.updateItem(tenantId, id, itemId, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * DELETE /api/ecommerce/cart/:id/items/:itemId + * Remove item from cart + */ +router.delete('/cart/:id/items/:itemId', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id, itemId } = req.params; + + const result = await cartService.removeItem(tenantId, id, itemId); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, message: 'Item removed' }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/cart/:id/coupon + * Apply coupon to cart + */ +router.post('/cart/:id/coupon', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id } = req.params; + + const result = await cartService.applyCoupon(tenantId, id, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * DELETE /api/ecommerce/cart/:id/coupon + * Remove coupon from cart + */ +router.delete('/cart/:id/coupon', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id } = req.params; + + const result = await cartService.removeCoupon(tenantId, id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * PUT /api/ecommerce/cart/:id/shipping + * Set shipping information + */ +router.put('/cart/:id/shipping', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id } = req.params; + + const result = await cartService.setShipping(tenantId, id, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * PUT /api/ecommerce/cart/:id/billing + * Set billing information + */ +router.put('/cart/:id/billing', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id } = req.params; + + const result = await cartService.setBilling(tenantId, id, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/cart/:id/merge + * Merge guest cart into customer cart + */ +router.post('/cart/:id/merge', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const guestSessionId = req.params.id; + const customerId = (req as any).user?.id || req.body.customerId; + + if (!customerId) { + return res.status(400).json({ success: false, error: { code: 'MISSING_CUSTOMER', message: 'Customer ID required' } }); + } + + const result = await cartService.mergeCarts(tenantId, guestSessionId, customerId); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * DELETE /api/ecommerce/cart/:id + * Clear cart + */ +router.delete('/cart/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { id } = req.params; + + const result = await cartService.clearCart(tenantId, id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +// ==================== ORDER ENDPOINTS ==================== + +/** + * GET /api/ecommerce/orders + * List orders + */ +router.get('/orders', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { status, paymentStatus, customerId, dateFrom, dateTo, page, limit } = req.query; + + const result = await ecommerceOrderService.list(tenantId, { + status: status as EcommerceOrderStatus, + paymentStatus: paymentStatus as PaymentStatus, + customerId: customerId as string, + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + page: page ? Number(page) : 1, + limit: limit ? Number(limit) : 20, + }); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, ...result.data }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/ecommerce/orders/:id + * Get order by ID + */ +router.get('/orders/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.getById(tenantId, req.params.id); + + if (!result.success) { + return res.status(404).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/ecommerce/orders/number/:number + * Get order by number + */ +router.get('/orders/number/:number', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.getByNumber(tenantId, req.params.number); + + if (!result.success) { + return res.status(404).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders + * Create order from cart + */ +router.post('/orders', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.createFromCart(tenantId, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.status(201).json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/confirm + * Confirm order + */ +router.post('/orders/:id/confirm', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.confirm(tenantId, req.params.id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/payment + * Update payment status + */ +router.post('/orders/:id/payment', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { status, transactionId, paymentDetails } = req.body; + + const result = await ecommerceOrderService.updatePaymentStatus( + tenantId, + req.params.id, + status, + transactionId, + paymentDetails + ); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/ship + * Ship order + */ +router.post('/orders/:id/ship', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.ship(tenantId, req.params.id, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/deliver + * Mark order as delivered + */ +router.post('/orders/:id/deliver', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.markDelivered(tenantId, req.params.id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/ready-for-pickup + * Mark order ready for pickup + */ +router.post('/orders/:id/ready-for-pickup', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.markReadyForPickup(tenantId, req.params.id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/picked-up + * Mark order as picked up + */ +router.post('/orders/:id/picked-up', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await ecommerceOrderService.markPickedUp(tenantId, req.params.id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/orders/:id/cancel + * Cancel order + */ +router.post('/orders/:id/cancel', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const { reason } = req.body; + const result = await ecommerceOrderService.cancel(tenantId, req.params.id, reason); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +// ==================== SHIPPING ENDPOINTS ==================== + +/** + * GET /api/ecommerce/shipping-rates + * List shipping rates + */ +router.get('/shipping-rates', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const activeOnly = req.query.activeOnly === 'true'; + const result = await shippingRateService.list(tenantId, activeOnly); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/ecommerce/shipping-rates/:id + * Get shipping rate by ID + */ +router.get('/shipping-rates/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await shippingRateService.getById(tenantId, req.params.id); + + if (!result.success) { + return res.status(404).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/shipping-rates + * Create shipping rate + */ +router.post('/shipping-rates', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const userId = getUserId(req); + const result = await shippingRateService.create(tenantId, req.body, userId); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.status(201).json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * PATCH /api/ecommerce/shipping-rates/:id + * Update shipping rate + */ +router.patch('/shipping-rates/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await shippingRateService.update(tenantId, req.params.id, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +/** + * DELETE /api/ecommerce/shipping-rates/:id + * Delete shipping rate + */ +router.delete('/shipping-rates/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await shippingRateService.delete(tenantId, req.params.id); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, message: 'Shipping rate deleted' }); + } catch (error) { + next(error); + } +}); + +/** + * POST /api/ecommerce/shipping-rates/calculate + * Calculate available shipping options for cart + */ +router.post('/shipping-rates/calculate', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = getTenantId(req); + const result = await shippingRateService.calculateShippingOptions(tenantId, req.body); + + if (!result.success) { + return res.status(400).json({ success: false, error: result.error }); + } + + return res.json({ success: true, data: result.data }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/modules/ecommerce/controllers/index.ts b/src/modules/ecommerce/controllers/index.ts new file mode 100644 index 0000000..2691152 --- /dev/null +++ b/src/modules/ecommerce/controllers/index.ts @@ -0,0 +1,6 @@ +/** + * Ecommerce Controllers Index + * @module Ecommerce + */ + +export { default as ecommerceController } from './ecommerce.controller'; diff --git a/src/modules/ecommerce/services/cart.service.ts b/src/modules/ecommerce/services/cart.service.ts new file mode 100644 index 0000000..87ff60e --- /dev/null +++ b/src/modules/ecommerce/services/cart.service.ts @@ -0,0 +1,509 @@ +/** + * CartService - Gestión de carritos de compra + * @module Ecommerce + */ + +import { Repository, DeepPartial } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { Cart, CartStatus } from '../entities/cart.entity'; +import { CartItem } from '../entities/cart-item.entity'; + +interface ServiceResult { + success: boolean; + data?: T; + error?: { code: string; message: string }; +} + +interface AddItemDTO { + productId: string; + productCode: string; + productName: string; + productSlug?: string; + productImage?: string; + variantId?: string; + variantName?: string; + variantSku?: string; + variantOptions?: { name: string; value: string }[]; + quantity: number; + unitPrice: number; + originalPrice: number; + taxRate?: number; + taxIncluded?: boolean; + warehouseId?: string; +} + +interface UpdateItemDTO { + quantity?: number; + savedForLater?: boolean; + notes?: string; +} + +interface ApplyCouponDTO { + couponId: string; + couponCode: string; + discountAmount: number; +} + +interface SetShippingDTO { + shippingMethodId: string; + shippingMethodName: string; + shippingAmount: number; + shippingAddress: Cart['shippingAddress']; +} + +interface SetBillingDTO { + billingAddress: Cart['billingAddress']; + billingSameAsShipping?: boolean; + requiresInvoice?: boolean; + invoiceRfc?: string; + invoiceUsoCfdi?: string; +} + +class CartService { + private repository: Repository; + private itemRepository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Cart); + this.itemRepository = AppDataSource.getRepository(CartItem); + } + + /** + * Get or create cart for customer/session + */ + async getOrCreateCart( + tenantId: string, + options: { customerId?: string; sessionId?: string } + ): Promise> { + try { + let cart: Cart | null = null; + + if (options.customerId) { + cart = await this.repository.findOne({ + where: { tenantId, customerId: options.customerId, status: CartStatus.ACTIVE }, + relations: ['items'], + }); + } else if (options.sessionId) { + cart = await this.repository.findOne({ + where: { tenantId, sessionId: options.sessionId, status: CartStatus.ACTIVE }, + relations: ['items'], + }); + } + + if (!cart) { + cart = this.repository.create({ + tenantId, + customerId: options.customerId, + sessionId: options.sessionId, + status: CartStatus.ACTIVE, + currencyCode: 'MXN', + }); + cart = await this.repository.save(cart); + } + + return { success: true, data: cart }; + } catch (error: any) { + return { success: false, error: { code: 'CART_ERROR', message: error.message } }; + } + } + + /** + * Get cart by ID + */ + async getById(tenantId: string, id: string): Promise> { + try { + const cart = await this.repository.findOne({ + where: { id, tenantId }, + relations: ['items'], + }); + + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + return { success: true, data: cart }; + } catch (error: any) { + return { success: false, error: { code: 'CART_ERROR', message: error.message } }; + } + } + + /** + * Add item to cart + */ + async addItem(tenantId: string, cartId: string, data: AddItemDTO): Promise> { + try { + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + // Check if item already exists + let item = await this.itemRepository.findOne({ + where: { + tenantId, + cartId, + productId: data.productId, + variantId: data.variantId || undefined, + }, + }); + + if (item) { + // Update quantity + item.quantity = Number(item.quantity) + data.quantity; + } else { + // Create new item + item = this.itemRepository.create({ + tenantId, + cartId, + ...data, + taxRate: data.taxRate ?? 0.16, + taxIncluded: data.taxIncluded ?? true, + }); + } + + // Calculate totals + this.calculateItemTotals(item); + item = await this.itemRepository.save(item); + + // Recalculate cart totals + await this.recalculateCartTotals(tenantId, cartId); + + return { success: true, data: item }; + } catch (error: any) { + return { success: false, error: { code: 'ADD_ITEM_ERROR', message: error.message } }; + } + } + + /** + * Update cart item + */ + async updateItem(tenantId: string, cartId: string, itemId: string, data: UpdateItemDTO): Promise> { + try { + const item = await this.itemRepository.findOne({ + where: { id: itemId, tenantId, cartId }, + }); + + if (!item) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Item not found' } }; + } + + if (data.quantity !== undefined) { + if (data.quantity <= 0) { + // Remove item + await this.itemRepository.remove(item); + await this.recalculateCartTotals(tenantId, cartId); + return { success: true, data: item }; + } + item.quantity = data.quantity; + } + + if (data.savedForLater !== undefined) { + item.savedForLater = data.savedForLater; + } + + if (data.notes !== undefined) { + item.notes = data.notes; + } + + this.calculateItemTotals(item); + const updatedItem = await this.itemRepository.save(item); + await this.recalculateCartTotals(tenantId, cartId); + + return { success: true, data: updatedItem }; + } catch (error: any) { + return { success: false, error: { code: 'UPDATE_ERROR', message: error.message } }; + } + } + + /** + * Remove item from cart + */ + async removeItem(tenantId: string, cartId: string, itemId: string): Promise> { + try { + const item = await this.itemRepository.findOne({ + where: { id: itemId, tenantId, cartId }, + }); + + if (!item) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Item not found' } }; + } + + await this.itemRepository.remove(item); + await this.recalculateCartTotals(tenantId, cartId); + + return { success: true }; + } catch (error: any) { + return { success: false, error: { code: 'REMOVE_ERROR', message: error.message } }; + } + } + + /** + * Apply coupon to cart + */ + async applyCoupon(tenantId: string, cartId: string, data: ApplyCouponDTO): Promise> { + try { + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + cart.couponId = data.couponId; + cart.couponCode = data.couponCode; + cart.couponDiscount = data.discountAmount; + + await this.repository.save(cart); + await this.recalculateCartTotals(tenantId, cartId); + + const updatedCart = await this.repository.findOne({ + where: { id: cartId, tenantId }, + relations: ['items'], + }); + + return { success: true, data: updatedCart! }; + } catch (error: any) { + return { success: false, error: { code: 'COUPON_ERROR', message: error.message } }; + } + } + + /** + * Remove coupon from cart + */ + async removeCoupon(tenantId: string, cartId: string): Promise> { + try { + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + cart.couponId = null as any; + cart.couponCode = null as any; + cart.couponDiscount = 0; + + await this.repository.save(cart); + await this.recalculateCartTotals(tenantId, cartId); + + const updatedCart = await this.repository.findOne({ + where: { id: cartId, tenantId }, + relations: ['items'], + }); + + return { success: true, data: updatedCart! }; + } catch (error: any) { + return { success: false, error: { code: 'COUPON_ERROR', message: error.message } }; + } + } + + /** + * Set shipping information + */ + async setShipping(tenantId: string, cartId: string, data: SetShippingDTO): Promise> { + try { + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + cart.shippingMethodId = data.shippingMethodId; + cart.shippingMethodName = data.shippingMethodName; + cart.shippingAmount = data.shippingAmount; + cart.shippingAddress = data.shippingAddress; + + await this.repository.save(cart); + await this.recalculateCartTotals(tenantId, cartId); + + const updatedCart = await this.repository.findOne({ + where: { id: cartId, tenantId }, + relations: ['items'], + }); + + return { success: true, data: updatedCart! }; + } catch (error: any) { + return { success: false, error: { code: 'SHIPPING_ERROR', message: error.message } }; + } + } + + /** + * Set billing information + */ + async setBilling(tenantId: string, cartId: string, data: SetBillingDTO): Promise> { + try { + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + cart.billingAddress = data.billingAddress; + cart.billingSameAsShipping = data.billingSameAsShipping ?? false; + cart.requiresInvoice = data.requiresInvoice ?? false; + if (data.invoiceRfc) cart.invoiceRfc = data.invoiceRfc; + if (data.invoiceUsoCfdi) cart.invoiceUsoCfdi = data.invoiceUsoCfdi; + + const updatedCart = await this.repository.save(cart); + + return { success: true, data: updatedCart }; + } catch (error: any) { + return { success: false, error: { code: 'BILLING_ERROR', message: error.message } }; + } + } + + /** + * Merge guest cart into customer cart + */ + async mergeCarts(tenantId: string, guestSessionId: string, customerId: string): Promise> { + try { + const guestCart = await this.repository.findOne({ + where: { tenantId, sessionId: guestSessionId, status: CartStatus.ACTIVE }, + relations: ['items'], + }); + + if (!guestCart || guestCart.items.length === 0) { + const customerResult = await this.getOrCreateCart(tenantId, { customerId }); + return customerResult; + } + + let customerCart = await this.repository.findOne({ + where: { tenantId, customerId, status: CartStatus.ACTIVE }, + relations: ['items'], + }); + + if (!customerCart) { + // Transfer guest cart to customer + guestCart.customerId = customerId; + guestCart.sessionId = null as any; + const updatedCart = await this.repository.save(guestCart); + return { success: true, data: updatedCart }; + } + + // Merge items + for (const guestItem of guestCart.items) { + const existingItem = customerCart.items.find( + (i) => i.productId === guestItem.productId && i.variantId === guestItem.variantId + ); + + if (existingItem) { + existingItem.quantity = Number(existingItem.quantity) + Number(guestItem.quantity); + this.calculateItemTotals(existingItem); + await this.itemRepository.save(existingItem); + } else { + guestItem.cartId = customerCart.id; + await this.itemRepository.save(guestItem); + } + } + + // Mark guest cart as merged + guestCart.status = CartStatus.MERGED; + guestCart.mergedIntoCartId = customerCart.id; + await this.repository.save(guestCart); + + await this.recalculateCartTotals(tenantId, customerCart.id); + + const mergedCart = await this.repository.findOne({ + where: { id: customerCart.id, tenantId }, + relations: ['items'], + }); + + return { success: true, data: mergedCart! }; + } catch (error: any) { + return { success: false, error: { code: 'MERGE_ERROR', message: error.message } }; + } + } + + /** + * Clear cart (remove all items) + */ + async clearCart(tenantId: string, cartId: string): Promise> { + try { + await this.itemRepository.delete({ tenantId, cartId }); + + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Cart not found' } }; + } + + cart.itemsCount = 0; + cart.itemsQuantity = 0; + cart.subtotal = 0; + cart.taxAmount = 0; + cart.discountAmount = 0; + cart.total = 0; + + const updatedCart = await this.repository.save(cart); + return { success: true, data: updatedCart }; + } catch (error: any) { + return { success: false, error: { code: 'CLEAR_ERROR', message: error.message } }; + } + } + + /** + * Mark cart as abandoned + */ + async markAbandoned(tenantId: string, cartId: string): Promise> { + try { + await this.repository.update( + { id: cartId, tenantId, status: CartStatus.ACTIVE }, + { status: CartStatus.ABANDONED } + ); + return { success: true }; + } catch (error: any) { + return { success: false, error: { code: 'UPDATE_ERROR', message: error.message } }; + } + } + + /** + * Calculate item totals + */ + private calculateItemTotals(item: CartItem): void { + const lineSubtotal = Number(item.quantity) * Number(item.unitPrice); + const discountAmount = Number(item.discountAmount) || lineSubtotal * (Number(item.discountPercent) / 100); + const afterDiscount = lineSubtotal - discountAmount; + + let taxAmount: number; + if (item.taxIncluded) { + taxAmount = afterDiscount - afterDiscount / (1 + Number(item.taxRate)); + } else { + taxAmount = afterDiscount * Number(item.taxRate); + } + + item.subtotal = Number((afterDiscount - (item.taxIncluded ? taxAmount : 0)).toFixed(2)); + item.taxAmount = Number(taxAmount.toFixed(2)); + item.total = Number((afterDiscount + (item.taxIncluded ? 0 : taxAmount)).toFixed(2)); + } + + /** + * Recalculate cart totals + */ + private async recalculateCartTotals(tenantId: string, cartId: string): Promise { + const items = await this.itemRepository.find({ + where: { tenantId, cartId, savedForLater: false }, + }); + + const cart = await this.repository.findOne({ where: { id: cartId, tenantId } }); + if (!cart) return; + + let itemsCount = 0; + let itemsQuantity = 0; + let subtotal = 0; + let taxAmount = 0; + + for (const item of items) { + itemsCount++; + itemsQuantity += Number(item.quantity); + subtotal += Number(item.subtotal); + taxAmount += Number(item.taxAmount); + } + + cart.itemsCount = itemsCount; + cart.itemsQuantity = itemsQuantity; + cart.subtotal = Number(subtotal.toFixed(2)); + cart.taxAmount = Number(taxAmount.toFixed(2)); + cart.discountAmount = Number(cart.couponDiscount) + items.reduce((sum, i) => sum + Number(i.discountAmount), 0); + + const total = subtotal + taxAmount + Number(cart.shippingAmount) - Number(cart.couponDiscount) - Number(cart.loyaltyPointsValue); + cart.total = Number(Math.max(0, total).toFixed(2)); + + await this.repository.save(cart); + } +} + +export const cartService = new CartService(); +export { CartService }; diff --git a/src/modules/ecommerce/services/ecommerce-order.service.ts b/src/modules/ecommerce/services/ecommerce-order.service.ts new file mode 100644 index 0000000..e0010e0 --- /dev/null +++ b/src/modules/ecommerce/services/ecommerce-order.service.ts @@ -0,0 +1,469 @@ +/** + * EcommerceOrderService - Gestión de pedidos e-commerce + * @module Ecommerce + */ + +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { EcommerceOrder, EcommerceOrderStatus, PaymentStatus, FulfillmentType } from '../entities/ecommerce-order.entity'; +import { EcommerceOrderLine } from '../entities/ecommerce-order-line.entity'; +import { Cart, CartStatus } from '../entities/cart.entity'; +import { CartItem } from '../entities/cart-item.entity'; + +interface ServiceResult { + success: boolean; + data?: T; + error?: { code: string; message: string }; +} + +interface CreateOrderDTO { + cartId: string; + customerEmail: string; + customerPhone?: string; + customerName: string; + paymentMethod?: string; + paymentGateway?: string; + fulfillmentType?: FulfillmentType; + pickupBranchId?: string; + notes?: string; +} + +interface OrderFilters { + status?: EcommerceOrderStatus; + paymentStatus?: PaymentStatus; + customerId?: string; + dateFrom?: Date; + dateTo?: Date; + page?: number; + limit?: number; +} + +class EcommerceOrderService { + private repository: Repository; + private lineRepository: Repository; + private cartRepository: Repository; + private cartItemRepository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(EcommerceOrder); + this.lineRepository = AppDataSource.getRepository(EcommerceOrderLine); + this.cartRepository = AppDataSource.getRepository(Cart); + this.cartItemRepository = AppDataSource.getRepository(CartItem); + } + + /** + * Generate order number + */ + private generateOrderNumber(): string { + const date = new Date(); + const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `ECO-${dateStr}-${random}`; + } + + /** + * Create order from cart + */ + async createFromCart(tenantId: string, data: CreateOrderDTO): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const cart = await this.cartRepository.findOne({ + where: { id: data.cartId, tenantId, status: CartStatus.ACTIVE }, + relations: ['items'], + }); + + if (!cart) { + return { success: false, error: { code: 'CART_NOT_FOUND', message: 'Cart not found or not active' } }; + } + + if (!cart.items || cart.items.length === 0) { + return { success: false, error: { code: 'EMPTY_CART', message: 'Cart is empty' } }; + } + + // Create order + const order = this.repository.create({ + tenantId, + number: this.generateOrderNumber(), + status: EcommerceOrderStatus.PENDING, + paymentStatus: PaymentStatus.PENDING, + fulfillmentType: data.fulfillmentType || FulfillmentType.SHIP, + customerId: cart.customerId, + customerEmail: data.customerEmail, + customerPhone: data.customerPhone, + customerName: data.customerName, + isGuest: !cart.customerId, + currencyCode: cart.currencyCode, + subtotal: cart.subtotal, + discountAmount: cart.discountAmount, + taxAmount: cart.taxAmount, + shippingAmount: cart.shippingAmount, + total: cart.total, + itemsCount: cart.itemsCount, + itemsQuantity: cart.itemsQuantity, + couponId: cart.couponId, + couponCode: cart.couponCode, + couponDiscount: cart.couponDiscount, + loyaltyPointsUsed: cart.loyaltyPointsToUse, + loyaltyPointsValue: cart.loyaltyPointsValue, + shippingAddress: cart.shippingAddress, + billingAddress: cart.billingAddress || cart.shippingAddress, + shippingMethodId: cart.shippingMethodId, + shippingMethodName: cart.shippingMethodName, + pickupBranchId: data.pickupBranchId, + paymentMethod: data.paymentMethod, + paymentGateway: data.paymentGateway, + requiresInvoice: cart.requiresInvoice, + invoiceRfc: cart.invoiceRfc, + invoiceUsoCfdi: cart.invoiceUsoCfdi, + isGift: cart.isGift, + giftMessage: cart.giftMessage, + customerNotes: data.notes || cart.notes, + cartId: cart.id, + utmSource: cart.utmSource, + utmMedium: cart.utmMedium, + utmCampaign: cart.utmCampaign, + deviceType: cart.deviceType, + ipAddress: cart.ipAddress, + }); + + const savedOrder = await queryRunner.manager.save(order); + + // Create order lines from cart items + const activeItems = cart.items.filter((item) => !item.savedForLater); + for (let i = 0; i < activeItems.length; i++) { + const item = activeItems[i]; + const line = this.lineRepository.create({ + tenantId, + orderId: savedOrder.id, + lineNumber: i + 1, + productId: item.productId, + productCode: item.productCode, + productName: item.productName, + productImage: item.productImage, + variantId: item.variantId, + variantName: item.variantName, + variantSku: item.variantSku, + variantOptions: item.variantOptions, + quantity: item.quantity, + unitPrice: item.unitPrice, + originalPrice: item.originalPrice, + discountPercent: item.discountPercent, + discountAmount: item.discountAmount, + promotionId: item.promotionId, + promotionName: item.promotionName, + taxRate: item.taxRate, + taxAmount: item.taxAmount, + taxIncluded: item.taxIncluded, + subtotal: item.subtotal, + total: item.total, + warehouseId: item.warehouseId, + isGift: item.isGift, + giftMessage: item.giftMessage, + notes: item.notes, + }); + await queryRunner.manager.save(line); + } + + // Mark cart as converted + cart.status = CartStatus.CONVERTED; + cart.convertedToOrderId = savedOrder.id; + cart.convertedAt = new Date(); + await queryRunner.manager.save(cart); + + await queryRunner.commitTransaction(); + + const fullOrder = await this.repository.findOne({ + where: { id: savedOrder.id, tenantId }, + relations: ['lines'], + }); + + return { success: true, data: fullOrder! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { success: false, error: { code: 'CREATE_ORDER_ERROR', message: error.message } }; + } finally { + await queryRunner.release(); + } + } + + /** + * Get order by ID + */ + async getById(tenantId: string, id: string): Promise> { + try { + const order = await this.repository.findOne({ + where: { id, tenantId }, + relations: ['lines'], + }); + + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + return { success: true, data: order }; + } catch (error: any) { + return { success: false, error: { code: 'ORDER_ERROR', message: error.message } }; + } + } + + /** + * Get order by number + */ + async getByNumber(tenantId: string, number: string): Promise> { + try { + const order = await this.repository.findOne({ + where: { number, tenantId }, + relations: ['lines'], + }); + + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + return { success: true, data: order }; + } catch (error: any) { + return { success: false, error: { code: 'ORDER_ERROR', message: error.message } }; + } + } + + /** + * List orders with filters + */ + async list(tenantId: string, filters: OrderFilters = {}): Promise> { + try { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('order') + .leftJoinAndSelect('order.lines', 'lines') + .where('order.tenant_id = :tenantId', { tenantId }); + + if (filters.status) { + queryBuilder.andWhere('order.status = :status', { status: filters.status }); + } + + if (filters.paymentStatus) { + queryBuilder.andWhere('order.payment_status = :paymentStatus', { paymentStatus: filters.paymentStatus }); + } + + if (filters.customerId) { + queryBuilder.andWhere('order.customer_id = :customerId', { customerId: filters.customerId }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('order.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('order.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + + const [data, total] = await queryBuilder + .orderBy('order.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { success: true, data: { data, total } }; + } catch (error: any) { + return { success: false, error: { code: 'LIST_ERROR', message: error.message } }; + } + } + + /** + * Confirm order + */ + async confirm(tenantId: string, id: string): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + if (order.status !== EcommerceOrderStatus.PENDING) { + return { success: false, error: { code: 'INVALID_STATUS', message: 'Order cannot be confirmed' } }; + } + + order.status = EcommerceOrderStatus.CONFIRMED; + order.confirmedAt = new Date(); + const updatedOrder = await this.repository.save(order); + + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'CONFIRM_ERROR', message: error.message } }; + } + } + + /** + * Update payment status + */ + async updatePaymentStatus( + tenantId: string, + id: string, + status: PaymentStatus, + transactionId?: string, + paymentDetails?: Record + ): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + order.paymentStatus = status; + if (transactionId) order.paymentTransactionId = transactionId; + if (paymentDetails) order.paymentDetails = paymentDetails; + + if (status === PaymentStatus.CAPTURED) { + order.amountPaid = order.total; + if (order.status === EcommerceOrderStatus.PENDING) { + order.status = EcommerceOrderStatus.CONFIRMED; + order.confirmedAt = new Date(); + } + } + + const updatedOrder = await this.repository.save(order); + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'PAYMENT_ERROR', message: error.message } }; + } + } + + /** + * Ship order + */ + async ship( + tenantId: string, + id: string, + data: { carrier?: string; trackingNumber?: string; trackingUrl?: string; estimatedDelivery?: Date } + ): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + if (order.fulfillmentType !== FulfillmentType.SHIP) { + return { success: false, error: { code: 'INVALID_FULFILLMENT', message: 'Order is not for shipping' } }; + } + + order.status = EcommerceOrderStatus.SHIPPED; + order.shippedAt = new Date(); + if (data.carrier) order.shippingCarrier = data.carrier; + if (data.trackingNumber) order.trackingNumber = data.trackingNumber; + if (data.trackingUrl) order.trackingUrl = data.trackingUrl; + if (data.estimatedDelivery) order.estimatedDelivery = data.estimatedDelivery; + + const updatedOrder = await this.repository.save(order); + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'SHIP_ERROR', message: error.message } }; + } + } + + /** + * Mark order as delivered + */ + async markDelivered(tenantId: string, id: string): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + order.status = EcommerceOrderStatus.DELIVERED; + order.deliveredAt = new Date(); + + const updatedOrder = await this.repository.save(order); + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'DELIVERY_ERROR', message: error.message } }; + } + } + + /** + * Mark order ready for pickup + */ + async markReadyForPickup(tenantId: string, id: string): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + if (order.fulfillmentType !== FulfillmentType.PICKUP) { + return { success: false, error: { code: 'INVALID_FULFILLMENT', message: 'Order is not for pickup' } }; + } + + order.status = EcommerceOrderStatus.READY_FOR_PICKUP; + order.pickupReadyAt = new Date(); + + const updatedOrder = await this.repository.save(order); + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'PICKUP_ERROR', message: error.message } }; + } + } + + /** + * Mark order as picked up + */ + async markPickedUp(tenantId: string, id: string): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + order.status = EcommerceOrderStatus.DELIVERED; + order.pickedUpAt = new Date(); + order.deliveredAt = new Date(); + + const updatedOrder = await this.repository.save(order); + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'PICKUP_ERROR', message: error.message } }; + } + } + + /** + * Cancel order + */ + async cancel(tenantId: string, id: string, reason?: string): Promise> { + try { + const order = await this.repository.findOne({ where: { id, tenantId } }); + if (!order) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Order not found' } }; + } + + const nonCancellable = [EcommerceOrderStatus.SHIPPED, EcommerceOrderStatus.DELIVERED, EcommerceOrderStatus.CANCELLED]; + if (nonCancellable.includes(order.status)) { + return { success: false, error: { code: 'INVALID_STATUS', message: 'Order cannot be cancelled' } }; + } + + order.status = EcommerceOrderStatus.CANCELLED; + order.cancelledAt = new Date(); + if (reason) order.cancellationReason = reason; + + const updatedOrder = await this.repository.save(order); + return { success: true, data: updatedOrder }; + } catch (error: any) { + return { success: false, error: { code: 'CANCEL_ERROR', message: error.message } }; + } + } + + /** + * Get customer orders + */ + async getCustomerOrders(tenantId: string, customerId: string, page: number = 1, limit: number = 10): Promise> { + return this.list(tenantId, { customerId, page, limit }); + } +} + +export const ecommerceOrderService = new EcommerceOrderService(); +export { EcommerceOrderService }; diff --git a/src/modules/ecommerce/services/index.ts b/src/modules/ecommerce/services/index.ts new file mode 100644 index 0000000..80c7a0d --- /dev/null +++ b/src/modules/ecommerce/services/index.ts @@ -0,0 +1,8 @@ +/** + * Ecommerce Services Index + * @module Ecommerce + */ + +export * from './cart.service'; +export * from './ecommerce-order.service'; +export * from './shipping-rate.service'; diff --git a/src/modules/ecommerce/services/shipping-rate.service.ts b/src/modules/ecommerce/services/shipping-rate.service.ts new file mode 100644 index 0000000..41f8ee1 --- /dev/null +++ b/src/modules/ecommerce/services/shipping-rate.service.ts @@ -0,0 +1,357 @@ +/** + * ShippingRateService - Gestión de tarifas de envío + * @module Ecommerce + */ + +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ShippingRate, ShippingRateStatus, ShippingCalculation } from '../entities/shipping-rate.entity'; + +interface ServiceResult { + success: boolean; + data?: T; + error?: { code: string; message: string }; +} + +interface CreateShippingRateDTO { + code: string; + name: string; + description?: string; + calculationType: ShippingCalculation; + flatRate?: number; + weightRates?: { minWeight: number; maxWeight: number; rate: number }[]; + priceRates?: { minPrice: number; maxPrice: number; rate: number }[]; + quantityRates?: { minQuantity: number; maxQuantity: number; rate: number }[]; + freeShippingThreshold?: number; + carrierCode?: string; + carrierName?: string; + carrierService?: string; + minDeliveryDays?: number; + maxDeliveryDays?: number; + deliveryMessage?: string; + allowedCountries?: string[]; + allowedStates?: string[]; + priority?: number; + displayName?: string; +} + +interface UpdateShippingRateDTO extends Partial { + status?: ShippingRateStatus; +} + +interface CalculateShippingDTO { + postalCode: string; + country?: string; + state?: string; + cartSubtotal: number; + cartWeight?: number; + cartQuantity: number; + productIds?: string[]; + categoryIds?: string[]; + customerLevel?: string; +} + +interface ShippingOption { + id: string; + code: string; + name: string; + displayName: string; + rate: number; + deliveryMessage?: string; + minDeliveryDays?: number; + maxDeliveryDays?: number; + carrierName?: string; +} + +class ShippingRateService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ShippingRate); + } + + /** + * Create shipping rate + */ + async create(tenantId: string, data: CreateShippingRateDTO, createdBy?: string): Promise> { + try { + // Check code uniqueness + const existing = await this.repository.findOne({ + where: { tenantId, code: data.code }, + }); + + if (existing) { + return { success: false, error: { code: 'DUPLICATE_CODE', message: 'Shipping rate code already exists' } }; + } + + const rate = this.repository.create({ + tenantId, + ...data, + status: ShippingRateStatus.ACTIVE, + createdBy, + }); + + const savedRate = await this.repository.save(rate); + return { success: true, data: savedRate }; + } catch (error: any) { + return { success: false, error: { code: 'CREATE_ERROR', message: error.message } }; + } + } + + /** + * Get shipping rate by ID + */ + async getById(tenantId: string, id: string): Promise> { + try { + const rate = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!rate) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Shipping rate not found' } }; + } + + return { success: true, data: rate }; + } catch (error: any) { + return { success: false, error: { code: 'GET_ERROR', message: error.message } }; + } + } + + /** + * List all shipping rates + */ + async list(tenantId: string, activeOnly: boolean = false): Promise> { + try { + const where: any = { tenantId }; + if (activeOnly) { + where.status = ShippingRateStatus.ACTIVE; + } + + const rates = await this.repository.find({ + where, + order: { priority: 'ASC', name: 'ASC' }, + }); + + return { success: true, data: rates }; + } catch (error: any) { + return { success: false, error: { code: 'LIST_ERROR', message: error.message } }; + } + } + + /** + * Update shipping rate + */ + async update(tenantId: string, id: string, data: UpdateShippingRateDTO): Promise> { + try { + const rate = await this.repository.findOne({ where: { id, tenantId } }); + if (!rate) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Shipping rate not found' } }; + } + + Object.assign(rate, data); + const updatedRate = await this.repository.save(rate); + + return { success: true, data: updatedRate }; + } catch (error: any) { + return { success: false, error: { code: 'UPDATE_ERROR', message: error.message } }; + } + } + + /** + * Delete shipping rate + */ + async delete(tenantId: string, id: string): Promise> { + try { + const rate = await this.repository.findOne({ where: { id, tenantId } }); + if (!rate) { + return { success: false, error: { code: 'NOT_FOUND', message: 'Shipping rate not found' } }; + } + + await this.repository.remove(rate); + return { success: true }; + } catch (error: any) { + return { success: false, error: { code: 'DELETE_ERROR', message: error.message } }; + } + } + + /** + * Calculate available shipping options for cart + */ + async calculateShippingOptions(tenantId: string, data: CalculateShippingDTO): Promise> { + try { + const rates = await this.repository.find({ + where: { tenantId, status: ShippingRateStatus.ACTIVE }, + order: { priority: 'ASC' }, + }); + + const options: ShippingOption[] = []; + + for (const rate of rates) { + // Check geographic restrictions + if (!this.isLocationAllowed(rate, data)) { + continue; + } + + // Check product/category restrictions + if (this.hasExcludedProducts(rate, data)) { + continue; + } + + // Check customer level restrictions + if (!this.isCustomerLevelAllowed(rate, data.customerLevel)) { + continue; + } + + // Calculate rate + const calculatedRate = this.calculateRate(rate, data); + if (calculatedRate === null) { + continue; + } + + options.push({ + id: rate.id, + code: rate.code, + name: rate.name, + displayName: rate.displayName || rate.name, + rate: calculatedRate, + deliveryMessage: rate.deliveryMessage, + minDeliveryDays: rate.minDeliveryDays, + maxDeliveryDays: rate.maxDeliveryDays, + carrierName: rate.carrierName, + }); + } + + return { success: true, data: options }; + } catch (error: any) { + return { success: false, error: { code: 'CALCULATE_ERROR', message: error.message } }; + } + } + + /** + * Check if location is allowed + */ + private isLocationAllowed(rate: ShippingRate, data: CalculateShippingDTO): boolean { + // Check country + if (rate.allowedCountries && rate.allowedCountries.length > 0) { + if (!data.country || !rate.allowedCountries.includes(data.country)) { + return false; + } + } + + // Check state + if (rate.allowedStates && rate.allowedStates.length > 0) { + if (!data.state || !rate.allowedStates.includes(data.state)) { + return false; + } + } + + // Check postal code + if (rate.allowedPostalCodes && rate.allowedPostalCodes.length > 0) { + if (!rate.allowedPostalCodes.includes(data.postalCode)) { + return false; + } + } + + // Check excluded postal codes + if (rate.excludedPostalCodes && rate.excludedPostalCodes.length > 0) { + if (rate.excludedPostalCodes.includes(data.postalCode)) { + return false; + } + } + + return true; + } + + /** + * Check if cart has excluded products + */ + private hasExcludedProducts(rate: ShippingRate, data: CalculateShippingDTO): boolean { + if (rate.excludedProducts && rate.excludedProducts.length > 0 && data.productIds) { + for (const productId of data.productIds) { + if (rate.excludedProducts.includes(productId)) { + return true; + } + } + } + + if (rate.excludedCategories && rate.excludedCategories.length > 0 && data.categoryIds) { + for (const categoryId of data.categoryIds) { + if (rate.excludedCategories.includes(categoryId)) { + return true; + } + } + } + + return false; + } + + /** + * Check if customer level is allowed + */ + private isCustomerLevelAllowed(rate: ShippingRate, customerLevel?: string): boolean { + if (!rate.customerLevels || rate.customerLevels.length === 0) { + return true; + } + + if (!customerLevel) { + return true; // Allow guests if no level specified + } + + return rate.customerLevels.includes(customerLevel); + } + + /** + * Calculate shipping rate based on calculation type + */ + private calculateRate(rate: ShippingRate, data: CalculateShippingDTO): number | null { + // Check free shipping threshold + if (rate.freeShippingThreshold && data.cartSubtotal >= rate.freeShippingThreshold) { + return 0; + } + + switch (rate.calculationType) { + case ShippingCalculation.FREE: + return 0; + + case ShippingCalculation.FLAT_RATE: + return Number(rate.flatRate) || 0; + + case ShippingCalculation.BY_WEIGHT: + if (!data.cartWeight || !rate.weightRates) return null; + for (const tier of rate.weightRates) { + if (data.cartWeight >= tier.minWeight && data.cartWeight <= tier.maxWeight) { + return tier.rate; + } + } + return null; + + case ShippingCalculation.BY_PRICE: + if (!rate.priceRates) return null; + for (const tier of rate.priceRates) { + if (data.cartSubtotal >= tier.minPrice && data.cartSubtotal <= tier.maxPrice) { + return tier.rate; + } + } + return null; + + case ShippingCalculation.BY_QUANTITY: + if (!rate.quantityRates) return null; + for (const tier of rate.quantityRates) { + if (data.cartQuantity >= tier.minQuantity && data.cartQuantity <= tier.maxQuantity) { + return tier.rate; + } + } + return null; + + case ShippingCalculation.CARRIER_CALCULATED: + // Would need carrier API integration + return Number(rate.flatRate) || null; + + default: + return null; + } + } +} + +export const shippingRateService = new ShippingRateService(); +export { ShippingRateService };