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>
510 lines
15 KiB
TypeScript
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 };
|