import { Injectable, BadRequestException } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/postgresql'; import { Product } from 'src/modules/products/entities/product.entity'; import { UserAddress } from 'src/modules/users/entities/user-address.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 { 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 { PaymentMethodService } from 'src/modules/payments/services/payment-method.service'; import { Variant } from 'src/modules/products/entities/variant.entity'; @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 validateAndGetVariant(variantId: string, shopId: string, quantity: number): Promise { const variant = await this.productService.findOrFailVariant(variantId) if (variant.product.shop.id !== shopId) { throw new BadRequestException(CartMessage.PRODUCT_NOT_BELONGS_TO_SHOP); } return variant; } /** * Validate address belongs to user */ validateAddressOwnership(address: UserAddress, userId: string): void { if (address.user.id !== userId) { throw new BadRequestException(CartMessage.ADDRESS_NOT_BELONGS_TO_USER); } } /** * Assert address is inside shop service area */ async assertAddressInsideServiceArea( shopId: string, latitude?: number | null, longitude?: number | null, ): Promise { if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) { throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES); } const shop = await this.shopService.findOrFail(shopId); const serviceArea = shop.serviceArea; // If no service area is defined, assume service is available everywhere if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return; // GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring const rings = serviceArea.coordinates; if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return; const ring = rings[0]; const point: [number, number] = [Number(longitude), Number(latitude)]; // Ensure polygon has the correct tuple typing ([lng, lat] pairs) const polygon: [number, number][] = ring.map( coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number], ); if (!GeographicUtils.isPointInPolygon(point, polygon)) { throw new BadRequestException(CartMessage.ADDRESS_OUTSIDE_SERVICE_AREA); } } /** * Get enabled delivery method or throw if not found or disabled */ async getEnabledDeliveryMethodOrFail(shopId: string, deliveryMethodId: string): Promise { const deliveryMethod = await this.deliveryService.findOrFail(shopId, deliveryMethodId) if (!deliveryMethod.enabled) { throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED); } return deliveryMethod; } /** * Assert delivery method matches expected type */ assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void { if (actual !== expected) { throw new BadRequestException(message); } } /** * Require delivery method ID or throw */ requireDeliveryMethodId(cart: Cart, message: string): string { if (!cart.deliveryMethodId) { throw new BadRequestException(message); } return cart.deliveryMethodId; } /** * Get enabled payment method or throw if not found or disabled */ async getEnabledPaymentMethodOrFail(shopId: string, paymentMethodId: string): Promise { 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); } return paymentMethod; } /** * Assert wallet has enough balance */ async assertWalletHasEnoughBalance(userId: string, shopId: string, amount: number): Promise { const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, shopId); if (balance < amount) { throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT); } } /** * Assert coupon usage limit */ async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise { if (!maxUsesPerUser) return; const ordersThatUsedTheCouponCount = await this.em.count(Order, { user: { id: userId }, couponDetail: { couponId: couponId }, status: { $ne: OrderStatus.CANCELED }, deletedAt: null, }); if (ordersThatUsedTheCouponCount >= maxUsesPerUser) { throw new BadRequestException(CartMessage.COUPON_MAX_USES_REACHED); } } /** * Assert coupon matches cart if restricted */ async assertCouponMatchesCartIfRestricted( cart: Cart, foodCategories?: string[] | null, products?: string[] | null, ): Promise { const categoryRestriction = foodCategories?.filter(Boolean) ?? []; const foodRestriction = products?.filter(Boolean) ?? []; const hasCategoryRestriction = categoryRestriction.length > 0; const hasFoodRestriction = foodRestriction.length > 0; if (!hasCategoryRestriction && !hasFoodRestriction) return; const foodMap = await this.getFoodsInCartWithCategories(cart); for (const item of cart.items) { const product = foodMap.get(item.variantId); if (!product) continue; const matchesCategory = hasCategoryRestriction && product.category && categoryRestriction.includes(product.category.id); const matchesFood = hasFoodRestriction && foodRestriction.includes(product.id); if (matchesCategory || matchesFood) return; } throw new BadRequestException(CartMessage.COUPON_CANNOT_BE_APPLIED); } /** * Get products in cart with categories */ async getFoodsInCartWithCategories(cart: Cart): Promise> { const productIds = cart.items.map(item => item.variantId); if (productIds.length === 0) return new Map(); const products = await this.em.find(Product, { id: { $in: productIds } }, { populate: ['category'] }); return new Map(products.map(f => [f.id, f])); } }