cart refactored

This commit is contained in:
2026-02-09 15:29:17 +03:30
parent ff7d2c912a
commit 65b937d662
13 changed files with 109 additions and 138 deletions
+8
View File
@@ -20,6 +20,10 @@ import { CartCalculationService } from './providers/cart-calculation.service';
import { CartItemService } from './providers/cart-item.service';
import { PointTransaction } from '../users/entities/point-transaction.entity';
import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
import { ShopsModule } from '../shops/shops.module';
import { DeliveryModule } from '../delivery/delivery.module';
import { ProductModule } from '../products/product.module';
import { PaymentsModule } from '../payments/payments.module';
@Module({
imports: [
@@ -39,6 +43,10 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
JwtModule,
UtilsModule,
CouponModule,
ShopsModule,
DeliveryModule,
ProductModule,
PaymentsModule
],
controllers: [CartController],
providers: [
@@ -1,8 +1,8 @@
import type { OrderCouponDetail, OrderUserAddress } from 'src/modules/orders/interface/order.interface';
export interface CartItem {
foodId: string;
foodTitle?: string;
productId: string;
productTitle?: string;
quantity: number;
price: number;
discount: number;
@@ -14,7 +14,7 @@ export class CartCalculationService {
constructor(
private readonly em: EntityManager,
private readonly couponService: CouponService,
) {}
) { }
/**
* Calculate total price for a cart item
@@ -71,7 +71,7 @@ export class CartCalculationService {
const foodMap = await this.getFoodsInCartWithCategories(cart);
for (const item of cart.items) {
const product = foodMap.get(item.foodId);
const product = foodMap.get(item.productId);
if (!product) continue;
const matchesCategory =
@@ -227,7 +227,7 @@ export class CartCalculationService {
* Get products in cart with categories
*/
private async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Product>> {
const foodIds = cart.items.map(item => item.foodId);
const foodIds = cart.items.map(item => item.productId);
if (foodIds.length === 0) return new Map();
const products = await this.em.find(Product, { id: { $in: foodIds } }, { populate: ['category'] });
+18 -16
View File
@@ -4,13 +4,15 @@ import { Cart, CartItem } from '../interfaces/cart.interface';
import { CartValidationService } from './cart-validation.service';
import { CartCalculationService } from './cart-calculation.service';
import { CartMessage } from 'src/common/enums/message.enum';
import { ProductService } from 'src/modules/products/providers/product.service';
@Injectable()
export class CartItemService {
constructor(
private readonly validationService: CartValidationService,
private readonly calculationService: CartCalculationService,
) {}
private readonly productService: ProductService,
) { }
/**
* Create a new cart item from product
@@ -20,8 +22,8 @@ export class CartItemService {
const itemDiscount = product.discount || 0;
return {
foodId: product.id,
foodTitle: product.title,
productId: product.id,
productTitle: product.title,
quantity,
price: itemPrice,
discount: itemDiscount,
@@ -37,8 +39,8 @@ export class CartItemService {
const itemDiscount = product.discount || 0;
return {
...(previous ?? ({} as CartItem)),
foodId: product.id,
foodTitle: product.title,
productId: product.id,
productTitle: product.title,
quantity,
price: itemPrice,
discount: itemDiscount,
@@ -49,16 +51,16 @@ export class CartItemService {
/**
* Get item index in cart
*/
getItemIndex(cart: Cart, foodId: string): number {
return cart.items.findIndex(item => item.foodId === foodId);
getItemIndex(cart: Cart, shopId: string): number {
return cart.items.findIndex(item => item.productId === shopId);
}
/**
* Add or increment item in cart
*/
async addOrIncrementItem(cart: Cart, foodId: string, restaurantId: string, quantityToAdd: number): Promise<void> {
const product = await this.validationService.validateAndGetFood(foodId, restaurantId, quantityToAdd);
async addOrIncrementItem(cart: Cart, shopId: string, restaurantId: string, quantityToAdd: number): Promise<void> {
const product = await this.validationService.validateAndGetFood(shopId, restaurantId, quantityToAdd);
const index = this.getItemIndex(cart, product.id);
if (index < 0) {
@@ -68,15 +70,15 @@ export class CartItemService {
const existingItem = cart.items[index];
const newQuantity = existingItem.quantity + quantityToAdd;
this.validationService.validateStock(product, newQuantity);
cart.items[index] = this.buildCartItemFromFood(product, newQuantity, existingItem);
}
/**
* Remove item from cart
*/
removeItemOrFail(cart: Cart, foodId: string): void {
const itemIndex = this.getItemIndex(cart, foodId);
removeItemOrFail(cart: Cart, shopId: string): void {
const itemIndex = this.getItemIndex(cart, shopId);
if (itemIndex < 0) {
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
}
@@ -86,8 +88,8 @@ export class CartItemService {
/**
* Decrement item quantity or remove if quantity reaches 0
*/
async decrementOrRemoveItem(cart: Cart, foodId: string): Promise<void> {
const itemIndex = this.getItemIndex(cart, foodId);
async decrementOrRemoveItem(cart: Cart, shopId: string): Promise<void> {
const itemIndex = this.getItemIndex(cart, shopId);
if (itemIndex < 0) {
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
}
@@ -100,7 +102,7 @@ export class CartItemService {
return;
}
const product = await this.validationService.getFoodOrFail(foodId);
const product = await this.productService.findOrFail(shopId);
cart.items[itemIndex] = this.buildCartItemFromFood(product, newQuantity, existingItem);
}
}
@@ -1,37 +1,40 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { Product } from 'src/modules/products/entities/product.entity';
import { Shop } from 'src/modules/shops/entities/shop.entity';
import { UserAddress } from 'src/modules/users/entities/user-address.entity';
import { User } from 'src/modules/users/entities/user.entity';
import { PaymentMethod } from 'src/modules/payments/entities/payment-method.entity';
import { Delivery } from 'src/modules/delivery/entities/delivery.entity';
import { DeliveryMethodEnum } from 'src/modules/delivery/interface/delivery';
import { PointTransaction } from 'src/modules/users/entities/point-transaction.entity';
import { Order } from 'src/modules/orders/entities/order.entity';
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
import { Cart } from '../interfaces/cart.interface';
import { GeographicUtils } from '../utils/geographic.utils';
import { CartMessage } from 'src/common/enums/message.enum';
import { WalletTransactionRepository } from 'src/modules/users/repositories/wallet-transaction.repository';
import { ProductService } from 'src/modules/products/providers/product.service';
import { ShopService } from 'src/modules/shops/providers/shops.service';
import { DeliveryService } from 'src/modules/delivery/providers/delivery.service';
import { PaymentsService } from 'src/modules/payments/services/payments.service';
import { PaymentMethodService } from 'src/modules/payments/services/payment-method.service';
@Injectable()
export class CartValidationService {
constructor(
private readonly em: EntityManager,
private readonly walletTransactionRepository: WalletTransactionRepository,
private readonly productService: ProductService,
private readonly shopService: ShopService,
private readonly deliveryService: DeliveryService,
private readonly paymentMethodService: PaymentMethodService,
) { }
/**
* Validate product exists and belongs to shop
*/
async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise<Product> {
const product = await this.em.findOne(Product, { id: foodId }, { populate: ['shop'] });
if (!product) {
throw new NotFoundException(CartMessage.PRODUCT_NOT_FOUND);
}
async validateAndGetFood(productId: string, shopId: string, quantity: number): Promise<Product> {
const product = await this.productService.findOrFail(productId)
if (product.shop.id !== restaurantId) {
if (product.shop.id !== shopId) {
throw new BadRequestException(CartMessage.PRODUCT_NOT_BELONGS_TO_SHOP);
}
@@ -39,50 +42,6 @@ export class CartValidationService {
}
/**
* Get user or throw if not found
*/
async getUserOrFail(userId: string): Promise<User> {
const user = await this.em.findOne(User, { id: userId });
if (!user) {
throw new NotFoundException(CartMessage.USER_NOT_FOUND);
}
return user;
}
/**
* Get user address or throw if not found
*/
async getUserAddressOrFail(addressId: string): Promise<UserAddress> {
const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] });
if (!address) {
throw new NotFoundException(CartMessage.ADDRESS_NOT_FOUND);
}
return address;
}
/**
* Get product or throw if not found
*/
async getFoodOrFail(foodId: string): Promise<Product> {
const product = await this.em.findOne(Product, { id: foodId });
if (!product) {
throw new NotFoundException(CartMessage.PRODUCT_NOT_FOUND);
}
return product;
}
/**
* Get shop or throw if not found
*/
async getRestaurantOrFail(restaurantId: string): Promise<Shop> {
const shop = await this.em.findOne(Shop, { id: restaurantId });
if (!shop) {
throw new NotFoundException(CartMessage.SHOP_NOT_FOUND);
}
return shop;
}
/**
* Validate address belongs to user
*/
@@ -96,7 +55,7 @@ export class CartValidationService {
* Assert address is inside shop service area
*/
async assertAddressInsideServiceArea(
restaurantId: string,
shopId: string,
latitude?: number | null,
longitude?: number | null,
): Promise<void> {
@@ -104,7 +63,7 @@ export class CartValidationService {
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
}
const shop = await this.getRestaurantOrFail(restaurantId);
const shop = await this.shopService.findOrFail(shopId);
const serviceArea = shop.serviceArea;
// If no service area is defined, assume service is available everywhere
@@ -130,34 +89,13 @@ export class CartValidationService {
/**
* Get delivery method for shop or throw if not found
*/
async getDeliveryMethodForRestaurantOrFail(
restaurantId: string,
deliveryMethodId: string,
): Promise<Delivery> {
const deliveryMethod = await this.em.findOne(Delivery, {
id: deliveryMethodId,
shop: { id: restaurantId },
});
if (!deliveryMethod) {
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND_FOR_SHOP);
}
return deliveryMethod;
}
/**
* Get enabled delivery method or throw if not found or disabled
*/
async getEnabledDeliveryMethodOrFail(restaurantId: string, deliveryMethodId: string): Promise<Delivery> {
const deliveryMethod = await this.em.findOne(
Delivery,
{ id: deliveryMethodId, shop: { id: restaurantId } },
{ populate: ['shop'] },
);
if (!deliveryMethod) {
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND);
}
async getEnabledDeliveryMethodOrFail(shopId: string, deliveryMethodId: string): Promise<Delivery> {
const deliveryMethod = await this.deliveryService.findOrFail(shopId, deliveryMethodId)
if (!deliveryMethod.enabled) {
throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED);
}
@@ -186,14 +124,10 @@ export class CartValidationService {
/**
* Get enabled payment method or throw if not found or disabled
*/
async getEnabledPaymentMethodOrFail(restaurantId: string, paymentMethodId: string): Promise<PaymentMethod> {
const paymentMethod = await this.em.findOne(
PaymentMethod,
{ id: paymentMethodId, shop: { id: restaurantId } },
{ populate: ['shop'] },
);
if (!paymentMethod) {
throw new NotFoundException(CartMessage.PAYMENT_METHOD_NOT_FOUND);
async getEnabledPaymentMethodOrFail(shopId: string, paymentMethodId: string): Promise<PaymentMethod> {
const paymentMethod = await this.paymentMethodService.findOneOrFail(paymentMethodId)
if (paymentMethod.shop.id !== shopId) {
throw new BadRequestException('CartMessage.PAYMENT_METHOD_NOT_BELONGS_TO_SHOP');
}
if (!paymentMethod.enabled) {
throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_ENABLED);
@@ -204,8 +138,8 @@ export class CartValidationService {
/**
* Assert wallet has enough balance
*/
async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restaurantId);
async assertWalletHasEnoughBalance(userId: string, shopId: string, amount: number): Promise<void> {
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, shopId);
if (balance < amount) {
throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT);
@@ -247,7 +181,7 @@ export class CartValidationService {
const foodMap = await this.getFoodsInCartWithCategories(cart);
for (const item of cart.items) {
const product = foodMap.get(item.foodId);
const product = foodMap.get(item.productId);
if (!product) continue;
const matchesCategory = hasCategoryRestriction && product.category && categoryRestriction.includes(product.category.id);
@@ -262,19 +196,14 @@ export class CartValidationService {
* Get products in cart with categories
*/
async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Product>> {
const foodIds = cart.items.map(item => item.foodId);
if (foodIds.length === 0) return new Map();
const productIds = cart.items.map(item => item.productId);
if (productIds.length === 0) return new Map();
const products = await this.em.find(Product, { id: { $in: foodIds } }, { populate: ['category'] });
const products = await this.em.find(Product, { id: { $in: productIds } }, { populate: ['category'] });
return new Map(products.map(f => [f.id, f]));
}
/**
* Validate stock for a product
*/
validateStock(product: Product, quantity: number): void {
// TODO: Implement stock validation logic
// For now, assume unlimited stock
}
}
+12 -10
View File
@@ -12,6 +12,9 @@ import { CartValidationService } from './cart-validation.service';
import { CartCalculationService } from './cart-calculation.service';
import { CartItemService } from './cart-item.service';
import { CartMessage } from 'src/common/enums/message.enum';
import { UserService } from 'src/modules/users/providers/user.service';
import { ShopService } from 'src/modules/shops/providers/shops.service';
import { DeliveryService } from 'src/modules/delivery/providers/delivery.service';
@Injectable()
export class CartService {
@@ -21,6 +24,9 @@ export class CartService {
private readonly calculationService: CartCalculationService,
private readonly itemService: CartItemService,
private readonly couponService: CouponService,
private readonly userService: UserService,
private readonly shopService: ShopService,
private readonly deliveryService: DeliveryService,
) { }
/**
@@ -54,7 +60,7 @@ export class CartService {
throw new BadRequestException(CartMessage.ADDRESS_REQUIRED);
}
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
const address = await this.validationService.getUserAddressOrFail(addressId);
const address = await this.userService.getUserAddressOrFail(addressId);
this.validationService.validateAddressOwnership(address, userId);
// ensure address is within shop service area
@@ -111,7 +117,7 @@ export class CartService {
}
// Find shop
const shop = await this.validationService.getRestaurantOrFail(restaurantId);
const shop = await this.shopService.findOrFail(restaurantId);
// Create new cart
const now = this.nowIso();
@@ -194,7 +200,7 @@ export class CartService {
const cart = await this.findOneOrFail(userId, restaurantId);
const orderAmount = cart.subTotal - cart.itemsDiscount;
const user = await this.validationService.getUserOrFail(userId);
const user = await this.userService.findOneOrFail(userId);
// check if coupon is valid and belong to the shop
const { valid, coupon, message } = await this.couponService.validateCoupon(
applyCouponDto.code,
@@ -252,10 +258,8 @@ export class CartService {
cart,
'Delivery method must be set before setting address',
);
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
restaurantId,
deliveryMethodId,
);
const deliveryMethod = await this.deliveryService.findOrFail(restaurantId, deliveryMethodId);
this.validationService.assertDeliveryMethod(
deliveryMethod.method,
DeliveryMethodEnum.DeliveryCourier,
@@ -263,7 +267,7 @@ export class CartService {
);
// Find and validate address belongs to user
const address = await this.validationService.getUserAddressOrFail(addressId);
const address = await this.userService.getUserAddressOrFail(addressId);
// Verify address belongs to the user
this.validationService.validateAddressOwnership(address, userId);
@@ -354,7 +358,6 @@ export class CartService {
*/
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
if (method === DeliveryMethodEnum.CustomerPickup) {
cart.userAddress = null;
cart.tableNumber = undefined;
@@ -366,7 +369,6 @@ export class CartService {
return;
}
}
/**
+1 -1
View File
@@ -27,7 +27,7 @@ import { DeliveryModule } from '../delivery/delivery.module';
@Module({
imports: [
MikroOrmModule.forFeature([Order, OrderItem, User, Shop, Product, Variant, UserAddress, PaymentMethod]),
CartModule,
forwardRef(() => CartModule),
UtilsModule,
AuthModule,
forwardRef(() => PaymentsModule),
@@ -366,7 +366,7 @@ export class OrdersService {
const orderItemsData: OrderItemData[] = [];
for (const cartItem of cart.items) {
const variant = await this.em.findOne(Variant, { id: cartItem.foodId }, { populate: ['product', 'product.shop'] });
const variant = await this.em.findOne(Variant, { id: cartItem.productId }, { populate: ['product', 'product.shop'] });
if (!variant) throw new NotFoundException(OrderMessage.PRODUCT_NOT_FOUND);
if (variant.product.shop.id !== shopId) {
@@ -13,6 +13,7 @@ import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
import { PaymentRepository } from '../repositories/payment.repository';
@Injectable()
export class PaymentsService {
@@ -22,6 +23,7 @@ export class PaymentsService {
private readonly em: EntityManager,
private readonly gatewayManager: GatewayManager,
private readonly eventEmitter: EventEmitter2,
private readonly paymentRepository: PaymentRepository,
) { }
async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> {
@@ -141,7 +143,7 @@ export class PaymentsService {
payment.paidAt = new Date();
order.status = OrderStatus.PAID;
em.persist([ payment, order, newWalletTransaction]);
em.persist([payment, order, newWalletTransaction]);
await em.flush();
});
this.eventEmitter.emit(
@@ -278,6 +280,13 @@ export class PaymentsService {
payment.order.status = OrderStatus.CANCELED;
}
async findOneOrFail(paymentId: string): Promise<Payment> {
const payment=await this.paymentRepository.findOne({ id: paymentId });
if (!payment) {
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
}
return payment;
}
private async getOrCreateLatestPendingPayment(
orderId: string,
+1 -1
View File
@@ -25,6 +25,6 @@ import { Favorite } from './entities/favorite.entity';
],
controllers: [FoodController, CategoryController],
providers: [ProductService, CategoryService, ProductRepository, CategoryRepository],
exports: [ProductRepository, CategoryRepository],
exports: [ProductRepository, CategoryRepository, ProductService],
})
export class ProductModule {}
@@ -392,4 +392,12 @@ export class UserService {
}
return user;
}
async getUserAddressOrFail(addressId: string): Promise<UserAddress> {
const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] });
if (!address) {
throw new NotFoundException("User address not found.");
}
return address;
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { UserAddress } from '../entities/user-address.entity';
@Injectable()
export class AddressRepository extends EntityRepository<UserAddress> {
constructor(readonly em: EntityManager) {
super(em, UserAddress);
}
}
+7 -4
View File
@@ -6,21 +6,24 @@ import { User } from './entities/user.entity';
import { UserAddress } from './entities/user-address.entity';
import { JwtModule } from '@nestjs/jwt';
import { UserRepository } from './repositories/user.repository';
import { WalletTransactionRepository } from './repositories/wallet-transaction.repository';
import { WalletTransactionRepository } from './repositories/wallet-transaction.repository';
import { WalletService } from './providers/wallet.service';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { PointTransaction } from './entities/point-transaction.entity';
import { PointTransactionRepository } from './repositories/point-transaction.repository';
import { ShopsModule } from '../shops/shops.module';
import { AddressRepository } from './repositories/address.repository';
@Module({
providers: [UserService, WalletService, UserRepository, WalletTransactionRepository, PointTransactionRepository],
providers: [UserService, WalletService, UserRepository,
WalletTransactionRepository, PointTransactionRepository, AddressRepository],
controllers: [UsersController],
imports: [
MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction]),
JwtModule,
ShopsModule
],
exports: [UserService, WalletService, UserRepository, WalletTransactionRepository, PointTransactionRepository],
exports: [UserService, WalletService, UserRepository,
WalletTransactionRepository, PointTransactionRepository, AddressRepository],
})
export class UserModule {}
export class UserModule { }