/** * 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 };