[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>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 18:01:06 -06:00
parent c24290b0ee
commit c6b8ab019a
6 changed files with 1965 additions and 0 deletions

View File

@ -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;

View File

@ -0,0 +1,6 @@
/**
* Ecommerce Controllers Index
* @module Ecommerce
*/
export { default as ecommerceController } from './ecommerce.controller';

View File

@ -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<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 };

View File

@ -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<T> {
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<EcommerceOrder>;
private lineRepository: Repository<EcommerceOrderLine>;
private cartRepository: Repository<Cart>;
private cartItemRepository: Repository<CartItem>;
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<{ data: EcommerceOrder[]; total: number }>> {
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<ServiceResult<EcommerceOrder>> {
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<string, any>
): Promise<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<EcommerceOrder>> {
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<ServiceResult<{ data: EcommerceOrder[]; total: number }>> {
return this.list(tenantId, { customerId, page, limit });
}
}
export const ecommerceOrderService = new EcommerceOrderService();
export { EcommerceOrderService };

View File

@ -0,0 +1,8 @@
/**
* Ecommerce Services Index
* @module Ecommerce
*/
export * from './cart.service';
export * from './ecommerce-order.service';
export * from './shipping-rate.service';

View File

@ -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<T> {
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<CreateShippingRateDTO> {
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<ShippingRate>;
constructor() {
this.repository = AppDataSource.getRepository(ShippingRate);
}
/**
* Create shipping rate
*/
async create(tenantId: string, data: CreateShippingRateDTO, createdBy?: string): Promise<ServiceResult<ShippingRate>> {
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<ServiceResult<ShippingRate>> {
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<ServiceResult<ShippingRate[]>> {
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<ServiceResult<ShippingRate>> {
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<ServiceResult<void>> {
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<ServiceResult<ShippingOption[]>> {
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 };