erp-retail-backend-v2/src/modules/ecommerce/services/cart.service.ts
Adrian Flores Cortes c6b8ab019a [ERP-RETAIL] feat(ecommerce): Add cart, order, and shipping services with controller
Implement complete ecommerce backend:
- CartService: cart CRUD, items, coupons, shipping, cart merging
- EcommerceOrderService: order creation, payment, shipping, fulfillment workflow
- ShippingRateService: rate calculation with geo/product/customer restrictions
- EcommerceController: REST API endpoints for all ecommerce operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:01:06 -06:00

510 lines
15 KiB
TypeScript

/**
* 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<T> {
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<Cart>;
private itemRepository: Repository<CartItem>;
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<ServiceResult<Cart>> {
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<ServiceResult<Cart>> {
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<ServiceResult<CartItem>> {
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<ServiceResult<CartItem>> {
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<ServiceResult<void>> {
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<ServiceResult<Cart>> {
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<ServiceResult<Cart>> {
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<ServiceResult<Cart>> {
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<ServiceResult<Cart>> {
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<ServiceResult<Cart>> {
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<ServiceResult<Cart>> {
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<ServiceResult<void>> {
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<void> {
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 };